인기 검색어

Research

SeedSeeker

  • -

FuzzSeedSeeker

서론

Fuzzing은 소프트웨어의 보안 취약점을 발견하기 위한 자동화된 테스트 기법 중 하나입니다. 이 방법은 임의의 데이터(또는 “Fuzz")를 소프트웨어 입력으로 제공하여 예외, 충돌, 또는 예상치 못한 행동을 유발하는지 탐색합니다. 소프트웨어 개발 및 보안 연구 분야에서 널리 사용되는 Fuzzing은 시스템의 견고성을 향상시키고, 잠재적인 보안 위험을 사전에 식별하여 해결할 수 있는 효과적인 방법으로 인정받고 있습니다.

Fuzzing 과정에서 'Seed'의 중요성은 어느정도일까요? Seed는 퍼징 과정에서 사용되는 초기 입력 값이나 데이터 셋을 의미합니다. 좋은 Seed는 Fuzzer가 더 깊이 있는 경로를 탐색하도록 하여 보다 효율적으로 취약점을 발견할 수 있게 합니다. 예를 들어, 특정 파일 형식을 처리하는 소프트웨어를 대상으로 하는 경우, 해당 파일 형식의 유효한 예시를 Seed로 사용하면 Fuzzer가 더 유의미한 입력 값을 생성하여 소프트웨어의 깊숙한 영역까지 탐색할 수 있습니다.

Seed의 품질과 다양성은 Fuzzing의 성공에 직접적인 영향을 미칩니다. 고품질의 Seed는 Fuzzer가 소프트웨어의 다양한 부분을 테스트하고, 잠재적으로 취약한 코드 경로를 발견하는 데 도움을 줍니다. 반면, 낮은 품질의 Seed는 테스트의 범위를 제한하고, 중요한 취약점을 간과할 수 있습니다. 따라서, 효과적인 Fuzzing 캠페인을 수행하기 위해서는 다양하고, 관련성 높은 Seed를 신중하게 선택하고 준비하는 것이 필수적입니다.

그렇기에, 이런 과정을 DynamoRio의 Coverage 측정 유틸 DrRun을 사용해, 파이썬 스크립트를 사용하여 전자동화 해주는 SeedSeeker을 제안합니다.

본론

그렇다면, Seed가 고품질인지, 저품질인지(Fuzzing 타겟에 적합한지)의 여부는 어떻게 판단할까요? Fuzzing은 타겟 프로그램의 최대한 깊숙히 도달할 수 있는 input값을 찾는 것을 목표로 합니다.

그렇기에, 타겟 프로그램의 최대한 깊숙히 도달할 수 있도록 하는 여러 조건문과 basic Block을 거치는 초기 input값이 고품질의 Seed가 될 수 있습니다. 어떤 input값이 어느정도 깊숙히 도달했는지의 여부는 coverage를 통해 상대적인 값으로 알 수 있고, 이는 Coverage를 측정할 수 있는 INTEL PT(INTEL), TinyInst(Google), DynamoRio등으로 타겟 프로그램의 실행된 Instrucion/basic block의 개수를 Trace함으로서 알 수 있습니다. 기본적인 아이디어는 여러 뭉치의 Seed를 직접 input으로 전달해 각 Seed마다 가지는 Coverage를 Count하여 사용자에게 어떤 Seed를 기반으로 Mutation을 진행했을 때 가장 많은 Edge와 Coverage를 얻을 수 있는지 알려주는 것이 기본 아이디어입니다.

그러나, SeedSeeker에 대한 근본적인 물음인 “어차피 seed는 많을 수록 좋은 것 아니야?”와 같은 질문에 답을 하자면 각 Seed가 서로 다른 path를 가져 서로 다른 basic block을 Trigger한다면 좋지만, 만약 Fuzzing을 타겟으로 하는 함수가 해당 seed의 파일 형식을 지원하지 않거나, 정말 1~2줄의 적은 동작만 수행한다면 해당 형식의 Seed는 아무리 Mutation을 진행시킨다고 해도 더 넓은 basic block 탐색을 할 수 없게 되고, 이는 Master Node의 불필요한 Mutation을 야기시켜 성능을 저하시키게 됩니다. 예를 들어, 실행 파일을 검사하는 백신 함수에 이미지 파일의 형식을 삽입하게 되면 처리를 하지 않고 예외처리나 return으로 빠지게 되기 때문에 낮은 Coverage를 가질 수 밖에 없게 됩니다, 그러나 많은 Fuzzer들은 제공 된 Seed를 기반으로 계속 mutation시키기 때문에 50개의 Seed 중 40개가 낮은 coverage를 가지고, 10개만 높은 Coverage를 가진다면, 50번의 Cycle중 40번의 Cycle은 거의 무가치하게 되게 됩니다.

When deploying fuzzers at an industrial scale it is imperative that seeds exhibiting redundant behavior be removed from the fuzzing queue, as they will lead to wasted cycles. 
산업 규모에 퍼저를 배포할 때는 반드시 중복 동작(낮은 Coverage)를 보이는 시드는 퍼징 대기열에서 제거해야 합니다. 퍼징 대기열에서 제거해야 합니다. 불필요한 주기를 초래할 수 있기 때문입니다.
-Seed selection for successful fuzzing 논문 중 발췌

 

그렇기에, 백신/File Parsing 부분의 harness를 작성하는 글의 대부분은 보면 아래와 같은 문구가 늘 하나씩 있습니다.

With some carefully selected seed files by observing coverage feedback, we get into the fuzzing process. ”Coverage Feedback을 관찰하여 신중하게 선택된 일부 시드 파일을 사용하여 퍼징 프로세스에 들어갑니다” - S2W windows Defender Fuzzing Article
출처 : https://medium.com/s2wblog/fuzzing-the-shield-cve-2022-24548-96f568980c0
 

Fuzzing the Shield: CVE-2022–24548

Author: Daejin Lee, Seunghoe Kim, Donguk Kim, Eugene Jang

medium.com

이렇게 타겟 함수가 어떤 함수인지 정확히 알고 Fuzzing에 대한 경험이 풍부하다면 Seed의 중요성을 익히 알고 있지만, 대부분의 사람들은 Seed에 신경쓰지 않고 Fuzzing을 진행하는 경우가 많습니다.

아래는 Seed selection for successful fuzzing라는 논문에서 발췌한 내용입니다

Remarkably, Klees et al. [37] found that “most papers treated the choice of seed casually, apparently assuming that any seed would work equally well, without providing particulars”. 놀랍게도 Klees 등[37]은 "대부분의 논문에서 시드 선택에 대한 구체적인 설명 없이 모든 시드가 똑같이 잘 작동할 것이라고 가 정하여 아무렇게나 처리했다"는 사실을 발견했습니다. -Seed selection for successful fuzzing 논문 중 발췌

출처 :https://dl.acm.org/doi/10.1145/3460319.3464795

 

위에서 언급한 Seed selection for successful fuzzing 는 현 연구에서 필요할 “Seed 선택의 중요성” 에 대해 연구한 논문입니다.

위 논문에서는 동일한 바이너리(binutils)에 대해 모든 조건을 동일시하지만, Seed만 (없음/아무거나/파일 구조만 맞는 것/높은 Coverage를 제공하는 Corpus)로 나누어 Fuzzing을 진행하고, 시간에 따른 Regions(Edge/Coverage와 동일한 개념)의 변화에 대해 그래프로 보여줍니다. 아래 그래프를 보면 알 수 있 듯, 초기에 높은 Coverage를 제공하는 Seed와 AFL++의 조합이 가장 많은 Coverage 탐색을 이끌어 낸 것을 확인할 수 있습니다. 여기서 주목해봐야 할 점이 AFLFast나 honggfuzz는 그렇게 많은 차이가 나지 않는데, 왜 AFL++만 유독 더 넓은 Coverage를 얻을 수 있었는지를 살펴봐야 합니다.

복제 실험: 레드퀸. 시드 선택의 중요성을 입증하기 위해 REDQEEN 평가의 실험을 재현해 보겠습니다. … 중략 Redqeen 대신 "CmpLog" 계측이 활성화된 AFL++[23]를 사용하여 Redqeen의 "입력-상태 대응"을 구현했습니다.

-Seed selection for successful fuzzing 논문 중 발췌

 

위에서 볼 수 있 듯, AFLFast와 hongFuzz는 그냥 단순히 bitflip이나 무작위 바이트 flip을 통한 Fuzzing을 진행합니다. 그러나, AFL++는 Redqueen을 사용하여 smart-mutation을 지원하는데, redqueen의 원리를 살펴보면 CMP 구문의 인자를 가져와, mutation시 CMP Instuctor의 인자를 기반으로 mutation을 진행해 CMP 구문을 통과할 수 있게하는데, 이미 완성 된 구조(corpus)에서 특정 바이트만 redqueen으로 Flip할 수 있으니 훨씬 더 높은 Coverage를 갖게 됩니다. 일반적인 Fuzzing에서도 Seed의 중요성은 크지만, RedQueen과 같은 CMPLOG를 기반으로 한 Mutation을 진행한다면, 이 때 Seed의 중요성은 더욱 크게 두드러집니다. 현재 Windows용으로 만들어진 오래 된 winAFL을 제외하면, Snapshot 기반 Fuzzer “kAFL”도 RedQueen을 사용하고 있고, Jackalope의 경우에도 CMP 부분에서의 Mutation을 추가할 수 있기 때문에, Seed의 중요성은 의 그래프와, Mozila 브라우저 개발자의 인용으로 Seed의 중요성을 확인할 수 있습니다.

 

Mutation-based strategies are typically superior to others if the original samples [i.e., seeds] are of good quality because the originals carry a lot of semantics that the fuzzer does not have to know about or implement. However, success here really stands and falls with the quality of the samples. If the originals don’t cover certain parts of the implementation, then the fuzzer will also have to do more work to get there.

원본 샘플[즉, 시드]의 품질이 좋은 경우 일반적으로 돌연변이 기반 전략이 다른 전략보다 우수한데, 원본에는 퍼저가 알 필요가 없는 많은 의미가 담겨 있기 때문입니다. 퍼저가 알거나 구현할 필요가 없기 때문입니다. 그러나 여기서 성공 여부는 실제로 샘플의 샘플의 품질에 달려 있습니다. 원본이 구현의 특정 부분을 다루지 않는다면 퍼저는 더 많은 작업을 수행해야 합니다.

-Seed selection for successful fuzzing 논문 중 발췌 (Mozila 브라우저 개발자)

개발 과정

INTEL PT vs Dynamorio vs TinyInst

위 3가지는 전부 프로그램의 실행 Flow를 추적하여 얼마나 많은 basic Block을 거쳤는지 알 수 있게 해준다. INTEL PT의 경우에는 libipt / WinIPT 같은 툴을 사용하여 따로 Trace Log를 Decode해야하고, 다른 두 Instrument Engine은 프로그램을 디버거를 붙여 RIP를 추적하 듯 실행 흐름을 추적하기 때문에 Decode는 필요없지만, RIP를 추적하기 위해 예외나 Interrupt를 만들어야하기 때문에 속도 오버헤드가 크다는 단점이 있다. 아래는 각 Instrument 측정 방법의 장단점과 선택 계기에 대해 설명한다.

INTEL PT

Intel Processor Trace (Intel PT)는 Intel의 최신 프로세서에서 지원하는 성능 분석 및 디버깅 기능으로, 실행 중인 프로그램의 흐름을 추적하여 그 정보를 기록하는 능력을 제공하는데, 위 기능을 활용하면 DynamoRio나 TinyInst와 달리 거의 적은(1%<)의 오버헤드로 현재 프로세스의 흐름을 추적 가능해 kAFL을 사용할 때도 INTEL PT를 사용해본 경험이 있어 INTEL PT로 Coverage를 측정하여 Seed를 전처리 하는 방식을 고민했지만 아래와 같은 장단점이 있었는데 아래의 인텔 CPU만 지원한다는 점에서 범용성이 떨어져 다른 방식을 사용하기로 결정했다.

장점

  • 오버헤드가 정말 적다
  • 예외 처리나 이상한 동작이 거의 없다. (guest에서 그대로 진행되기 때문)

단점

  • decode를 잘못 구현하면 오히려 느려진다
  • 리눅스 기반 decoder가 대부분
  • 인텔 CPU만 지원한다 ← 제일 큰 탈락 사유

TinyInst

Google Project Zero에서 개발한 Instrument 엔진으로 Dynamorio보다 더 가벼우나, 정말 치명적인 단점이 있다. 작동 방식은 일부로 실행 페이지에 NX비트를 삽입함으로 서 NONEXECUTE 페이지에서 instruction을 실행하려고 시도해 VEH와 같은 예외 처리기로 실행 흐름을 넘기고, 이를 기록하여 진행하는 방식. 단점과 장점은 아래에서 설명할 예정. 또, 성능 오버헤드가 측정시 20%, 측정 안할 시 15%로 무시 못 할 오버헤드를 보여준다. 그래도 Dynamorio보다 가벼워서 TinyInst로 진행하려 했으나 아래와 같은 장단점 때문에 개발 도중, Dynamorio로 이전하게 되었다

장점

  • Dynamorio 보다 가벼운 Framework
  • 타 엔진 대비 최신
  • Basic Block 추적을 위한 타 Framework 요구 X

단점

  • DEP/NX가 무조건 켜져있어야함
  • 코드 실행 중 수정(Themida/VMP 패킹)이 있으면 실행 시 오류 발생 ← 최고의 전환 사유
  • 스택 반환 주소 해제가 있으면 실행 오류 발생(SEH 핸들러에서 스택해제 등) ← 2번째 전환 사유
  • 코드 패치 이슈로 인한 CRC오류 발생 (아닐 수도 있으나 TinyInst에서만 CRC에서 오류가 Trigger)

위 두 가지의 가장 큰 전환 사유를 해결하기 위해 github에 issue를 남겨서 해결 방안을 모색해봤는데(예외 발생 시 return주소를 강제로 계측 주소로 설정하도록) 그럼에도 문제가 해결되지 않았음. 결국 어쩔 수 없이 원래 TinyInst 버전을 갈아엎고 Dynamorio로 오게 됨.

https://github.com/googleprojectzero/Jackalope/issues/55

 

"process dead" issue that is not occured by WinAFL or other Fuzzer · Issue #55 · googleprojectzero/Jackalope

Hello, I'm trying to use Jackalope, and I have a 'process death' issue that doesn't happen with winAFL or kAFL. The fuzzer should be executed on the assumption that it is repeated and executed with...

github.com

Dynamorio

DynamoRIO는 프로그램 분석 및 이해, 프로파일링, 계측, 최적화, 번역 등 다양한 용도로 동적 도구를 구축하기 위한 인터페이스를 포함 한 툴킷으로 많은 동적 분석 도구를 제공함. 여기서 Code Coverage를 얻기 위해 drrun을 사용. 아래와 같은 장단점으로 결국 Dynamorio로 SeedSeeker을 제작

장점

  • 예외처리나 코드 실행이 Guest와 비슷한 수준으로 진행
  • Funky한 Coverage가 거의 발생하지 않음(동일 Seed, 다른 Coverage를 Funky하다고 표현함.)
  • TinyInst에서 처리하지 못하는 SEH나 VEH등의 핸들러를 디버기에게 넘겨주고 진행함
  • 호환성이 높음
  • 업데이트가 잘 되고 유지보수가 좋음

단점

  • Instrument Trace용으로만 개발된 것이 아니라 타 유틸대비 무거움
  • 속도/ 오버헤드가 큼

Dynamorio’s Drcov

DynamoRIO 도구 중 drcov 는 어떤 기본 블록이 실행되었는지에 대한 정보를 수집합니다.  프로세스별로 별도의 로그 파일에 결과를 기록합니다.

Drcov에서 사용 가능 한 인수는 밑의 4개가 있습니다.

  • dump_text:
  • 로그 파일을 텍스트 형식으로 덤프합니다.
  • dump_binary:
  • 기본적으로 켜져 있으며 로그 파일을 바이너리 형식으로 덤프합니다.
  • [no_]nudge_kills: Windows에만 해당됩니다. 기본적으로 켜져 있습니다. Nudge를 사용하여 종료 이벤트가 호출되도록 프로세스 종료를 알립니다.
  • logdir
  • dir: 기본적으로 "."인 로그 디렉터리를 설정합니다.

여기서 우리가 눈 여겨 볼 만한 두 가지 인수는 dump_text와 logdir가 있습니다

일반적으로 drcov를 사용하여 coverage를 얻는 방법은

..~\\drrun.exe -t drcov -- 프로그램 위치 및 인수

위 명령줄을 통하여 coverage 파일을 얻을 수 있는데, 이렇게 얻은 Coverage File은 바이너리로 덤프되어 Parsing시 불편함을 초래함으로

~\\drrun.exe -t drcov -logdir 원하는 path  -dump_text -- 프로그램 위치 및 인수

위 명령줄을 통하여 아래와 같은 형식의 바이너리 Coverage를 덤프할 수 있었다.

DRCOV VERSION: 3
DRCOV FLAVOR: drcov
Module Table: version 5, count 40
Columns: id, containing_id, start, end, entry, offset, preferred_base, checksum, timestamp, path
  0,   0, 0x000000006b110000, 0x000000006b1ae000, 0x000000006b12aa70, 0000000000000000, 0x000000006b110000, 0x0008f9ca, 0x64534b3b,  C:\\ProgramData\\Symantec\\Symantec Endpoint Protection\\14.3.10148.8000.105\\Data\\Sysfer\\x64\\sysfer.dll
  1,   1, 0x0000000071000000, 0x00000000711c9000, 0x00000000710a7c30, 0000000000000000, 0x0000000071000000, 0x0019d6af, 0x64de8a6d,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/lib64\\release\\dynamorio.dll
  2,   2, 0x00007ff5d85e0000, 0x00007ff5d85e6000, 0x00007ff5d85e0000, 0000000000000000, 0x0000000072000000, 0x00000000, 0x64de8af4,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0\\tools\\lib64\\release\\drcov.dll
  3,   3, 0x00007ff5d85f0000, 0x00007ff5d85f9000, 0x00007ff5d85f0000, 0000000000000000, 0x0000000073800000, 0x00000000, 0x64de8af3,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/ext\\lib64\\release/drcovlib.dll
  4,   4, 0x00007ff5d8600000, 0x00007ff5d860f000, 0x00007ff5d8600000, 0000000000000000, 0x0000000077000000, 0x00000000, 0x64de8add,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/ext\\lib64\\release/drx.dll
  5,   5, 0x00007ff5d8610000, 0x00007ff5d861c000, 0x00007ff5d8610000, 0000000000000000, 0x0000000078000000, 0x00000000, 0x64de8adb,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/ext\\lib64\\release/drreg.dll
.
.
.
중략
module[ 12]: 0x000000000005aa30,  19
module[ 12]: 0x000000000005aa43,  21
module[ 12]: 0x00000000000a3040,   6
.
.
.
중략

그렇다면 현재 프로그램이 실행한 Basic Block을 Parsing하기 위해 이 Dump 된 Coverage의 구성 요소를 알아야한다. 큰 부분들로 나누어서 봐보자.

DRCOV VERSION: 3
DRCOV FLAVOR: drcov
Module Table: version 5, count 40

위 부분은 DRCOV의 버전과 사용 툴킷, 타깃 프로세스의 Module Count를 나타낸다.

딱히 Coverage나 Fuzzing 때 Module Count는 큰 의미가 없으니 제쳐두자.

Columns: id, containing_id, start, end, entry, offset, preferred_base, checksum, timestamp, path
  0,   0, 0x000000006b110000, 0x000000006b1ae000, 0x000000006b12aa70, 0000000000000000, 0x000000006b110000, 0x0008f9ca, 0x64534b3b,  C:\\ProgramData\\Symantec\\Symantec Endpoint Protection\\14.3.10148.8000.105\\Data\\Sysfer\\x64\\sysfer.dll
  1,   1, 0x0000000071000000, 0x00000000711c9000, 0x00000000710a7c30, 0000000000000000, 0x0000000071000000, 0x0019d6af, 0x64de8a6d,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/lib64\\release\\dynamorio.dll
  2,   2, 0x00007ff5d85e0000, 0x00007ff5d85e6000, 0x00007ff5d85e0000, 0000000000000000, 0x0000000072000000, 0x00000000, 0x64de8af4,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0\\tools\\lib64\\release\\drcov.dll
  3,   3, 0x00007ff5d85f0000, 0x00007ff5d85f9000, 0x00007ff5d85f0000, 0000000000000000, 0x0000000073800000, 0x00000000, 0x64de8af3,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/ext\\lib64\\release/drcovlib.dll
  4,   4, 0x00007ff5d8600000, 0x00007ff5d860f000, 0x00007ff5d8600000, 0000000000000000, 0x0000000077000000, 0x00000000, 0x64de8add,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/ext\\lib64\\release/drx.dll
  5,   5, 0x00007ff5d8610000, 0x00007ff5d861c000, 0x00007ff5d8610000, 0000000000000000, 0x0000000078000000, 0x00000000, 0x64de8adb,  C:\\Users\\hyjun\\Downloads\\DynamoRIO-Windows-10.0.0/ext\\lib64\\release/drreg.dll

아래는 현재 프로세스 안에서 동작하는 모듈들의 정보로 서, 각각 Columns를 살펴 보면 알 수 있 듯, 각 모듈에 id를 붙여 인덱스 형태로 동작하게 된다. 대부분 Fuzzing시 특정 모듈에서만 Coverage를 측정하고 그 Coverage를 기반으로 Mutation을 진행하기 때문에 이 부분을 눈 여겨 봐야한다. 마지막 필드에 모듈 이름이 기재되어 있으므로, 파일 Parsing 시 원하는 모듈의 이름을 입력 받고, 그 모듈의 이름을 가진 줄을 출력한 후 해당 줄에 해당하는 Columns ID를 추출하면 될 것 같다라는 가정을 할 수 있었다.

BB Table: 132676 bbs
module id, start, size:
module[ 12]: 0x000000000005aa30,  19
module[ 12]: 0x000000000005aa43,  21
module[ 12]: 0x00000000000a3040,   6

다음으로는 모듈과 관련 된 정보가 기재되어 있는 테이블이다. 위 테이블에서는 모듈의 Index와 Basic Block의 Start 주소를 RVA 형식으로 표현, 또 Basic Block의 크기(SIZE)를 제공하여 위 모듈 정보에서 원하는 타깃 모듈의 인덱스를 가져와 타깃 프로세스에서 특정 모듈의 어느 주소를 실행시켰는지 파싱할 수 있다.

위의 정보들을 기반으로 Python에서 위 과정을 전 자동화하기 위해 Drcov로 타깃 프로세스에서 Seed 폴더에 있는 모든 파일의 Coverage 파일을 Text 형식으로 Dump하는 것부터 시작하겠다.

def find_files_with_string(directory, search_string):
    result = []

    for root, _, files in os.walk(directory):
        for filename in files:
            if search_string in filename:
                result.append(os.path.join(root, filename))

    return result

def rename_new_files(file_path, new_file_paths):        
        try:
            os.rename(file_path, new_file_paths)
        except Exception as e:
            print(f"error: {str(e)}")

def run_dynamic_rio(run_process_command,filename):
    dynamic_rio_path = "C:\\\\Users\\\\hyjun\\\\Downloads\\\\DynamoRIO-Windows-10.0.0\\\\bin64\\\\drrun.exe -t drcov -logdir C:\\\\CoverageDump -dump_text -- " + run_process_command.replace("@@",filename)
    try:
        subprocess.run(dynamic_rio_path, shell=True)
    except FileNotFoundError:
        print(f"not found '{dynamic_rio_path}'")
    except Exception as e:
        print(f"error: {str(e)}")

def make_cov_file(coverageDumpPath, coverageFilePath):
    make_temp_covfold(coverageDumpPath)

    search_string = 'drcov'

    if os.path.exists(coverageFilePath):
        for root, dirs, files in os.walk(coverageFilePath):
            for file in files:
                file_path = os.path.join(root, file)
                
                run_process_command = "C:\\\\Users\\\\hyjun\\\\Downloads\\\\DefenderFuzzing-ver3\\\\x64\\\\Release\\\\HarnessForDef.exe -f @@"
								# 타겟 프로세스 실행 명령줄 @@는 FilePath(seed위치로 대체된다)
                filename = file_path.replace(coverageFilePath+"\\\\","")
                run_dynamic_rio(run_process_command,file_path)

                new_path = find_files_with_string(coverageDumpPath, search_string)
                new_filename = new_path[0].replace(coverageDumpPath+"\\\\","")
                parts = filename.split(".")
                #if len(parts) > 1:
                #    parts[-1] = "cov"
                #    filenamewithcov = ".".join(parts)
                #else:
                filenamewithcov = filename + ".cov"
                rename_new_files(new_path[0],new_path[0].replace(new_filename,filenamewithcov))
    else:
        print(f"'{coverageFilePath}' directory Not Found")

위 코드는 drcov를 사용하여 coverageFilePath내의 파일을 순회하며 coverageDumpPath에 Coverage File을 .cov 형태로 생성하는 코드이다. 위 코드로 생성된 cov파일은 seed이름.확장자.cov의 형태를 가지며, text형태로 Dump 되었기 때문에 분석에 용이하다.

이렇게 생성된 Cov 파일을 파싱하기 위해 위에서 분석한대로 module의 Index를 가져오고, Index에 해당하는 line의 개수를 셈으로 서 Coverage를 상대적으로 측정할 수 있다. 다음은 이 모든 과정을 병합한 코드이다.

import subprocess
import os
import shutil
     
previous_files = []

def find_lines_with_string(filename, search_string):
    try:
        with open(filename, 'r') as file:
            lines = file.readlines()
            for i, line in enumerate(lines):
                if search_string in line:
                    return line.strip()
    except FileNotFoundError:
        print(f"File '{filename}' not found.")
    except Exception as e:
        print(f"An error occurred: {str(e)}")

def count_lines_with_string(filename, target_string):
    count = 0

    try:
        with open(filename, 'r', encoding='utf-8') as file:
            for line in file:
                if target_string in line:
                    count += 1
    except FileNotFoundError:
        print(f"File '{filename}'not exist")
    except Exception as e:
        print(f"error: {str(e)}")

    return count

def make_temp_covfold(directory_path):
    try:
        if os.path.exists(directory_path):
            shutil.rmtree(directory_path)
            print(f"'{directory_path}' removed")
        
        os.makedirs(directory_path)
        print(f"'{directory_path}' recreated")
    except Exception as e:
        print(f"error: {str(e)}")

def run_dynamic_rio(run_process_command,filename):
    dynamic_rio_path = "C:\\\\Users\\\\hyjun\\\\Downloads\\\\DynamoRIO-Windows-10.0.0\\\\bin64\\\\drrun.exe -t drcov -logdir C:\\\\CoverageDump -dump_text -- " + run_process_command.replace("@@",filename)
    try:
        subprocess.run(dynamic_rio_path, shell=True)
    except FileNotFoundError:
        print(f"not found '{dynamic_rio_path}'")
    except Exception as e:
        print(f"error: {str(e)}")

def find_files_with_string(directory, search_string):
    result = []

    for root, _, files in os.walk(directory):
        for filename in files:
            if search_string in filename:
                result.append(os.path.join(root, filename))

    return result

def rename_new_files(file_path, new_file_paths):        
        try:
            os.rename(file_path, new_file_paths)
        except Exception as e:
            print(f"error: {str(e)}")

def make_cov_file(coverageDumpPath, coverageFilePath):
    make_temp_covfold(coverageDumpPath)

    search_string = 'drcov'

    if os.path.exists(coverageFilePath):
        for root, dirs, files in os.walk(coverageFilePath):
            for file in files:
                file_path = os.path.join(root, file)
                
                run_process_command = "C:\\\\Users\\\\hyjun\\\\Downloads\\\\DefenderFuzzing-ver3\\\\x64\\\\Release\\\\HarnessForDef.exe -f @@"
                filename = file_path.replace(coverageFilePath+"\\\\","")
                run_dynamic_rio(run_process_command,file_path)

                new_path = find_files_with_string(coverageDumpPath, search_string)
                new_filename = new_path[0].replace(coverageDumpPath+"\\\\","")
                parts = filename.split(".")
                #if len(parts) > 1:
                #    parts[-1] = "cov"
                #    filenamewithcov = ".".join(parts)
                #else:
                filenamewithcov = filename + ".cov"
                rename_new_files(new_path[0],new_path[0].replace(new_filename,filenamewithcov))
    else:
        print(f"'{coverageFilePath}' directory Not Found")

def Check_cov_per_file(modulename,covfilepath):
    if os.path.exists(covfilepath):
        results = {}
        for root, dirs, files in os.walk(covfilepath):
            for file in files:
                file_path = os.path.join(root, file)

                search_string = modulename
                finded_one = find_lines_with_string(file_path, search_string)   
                if(finded_one != None):
                    parts = finded_one.split(',')
                    first_number = parts[0].strip()
                    firstnum = int(first_number)
                    if firstnum < 10:
                        search_string1 = "module[  x]"
                        modified_string = search_string1.replace("[  x]", f"[  {first_number}]")
                    else:
                        search_string1 = "module[ x]"
                        modified_string = search_string1.replace("[ x]", f"[ {first_number}]")
                    finded_module = count_lines_with_string(file_path,modified_string)
                    results[file_path] = finded_module
                else:
                    results[file_path] = 0
        print("")
        print("---------------------------------------------------")
        sorted_results = sorted(results.items(), key=lambda x: x[1], reverse=True)
        for file_path, finded_module in sorted_results:
            print(file_path + "'s Count : " + str(finded_module))
        print("---------------------------------------------------")

make_cov_file("C:\\\\CoverageDump","C:\\\\CoverageValidate")
Check_cov_per_file("mpengine.dll", "C:\\\\CoverageDump")

위 코드를 살펴보면, Windows Defender를 Fuzzing하기 위한 harness에 Seed들을 여러개 삽입하고 그 Coverage Feedback을 얻고, Count를 출력하는 코드로 서, 출력은 다음과 같다.

---------------------------------------------------
C:\\CoverageDump\\HarnessForDef_upx.exe.cov's Count : 113030
C:\\CoverageDump\\aaa.vmp.exe.cov's Count : 108887
C:\\CoverageDump\\HarnessForDef.exe.cov's Count : 106135
C:\\CoverageDump\\Hello.MacroEnabled.docm.cov's Count : 97230
C:\\CoverageDump\\small_archive.bz2.cov's Count : 92498
C:\\CoverageDump\\small_archive.xz.cov's Count : 91827
C:\\CoverageDump\\small_archive.zip.cov's Count : 91721
C:\\CoverageDump\\small_archive.arj.cov's Count : 91593
C:\\CoverageDump\\small_archive.rar.cov's Count : 91321
C:\\CoverageDump\\small_archive.lha.cov's Count : 91080
C:\\CoverageDump\\small_archive.Z.cov's Count : 90974
C:\\CoverageDump\\small_archive.gz.cov's Count : 90923
C:\\CoverageDump\\small_archive.cab.cov's Count : 90873
C:\\CoverageDump\\small_archive.lz.cov's Count : 90767
C:\\CoverageDump\\small_archive.lzma.cov's Count : 90648
C:\\CoverageDump\\small_archive.zoo.cov's Count : 90616
C:\\CoverageDump\\small_archive.tar.cov's Count : 90577
C:\\CoverageDump\\small_archive.cpio.cov's Count : 90399
C:\\CoverageDump\\small_archive.a.cov's Count : 89540
C:\\CoverageDump\\Hello.Macro.Word97-2004.doc.cov's Count : 89147
C:\\CoverageDump\\Hello.docx.cov's Count : 89139
C:\\CoverageDump\\small_archive.rz.cov's Count : 88731
C:\\CoverageDump\\HelloWorld.7z.cov's Count : 88700
C:\\CoverageDump\\HelloWorld.rar.cov's Count : 88096
C:\\CoverageDump\\small_archive.lrz.cov's Count : 87781
.
.
.
.
.
.
C:\\CoverageDump\\Test Email from Google.cov's Count : 140
---------------------------------------------------

위의 결과에서 볼 수 있 듯, 최악의 경우(이메일 형식의 파일)은 Coverage가 140에 달하는 결과를 보여준다. (타게팅한 함수에서 Email 자체를 파싱하지 못하고 return) 이런 Seed 파일을 아무것도 모르고 사용했다가는, 초기 init값으로 계속 사용되며 Mutation Cycle, Fuzzing Cycle을 낭비하게 된다.

지금까지는 각기 다른 확장자의 파일들의 Coverage를 측정했었다. 그렇다면 이제 Seed의 ‘중복 방지’를 해야할 시간이다. 위 논문에서 언급했던 중복 방지의 중요성을 기억하는가?

When deploying fuzzers at an industrial scale it is imperative that seeds exhibiting redundant behavior be removed from the fuzzing queue, as they will lead to wasted cycles.
산업 규모에 퍼저를 배포할 때는 반드시 중복 동작(낮은 Coverage)를 보이는 시드는 퍼징 대기열에서 제거해야 합니다. 퍼징 대기열에서 제거해야 합니다. 불필요한 주기를 초래할 수 있기 때문입니다.

-Seed selection for successful fuzzing 논문 중 발췌

중복 동작이라 함은, 동일 한 Path를 가지는 Seed를 의미할 것이다. 동일 한 Path를 가지는 Seed를 제거하기 위해서는 일단 Cov파일 당 Path를 전부 덤프하여 Cov 파일간의 동일성을 따져보면 될텐데, Path를 덤프하는 것은 큰 문제가 되지 않지만(오버헤드 문제가 있기는하다), 어떻게 동일성을 따질지가 문제가 될 수 있다. 여기서는 A B C파일이 있다면 A B, A C, B C 이런 식으로 파일간의 동일성을 따지는 방식을 사용하여 코드를 작성했다. 최종 코드는 아래와 같다.

import subprocess
import os
import shutil
import re
previous_files = []
cov_dict_with_addr = {}

def print_common_counts_sorted(common_counts):
    # 결과를 count 기준으로 내림차순 정렬
    sorted_pairs = sorted(common_counts.items(), key=lambda x: x[1], reverse=True)
    # 결과 문자열 리스트 생성
    result_str_list = [f"'{pair}': {count}" for pair, count in sorted_pairs]
    return "\\n".join(result_str_list)

def find_lines_with_string(filename, search_string):
    try:
        with open(filename, 'r') as file:
            lines = file.readlines()
            for i, line in enumerate(lines):
                if search_string in line:
                    return line.strip()
    except FileNotFoundError:
        print(f"File '{filename}' not found.")
    except Exception as e:
        print(f"An error occurred: {str(e)}")

def count_common_addresses_multiple(modules_data):
    module_names = list(modules_data.keys())  # 모듈 이름 목록
    common_counts = {}  # 결과를 저장할 딕셔너리
    
    # 모든 모듈 조합에 대해 비교
    for i in range(len(module_names)):
        for j in range(i + 1, len(module_names)):
            # 두 모듈 간의 동일한 주소 개수 계산
            common_addresses = set(modules_data[module_names[i]]) & set(modules_data[module_names[j]])
            # 결과 저장
            common_counts[f"{module_names[i]}  -  {module_names[j]}"] = len(common_addresses)
            
    return common_counts

def count_lines_with_string(filename, target_string):
    count = 0

    try:
        with open(filename, 'r', encoding='utf-8') as file:
            for line in file:
                if target_string in line:
                    count += 1
                    if filename not in cov_dict_with_addr:
                        cov_dict_with_addr[filename] = [re.search(r"0x[0-9a-fA-F]+", line).group()]
                    else:
                        cov_dict_with_addr[filename].append(re.search(r"0x[0-9a-fA-F]+", line).group())
    except FileNotFoundError:
        print(f"File '{filename}'not exist")
    except Exception as e:
        print(f"error: {str(e)}")

    return count

def make_temp_covfold(directory_path):
    try:
        if os.path.exists(directory_path):
            shutil.rmtree(directory_path)
            print(f"'{directory_path}' removed")
        
        os.makedirs(directory_path)
        print(f"'{directory_path}' recreated")
    except Exception as e:
        print(f"error: {str(e)}")

def run_dynamic_rio(run_process_command,filename):
    dynamic_rio_path = "C:\\\\Users\\\\hyjun\\\\Downloads\\\\DynamoRIO-Windows-10.0.0\\\\bin64\\\\drrun.exe -t drcov -logdir C:\\\\CoverageDump -dump_text -- " + run_process_command.replace("@@",filename)
    try:
        subprocess.run(dynamic_rio_path, shell=True)
    except FileNotFoundError:
        print(f"not found '{dynamic_rio_path}'")
    except Exception as e:
        print(f"error: {str(e)}")

def find_files_with_string(directory, search_string):
    result = []

    for root, _, files in os.walk(directory):
        for filename in files:
            if search_string in filename:
                result.append(os.path.join(root, filename))

    return result

def rename_new_files(file_path, new_file_paths):        
        try:
            os.rename(file_path, new_file_paths)
        except Exception as e:
            print(f"error: {str(e)}")

def make_cov_file(coverageDumpPath, coverageFilePath):
    make_temp_covfold(coverageDumpPath)

    search_string = 'drcov'

    if os.path.exists(coverageFilePath):
        for root, dirs, files in os.walk(coverageFilePath):
            for file in files:
                file_path = os.path.join(root, file)
                
                run_process_command = "C:\\\\Users\\\\hyjun\\\\Downloads\\\\DefenderFuzzing-ver3\\\\x64\\\\Release\\\\HarnessForDef.exe -f @@"
                filename = file_path.replace(coverageFilePath+"\\\\","")
                run_dynamic_rio(run_process_command,file_path)

                new_path = find_files_with_string(coverageDumpPath, search_string)
                new_filename = new_path[0].replace(coverageDumpPath+"\\\\","")
                parts = filename.split(".")
                #if len(parts) > 1:
                #    parts[-1] = "cov"
                #    filenamewithcov = ".".join(parts)
                #else:
                filenamewithcov = filename + ".cov"
                rename_new_files(new_path[0],new_path[0].replace(new_filename,filenamewithcov))
    else:
        print(f"'{coverageFilePath}' directory Not Found")

def Check_cov_per_file(modulename,covfilepath):
    if os.path.exists(covfilepath):
        results = {}
        for root, dirs, files in os.walk(covfilepath):
            for file in files:
                file_path = os.path.join(root, file)

                search_string = modulename
                finded_one = find_lines_with_string(file_path, search_string)   
                if(finded_one != None):
                    parts = finded_one.split(',')
                    first_number = parts[0].strip()
                    firstnum = int(first_number)
                    if firstnum < 10:
                        search_string1 = "module[  x]"
                        modified_string = search_string1.replace("[  x]", f"[  {first_number}]")
                    else:
                        search_string1 = "module[ x]"
                        modified_string = search_string1.replace("[ x]", f"[ {first_number}]")
                    finded_module = count_lines_with_string(file_path,modified_string)
                    results[file_path] = finded_module
                else:
                    results[file_path] = 0
        print("")
        print("---------------------------------------------------")
        sorted_results = sorted(results.items(), key=lambda x: x[1], reverse=True)
        for file_path, finded_module in sorted_results:
            print(file_path + "'s Count : " + str(finded_module))
        print("---------------------------------------------------")

make_cov_file("C:\\\\CoverageDump","C:\\\\CoverageValidate")
Check_cov_per_file("mpengine.dll", "C:\\\\CoverageDump")
common_addresses_counts = count_common_addresses_multiple(cov_dict_with_addr)
formatted_output = print_common_counts_sorted(common_addresses_counts)
print(formatted_output)

특히나 이 동일성과 coverage 측정 기능은 타겟 함수가 “정확히” 어떻게 처리되는지 모르는 경우 용이하다.

단편적으로 확장자가 동일하면 처리 루틴이 같다고 생각하기 때문에 아래와 같은 결과를 추측할 수 있다. 점선으로 구분 된 곳이 위가 순수 Coverage Count, 두 번째가 파일 간 유사 Path의 개수이다. 여기서는 dll이 105000을 넘는 엄청 큰 Coverage를 가지고 있어 3개의 dll을 전부 Seed로 제공할 수 있지만, 정작 겹치는 Coverage Count를 보면 appverifUI.dll과 vfCompat.dll 간의 유사 path는 83477으로, 두 seed를 사용할 경우 추가적으로 얻을 수 있는 path는 타 seed 대비 15000도 안된다는 것이다. 이런 결과를 미루어보아 확장자가 같으면 coverage가 비슷하구나~ 라고 판단할 수 있지만 그렇지 않다.

C:\\CoverageDump\\vfcompat.dll.cov's Count : 109572
C:\\CoverageDump\\appverifUI.dll.cov's Count : 107682
C:\\CoverageDump\\hello-world-x64.dll.cov's Count : 104883
---------------------------------------------------
'C:\\CoverageDump\\appverifUI.dll.cov  -  C:\\CoverageDump\\vfcompat.dll.cov': 83477
'C:\\CoverageDump\\appverifUI.dll.cov  -  C:\\CoverageDump\\hello-world-x64.dll.cov': 81313
'C:\\CoverageDump\\hello-world-x64.dll.cov  -  C:\\CoverageDump\\vfcompat.dll.cov': 70102

그 예로 아래를 참고하면 전부 동일한 zip 파일 형식을 가지고 있는 경우에도 Coverage차이가 1.8배 이상 난다.(심지어 유사 path는 60000개 정도밖에 안되기 때문에 80000개의 path가 새로운 path이다.) 왜 이런지 분석해봤는데, Defender의 경우 Zip 파일을 헤더만 분석하는 것이 아닌, 직접 Unpacking을 하고 Unpacking한 바이너리를 직접 또 다시 재귀적으로 분석한다. 위 과정에서 path traversial이나 unpacking 과정을 타게팅으로 한 취약점을 추가적으로 식별할 수도 있다.

---------------------------------------------------
C:\\CoverageDump\\DNSSetup.zip.cov's Count : 140911
C:\\CoverageDump\\hello-world-dll-1.0.0.zip.cov's Count : 98462
C:\\CoverageDump\\Black_Wane.zip.cov's Count : 97439
---------------------------------------------------
'C:\\CoverageDump\\Black_Wane.zip.cov  -  C:\\CoverageDump\\DNSSetup.zip.cov': 65331
'C:\\CoverageDump\\hello-world-dll-1.0.0.zip.cov  -  C:\\CoverageDump\\SecretDNSSetup.zip.cov': 65168
'C:\\CoverageDump\\Black_Wane.zip.cov  -  C:\\CoverageDump\\hello-world-dll-1.0.0.zip.cov': 64996

실제 적용 결과 및 사례

NDA (Crash 제보 중)

밝힐 수는 없지만, 특정 라이브러리에 도전하면서 조금 겁을 먹었던 부분이 위 라이브러리는 자체적으로 퍼징 테스트를 진행한다고 홍보를 하고 있었기 때문인데, 문득 이 연구를 테스트 해보기 위해서 개발한 FuzzSeedSeeker 프로그램을 사용하여 가지치기를 한 Seed와 그렇지 않은 Fuzzing test를 진행하면 SeedSeeker의 성능을 입증할 수 있을 것이라 생각해, 2일을 퍼징한 결과, SeedSeeker을 사용한 Fuzzer에서 취약점이 나왔다. 크롤링한 Seed는 ClamAV Seed나 웹의 아카이브 검색으로 다운받은 여러 파일을 사용하였다.

사진 1. SeedSeeker로 선별 된 Seed사용                                사진 2. 크롤링한 모든 Seed 사용

 

위의 사진에서 볼 수 있 듯이, 오른쪽의 모든 Seed를 사용한 것은 6000만번의 Exec을 통해 2661개의 Unique Sample을 생성하고, 0개의 Crash를 생성한 반면, 왼쪽의 SeedSeeker로 엄선 된 Seed를 사용한 Fuzzer는 1900만번의 상대적으로 적은 Exec(3분의 1)만을 통해 2400개의 달하는 Unique Sample과 2개의 Crash를 찾아낼 수 있었다. 특히 오른쪽의 모든 Seed를 사용한 Fuzzer는 2일정도가 지나자 [Failed] Allocation Buffer For input Sample이라는 오류와 함께 종료되었는데, SeedSeeker로 선별 된 Seed는 2일간 아무 문제 없이 현재 글을 쓰는 시점에서 35개의 crash(unique는 13개)를 찾아냈다.

 

Windows Defender - mpengine.dll - kAFL with RedQueen

다음은 Seed가 더욱 중요하다고 알려진 RedQueen을 통한 Fuzzing에서의 Seed의 중요성을 알아보고자 한다.

사진 1. SeedSeeker로 엄선 된 Seed로 진행한 결과.             사진 2. 무더기 Seed 파일 뭉치를 사용한 결과.

 

위 사진을 참고하면, Redq의 숫자를 통해 Redq이 감지한 CMP구문의 인자들의 개수를 볼 수 있다. 왼쪽을 보면 약 5000개, 오른쪽은 3700개로 1300개 이상의 CMP 구문을 더 감지한 것을 확인할 수 있다. 더 신기한 것은 EXEC의 개수가 무더기 Seed를 사용한 쪽이 2배나 더 높은(1.2million vs 600k)데에도 불구하고, 더 높은 Regular path의 개수와 Edge의 개수, Redq의 개수를 얻을 수 있었다는 것이다. 또 오른쪽은 1개의 Crash를 찾은 반면, 왼쪽은 7개의 Crash를 찾을 수 있었다. 물론 이 중 Crash가 대부분 SEH 핸들러에 의해 처리되어 빠른 비교를 위해 Fuzzing을 중단하고 Jackalope으로 넘어가게 되었지만, 이로 서 적은 EXEC(시간)에 더 높은 Coverage를 얻을 수 있었다는 것을 증명할 수 있었다.

https://github.com/hyjun0407/FuzzSeedSeeker

 

GitHub - hyjun0407/FuzzSeedSeeker

Contribute to hyjun0407/FuzzSeedSeeker development by creating an account on GitHub.

github.com

 

결론

  • 대부분의 Fuzzing을 진행하는 논문과 연구에서는 Seed에 대해 신경쓰지 않음
  • 그러나 Seed는 위 연구를 통해 알 수 있 듯, 최대 2배 이상의 Coverage 흭득에 영향을 줌
  • 원래 연구인 Seed selection for successful fuzzing 에서는 리눅스 위주의 실험을 진행함
  • 본 연구인 FuzzSeedSeeker는 윈도우에서의 실험을 진행하고, 많은 사람들이 사용할 수 있도록 오픈소스로”고품질”의 Seed를 얻을 수 있게 했음에 의의를 둠
  • 실제로 FuzzSeedSeeker을 통해 얻은 Seed로 Fuzzing을 진행했을 때 Crash의 개수가 상당히 차이 났고, 이미 Fuzzing을 진행해서 배포하는 라이브러리에서 unique취약점을 13개 찾을 수 있었음.
  • 또, RedQueen과 같은 CMPLOG Fuzzing에서 더욱 두드러지는 높은 Edge/Coverage 달성률을 보여줬음.

한계

  • Drio의 속도가 PT보다 느려 몇 십만개가 되는 Seed 전처리에 많은 시간이 걸릴 수 있음.
  • Basic Block의 Size는 고려하지 않고 Basic Block의 개수만 고려하였음.
    • 이는 Size를 고려하게 함으로서 해결하려 했으나, 총 Size를 고려하여 얻은 결과와 Basic Block의 개수를 고려하여 결정하는 방법 모두 같은 결과를 도출했음. Size를 고려함으로 서 생기는 오버헤드가 정확도보다 더 높은 손실을 요구하기에 제외했음.
  • 정말 작은 바이너리(파일 파싱 말고 ioctl같은 경우)의 경우에는, Seed 전처리 결과에 영향이 없을 수 있음.
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.