0%

The Fuzzing Book_01_Fuzzing: Breaking Things with Random Inputs

https://www.fuzzingbook.org/html/Fuzzer.html

The essence of fuzzing : Create random inputs, and see if they break things.


Synopsis

Fuzzing 아키텍처에 대한 소개

  • Fuzzer as a base class for fuzzers.
  • Runner as a base class for programs under test.(PUT)

Fuzzers

image

  • Fuzzer는 fuzzer들에 대한 기본 클래스로 RandomFuzzer와 같이 확장해서 쓸 쑤 있다.

  • fuzz() 함수는 input을 생성하여 반환한다.

  • run() 함수는 fuzz()에서 생성된 input을 put에 넣어 fuzzing을 실행한다.

  • runs() 함수는 반복적으로 run()을 실행한다.

Runners

image

  • Runner는 Fuzzer에서 생성된 fuzzed string을 input으로 받는다.
  • 3가지 상태로 결과를 나타낸다. (PASS, FAIL or UNRESOLVED)
  • ProgramRunner는 external program을 input으로 주어 fuzzed input과 함께 fuzzing한다.
  • PrintRunner는 단순히 입력을 출력하고 PASS결과를 반환한다.

A Simple Fuzzer

1
2
3
4
5
6
def fuzzer(max_length: int = 100, char_start: int = 32, char_range: int = 32) -> str:
string_length = random.randrange(0, max_length + 1)
out = ""
for i in range(0, string_length):
out += chr(random.randrange(char_start, char_start + char_range))
return out
  • 위와 같이 간단하게 랜덤한 문자열을 만들 수 있다.
  • fuzzer(100, ord(‘a’), 26) 와 같이 사용한다면 최대길이 100인 랜덤한 소문자 알파벳 문자열을 생성한다.
  • fuzzer(100, ord(‘0’), 10) : 랜덤 숫자 생성

Fuzzing External Programs

  • 퍼징된 입력으로 외부 프로그램 호출을 확인하기 위해 2단계로 진행한다.
  • 퍼징된 데이터를 입력 파일로 만들고, 이 입력 파일을 선택한 프로그램의 input으로 준다.

Creating Input Files

1
2
3
4
5
6
7
8
9
10
basename = "input.txt"
tempdir = tempfile.mkdtemp()
FILE = os.path.join(tempdir, basename)
print(FILE)
data = fuzzer()
with open(FILE, "w") as f:
f.write(data)
contents = open(FILE).read()
print(contents)
assert(contents == data)
  • 임시 파일을 만든 후 fuzzer() 결과를 파일에 쓴다.
  • assert를 통해 제대로 동작했는지 확인한다.

Invoking External Programs

1
2
3
4
5
6
program = "bc"
with open(FILE, "w") as f:
f.write("2 + 2\n")
result = subprocess.run([program, FILE],
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
universal_newlines=True)
  • 호출할 외부 프로그램은 계산기이며, Input으로 줄 파일에 “2+2”를 쓴다.
  • subprocess를 통해 계산기를 Input파일을 주고 실행시킨다.
  • result.stdout, result.returncode, result.stderr를 통해 subprocess로 실행시킨 bc의 결과를 알 수 있다.

image

  • 만약 remove 프로그램에 대해 퍼징을 진행하고, 이때 모든 파일을 다 지울 수 있는 경우가 나올 확률은?
    • 무작위로 생성될 문자열의 길이가 1 이면서, 그 값이 / 일 경우 (rm -rf /)
    • 1/100 * 1/32 가 된다.
    • 즉 remove 프로그램에 대해 퍼징을 한다면 위의 확률로 모든 파일을 지울 수 있다.

Long-Running Fuzzing

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
trials = 100
program = "bc"

runs = []

for i in range(trials):
data = fuzzer()
with open(FILE, "w") as f:
f.write(data)
result = subprocess.run([program, FILE],
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
universal_newlines=True)
runs.append((data, result))
count = sum(1 for(data,reuslt) in runs if result.stderr == "")
print (count)

errors = [(data,result) for (data,result) in runs if result.stderr != ""]
(first_data, first_result) = errors[0]

print(repr(first_data))
print(first_result.stderr)

[result.stderr for (data, result) in runs if result.stderr != ""
and "illegal character" not in result.stderr
and "parse error" not in result.stderr
and "syntax error" not in result.stderr]

  • 반복문을 통해 여러번 퍼징을 할 수 있다.
  • 정상적인 호출이 일어난 횟수와 에러가 발생한 횟수를 count할 수 있다.
  • 발생한 에러중에서 위와 같은 에러가 아니라면 아마도 새로운 종류의 crash일 것이다.

Bugs Fuzzers Find

Buffer Overflows

1
2
3
4
def crash_if_too_long(s):
buffer = "Thursday"
if len(s) > len(buffer):
raise ValueError
  • 입력값이 buffer에 있는 문자열 보다 길면 Overflow임을 의미한다.
1
2
3
4
5
trials = 100
with ExpectError():
for i in range(trials):
s = fuzzer()
crash_if_too_long(s)
  • fuzzer()함수로 fuzzed string을 만든 후 crash_if_too_long 함수에 input으로 주어 실행시킨다.

image

  • 여기서 ExpectedError 메시지가 뜨지만, Fuzzing을 통해 UnexpectedError를 발견할 수 있다.

Missing Error Checks

  • 대부분 프로그래밍 언어는 예외처리 대신에 특정한 오류 코드를 사용한다.
  • C언어의 getchar()은 더이상 input이 가능하지 않으면 EOF를 반환한다.
1
while (getchar() != ' ');
  • 프로그램이 사용자가 공백문자를 이력할 때까지 읽어들이는 상황에서
  • 입력이 조기에 종료되었다면, getchar()은 계속 EOF을 반환할 것이며,
  • 이는 무한 루프로 들어가게 된다.
1
2
3
4
5
6
7
def hang_if_no_space(s):
i = 0
while True:
if i < len(s):
if s[i] == ' ':
break
i += 1
  • 공백문자를 입력받으면 break가 된다.
1
2
3
4
5
trials = 100
with ExpectTimeout(2):
for i in range(trials):
s = fuzzer()
hang_if_no_space(s)
  • ExpectTimeout(2)로 지정하고 코드를 돌리면, 2초 뒤에 TImeout Error 메시지가 뜬다.

image

Rogue Numbers

  • 퍼징으로 쉽게 정상적이지 않은 값을 만들 수 있고, 이는 흥미로운 행동을 야기한다.
    1
    2
    3
    4
    5
    6
    char *read_input() {
    size_t size = read_buffer_size();
    char *buffer = (char *)malloc(size);
    // fill buffer
    return (buffer);
    }
  • 만약 size가 프로그램 메모리보다 크면 어떻게 될 것인가.
  • 아니면 size가 문자 수 보다 작으면, 혹은 음수이면 어떻게 될 것인가.
  • 랜덤으로 생성된 숫자를 생성하여 퍼징은 모든 종류의 에러를 발생시킬 수 있다.
    1
    2
    3
    def collapse_if_too_large(s):
    if int(s) > 1000:
    raise ValueError
  • s의 크기가 1000보다 크면 ValueError를 발생한다.
1
2
3
4
long_number = fuzzer(100, ord('0'), 10)
print(long_number)
with ExpectError():
collapse_if_too_large(long_number)
  • fuzzer()함수를 통해 무작위 숫자를 만들고, collapse_if_too_large()함수의 인자로 준다.

image

  • 위와 같은 Bug를 찾을 수 있다.
  • 하지만 누군가는 나쁜 프로그래밍이나, 언어의 문제라고 주장하지만
  • 매일 수천명의 사람들이 프로그램을 시작하고 같은 실수를 반복한다.

Catching Errors

  • 처음에 crash와 hang으로 오류를 식별할 수 있었다.
  • 다만 오류를 감지하기 어렵다면, 우리는 추가적인 확인이 필요하다.

Generic Checkers

  • C나 C++같은 언어에서 프로그램은 메모리에 대해 접근할 수 있다.
  • 취약점이 있다면 초기화 되지 않은 메모리나, 이미 free된 메모리에도 접근할 수 있다.
  • 런타임에 이러한 문제를 포착하는데 도움이 되는 도구가 있으며 퍼징과 결합하면 좋다.

Checking Memory Acccess

  • 테스트 중에 문제가 있는 메모리 접근을 포착하기 위해, 특별한 메모리 검사 환경에서 C프로그램을 실행할 수 있다.
  • Address Sanitizer : memory corruption bug를 감지하는 도구.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
with open("program.c", "w") as f:
f.write("""
#include <stdlib.h>
#include <string.h>

int main(int argc, char** argv) {
/* Create an array with 100 bytes, initialized with 42 */
char *buf = malloc(100);
memset(buf, 42, 100);

/* Read the N-th element, with N being the first command-line argument */
int index = atoi(argv[1]);
char val = buf[index];

/* Clean up memory so we don't leak */
free(buf);
return val;
}
""")
  • 위와 같이 C코드를 생성하는 스크립트를 작성한다.
  • malloc으로 동적 할당을 한 후 42값을 채워준다.
  • 프로그램 실행 시 인자로 받은 값을 인덱스로 하여 동적할당한 메모리에 접근한다.
  • 이때 인덱스 값이 메모리를 넘어간다면 OOB 취약점이 발생한다.
    1
    clang -fsanitize=address -g -o program program.c
  • 컴파일 시 -fsanitize 옵션을 활성화한다.

image

  • 할당된 메모리보다 작은 인덱스 값을 주면 정상적으로 출력이 된다.

image

  • 하지만 할당된 메모리를 초과하는 인덱스 값을 주면 Out of bound 에러가 발생한다.
  • Address Sanitizer는 어느 주소에서 취약점이 발생했는지 알려준다.

image

  • gdb로 해당 주소를 확인하면 초기화가 된 공간 말고 0x15e 위치를 접근하려 한 것을 확인할 수 있다.

image

image

  • C프로그램에서 오류를 찾고 싶다면, 퍼징과 함께 사용하면 좋다.
  • 메모리를 더 많이 소모하지만, 버그를 찾는 데 필요한 인간의 노력에 비해 매우 저렴하다.
  • Out of bound는 공격자가 의도하지 않은 메모리에 엑세스, 수정이 가능 하기에 보안 위험이 있다.
  • 대표적인 OOB 취약점으로 HeartBleed가 있다.

HeartBleed

  • OpenSSL 라이브러리의 보안 버그, SSL heartbeat 서비스에서 발견된 취약점

image

image

image

  • OpenSSL에서 이러한 메모리에는 암호화 인증서, 개인 키 등이 포함 될 수 있으며
  • 메모리에 엑세스한 사실을 아무도 알아채지 못했다.
  • 하지만 Address Sanitizer로 OpenSSL 라이브러리를 컴파일한 후에
  • Out of bound가 발생했음을 알아차렸다.

Information Leaks

  • Memory Leak은 illegal한 메모리 접근에 대해서만 발생하는 것이 아닌, 유효한 메모리 내에서도 발생할 수 있다.
1
2
3
4
5
6
7
secrets = ("<space for reply>" + fuzzer(100) +
"<secret-certificate>" + fuzzer(100) +
"<secret-key>" + fuzzer(100) + "<other-secrets>")

uninitialized_memory_marker = "deadbeef"
while len(secrets) < 2048:
secrets += uninitialized_memory_marker
  • secrets 문자열을 만든다.
1
2
3
4
5
6
7
8
9
def heartbeat(reply: str, length: int, memory: str) -> str:
# Store reply in memory
memory = reply + memory[len(reply):]

# Send back heartbeat
s = ""
for i in range(length):
s += memory[i]
return s
  • heartbeat 기능을 위와 같이 구현한다.
    1
    2
    3
    heartbeat("potato", 6, memory=secrets)
    heartbeat("bird", 4, memory=secrets)
    heartbeat("hat", 500, memory=secrets)

image

  • 순서대로 함수를 호출하면 두 번째 인자에 따라 뒤에 출력되는 데이터가 다른 것을 확인할 수 있다.
  • 어떻게 하면 탐지 할 수 있을까?
  • 출력하기전에 secret과 uninitialized_memory_marker가 문자열에 있으면 assert.

image

  • 퍼징 중에는 항상 가능한 많은 automatic checker를 켜야 한다.
  • 오류를 탐지할 수 있는 옵션 없이 프로그램만 실행하면 많은 기회를 놓치기 때문이다.

A Fuzzing Architecture

Runner Classes

  • Runner는 일반적으로 program or function under test이다.
1
2
3
4
5
6
7
8
class Runner:
PASS = "PASS"
FAIL = "FAIL"
UNRESOLVED = "UNRESOLVED"
def __init__(self) -> None:
pass
def run(self, inp: str) -> Any:
return (inp, Runner.UNRESOLVED)
1
2
3
4
class PrintRunner(Runner):
def run(self, inp) -> Any:
print(inp)
return (inp, Runner.UNRESOLVED)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ProgramRunner(Runner):
def __init__(self, program: Union[str, List[str]]) -> None:
self.program = program

def run_process(self, inp: str = "") -> subprocess.CompletedProcess:
return subprocess.run(self.program, input=inp, stdout=subprocess.PIPE, stderr=subprocess.PIPE, universal_newlines=True)

def run(self, inp: str = "") -> Tuple[subprocess.CompletedProcess, Outcome]:
result = self.run_process(inp)
if result.returncode == 0:
outcome = self.PASS
elif result.returncode < 0:
outcome = self.FAIL
else:
outcome = self.UNRESOLVED
return (result,outcome)
1
2
3
class BinaryProgramRunner(ProgramRunner):
def run_process(self, inp: str="") -> subprocess.CompletedProcess:
return subprocess.run(self.program,input=inp.encode(),stdout=subprocess.PIPE, stderr=subprocess.PIPE)

Fuzzer Classes

1
2
3
4
5
6
7
8
9
10
11
12
class Fuzzer:
def __init__(self) -> None:
pass
def fuzz(self) -> str:
return ""
def run(self, runner: Runner = Runner()) -> Tuple[subprocess.CompletedProcess, Outcome]:
return runner.run(self.fuzz())
def runs(self, runner: Runner = PrintRunner(), trials: int = 10) -> List[Tuple[subprocess.CompletedProcess, Outcome]]:
outcomes = []
for i in range(trials):
outcomes.append(self.run(runner))
return outcomes
1
2
3
4
5
6
7
8
9
10
11
12
class RandomFuzzer(Fuzzer):
def __init__(self, min_length: int = 10, max_length: int = 100, char_start: int = 32, char_range: int = 32) -> None:
self.min_length = min_length
self.max_length = max_length
self.char_start = char_start
self.char_range = char_range
def fuzz(self) -> str:
string_length = random.randrange(self.min_length, self.max_length + 1)
out = ""
for i in range(0, string_length):
out += chr(random.randrange(self.char_start,self.char_start + self.char_range))
return out