[Research] 퇴근 후에도 풀가동! 꿀잠 자는 퍼징 공장장 되기 Part 1 (KR)

0. Introduction
안녕하세요! 지난 막내들이 말아주는 Windows LPE 버그헌팅 체험기 시리즈에서 Part 3을 맡았던 Libera입니다. 다들 잘 지내고 계신가요?
취약점을 찾는 방법은 크게 두 갈래라고 생각합니다. 하나는 코드를 직접 읽으면서 취약한 부분을 찾는 auditing, 다른 하나는 퍼저에게 수많은 입력을 던지게 해서 비정상 동작을 유도하는 fuzzing이라고 생각합니다. 지난 시리즈에서 저는 커널 드라이버를 오디팅으로 파고들었습니다. IDA를 켜서 디스패치 루틴을 시작으로 분석을 진행했죠.
오디팅은 한 번 경험해 봤으니, 이번엔 나머지 한 축인 퍼징을 제대로 공부해 보자는 게 이번 연구의 목표입니다. 내가 자고 있을 때도 취약점을 찾을 수 있다는 건 매우 매력적인 말이지요. 그래서 이번 시리즈에서는 WTF(what the fuzz) 라는 퍼저로 Windows 소켓 커널 드라이버인 afd.sys를 퍼징하기 위한 시스템을 구축한 과정을 정리하려고 합니다.
오늘 Part 1은 WTF가 뭔지 이해하고, 퍼저를 실행하기 위해 필요한 것들을 알아가 보려고 합니다. afd.sys 이야기는 다음 파트부터 본격적으로 시작하고 이번 파트에서는 WTF에 대해서 알아가 보시죠!
1. 왜 “스냅샷” 퍼징인가?
퍼저라고 하면 보통 AFL이나 libFuzzer를 떠올리실 겁니다. 이 도구들의 기본 동작은 이렇습니다.
- 타겟 프로그램을 실행한다
- 입력(테스트케이스)을 하나 던진다
- 크래시가 나는지 본다
- 프로그램을 종료하고, 입력을 조금 바꿔서 다시 1번으로
매번 프로그램을 새로 켜는 방식이죠. 콘솔에서 파일 하나 받아 파싱하는 프로그램이라면 이걸로 충분합니다. 하지만 이번에 타겟으로 잡은 afd.sys는 그렇게 단순하지 않습니다.
afd.sys에서 원하는 코드(함수)에 도달하려면, 그 전에 소켓을 만들고(socket), 주소에 묶고(bind), 연결을 맺는(connect) 등 일련의 준비 과정을 거쳐야 합니다. 만약 매 테스트케이스마다 이 과정을 처음부터 다시 한다면? 퍼저가 정작 버그를 찾는 데 써야 할 시간을, 매번 소켓 연결을 다시 맺는 데 낭비하게 됩니다. 게다가 이건 커널 코드라 프로그램이 종료 되었다가 켜지는 것도 제약이 있습니다.
여기서 등장하는 게 스냅샷 퍼징 입니다.
스냅샷 퍼징이란?
타겟이 원하는 상태에 도달한 그 순간의 모습(메모리 전체 + CPU 레지스터 전체)을 통째로 사진 찍듯 저장해 둡니다. 그 다음부터는 프로그램을 처음부터 실행하는 게 아니라, 저장해 둔 스냅샷을 불러와서 그 시점부터 입력을 주입하고 잠깐 실행한 뒤, 다시 스냅샷 상태로 되돌립니다.

비유하자면 게임의 “세이브 포인트”와 비슷해요. 보스 직전에 세이브해 두면, 보스에게 죽어도 처음 스테이지부터 다시 하지 않고 보스 앞에서 바로 재시작할 수 있죠. 스냅샷 퍼징은 입력 데이터가 사용되기 바로 직전에 세이브를 하고, 그 지점에서 입력만 바꿔가며 무한히 재시도하는 방식입니다.
이 방식의 장점은 아래와 같습니다.
- 상태 제어: 연결, 인증, 초기화 등이 끝난 상태에서 퍼징을 시작할 수 있습니다.
- 결정론적 실행: 디스크, 네트워크 같은 외부 요인 없이 실행이 가능해 크래시 재현이 쉽습니다.
- 빠른 복원: 매번 전체 메모리가 아니라 변경된 페이지만 되돌리면 되어 복원이 빠릅니다.
- 커널 퍼징: 유저모드, 커널모드 구분없이 동일한 방식으로 타겟을 퍼징할 수 있습니다.
마지막 두 장점이 특히 중요합니다. afd.sys는 커널 드라이버고, 적어도 소켓이 연결된 상태에서 퍼징을 진행해야 하니까요. afd.sys처럼 실제로 동작할 때 상태 조건이 다양할 수록 스냅샷 퍼징이 유용합니다.
2. 왜 WTF였나 feat.KAFL
사실 커널 퍼징 하면 가장 유명한 도구는 WTF가 아니라 KAFL 입니다. 그런데도 제가 WTF를 고른 데는 조금 현실적인 이유가 있었어요.
KAFL은 OS 커널을 대상으로 하는 하드웨어 기반 커버리지 퍼저입니다. 핵심은 Intel PT(Intel Processor Trace) 라는 CPU 기능을 쓴다는 점이에요.
Intel PT란?
CPU가 실행한 분기 흐름을 하드웨어가 직접 기록해 주는 기능입니다. 소프트웨어로 일일이 추적하지 않아도 CPU가 어디로 분기했는지를 알려주니, 커버리지를 거의 네이티브 속도로 모을 수 있습니다.
문제는 이 방식이 하드웨어와 환경을 탄다는 겁니다. KAFL은 Intel PT를 지원하는 CPU가 필요하고, VM 안에서 Intel PT를 끌어다 쓰기 위해 자체 개발 커널(KVM) 위에서 돌아갑니다.

저도 알고 싶지 않았습니다. 아직 KAFL의 커스텀 커널이 Intel CPU의 최신 아키텍처인 Ultra 시리즈를 지원하지 않는다는 것을요….
퍼징을 하려고 컴퓨터를 한대 맞췄는데 Ultra Core는 KAFL에서 아직 지원하지 않는거 같더라구요. 구매를 한 후 KAFL 세팅을 하려고 컴퓨터와 씨름을 하던 중 github 등에서 Ultra Core CPU를 사용하는 사람들의 빌드 실패 내용들이 조금씩 올라왔습니다. 저도 시도를 여러번 했지만 Nyx 커널로 변경 후 실행했을 때 Intel PT의 인식과 KVM 기능의 동작이 불분명했고, QEMU-Nyx 기반의 Windows VM을 띄우는 템플릿 빌드 단계에서 부터 막혔습니다. 😭
그래서 눈을 돌린 게 WTF 였습니다. WTF가 제 상황에서 더 나았던 점은 이렇습니다.
- 특별한 하드웨어에 의존하지 않는다.
- WTF의 bochscpu 백엔드는 CPU를 소프트웨어로 에뮬레이션하기 때문에 Intel PT 같은 하드웨어 기능 없이도 커버리지를 측정합니다.
- 타겟에 집중해 가볍게 시작할 수 있다.
- KAFL이 전체 VM을 세우는 무거운 인프라라면, WTF는 원하는 한 지점의 스냅샷만 떠서 특정 경로를 집중 퍼징할 수 있고, 상황에 따라 백엔드(정밀한 bochscpu / 빠른 KVM)를 골라 쓸 수 있습니다.
물론 KAFL이 환경만 맞으면 속도 면에서 강력한 도구입니다. 다만 내 환경에서 지금 당장 돌릴 수 있느냐 가 저에겐 더 중요했고, 그 답이 WTF였던 거죠. 자, 그럼 이 WTF가 대체 뭔지 본격적으로 보겠습니다.
3. WTF, what the fuzz
WTF는 Axel 0vercl0k Souchet이 만든 오픈소스 스냅샷 퍼저로, 특히 Windows 타겟을 퍼징하는 데 강합니다. 유저모드든 커널모드든, 스냅샷만 뜰 수 있으면 퍼징할 수 있다는 게 핵심이에요.
WTF가 유명해진 계기 중 하나가 Thalium 팀의 RDPEGFX 퍼징 사례입니다. Microsoft RDP 클라이언트의 그래픽 채널을 WTF로 퍼징해서 실제 CVE(CVE-2022-30221)를 찾아냈죠. 저도 이 사례에서 많은 걸 배웠고, 이번 시리즈 곳곳에서 그 교훈을 인용할 예정입니다. (RDPEGFX 팀, 감사합니다 🙏)
그럼 WTF가 내부적으로 어떻게 동작하는지부터 보겠습니다.
3.1 WTF의 4단계 루프
WTF의 퍼징은 결국 아래 4단계를 무한 반복하는 것입니다.
- 스냅샷 (Snapshot)
타겟을 디버거로 멈춰서 원하는 상태로 만든 뒤, 그 순간의 물리 메모리 전체와 CPU 상태(레지스터)를 덤프로 저장합니다. 이건 퍼징 시작 전에 딱 한 번 하는 준비 작업입니다.
물론 여러 함수를 타겟으로 병렬로 퍼징하려면 스냅샷도 여러개가 필요합니다. - 하네스 (Harness)
테스트케이스를 타겟의 어디에 어떻게 집어넣을지, 그리고 언제 실행을 멈출지를 코드로 작성합니다. 퍼저가 만들어 준 바이트를 타겟이 이해할 수 있는 위치(입력 버퍼)에 써 주는 역할이죠. - 실행 (Execution)
스냅샷을 불러와 입력을 주입한 상태로 타겟 코드를 실행합니다. 실행하는 동안 커버리지를 추적하고, 크래시가 나는지 감지하고, 어떤 메모리가 바뀌었는지 dirty memory를 기록합니다.
커버리지(coverage)란?
”퍼저가 이번 입력이 새로운 코드 경로를 건드렸는가?” 를 판단하는 지표입니다. 같은 코드만 계속 도는 입력은 의미가 없고, 한 번도 안 가본 분기로 들어가는 입력이 저희가 노려야 하는 입력이에요. 퍼저는 새 커버리지를 만든 입력을 따로 보관했다가, 그걸 또 변형해서 점점 더 깊은 코드로 파고듭니다.
- 복원 (Restore)
실행하면서 변경된 물리 메모리만 원래 스냅샷 상태로 되돌리고, CPU 레지스터도 리셋합니다. 그리고 새 테스트케이스를 만들어 다시 2번으로. 전체 메모리가 아니라 바뀐 페이지만 되돌리기 때문에 이 복원이 굉장히 빠릅니다.
3.2 실행 백엔드 3종
위 3번 실행 단계에서, WTF는 타겟 코드를 실제로 굴리기 위해 세 가지 백엔드중 하나를 고를 수 있습니다. 각각 속도와 정밀함이 다릅니다.
- bochscpu: 소프트웨어 CPU 에뮬레이션 방식으로 속도는 가장 느리지만 모든 명령어의 RIP를 수집해서 디버깅에 유용하고, 정밀한 Trace가 가능합니다.
- WHV: Windows 하이퍼바이저 방식으로 속도는 중간 정도 입니다. 커버리지는 Basic Block 진입점을 사용해서 수집합니다.
- KVM: Linux 하이퍼바이저 방식으로 속도가 가장 빠릅니다. 커버리지는 WHV와 같은 방식으로 수집합니다.
bochscpu 는 CPU 자체를 소프트웨어로 흉내 내는 에뮬레이터입니다. 명령어 하나하나를 전부 가로채서 확인이 가능해요. WTF는 before_execution이라는 훅을 모든 명령어마다 호출해서 현재 RIP(실행 위치)를 꺼내 집합에 넣습니다. 처음 보는 주소면 새 커버리지로 기록하고요. 이렇게 모든 명령어를 들여다보니 정밀하지만, 그만큼 느립니다. 그래서 대규모 퍼징보다는 하네스 디버깅이나 크래시 trace 분석에 씁니다. (앞서 KAFL 대신 WTF가 제 Ultra 7에서 그냥 돌아갔던 것도, 바로 이 bochscpu가 하드웨어 기능 없이 소프트웨어로만 커버리지를 모으기 때문입니다.)
WHV와 KVM 은 하드웨어 가상화입니다. 진짜 CPU 위에서 타겟을 돌리니 빠르지만, 대신 명령어 단위로 들여다볼 수는 없습니다. 그래서 커버리지를 다른 방식으로 모읍니다. 바로 basic block의 시작 주소마다 breakpoint를 한 번씩 심는 것이죠.
basic block(BB)이란?
분기(jump/branch)가 전혀 없이 쭉 실행되는 명령어 묶음으로 조건 분기를 만나면 거기서 블록이 끝납니다.asm 예시 코드로 흐름을 보면 아래와 같습니다.
addbb_A: mov eax, [rcx+4] ; ─┐ cmp eax, 8 ; │ 여기까지가 BB 하나 (A) jb too_small ; ─┘ 조건 분기 → 블록 끝 addbb_B: mov edx, [rcx+8] ; ─┐ jb가 안 걸리면 여기로 (B) test edx, 1 ; │ jz default_case ; ─┘ too_small: xor eax, eax ; ← 다른 곳에서 점프해 들어옴 → 새 BB ret
하드웨어 백엔드를 쓰려면 사전에 타겟의 모든 basic block 시작 주소 목록이 필요한데 이건 IDA 스크립트(gen_coveragefile_ida.py)로 뽑아낼 수 있습니다. 그러면 WTF가 초기화할 때 그 주소들에 int3(breakpoint)을 심어 두고, 퍼징 중에 어떤 int3에 걸리면 이 블록에 처음 도달했구나 라고 판단하고 커버리지를 올린 뒤 그 breakpoint를 제거합니다. 한 번 가본 곳은 더 볼 필요가 없으니까요.
실전 전략은 이렇습니다. 하네스를 처음 만들 땐 bochscpu로 천천히 돌리면서 입력이 제대로 들어가는지, 크래시 탐지가 동작하는지를 디버깅하고, 검증이 끝나면 WHV으로 갈아타서 빠르게 퍼징을 돌립니다. 저는 호스트를 windows로 진행하고 있어서 더 빠른 백엔드인 KVM을 사용하지 못했습니다.
4. 직접 세팅해서 돌려보기
개념을 봤으니, 제가 기본 세팅을 진행한 내용을 확인해 보죠. “WTF 퍼징을 돌아가게 만들려면 뭘 어디에 두어야 하는가?”이번 파트에서 제일 궁금하실 부분이죠. afd.sys 같은 실제 타겟은 다음 파트로 미루고, 여기서는 파일 구조와 흐름만 잡고 가겠습니다.
WTF로 퍼징을 한 번 돌리려면, 결국 아래 세 덩어리가 있어야 합니다.
- 하네스 — 입력을 어디에 주입할지 정의한 C++ 코드 (WTF 본체와 함께 빌드)
- 스냅샷 — 타겟의 메모리 + CPU 상태 덤프 (WinDbg로 추출)
- 시드(seed) 코퍼스 — 퍼징의 출발점이 될 초기 입력 몇 개 준비
스냅샷·시드·크래시는 타겟마다 아래와 같이 targets/<이름>/ 폴더 아래 정해진 하위 폴더에 두어야 합니다.
4.1 퍼징 실행 시 필요한 것
wtf/targets/afd/
├── inputs/ ← 시드(초기 입력) 테스트케이스
├── outputs/ ← 퍼징 중 새로 발견한 흥미로운 입력(minset)
├── coverage/ ← .cov 커버리지 파일
├── crashes/ ← 크래시가 저장되는 곳
└── state/ ← 스냅샷 (snapshot 도구가 이 아래에 하위 폴더를 만들어 mem.dmp + regs.json 저장)
4.2 하네스는 어디에 두는가

WTF는 하네스를 별도 파일로 따로 두는 게 아니라, WTF 폴더 안에 fuzzer 모듈로 넣고 함께 빌드하는 구조입니다. 모듈은 src/wtf 아래에 fuzzer_<이름>.cc 규칙으로 생성합니다.
wtf/
├── src/
│ └── wtf/
│ ├── fuzzer_afd.cc ← 여기에 내 하네스를 둔다
│ ├── fuzzer_hevd.cc ← WTF 기본 예제 모듈 (참고용)
│ └── ...
├── scripts/
│ └── gen_coveragefile_ida.py ← (KVM/WHV용) basic block 목록 추출
└── targets/
└── afd/ ← 이 타겟의 작업 폴더 (4.3에서 설명)
src/wtf/ 안에 fuzzer_afd.cc를 만들고, 그 안에서 내 하네스를 Target_t 객체로 이름과 함께 등록합니다. 여기서 세팅한 이름으로 나중에 실행할 때 --name afd 이런 방식으로 사용해서 어떤 하네스로 퍼징을 진행할지를 선택하게 됩니다.
하네스 안에는 크게 세 개의 함수를 구현합니다.
// src/wtf/fuzzer_afd.cc (개념만)
// 1. 셋업: 크래시 탐지 훅 설치, 종료 조건 정의, 노이즈 제거
bool Init(const Options_t &Opts, const CpuState_t &State) { ... }
// 2. 입력 주입: 퍼저가 만든 바이트(Buffer)를 타겟 메모리에 써 준다
bool InsertTestcase(const uint8_t *Buffer, const size_t BufferSize) { ... }
// 3. 복원: 매 실행 후 호출 (WTF가 알아서 해주는 부분이 대부분)
bool Restore() { ... }
// 위 세 함수를 "afd"라는 이름으로 등록 → 실행 시 --name afd 로 지목
Target_t Afd("afd", Init, InsertTestcase, Restore);
하네스는 WTF 폴더 안의 src/wtf/에 넣어둬야 하고 이후 빌드하면 wtf 실행 파일 하나로 합쳐지게 됩니다. 즉, 하네스의 수정 등을 하게 되면 새로 빌드를 해주고 퍼저를 다시 실행해줘야 합니다.
4.3 스냅샷 찍기 (어떤 파일을 쓰고, 무엇이 생성되는가)
스냅샷은 WinDbg 안에서 취득합니다. 이때 사용하는 파일은 WTF의 companion 프로젝트인 snapshot 입니다. WinDbg 확장(snapshot.dll)으로 로드한 뒤 !snapshot 명령으로 현재 상태를 덤프하죠.
스냅샷 취득의 전체 흐름은 이렇습니다.
1. 타겟을 Hyper-V VM(Windows, vCPU 1개·RAM 4GB 권장)에 올리고 커널 디버깅 연결
2. vm을 원하는 상태로 bp를 걸어서 멈춥니다. (예: DeviceIoControl 호출 직전에서 멈춤)
3. windbg에 snapshot.dll 로드 후 !snapshot을 실행합니다. → 메모리 + CPU 상태 덤프 생성
WinDbg에서는 대략 이런 명령으로 진행합니다.
// snapshot 확장 로드 (Rust로 빌드)
.load C:\path\to\snapshot\target\release\snapshot.dll
!snapshot C:\fuzzing\wtf\targets\afd\state // state 폴더에 스냅샷 저장
// 사용법을 확인하고 싶다면
!snapshot -h
[snapshot] Usage: snapshot [OPTIONS] [STATE_PATH]
-k, --kind <KIND> 스냅샷 종류 [기본: full] [active-kernel | full]
여기서 한가지 주의할 점은 위 처럼 !snapshot을 사용하면 state폴더에 OS빌드와 타임스탬프가 붙은 폴더를 생성하고 그 안에 json과 dmp파일을 넣습니다.
퍼징을 진행할 때 --state 로 가리켜야 하는 건 state/폴더가 아니라 하위에 생성된 state.1904….폴더입니다.
wtf/targets/afd/state/
└── state.19041.1.amd64fre..._20260418_0116/ ← snapshot이 자동 생성한 폴더
├── regs.json ← CPU 레지스터 상태 (RIP, RSP, CR3, MSR 등 전부)
└── mem.dmp ← 물리 메모리 전체 덤프 (Windows crash dump 형식)
regs.json은 스냅샷을 찍은 순간의 모든 CPU 상태입니다. 실행을 어디서부터 재개할지(RIP), 스택은 어디인지(RSP) 등이 여기 들어 있죠.mem.dmp는 그 순간의 물리 메모리 전체입니다. 용량이 커서 취득에 몇 분 걸릴 수 있습니다.
4.4 빌드하고 실행하기
이제 하네스와 스냅샷이 준비됐으니 실제로 돌려볼 수 있습니다. WTF를 빌드하면 wtf 실행 파일이 나오고, 이걸 세 가지 모드로 사용할 수 있습니다.
run— 단일 입력 실행 (하네스 검증용)
하네스가 제대로 동작하는지, 입력이 잘 주입되는지 테스트 할 수 있는 한번만 실행되는 모드입니다. 느리지만 정밀한 bochscpu로 돌립니다.
wtf.exe run --name afd --target targets\afd
--state targets\afd\state\<snap>
--input targets\afd\inputs\seed_0
master— 서버(마스터)
실제 퍼징은 master 하나가 입력 큐와 커버리지·크래시를 관리하고, 여러client(slave)가 붙어서 실행하는 구조입니다.master를 먼저 띄웁니다.
wtf.exe master --name afd --target targets\afd --max_len 0x1000
fuzz— Client
실제로 퍼징을 수행하는 Client입니다.--backend로 백엔드를 고르고, 코어 수만큼 여러 개 띄웁니다. (KVM이 안 되는 Windows에선bochscpu를 사용합니다.)
wtf.exe fuzz --name afd --target targets\afd
--state targets\afd\state\<snap> --backend bochscpu
정리하면 흐름은 이렇습니다.
하네스 작성 (src/wtf/fuzzer_afd.cc)
→ WTF 빌드 → wtf.exe
스냅샷 취득 (!snapshot) → targets/afd/state/<snap>/ (regs.json + mem.dmp)
시드 준비 (targets/afd/inputs/)
↓
wtf run 으로 하네스 검증 (bochscpu)
↓
wtf master 1개 + wtf fuzz 노드 여러 개로 분산 퍼징 (bochscpu)
↓
targets/afd/crashes/ 폴더에 크래시가 쌓이기 시작!

여기까지 오면 빈 화면에서 퍼징 카운터가 올라가는 걸 보실 수 있습니다. 위에는 Master의 로그가, 아래에는 Client의 로그가 출력되고 있습니다. 여기서 저희가 주요하게 봐야 하는 것은 총 5개의 값입니다.
- cov : cov가 증가되지 않으면 새로운 coverage로 들어가지 못하고 있다는 의미입니다.
- lastcov : 이 시간만큼 새로운 cov가 발견되지 못했다는 의미입니다.
- exec/s : 이 값은 초당 실행 횟수를 의미합니다. 이 값이 갑자기 급락하면 문제가 생겼을 수 있습니다.
- crash : 이 값이 증가하면 드디어 crash를 발견 했다는 의미입니다.
- corp : 만약 cov값이 증가했는데 corp가 증가하지 않으면 corpus 저장에 문제가 생긴 것 입니다. 반대로 corp만 늘고 cov가 늘지 않으면 중복 입력이 쌓이고 있다고 알 수 있습니다.

드디어 WTF 퍼저를 성공적으로 동작시킬 수 있었습니다. 저는 WTF의 companion 프로젝트인 **snapshot** 을 알지 못하고 snapshot을 하기 위한 파일을 wtf레포에서 찾으려 했었는데 여러분들은 시간 낭비 없이 세팅하시길 바래요. 😂
마치며
오늘은 스냅샷 퍼징이 뭔지, 왜 KAFL을 사용하지 않았는지, WTF의 4단계 루프와 3종 백엔드가 어떻게 동작하는지, 그리고 실제로 하네스·스냅샷·시드를 어디에 두고 어떤 명령으로 돌리는지 까지 훑었습니다.
다음 Part 2 에서는 afd.sys의 IOCTL 구조를 뜯어보고, 어떤 핸들러를 왜 골랐는지, 그리고 스냅샷을 찍기 위한 인사이트가 있는 논문과 내용을 windows에 어떻게 적용했는지를 다뤄 보겠습니다. 소켓 상태를 잘못 잡으면 하루를 통째로 날릴 수도 있다는 걸, 제가 몸으로 배웠거든요…. 😭
읽어주셔서 감사합니다. 다음 글에서 만나요!
Reference
본 글은 CC BY-SA 4.0 라이선스로 배포됩니다. 공유 또는 변경 시 반드시 출처를 남겨주시기 바랍니다.