TECH 으로 돌아가기
TECH HACKER NEWS 8분 읽기 27 READS

AFL++ 퍼즈 테스트를 더 날카롭게: 1,000개 프로젝트가 남긴 실전 교훈

퍼즈 테스트(fuzz testing)는 소프트웨어 결함을 기계적으로 찾아내는 데 매우 효과적인데도 여전히 과소평가되고 방치되는 기법이다. 복잡한 입력을 받아들여야 하는 프로그램이라면 언어를 가리지 않고, 아직 퍼징된 적이 없다면 대개 최소한 하나의 버그가 드러난다. 지금 쓰는 컴퓨터에 깔린 프로그램 중에도 그런 후보가 분명히 있다. nullprogram의 저자는 지난 6년간 약 1,000개 프로젝트를 퍼징하며 쌓은 요령을 체크리스트 형식으로 정리했는데, 대상은 glibc 기반 리눅스에서 소스가 공개된 C/C++ 프로그램을 AFL++로 테스트하는 경우다. 이미 퍼징된 적 있는 프로그램이라도 이 기법들을 적용하면 과거에 놓친 결함이 새로 나오기도 한다.

새니타이저를 더 민감하게 설정하기

출발점은 새니타이저다. 어서션이 많을수록 퍼징은 효과적인데, 새니타이저는 자동으로 삽입되는 일종의 어서션이기 때문이다. 기본적으로 Address Sanitizer(ASan)와 Undefined Behavior Sanitizer(UBSan)를 함께 켜되, ASan의 기본 설정은 퍼징에 이상적이지 않으므로 ASAN_OPTIONS 환경 변수로 조정해야 한다. 특히 use-after-return 탐지를 켜면 퍼징 속도가 약간 느려지지만 그만한 값어치를 한다. 더 중요한 함정은 ASan이 새 할당 메모리의 앞 4KiB만 패턴으로 채운다는 점이다. 4KiB를 넘는 메모리를 malloc한 뒤 초기화되지 않은 영역에 strlen 같은 연산을 하면, 그 뒤 어딘가에 0이 있을 가능성이 높아 버그가 탐지되지 않는다. 저자도 이 한계를 알아차리기 전까지 여러 버그를 놓쳤다고 한다. 할당 메모리를 사실상 전부 패턴으로 채우도록 설정값을 끌어올리면 persistent 모드에서 테스트 격리도 좋아진다. C++에서 GCC의 libstdc++를 링크한다면 -D_GLIBCXX_DEBUG도 고려할 만한데, ASan이 못 보는 컨테이너 용량 내부의 범위 초과 접근을 추가 어서션이 메워주기 때문이다.

하나의 번역 단위로 끌어모으는 템플릿 기법

저자는 AFL++의 persistent 모드(afl-gcc-fast, afl-clang-fast)를 권한다. 코드 한 줄 안 써도 되는 in-place 방식보다 대개 한 자릿수 배 빠르기 때문이다. 그는 fuzztmpl.c라는 템플릿 파일을 Vim 버퍼에 불러와 상황에 맞게 고쳐 쓴다. 핵심 발상은 21세기에 걸맞지 않은 '소스 파일당 번역 단위 하나'라는 고정관념을 버리는 것이다. 퍼즈 테스트에는 계측된 코드용 번역 단위 하나와 비계측 코드(대개 libc, libstdc++ 같은 라이브러리)용 하나, 최대 둘이면 충분하다. 테스트에 필요한 소스를 템플릿에 직접 include해 넣으면 수백 줄의 빌드 설정을 아낄 수 있다. 더 중요한 이점은 정적(static) 함수를 포함해 프로그램 안의 임의 함수를 테스트할 수 있다는 점이다. 공개 인터페이스에 얽매이지 않고 내부의 더 나은 진입점을 고를 수 있으며, 정적 변수에 직접 접근해 테스트마다 초기화할 수도 있다. 테스트 대상에 main 함수가 들어 있으면 원본 소스를 한 줄도 건드리지 않고 그 심볼만 교체해 잘라낼 수 있어, '여기 50바이트 파일을 열면 크래시'처럼 간결한 버그 리포트가 나온다.

격리와 입력 처리의 함정

각 테스트는 이전 테스트의 영향을 받지 않아야 하는데, persistent 모드에서 이 격리는 까다롭고 때로는 비현실적이다. strtok의 내부 버퍼 같은 것까지 포함해 모든 전역 상태를 초기화해야 한다. 퍼즈 테스트하기 쉽다는 점은 전역 변수를 피해야 할 또 하나의 이유인 셈이다. 출력 파라미터로 넘기는 스택 메모리처럼 초기화되지 않은 메모리를 테스트에 넘기지 말고 0이나 패턴으로 채워야 하며, 아레나(arena) 할당을 쓰는 프로그램이라면 격리가 완벽해지고 주소까지 테스트마다 일정해져 이런 고민이 사라진다.

가장 흔한 실수는 AFL++가 주는 buf와 len을 그대로 대상에 넘기는 것이다. 특히 설계가 잘 된 C 프로그램에서 그렇고, 공식 템플릿이 이를 부추긴다. 프로그램이 널 종료에 의존하지 않는다는 좋은 신호이기도 하지만, AFL++가 할당한 실제 버퍼는 len보다 크기 때문에 입력에 대한 읽기 범위 초과를 ASan이 탐지하지 못하는 함정이 생긴다. 대신 len에 딱 맞게 복사한 버퍼를 넘겨야 한다. 이미 검증된 퍼즈 테스트가 buf를 직접 쓰고 있다면 이 부분만 고쳐도 새 버그가 튀어나오는 경우가 있다.

리소스와 '넉넉한' 버퍼 다루기

테스트와 무관한 작업은 피하는 게 원칙이다. 소멸자(destructor)를 테스트에서 빼면 대상이 단순해지고 격리에도 도움이 된다. 다만 파일 디스크립터처럼 메모리 외 자원은 쉽게 고갈되므로 안정적인 퍼징을 위해 정리가 필요할 수 있다. 파일 경로만 받아 직접 열어 읽는 식의 설계가 나쁜 인터페이스는 메모리 파일 디스크립터를 만들고 그 /proc 이름을 쓰면 우회할 수 있다. 데이터가 영구 저장소에 닿지 않아 SSD 마모가 없고, 프로세스마다 파일이 고유해 병렬 퍼징에도 문제가 없으며 정리할 것도 없다. FILE 포인터가 필요하면 fmemopen으로 객체 위에 직접 만들 수 있다.

마지막으로 '병든' 프로그램에 흔한 넉넉한 고정 버퍼 크기를 주목하자. 65536처럼 큰 버퍼는 최악의 경로나 오버플로에 도달하기까지 많은 퍼징 시간을 잡아먹어 버그를 숨긴다. 이런 돌을 뒤집어 크기를 16 정도로 대폭 줄여보면, 짧은 버퍼에서 발견된 크래시를 원래 크기에서 재현하도록 입력을 늘리는 일은 대체로 간단하다. 이 글의 가치는 단순한 버그 탐지에 그치지 않는다. 저자의 말처럼 이런 기법을 아는 것만으로도 애초에 그런 결함을 만들지 않게 된다는 점이, 실무자에게는 더 오래 남는 소득이다.

SOURCE · HACKER NEWS
원문 전체 보기 → https://nullprogram.com/blog/2025/02/05/
SHARE
NEXT · CHOOSE

변화를 읽었다면,
내가 만들 수익 구조를 고릅니다.

정보를 더 모으는 데서 멈추지 않고, 광고·외주·판매·중개·구독 중 내 상황에 맞는 출발점을 정해보세요.

21가지 수익 구조 살펴보기 →
처리 중...