C++ Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기
들어가며: “이런 입력은 생각도 못 했어요”
예상치 못한 입력이 버그를 연다
41-1의 정적 분석과 41-2의 Sanitizer가 알려진 패턴과 실행 중 메모리·경합 문제를 잡는다면, 퍼즈 테스트는 무작위로 변형한 입력을 계속 넣어 “이런 걸 넣으면 터지네”를 자동으로 찾습니다. 파서, 디코더, 프로토콜 처리처럼 외부 입력을 받는 코드에서 특히 효과가 큽니다.
LLVM의 libFuzzer는 프로세스 안에서 도는 커버리지 기반 퍼저입니다. 퍼즈 타깃 함수 하나를 정해 두면 그 함수에 바이트 배열을 넘겨 반복 실행하고, 새로운 코드 경로를 연 입력을 코퍼스에 모아 다음 변형의 출발점으로 삼습니다. ASan, UBSan과 함께 쓰면 크래시로 드러나지 않는 버퍼 오버런이나 정수 오버플로도 잡을 수 있습니다.
이 글에서 다루는 것:
- 퍼즈 테스트 개념: 퍼저가 입력을 생성·변형해 타깃을 반복 호출하는 구조
- libFuzzer: LLVMFuzzerTestOneInput, 빌드 옵션, 코퍼스
- 예제: 정수 배열 파서, URL 파서, 패킷 파서, FuzzedDataProvider
- 자주 발생하는 에러와 해결법
- CI 연동: GitHub Actions, GitLab CI
- 시드 관리, 회귀 테스트, 장시간 퍼징
문제 시나리오
시나리오 1: JSON 파서 크래시 프로덕션 서버에서 JSON API가 가끔 크래시합니다. 재현이 안 됩니다.
// ❌ 문제: 외부 JSON 입력이 예상치 못한 형태일 때 크래시
// 예: {"key": 123456789012345678901234567890} // 정수 오버플로
// 예: {"key": "AAAAAAAA..."} // 매우 긴 문자열 → 버퍼 오버런
// 예: {"key": [[[[[[[[[[... // 깊은 중첩 → 스택 오버플로
void handle_request(const std::string& json) {
auto parser = JSONParser();
auto result = parser.parse(json); // 여기서 크래시!
process(result);
}
시나리오 2: URL 파서 취약점 사용자가 입력한 URL을 파싱하는 코드에서, 특정 URL 형식으로 크래시가 발생합니다.
// ❌ 문제: URL 파싱 시 경계 조건 검사 누락
// 예: "http://" + 10000자 'A' + "://"
// 예: "file://" + ".." * 1000
// 예: "%" + 임의 바이트
struct ParsedURL {
std::string scheme;
std::string host;
int port;
};
ParsedURL parse_url(const char* url, size_t len); // len 검사 없음?
시나리오 3: 네트워크 프로토콜 처리 바이너리 프로토콜 파서에서 길이 필드가 조작된 패킷을 받으면 메모리 오버런이 발생합니다.
// ❌ 문제: 패킷 길이 필드를 신뢰
// 예: length = 0xFFFFFFFF, 실제 payload = 4바이트
// 타입 정의
struct Packet {
uint32_t length; // 공격자가 조작 가능
uint8_t data[]; // length만큼 읽으면 오버런!
};
void process_packet(const uint8_t* buf, size_t size) {
if (size < 4) return;
uint32_t len = read_u32(buf);
memcpy(dest, buf + 4, len); // len > size-4 이면 위험!
}
왜 이런 일이 발생할까요?
개발자는 정상적인 입력을 기준으로 코드를 쓰고 테스트합니다. 하지만 공격자나 버그 있는 클라이언트는 형식이 깨진 데이터, 극단적인 값, 경계에 걸친 길이를 보냅니다. 사람이 이런 조합을 모두 떠올려 테스트 케이스로 만드는 것은 불가능하고, 퍼즈 테스트는 이 공백을 자동으로 채웁니다. 특히 위 세 시나리오의 공통점은 입력 안의 길이나 개수 필드를 믿었다는 것입니다. 퍼저는 이런 필드를 0, 최댓값, 실제 크기보다 1 큰 값 등으로 바꿔 보는 데 매우 빠르기 때문에, 이런 종류의 버그를 가장 먼저 찾아냅니다.
Fuzz Testing은 무엇을 찾아내나
자동화된 잘못된 입력 주입
퍼저는 타깃 함수에 입력 바이트를 넘겨 반복 호출합니다. 입력은 기존 코퍼스의 항목을 비트 뒤집기, 바이트 삽입·삭제, 다른 입력과 이어 붙이기 등으로 변형해서 만듭니다. 커버리지 기반 퍼저의 핵심은 새로운 코드 경로(분기)를 연 입력만 코퍼스에 남긴다는 점입니다. 그래서 무작위 대입과 달리 헤더 검사를 통과한 입력을 발판 삼아 점점 더 깊은 경로로 들어갈 수 있습니다.
목표는 크래시, assert 실패, Sanitizer 보고를 일으키는 입력을 찾는 것입니다. 찾은 입력은 별도 파일(artifact)로 저장되고, 버그를 고친 뒤에는 회귀 테스트용으로 보관합니다. 파싱, 디코딩, 역직렬화, 파일 포맷, 네트워크 프로토콜처럼 바이트 스트림을 해석하는 코드가 가장 잘 맞습니다.
퍼즈 테스트 흐름
flowchart TB
subgraph Input[입력 생성]
A[무작위 바이트]
B[코퍼스 시드]
C[변형 Mutation]
end
subgraph Fuzz[퍼즈 루프]
D[타겟 함수 호출]
E{크래시/에러?}
F[코퍼스에 저장]
G[새 경로 발견?]
end
subgraph Output[출력]
H[크래시 입력 저장]
I[회귀 테스트용]
end
A --> D
B --> C --> D
D --> E
E -->|Yes| F --> H
E -->|No| G -->|Yes| F
G -->|No| D
H --> I
libFuzzer vs AFL
| 항목 | libFuzzer | AFL/AFL++ |
|---|---|---|
| 실행 방식 | in-process (단일 프로세스) | fork server, persistent 모드 지원 |
| 속도 | 빠름 (프로세스 생성 없음) | persistent 모드면 비슷한 수준 |
| 계측 | LLVM SanitizerCoverage | 컴파일 시 계측(afl-clang-fast/lto), QEMU 등 바이너리 전용 모드 |
| 잘 맞는 용도 | CI의 짧은 실행 | 장시간 퍼징, 다양한 변형 전략 |
| 컴파일러 | Clang | GCC, Clang |
in-process 방식에는 대가도 있습니다. 한 프로세스에서 타깃을 수백만 번 호출하므로, 타깃이 전역 상태를 바꾸거나 메모리를 조금씩 새면 그 영향이 누적됩니다. 그래서 libFuzzer 타깃은 호출할 때마다 같은 입력에 같은 결과를 내도록(결정적으로) 작성해야 하고, 누수도 버그로 취급됩니다.
libFuzzer 사용하기
LLVMFuzzerTestOneInput
타깃으로 extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size)를 정의하면, 퍼저가 만든 바이트를 data와 size로 넘겨 이 함수를 반복 호출합니다. 이 안에서 p.parse(data, size)처럼 실제 파서나 디코더를 호출하고, 크래시나 assert 실패, ASan 보고가 나면 libFuzzer가 그 입력을 crash-<해시> 파일로 저장하고 종료합니다.
data가 가리키는 버퍼는 읽기 전용이고 호출이 끝나면 무효가 되므로, 포인터를 전역에 보관하면 안 됩니다. size < 4 같은 최소 길이 검사로 의미 없는 짧은 입력을 빨리 건너뛸 수 있습니다. 반환값은 0을 쓰며, 최근 libFuzzer에서는 -1을 반환하면 그 입력을 코퍼스에 추가하지 않습니다.
#include <stddef.h>
#include <stdint.h>
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (size < 4) return 0;
MyParser p;
p.parse(data, size); // 크래시나 assert 발생 시 퍼저가 입력 저장
return 0;
}
빌드 옵션
# Clang으로 libFuzzer + ASan + UBSan 빌드
clang++ -std=c++17 -g -fsanitize=fuzzer,address,undefined \
-fno-omit-frame-pointer \
-o fuzz_target fuzz_target.cpp my_parser.cpp
# CMake 예시
add_executable(fuzz_target fuzz_target.cpp my_parser.cpp)
target_compile_options(fuzz_target PRIVATE
-fsanitize=fuzzer,address,undefined
-fno-omit-frame-pointer
)
target_link_options(fuzz_target PRIVATE
-fsanitize=fuzzer,address,undefined
)
실행
# 무한 반복 (Ctrl+C로 중단)
./fuzz_target
# 10000회만 실행
./fuzz_target -runs=10000
# 코퍼스 디렉터리 사용 (시드 + 새 입력 저장)
mkdir -p corpus
./fuzz_target corpus
# 타임아웃 설정 (입력당 1초)
./fuzz_target -timeout=1 corpus
# 크래시 입력 최소화 (입력 파일을 인자로 넘김)
./fuzz_target -minimize_crash=1 -runs=10000 crash-<해시>
주요 libFuzzer 옵션
| 옵션 | 설명 | 예시 |
|---|---|---|
-runs=N | 실행 횟수 제한 | -runs=100000 |
-max_total_time=N | 총 실행 시간(초) | -max_total_time=60 |
-timeout=N | 입력당 타임아웃(초, 기본 1200) | -timeout=1 |
-rss_limit_mb=N | 메모리 제한(MB, 기본 2048) | -rss_limit_mb=2048 |
-max_len=N | 생성할 입력의 최대 길이 | -max_len=4096 |
-dict=file | 토큰 사전 파일 | -dict=keywords.dict |
-artifact_prefix=path | 크래시/타임아웃 저장 경로 접두어 | -artifact_prefix=artifacts/ |
-minimize_crash=1 | 크래시 입력 최소화 | ./fuzz -minimize_crash=1 crash-... |
-print_final_stats=1 | 종료 시 통계 출력 | 실행 횟수, 속도 등 |
정수 파서, URL 파서, 패킷 파서, FuzzedDataProvider 예제
예제 1: 간단한 정수 파서
길이 접두어가 있는 정수 배열을 파싱하는 코드를 퍼징합니다. 아래는 퍼저가 찾아낼 법한 버그 두 개를 고친 버전이고, 주석에 원래 코드의 문제를 남겼습니다.
// simple_int_parser.hpp
#pragma once
#include <cstddef>
#include <cstdint>
#include <vector>
inline uint32_t read_be32(const uint8_t* p) {
// ❌ (p[0] << 24): uint8_t가 int로 승격된 뒤 시프트되므로
// p[0] >= 0x80이면 int 오버플로 (C++17까지 UB, UBSan이 보고)
return (uint32_t(p[0]) << 24) | (uint32_t(p[1]) << 16) |
(uint32_t(p[2]) << 8) | uint32_t(p[3]);
}
class SimpleIntParser {
public:
std::vector<int32_t> parse(const uint8_t* data, size_t size) {
std::vector<int32_t> result;
if (size < 4) return result;
uint32_t count = read_be32(data);
// ❌ size_t needed = 4 + count * 4;
// count * 4가 uint32_t로 계산되어 count >= 0x40000000이면 0 근처로
// 래핑되고, 검사를 통과한 뒤 루프가 버퍼 밖을 읽음
if (count > (size - 4) / 4) return result;
result.reserve(count);
for (uint32_t i = 0; i < count; ++i) {
size_t offset = 4 + size_t(i) * 4;
result.push_back(static_cast<int32_t>(read_be32(data + offset)));
}
return result;
}
};
두 번째 버그는 사람이 리뷰로 찾기 어려운 전형적인 예입니다. size < needed 검사가 분명히 있으니 안전해 보이지만, 곱셈이 32비트에서 먼저 넘쳐 버리면 검사 자체가 무의미해집니다. 퍼저는 count 필드에 0x40000001 같은 값을 넣어 보는 순간 ASan의 heap-buffer-overflow로 이 문제를 드러냅니다. 비교를 나눗셈 쪽으로 옮기면 곱셈 오버플로가 끼어들 여지가 없어집니다.
// fuzz_simple_parser.cpp
#include <stddef.h>
#include <stdint.h>
#include "simple_int_parser.hpp"
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (size < 4) return 0; // 최소: 4바이트 length
SimpleIntParser parser;
(void)parser.parse(data, size);
return 0;
}
# 빌드 및 실행
clang++ -std=c++17 -g -fsanitize=fuzzer,address,undefined \
-o fuzz_simple fuzz_simple_parser.cpp
./fuzz_simple -runs=100000 corpus
예제 2: URL 파서 퍼징
// url_parser.hpp
#pragma once
#include <string>
#include <cstdint>
struct ParsedURL {
std::string scheme;
std::string host;
std::string path;
uint16_t port = 0;
bool valid = false;
};
ParsedURL parse_url(const uint8_t* data, size_t size);
// url_parser.cpp - 퍼징 대상
#include "url_parser.hpp"
#include <cstring>
#include <algorithm>
ParsedURL parse_url(const uint8_t* data, size_t size) {
ParsedURL out;
if (size == 0) return out;
const char* p = reinterpret_cast<const char*>(data);
const char* end = p + size;
// scheme 추출 (예: "http:")
const char* colon = static_cast<const char*>(std::memchr(p, ':', size));
if (!colon || colon >= end - 1) return out;
out.scheme.assign(p, colon - p);
p = colon + 1;
// "//" 스킵
if (end - p >= 2 && p[0] == '/' && p[1] == '/') p += 2;
// host (다음 '/' 또는 끝까지)
const char* slash = static_cast<const char*>(std::memchr(p, '/', end - p));
if (slash) {
out.host.assign(p, slash - p);
out.path.assign(slash, end - slash);
} else {
out.host.assign(p, end - p);
}
out.valid = true;
return out;
}
// fuzz_url_parser.cpp
#include <stddef.h>
#include <stdint.h>
#include "url_parser.hpp"
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (size > 65536) return 0; // 매우 큰 입력 제한
ParsedURL result = parse_url(data, size);
(void)result;
return 0;
}
예제 3: 프로토콜 패킷 파서 (길이 필드 검증)
// packet_parser.hpp
#pragma once
#include <cstdint>
#include <vector>
struct Packet {
uint32_t type;
std::vector<uint8_t> payload;
};
// 안전한 파서: 길이 필드 검증
bool parse_packet(const uint8_t* data, size_t size, Packet& out);
// packet_parser.cpp
#include "packet_parser.hpp"
#include <cstring>
bool parse_packet(const uint8_t* data, size_t size, Packet& out) {
if (size < 8) return false; // type(4) + length(4)
// read_be32는 예제 1과 동일 (uint32_t로 변환한 뒤 시프트)
out.type = read_be32(data);
uint32_t len = read_be32(data + 4);
// ✅ 핵심: 길이 검증 - 공격자가 len을 조작해도 안전
if (len > size - 8) return false;
if (len > 1024 * 1024) return false; // 1MB 제한
out.payload.assign(data + 8, data + 8 + len);
return true;
}
// fuzz_packet_parser.cpp
#include <stddef.h>
#include <stdint.h>
#include "packet_parser.hpp"
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
Packet pkt;
(void)parse_packet(data, size, pkt);
return 0;
}
예제 4: 구조화된 입력 (FuzzedDataProvider)
API가 바이트 배열이 아니라 정수, 문자열, 벡터 같은 여러 인자를 받는다면 FuzzedDataProvider로 입력을 나눠 씁니다. 이 헤더는 compiler-rt에 포함되어 Clang의 리소스 디렉터리(clang++ -print-resource-dir)의 include/에 설치되며, 이 경로는 Clang의 기본 include 경로라서 #include <fuzzer/FuzzedDataProvider.h>만 쓰면 됩니다.
주의할 점은 FuzzedDataProvider가 정수 값을 입력의 뒤쪽에서부터 꺼낸다는 것입니다. 그래서 입력 앞부분에 있는 문자열과 뒤쪽의 정수 필드가 서로 독립적으로 변형되어 퍼저가 효율적으로 탐색할 수 있습니다. 다만 코퍼스 파일을 사람이 직접 만들기는 어려워지므로, 사람이 읽을 수 있는 시드가 중요한 포맷이라면 raw 바이트를 그대로 파서에 넘기는 편이 낫습니다.
// fuzz_with_provider.cpp
#include <stddef.h>
#include <stdint.h>
#include <string>
#include <vector>
#include <fuzzer/FuzzedDataProvider.h>
#include "my_api.hpp"
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
if (size < 4) return 0;
FuzzedDataProvider provider(data, size);
// 구조화된 입력 생성
int mode = provider.ConsumeIntegralInRange<int>(0, 3);
std::string str = provider.ConsumeRandomLengthString(256);
std::vector<uint8_t> bytes = provider.ConsumeBytes<uint8_t>(
provider.ConsumeIntegralInRange<size_t>(0, 1024));
my_api_process(mode, str, bytes);
return 0;
}
# FuzzedDataProvider.h는 Clang 기본 include 경로에 있으므로 -I 불필요
clang++ -std=c++17 -g -fsanitize=fuzzer,address \
-o fuzz_provider fuzz_with_provider.cpp my_api.cpp
LLVMFuzzerTestOneInput 링크 에러, 느린 퍼징, OOM·타임아웃
문제 1: “undefined reference to LLVMFuzzerTestOneInput”
원인: 타깃 함수에 extern "C"가 빠져 이름이 C++ 방식으로 맹글링되었거나, 시그니처가 다르거나(예: const char*), 타깃 파일을 링크 대상에서 빠뜨린 경우입니다.
해결법:
// ✅ 올바른 시그니처 (extern "C" 필수)
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
// ...
return 0;
}
# ✅ 링크 옵션에 fuzzer 포함
clang++ -fsanitize=fuzzer,address -o fuzz_target fuzz_target.cpp
문제 2: 퍼징이 너무 느림 (초당 10회 미만)
원인: 타깃 함수가 무겁거나, 파일·네트워크 I/O를 하거나, 호출마다 큰 메모리를 할당합니다. 해결법:
if (size < N) return 0;으로 의미 없는 짧은 입력을 일찍 건너뜁니다.-max_len이나if (size > 64*1024) return 0;으로 입력 크기를 제한합니다.- 파일·네트워크 대신 메모리 버퍼를 받는 API를 퍼징합니다.
- 타깃이 매번 초기화하는 무거운 객체(설정 로딩 등)는
LLVMFuzzerInitialize나 함수 내 static 변수로 한 번만 만듭니다.
// ❌ 느림: 매번 파일 쓰기
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
std::ofstream f("/tmp/input");
f.write(reinterpret_cast<const char*>(data), size);
return parse_file("/tmp/input"); // I/O 병목!
}
// ✅ 빠름: 메모리에서 직접
extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
return parse_from_memory(data, size);
}
문제 3: OOM (Out of Memory)으로 프로세스 종료
원인: 입력의 길이 필드를 믿고 거대한 메모리를 할당하는 경우입니다(예: vector<int>(count)에서 count=0xFFFFFFFF). libFuzzer는 RSS가 -rss_limit_mb(기본 2048MB)를 넘거나, 한 번의 malloc이 그 한도를 넘으면 oom- 파일을 남기고 종료합니다.
해결법:
- 파서 안에서 길이·개수 필드에 상한을 두고 검증합니다.
- 정말 큰 입력을 처리해야 하는 타깃이라면
-rss_limit_mb=N으로 한도를 조정합니다.
// ✅ 파서 내부에서 상한 검사
if (count > 1024 * 1024) return {}; // 1,048,576개 초과 시 거부
# 2GB 메모리 제한
./fuzz_target -rss_limit_mb=2048 corpus
문제 4: 무한 루프 / 타임아웃
원인: 특정 입력이 파서를 무한 루프나 지수 시간 경로에 빠뜨립니다(예: 백트래킹 정규식의 ReDoS). 깊은 재귀는 타임아웃보다 스택 오버플로로 드러나는 경우가 많습니다. 해결법:
-timeout=N으로 입력당 제한을 짧게 둡니다. 기본값이 1200초라서 그대로 두면 무한 루프를 20분 뒤에야 알게 됩니다. 한도를 넘으면 libFuzzer가timeout-<해시>파일을 남기고 종료합니다.- 재귀 파서에는 최대 깊이 제한을 추가합니다.
./fuzz_target -timeout=5 corpus
참고로 -ignore_timeouts, -ignore_ooms 같은 옵션은 여러 프로세스로 나눠 도는 -fork 모드에서 타임아웃이 나도 퍼징을 계속할지 정하는 옵션입니다.
문제 5: “multiple definition of main” 링크 에러
원인: -fsanitize=fuzzer는 libFuzzer의 main을 함께 링크합니다. 그래서 자체 main이 있는 파일이나 기존 앱 바이너리 전체를 이 옵션으로 빌드하면 main이 두 번 정의됩니다. CMake에서 CMAKE_CXX_FLAGS에 -fsanitize=fuzzer를 전역으로 넣었을 때 흔히 발생합니다.
해결법:
- 라이브러리와 테스트 대상 코드는
-fsanitize=fuzzer-no-link로 계측만 하고, 퍼즈 타깃 실행 파일에만-fsanitize=fuzzer를 링크합니다. - sanitizer 목록의 순서는 결과에 영향을 주지 않습니다.
# 라이브러리: 계측만
clang++ -c -fsanitize=fuzzer-no-link,address,undefined my_parser.cpp
# 퍼즈 타깃: libFuzzer main 링크
clang++ -fsanitize=fuzzer,address,undefined fuzz.cpp my_parser.o -o fuzz
문제 6: 코퍼스가 비어 있거나 효과 없음
원인: 시드가 없으면 퍼저는 빈 입력에서 출발합니다. 커버리지 피드백이 있어도 JSON이나 URL처럼 형식이 정해진 입력은 첫 관문(예: http:// 접두어 검사)을 통과하기까지 오래 걸립니다.
해결법:
corpus/에 유효한 JSON, URL, 패킷 샘플을 시드로 넣습니다.-dict=file로 포맷의 키워드와 토큰을 알려 줍니다. 사전 파일은 한 줄에 하나씩 따옴표로 감싼 문자열을 씁니다.
# url.dict - URL 파서용 (각 항목은 따옴표로 감쌈)
"http"
"https"
"ftp"
"://"
"/"
"?"
"#"
"%"
./fuzz_target -dict=url.dict corpus
문제 7: FuzzedDataProvider 헤더를 찾을 수 없음
원인: GCC로 컴파일하고 있거나, 배포판 패키지가 compiler-rt를 따로 나눠 두어 설치되지 않은 경우가 대부분입니다(예: Debian/Ubuntu의 libclang-rt-<버전>-dev).
해결법:
# 헤더 위치 확인
clang++ -print-resource-dir
# 예: /usr/lib/llvm-14/lib/clang/14.0.0
ls "$(clang++ -print-resource-dir)/include/fuzzer/FuzzedDataProvider.h"
# 기본 경로에 없다면 CMake에서 리소스 디렉터리를 찾아 추가
execute_process(COMMAND ${CMAKE_CXX_COMPILER} -print-resource-dir
OUTPUT_VARIABLE CLANG_RESOURCE_DIR OUTPUT_STRIP_TRAILING_WHITESPACE)
target_include_directories(fuzz_target PRIVATE ${CLANG_RESOURCE_DIR}/include)
또는 FuzzedDataProvider.h를 프로젝트에 복사해 사용할 수 있습니다.
문제 8: 퍼징 중 “LeakSanitizer” 메모리 누수 보고
원인: 타깃 코드가 할당한 메모리를 해제하지 않습니다. libFuzzer는 같은 프로세스에서 타깃을 계속 호출하므로, 입력마다 조금씩 새는 메모리도 결국 OOM으로 이어집니다. 해결법: 입력에 따라 발생하는 누수는 실제 버그이므로 먼저 코드에서 해제를 고치는 것이 원칙입니다. 전역 캐시처럼 의도적으로 한 번만 할당하는 메모리 때문에 오탐이 난다면, 원인을 확인한 뒤 누수 검사를 끌 수 있습니다.
# 누수 검사만 끄기 (ASan의 다른 검사는 유지)
./fuzz_target -detect_leaks=0 corpus
CI에서 짧게 퍼징 돌리기
GitHub Actions
# .github/workflows/fuzz.yml
name: Fuzz Testing
on:
push:
branches: [main, develop]
schedule:
- cron: '0 2 * * *' # 매일 새벽 2시 장시간 퍼징
jobs:
fuzz-quick:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Clang
run: |
sudo apt-get update
sudo apt-get install -y clang
- name: Build fuzz target
run: |
cmake -S . -B build -DFUZZ=ON \
-DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
cmake --build build --target fuzz_target
- name: Run fuzz (60 seconds)
run: |
mkdir -p build/corpus build/artifacts
# 저장소의 시드 코퍼스가 있으면 복사
if [ -d corpus ]; then cp -r corpus/. build/corpus/; fi
# 크래시가 나면 0이 아닌 코드로 종료 → 스텝 실패
./build/fuzz_target build/corpus -max_total_time=60 \
-artifact_prefix=build/artifacts/
- name: Upload crash artifacts on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: fuzz-crash
path: build/artifacts/
fuzz-regression:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and run regression
run: |
# 코퍼스만 재실행 - 새 크래시 없는지 확인
./scripts/fuzz_regression.sh
GitLab CI
# .gitlab-ci.yml
fuzz:
stage: test
image: ubuntu:22.04
variables:
CC: clang
CXX: clang++
before_script:
- apt-get update && apt-get install -y clang cmake
script:
- cmake -S . -B build -DFUZZ=ON
- cmake --build build
- mkdir -p build/corpus build/artifacts
- ./build/fuzz_target build/corpus -max_total_time=120 -artifact_prefix=build/artifacts/
artifacts:
when: on_failure
paths:
- build/artifacts/
expire_in: 7 days
두 설정 모두 쉘의 timeout 명령 대신 -max_total_time을 썼습니다. timeout 60 ./fuzz_target ... || true처럼 쓰면 크래시가 나도 || true 때문에 스텝이 성공으로 끝나 실패 알림이 오지 않고, || true를 빼면 이번엔 시간이 다 된 것만으로 종료 코드 124가 나서 매번 실패합니다. -max_total_time은 시간이 다 되면 0으로, 크래시가 나면 0이 아닌 코드로 끝나므로 CI의 성공·실패 판정과 그대로 맞습니다. 크래시 파일은 코퍼스 디렉터리가 아니라 현재 디렉터리(또는 -artifact_prefix 경로)에 저장된다는 점도 업로드 경로를 정할 때 주의해야 합니다.
CMake 퍼즈 타겟 설정
# CMakeLists.txt
option(FUZZ "Build fuzz targets" OFF)
if(FUZZ)
add_executable(fuzz_target
fuzz_target.cpp
${LIB_SOURCES}
)
target_compile_options(fuzz_target PRIVATE
-fsanitize=fuzzer,address,undefined
-fno-omit-frame-pointer
-g
)
target_link_options(fuzz_target PRIVATE
-fsanitize=fuzzer,address,undefined
)
target_include_directories(fuzz_target PRIVATE ${CMAKE_SOURCE_DIR}/src)
endif()
시드 코퍼스, 야간 장시간 퍼징, 크래시 최소화, OSS-Fuzz
패턴 1: 시드 코퍼스 관리
# corpus/ 디렉터리 구조
corpus/
├── seed_001.json # 유효한 JSON 샘플
├── seed_002.json
├── url_001.txt # 다양한 URL 형식
├── packet_001.bin # 유효한 패킷
└── crash_abc123 # 이전에 발견한 크래시 입력 (회귀용)
시드는 퍼저가 변형의 출발점으로 삼는 입력으로, 유효한 형식일수록 깊은 경로까지 탐색하기 좋습니다. 이전에 발견한 크래시 입력은 버그를 고친 뒤 회귀 테스트용으로 함께 저장소에 넣어 둡니다. 다만 퍼징이 만들어 낸 코퍼스 전체를 매번 커밋하면 파일이 끝없이 늘어나므로, 주기적으로 -merge=1로 커버리지를 유지하는 최소 집합만 골라 저장하는 편이 좋습니다.
패턴 2: 회귀 테스트 스크립트
#!/bin/bash
# scripts/fuzz_regression.sh
set -e
BUILD_DIR=build
CORPUS=corpus
# 코퍼스가 없으면 스킵
if [ ! -d "$CORPUS" ]; then
echo "No corpus, skipping regression"
exit 0
fi
# 퍼즈 타겟 빌드
cmake --build $BUILD_DIR --target fuzz_target
echo "Running regression on $(find $CORPUS -type f | wc -l) inputs"
./$BUILD_DIR/fuzz_target $CORPUS -runs=0
# -runs=0: 코퍼스만 실행, 새 입력 생성 안 함
echo "Regression passed"
패턴 3: 장시간 퍼징 (야간/주말)
#!/bin/bash
# 24시간 퍼징, 결과를 별도 디렉터리에
CORPUS_DIR=corpus
ARTIFACT_DIR=fuzz_artifacts_$(date +%Y%m%d)
mkdir -p $ARTIFACT_DIR
./fuzz_target $CORPUS_DIR -max_total_time=86400 \
-artifact_prefix=$ARTIFACT_DIR/ \
-print_final_stats=1
# 크래시 발견 시 $ARTIFACT_DIR/ 에 저장
패턴 4: 다중 타겟 병렬 퍼징
# 여러 퍼즈 타겟을 동시에 실행
for target in fuzz_json fuzz_url fuzz_packet; do
./$target corpus_$target -max_total_time=3600 &
done
wait
패턴 5: 크래시 입력 최소화
# 같은 버그를 일으키는 더 짧은 입력을 찾음 (minimized-from-... 파일로 저장)
./fuzz_target -minimize_crash=1 -runs=100000 crash-<해시>
# 코퍼스 축소: 커버리지를 유지하는 최소 집합만 new_corpus로 병합
mkdir new_corpus && ./fuzz_target -merge=1 new_corpus corpus
패턴 6: AFL++와 libFuzzer 병행
같은 타깃이라도 퍼저마다 변형 전략이 달라 찾는 버그가 다를 수 있습니다. CI에서는 libFuzzer를, 야간 장시간 퍼징에서는 AFL++를 쓰는 구성이 흔하며, AFL++는 LLVMFuzzerTestOneInput 하니스를 그대로 빌드할 수 있습니다.
# 같은 하니스를 AFL++로 빌드: -fsanitize=fuzzer를 주면 AFL++ 드라이버가 링크됨
afl-clang-fast++ -g -fsanitize=fuzzer,address \
fuzz_target.cpp my_parser.cpp -o fuzz_target_afl
afl-fuzz -i corpus -o afl_output -- ./fuzz_target_afl
# afl_output/default/queue/ 는 코퍼스로, crashes/ 는 회귀 테스트 입력으로 가져옴
패턴 7: OSS-Fuzz 스타일 통합
Google의 OSS-Fuzz는 오픈소스 프로젝트에 무료 퍼징 인프라를 제공합니다. 비슷한 구조로 자체 퍼징 파이프라인을 구성할 수 있습니다.
OSS-Fuzz의 빌드 이미지에는 이미 Clang이 들어 있고, 빌드 스크립트는 환경 변수로 전달되는 컴파일러와 플래그($CC, $CXX, $CXXFLAGS)를 쓰고 퍼징 엔진은 $LIB_FUZZING_ENGINE으로 링크하는 것이 규칙입니다. 이렇게 해 두면 같은 스크립트로 libFuzzer, AFL++, honggfuzz 빌드를 모두 만들 수 있습니다.
# Dockerfile (간소화된 예시)
FROM gcr.io/oss-fuzz-base/base-builder
RUN apt-get update && apt-get install -y cmake
COPY . $SRC/myproject
WORKDIR $SRC/myproject
COPY build.sh $SRC/
# build.sh: OSS-Fuzz가 CC/CXX/CXXFLAGS/LIB_FUZZING_ENGINE/OUT을 설정해 줌
$CXX $CXXFLAGS -c src/my_parser.cpp -o my_parser.o
$CXX $CXXFLAGS fuzz/fuzz_target.cpp my_parser.o $LIB_FUZZING_ENGINE -o $OUT/fuzz_target
코퍼스와 회귀 테스트
시드와 회귀 테스트
코퍼스는 새 경로를 연 입력들이 모인 디렉터리이고, 크래시 입력은 별도 artifact 파일로 저장됩니다. 둘 다 저장소에서 관리하면 코드를 바꿨을 때 “이 입력으로 다시 터지는지”를 바로 확인할 수 있습니다. CI에서는 퍼즈 타깃을 sanitizer와 함께 빌드해 60초 정도만 돌리거나 기존 코퍼스만 재실행해 회귀를 검사하고, 장시간 퍼징은 야간 스케줄로 분리합니다. 타임아웃은 크래시와 똑같이 취급되어 timeout- 파일이 남고 퍼저가 종료되므로, 파서가 느려지는 변경도 회귀 테스트에서 잡힙니다.
코퍼스 디렉터리 전략
flowchart LR
subgraph Sources[시드 소스]
A[유효한 샘플]
B[이전 크래시]
C[수동 테스트 케이스]
end
subgraph Corpus[코퍼스]
D[corpus/]
end
subgraph CI[CI]
E[빌드]
F[회귀: corpus만 실행]
G[신규: 60초 퍼징]
end
A --> D
B --> D
C --> D
D --> E --> F
E --> G
G -->|크래시 발견| B
실전 적용 순서
flowchart TD
A[1. 타겟 함수 식별] --> B[2. LLVMFuzzerTestOneInput 래퍼 작성]
B --> C[3. Sanitizer 옵션으로 빌드]
C --> D[4. 로컬에서 1분 퍼징 테스트]
D --> E{크래시?}
E -->|Yes| F[5. 버그 수정 후 재실행]
E -->|No| G[6. 시드 코퍼스 추가]
F --> D
G --> H[7. CI에 퍼즈 job 추가]
H --> I[8. 코퍼스 git 관리]
I --> J[9. 장시간 퍼징 스케줄 설정]
처음 적용할 때는 외부 입력을 받는 함수 하나를 골라 LLVMFuzzerTestOneInput 래퍼를 만들고, sanitizer와 함께 빌드해 로컬에서 몇 분 돌려 보는 것으로 충분합니다. 오래된 파서라면 이 단계에서 이미 크래시가 나오는 경우가 많습니다. 버그를 고치고 크래시 입력을 회귀용으로 저장한 뒤, 시드 코퍼스를 추가하고 CI에 짧은 퍼징 잡을 붙이고, 마지막으로 야간 장시간 퍼징을 스케줄에 올리는 순서로 넓혀 갑니다.
퍼징 도입 점검 항목
- 타깃이 결정적으로 동작하고 파일·네트워크 I/O를 하지 않는가
-
-fsanitize=fuzzer는 퍼즈 타깃에만, 라이브러리는fuzzer-no-link로 빌드했는가 -
-timeout을 기본값(1200초)보다 짧게 두었는가 - 의미 있는 시드 코퍼스와 사전 파일이 있는가
- CI가 크래시 시 실패하고 artifact를 업로드하는가 (
|| true금지) - 크래시 입력을 회귀 테스트로 저장소에 보관하는가
같이 보면 좋은 글
- C++ WebAssembly(Wasm)와 Emscripten | C++을 브라우저에서 돌리기 [#35-2]
- C++ 직접적인 하드웨어 제어: volatile, 메모리 맵 I/O, 인터럽트 서비스 루틴 [#42-2]
- C++ Segmentation fault | core dump
- C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기 [#41-1]
- C++ volatile의 정확한 의미
- ASan과 TSan으로 런타임 버그 잡기
- [[nodiscard]]로 반환값 무시 막기
- C++23에서 실제로 쓸 기능
자주 묻는 질문 (FAQ)
Q. 퍼징 중 OOM이나 타임아웃으로 프로세스가 멈추면 버그로 봐야 하나요?
A. 외부 입력에 따라 메모리 사용량이나 실행 시간이 무한히 늘어난다면 그 자체가 서비스 거부로 이어질 수 있는 실제 버그인 경우가 많습니다. 예를 들어 입력의 길이 필드를 그대로 믿고 거대한 버퍼를 할당하거나, 종료 조건이 입력에 의존하는 루프가 흔한 원인입니다. 정상적으로 큰 입력을 처리하는 것이라면 -rss_limit_mb나 -timeout 옵션으로 한도를 조정하되, 먼저 파서에 최대 크기 검증을 넣을 수 있는지 검토하는 것이 좋습니다.
Q. 퍼즈 테스트와 단위 테스트의 차이는?
A. 단위 테스트는 개발자가 작성한 입력으로 의도한 동작을 검증합니다. 퍼즈 테스트는 퍼저가 생성한 변형 입력으로 의도하지 않은 동작(크래시, 미정의 동작)을 찾습니다. 퍼즈 테스트는 결과 값이 맞는지는 확인하지 않으므로, 둘 다 필요합니다.
Q. CI에서 퍼징 시간은 얼마나?
A. PR마다 1~2분 정도로 짧게 돌리고, 머지 후나 야간 스케줄에서 몇 시간 단위로 길게 돌리는 구성이 흔합니다. 짧은 실행은 새 버그를 찾기보다 코퍼스 재생으로 회귀를 막는 역할이 큽니다.
Q. 퍼즈 테스트 효과는 어떻게 측정하나요?
A. libFuzzer가 출력하는 상태 줄의 cov:(커버된 엣지 수)와 exec/s(초당 실행 수)를 봅니다. cov가 더 이상 늘지 않으면 시드나 사전을 보강할 때입니다. 어떤 코드가 한 번도 실행되지 않았는지 보려면 -fprofile-instr-generate -fcoverage-mapping으로 빌드한 바이너리에 코퍼스를 -runs=0으로 재생하고 llvm-cov로 리포트를 만듭니다.
Q. 프로덕션 빌드에 퍼즈 타겟을 포함해도 되나요?
A. 권장하지 않습니다. 퍼즈 타깃은 sanitizer와 libFuzzer의 main을 포함해 빌드되므로 오버헤드가 크고, 배포 바이너리에서 쓸 일이 없습니다. 별도 테스트 타깃으로만 빌드하고 CI와 개발 환경에서만 실행하세요.
이전 글: 안정성 확보 #41-2: ASan·TSan
다음 글: [실전 도메인 #42-1] 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기