상세 컨텐츠

본문 제목

AFL++로 네트워크 포렌식 파서 퍼징해서 GHSA 10건 제보·게재까지

보안

by 코드스키 2026. 7. 23. 20:17

본문

 AFL++ 네트워크 파서 퍼징과 GHSA 제보 대표 이미지
AFL++ 네트워크 파서 퍼징과 GHSA 제보 대표 이미지

 

 

 

 

nfdump 취약점이 GitHub Security Advisory로 공개된 화면

 

포트폴리오에 쓸 CVE를 하나 노리고 시작한 작업이다. 목표는 한 달 안에 CVE나 보안 기여 실적 1건. 취업이나 BoB 지원용으로 "실제 오픈소스에서 취약점을 찾아 제보한 경험"만큼 강한 카드가 없다고 판단했다.

문제는 타깃 선정이었다. 아무거나 퍼징하면 십중팔구 이미 남이 다 훑어놓은 코드다. 특히 이미지나 폰트, libpcap 자체 같은 유명 파서는 구글 OSS-Fuzz가 24시간 상시 퍼징 중이라 얕은 크래시는 이미 다 잡혀 있다. 그래서 방향을 이렇게 잡았다. 도메인은 네트워크 포렌식/DFIR 파서로. 내 EDR·침해대응 서사와도 붙고, 패킷에서 무슨 일이 있었는지 재구성하는 도구라 신뢰할 수 없는 입력(pcap)을 많이 먹는다는 이유였다. OSS-Fuzz에 등재된 프로젝트는 제외하기로 했는데(libyal, suricata, zeek, yara, pcapplusplus, sleuthkit 등은 실제로 등재 목록 1,366개를 받아서 배제 확인했다), 대신 자체 파싱 코드가 있고 pcap을 파일로 바로 먹으며(-r file), 아직 활발히 유지보수되는 프로젝트를 선호 조건으로 뒀다.

셋업

퍼징 셋업 자체는 단순하다. 12코어 리눅스 박스에서 AFL_USE_ASAN=1 CC=afl-clang-fast CXX=afl-clang-fast++로 계측 빌드를 하는데, afl-clang-fast가 커버리지 계측을, AddressSanitizer가 메모리 오류 탐지를 담당한다. 빌드 후엔 nm이나 strings__afl_*, __asan_* 심볼이 실제로 들어갔는지 꼭 확인해야 한다. 이거 안 하면 계측 안 된 바이너리를 몇 시간씩 헛돌리게 된다. 시드 코퍼스는 각 프로젝트의 tests/*.pcap을 그대로 썼는데, 프로젝트가 자기 테스트로 넣어둔 pcap이 제일 좋은 시드다. 실행은 afl-fuzz -i seeds -o out -M main ... -- ./target -r @@ 형태로, @@가 입력 파일 경로로 치환된다. 코어가 많아서 -M main 하나에 -S 보조 인스턴스 여러 개를 explore/exploit/fast 스케줄로 다르게 걸어 병렬로 돌렸다.

tcpflow는 시드를 정상 실행해도 종료 시 exit=1이 떴는데, 버그인 줄 알고 놀랐다가 알고 보니 LeakSanitizer가 잡는 양성 스타트업 누수(스캐너 로딩)였다. ASAN_OPTIONS=detect_leaks=0으로 LSan을 꺼야 dry-run이 막히지 않았다.

1차 — tcpflow, tcpreplay, pcapfix

세 개를 병렬로 돌렸다. pcapfix는 퍼저가 35초 만에 고유 크래시 1건을 찾았는데, fix_pcapng(pcapng.c)에서 파일이 제어하는 길이값을 검증 없이 할당 크기로 써서 malloc이 NULL을 반환하고 거기에 fread가 write하는 SEGV였다. tcpflow는 크래시가 139건 넘게 나왔고, 심지어 번들 테스트 파일 simson.pcap 자체가 ASAN에서 크래시했다. tcpip::print_packet(tcpip.cpp:383)의 heap over-read를 포함해 3개 사이트가 나왔다. tcpreplay는 크래시가 0이었다.

여기서 "CVE 3개다" 하고 신나면 안 된다. 진짜 일은 이게 known-ness가 있는지 확인하는 거였다. pcapfix는 확인해보니 중복이었다. open 이슈 #28(Honggfuzz)이 내가 퍼징한 바로 그 버전(1.1.7)에서 정확히 같은 0xfffffffffffffffc 할당 크래시를 이미 보고해뒀고, #38도 같은 영역이라 CVE 가치가 없었다. tcpflow는 미확정 후보로 남았다. 기존 이슈(#216은 다른 futimes 크래시, #247은 다른 함수의 로직 버그)와는 안 겹치고 published CVE도 없었지만, tcpflow는 과거 AFL 퍼징 이력이 있어서(이슈에 crashes/ 디렉토리 흔적) 비공개로 이미 알려졌을 가능성을 배제할 수 없었다. 그래서 novel 확정이라고는 말하지 않기로 했다.

여기서 얻은 교훈은 크래시 개수는 아무 의미가 없다는 거였다. 접근성 좋은 타깃일수록 쉬운 크래시는 이미 남이 다 봤다.

2차 — softflowd, justniffer, ngrep

기준을 "OSS-Fuzz 미등재 + 최근 퍼징 이력 적음 + 유지보수 중 + 자체 코드"로 바꾸고 골랐다. 그리고 인프라 버그 하나를 고쳤는데, 1차 때 퍼저들이 자꾸 죽었던 원인이 OOM이 아니라 detach 실패였다. nohup만으로 띄운 인스턴스는 SSH 세션이 끊길 때 SIGHUP으로 같이 죽었는데, setsid -f로 완전히 부모에서 떼어내니(PPID=1) 살아남았다.

justniffer는 크래시가 34건 나왔지만 전부 번들된 libnids-1.21(2010년산 3rd-party 라이브러리)의 IP 조각 재조립 버그(ip_fragment.c:417)였다. justniffer 자체 코드가 아니고 이 계열은 이미 잘 알려져 있어서 가치가 낮았다. 자체 포맷 파서 오버플로도 있었는데 그건 pcap이 아니라 사용자가 주는 -l 포맷 인자로 트리거되는 거라 원격 악용성이 낮았다. ngrep은 크래시가 0이었다(17분/53사이클/커버리지 10%에서 정체).

softflowd에서 유력한 후보가 나왔다. datalink_check(softflowd.c:1213)에서 frametype 바이트를 읽는데, caplenskiplen에 대해서만 검사(:1185)하고 실제로 인덱싱하는 ft_off + vlan_size + ft_len 범위는 검사하지 않는다. 16바이트짜리 짧은 패킷을 주면 버퍼 끝 뒤를 읽는다(CWE-125). softflowd 자체 코드고 근본원인이 명확하며 무관한 이슈뿐이고 유지보수 중(v1.1.1)이었다. 52KB 크래시를 1,468B PoC로 최소화해서 재현을 확인했다.

여기서의 교훈은 자체 코드냐 번들 라이브러리냐를 반드시 구분해야 한다는 거였다. justniffer 34건은 화려해 보여도 남의 라이브러리 버그라 내 크레딧이 안 된다.

3차 — nfdump

기준을 더 밀어붙여서 pcap 대신 자체 바이너리 포맷을 파싱하는, 아직 거의 안 퍼징된, 그런데 지금도 활발히 유지보수되는 파서를 찾았다. 골라진 게 nfdump(넷플로우/nfcapd 수집·분석 도구, phaag/nfdump, v1.7.8, 2026-06 커밋)였다. ngrep은 커버리지가 10%에서 막혀서 CmpLog(비교 연산을 학습해 매직바이트·길이 필드를 뚫는 AFL++ 기능)를 붙여 재개했는데, 58사이클을 더 돌려도 크래시가 0이라 표면이 얕아 깨끗하다고 결론 냈다.

nfdump는 12분 만에 크래시가 35건 나왔다. 심볼라이즈해서 dedup하니 32건이 한 곳에 몰렸는데, AddVrfNameRecord/AddIfNameRecord(ifvrf.c)였다. 인터페이스/VRF 이름 배열 레코드를 파싱하는데, 파일에서 읽은 numElements·elementSize를 레코드 크기 대비 검증 없이 그대로 루프·스트라이드로 써서 *ingress(uint32)를 데이터 블록 뒤로 읽는다(READ of size 4).

known-ness를 확인했다. 알려진 nfdump 버퍼오버플로 CVE(CVE-2019-1010057)는 nfx.c, nffile_inline.c, minilzo.c이고 2019년에 이미 수정됐다. ifvrf.c의 이 함수들은 어떤 CVE나 이슈, OSS-Fuzz 리포트와도 연결되지 않았다. 스택도 깔끔했다. AddVrfNameRecord (ifvrf.c:106) ← process_data (nfdump.c:755) ← main. 즉 nfdump -r <악성파일> 파일 파싱 주경로에서 바로 터지는, 최신 버전 자체 코드의 미보고 OOB read였다. 298바이트 PoC로 3/3 재현했다.

novelty는 타깃 신선도에 거의 비례한다는 걸 여기서 확실히 느꼈다. 남들이 이미 pcap을 퍼징한 도구(tcpflow) 대신, 자체 바이너리 포맷을 파싱하면서 최근 퍼징 흔적이 없는 도구(nfdump)로 갔더니 확신도 높은 후보가 바로 나왔다.

pcap 파서에서 OOB read가 반복되는 이유

세 타깃(tcpflow, softflowd)에서 나온 버그의 근본 원인이 사실상 같다. 패킷이 말하는 길이를 실제로 캡처된 길이(caplen)로 검증하지 않는다는 것이다. pcap의 각 레코드에는 원래 패킷 길이(len)와 실제 저장된 길이(caplen)가 따로 있다. 정상 캡처면 보통 같지만, 공격자가 만든 pcap은 헤더에 "이 패킷은 1500바이트야"라고 써놓고 실제로는 16바이트만 넣을 수 있다. 파서가 헤더의 길이나 프로토콜 구조체 크기를 믿고 그만큼 읽으면 libpcap이 할당한 버퍼(예: 2048B) 끝을 넘어간다. tcpflow는 TCP 페이로드 lengthcaplen으로 안 자르고 print_packet까지 넘겼고, caplen < sizeof(ether_header)일 때 부호 없는 뺄셈이 언더플로되면서 거대한 길이가 됐다. softflowd는 frametype 오프셋(ft_off + vlan_size + ft_len)이 caplen을 넘는지 안 봤다. 방어는 전부 같다. 읽기 직전에 필요한 오프셋 + 크기가 caplen 이하인지 확인하고, 아니면 패킷을 스킵하는 것.

이번에 찾은 건 전부 read 오버플로다. 버퍼 뒤를 몇 바이트 읽고 크래시가 나는 거라 프로그램이 죽으니 DoS이고, 인접 힙 메모리가 출력에 섞이면 경미한 정보 노출 가능성은 있다. 하지만 쓰기(write) primitive가 없으면 코드 실행(RCE)으로 이어지지 않는다. 리포트에 "RCE 가능"이라고 부풀려 쓰면 메인테이너 신뢰를 잃고, 심각도 과장은 보안 커뮤니티에서 바로 티가 난다. 그래서 초안에 "OOB read, DoS, RCE 아님"을 명시적으로 박아뒀다.

크래시가 났을 때 CVE 후보인지 거르려면 순서가 있다. 먼저 ASAN #0 프레임(file:line)으로 유니크한 크래시 지점만 세서 사이트별로 dedup한다(139건이 3개 사이트일 수 있다). 그다음 크래시 파일이 프로젝트 소스인지 번들된 오래된 라이브러리인지 본다. 원격이나 파일로 트리거되는지, 아니면 사용자가 직접 주는 인자로만 되는지도 확인한다(후자는 악용성이 낮다). 프로젝트 이슈 트래커와 CVE, 과거 퍼징 흔적을 검색해서 같은 사이트나 클래스가 이미 보고됐는지 본다. 마지막으로 afl-tmin으로 PoC를 줄여 근본 원인을 특정한다. 이 다섯 단계를 안 거치면 크래시 더미일 뿐 제보 가능한 버그가 아니다.

처음엔 퍼징 돌리면 CVE가 뚝딱 나오겠지 싶었는데, 실제로는 크래시를 내는 것보다 그 크래시가 가치 있는지 정직하게 깎아내리는 작업이 대부분이었다. pcapfix에서 35초 만에 크래시라고 흥분했다가 기존 이슈 #28을 발견하고 김이 샜을 때, 이게 진짜 보안 리서치의 질감이구나 싶었다.

제보 이후에는 예상보다 훨씬 큰 결과가 나왔다. nfdump에서 찾은 문제는 최종적으로 9건 모두 GitHub Security Advisory로 공개됐다. 원격 nfcapd/IPFIX 경로에서 발생하는 5건은 UAF 2건, SIGFPE 1건, CPU·디스크 고갈 2건으로 각각 CVSS 7.5를 받았다. nfdump -r 파일 파서에서 찾은 NBAR·ifvrf OOB, 구버전 v1 ident stack OOB, appendix log-flood DoS 4건은 각각 CVSS 5.5로 게재됐다.

  • 원격 nfcapd/IPFIX: GHSA-35rg-4vr7-fmr2, GHSA-h97c-j4xh-mrwh, GHSA-9q9x-c3p9-pp6r, GHSA-4x68-h7hg-9gh4, GHSA-7rq7-vj35-pw96
  • 파일 파서: GHSA-5c4g-q9hx-6x4m, GHSA-qrg4-hf97-rg7g, GHSA-jfw9-v5p4-pxw2, GHSA-7vhp-748h-jjx6

justniffer도 처음에는 번들된 libnids 문제라서 가치가 낮다고 판단했지만, 후속 분석에서 IP 조각 재조립 경로의 heap over-read를 별도로 정리해 GHSA-3gvq-jf6c-94v8로 제보했다. 이 건은 CVSS 8.2로 공개됐고 v0.6.14에서 수정됐다.

softflowd와 tcpflow는 저장소에 Private Vulnerability Reporting이 활성화돼 있지 않아 공개 GitHub Issue로 제출했다. 이 과정에서 초기 후보와 최종 제출 내용도 달라졌다. tcpflow에서 처음 본 세 사이트 중 dl_ethernet은 현재 코드에 이미 방어 로직이 있어 제외했고, 나머지 두 사이트를 같은 근본 원인인 caplen 미검증 문제로 묶어 simsong/tcpflow#277에 제출했다. softflowd는 후속 퍼징에서 MPLS 라벨 스택 깊이를 제한하지 않아 flow_compare()가 배열 밖을 읽고 flow-table 집계까지 망가뜨리는 문제를 확인해 irino/softflowd#67로 제출했다. 두 이슈 모두 공개돼 있으며, 현재 확인한 영향은 OOB read와 DoS·데이터 무결성 문제까지다. RCE는 주장하지 않았다.

처음 목표는 CVE나 보안 기여 1건이었다. 실제 결과는 published GHSA 10건과 공개 이슈 2건이 됐다. 다만 크래시가 많이 나왔다고 그대로 성과가 된 것은 아니었다. 중복을 버리고, 번들 라이브러리와 자체 코드를 구분하고, 재현되지 않는 사이트를 제외하고, 심각도를 과장하지 않는 과정이 퍼징 자체보다 오래 걸렸다. CVE ID는 아직 배정되지 않았지만, 메인테이너가 내용을 검토해 advisory로 공개하고 실제 수정까지 반영했다는 점이 더 확실한 결과로 남았다.

다음은 이제 두 가지다. 남은 CVE ID 배정 여부를 추적하는 것, 그리고 이번에 반복해서 확인한 "파일이 말하는 길이 vs 실제 캡처/버퍼 길이" 미검증 패턴을 EDR 쪽 탐지·안전 파싱 관점과 연결하는 것. malformed 입력에서 뭐가 터지는지는 결국 공격 표면 이야기라 서사가 이어진다.

참고

  • AFL++ 공식 문서 — instrumentation, -M/-S 병렬, afl-tmin
  • AddressSanitizer — ASAN_OPTIONS(detect_leaks, abort_on_error)
  • CWE-125: Out-of-bounds Read
  • Google OSS-Fuzz — 등재 프로젝트 목록(중복 회피용 기준)
  • 게재된 결과: phaag/nfdump GHSA 9건(published), onotelli/justniffer GHSA-3gvq-jf6c-94v8(published, fixed v0.6.14), irino/softflowd#67(공개 이슈), simsong/tcpflow#277(공개 이슈)

실험은 전부 본인 소유의 격리된 리눅스 환경에서, 공개된 오픈소스와 그 자체 테스트 데이터로만 수행했다. 발견한 버그는 메인테이너에게 책임 있게 제보했고, exploit이 아니라 크래시 재현용 최소 PoC만 다뤘다.

공식 자료

제보 채널을 고르는 과정은 PVR이 없을 때 취약점을 제보한 경험에 따로 적었다.

로컬 퍼징에서 공개 제보까지 연결된 도구들

AFL++가 입력을 변형해 경로를 넓히는 역할을 했다면, OSS-Fuzz는 오픈소스 프로젝트의 지속 퍼징 운영 모델을 보여줬고, GitHub Repository Security Advisory는 수정 전 비공개 협업과 CVE 요청을 연결하는 창구였다. 세 단계가 서로 다른 문제를 푼다.

AFL++ 공식 GitHub 저장소
AFL++ 공식 GitHub 저장소
Google OSS-Fuzz 공식 문서
Google OSS-Fuzz 공식 문서
GitHub Repository Security Advisory 공식 문서
GitHub Repository Security Advisory 공식 문서

크래시 수를 성과로 세기보다 고유한 root cause인지, 최신 커밋에서 재현되는지, sanitizer 스택과 최소 입력이 있는지를 먼저 확인했다. 동일 원인의 변형은 한 건으로 묶고, 관리자에게는 영향과 재현 환경을 과장 없이 전달했다.

공식 자료

관련글 더보기