C++ Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기 [#41-3]

C++ Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기 [#41-3]

이 글의 핵심

C++ Fuzz Testing: 예상치 못한 입력값으로 프로그램의 견고함 테스트하기 [#41-3]에 대해 정리한 개발 블로그 글입니다. 41-1 정적 분석, 41-2 Sanitizer로 알려진 패턴과 실행 시 메모리/경합을 잡았다면, 퍼즈 테스트는 무작위·변형된 입력을 계속 넣어서 "이런 걸 넣으면 터지네"를 자동으로 찾습니다. 파서, 디코더, 프로토콜… 개념과 예제 코드를 단계적으로 다루…

들어가며: “이런 입력은 생각도 못 했어요”

예상치 못한 입력이 버그를 연다

41-1 정적 분석, 41-2 Sanitizer로 알려진 패턴실행 시 메모리/경합을 잡았다면, 퍼즈 테스트무작위·변형된 입력을 계속 넣어서 “이런 걸 넣으면 터지네”를 자동으로 찾습니다. 파서, 디코더, 프로토콜 처리처럼 외부 입력을 받는 코드에 특히 유효합니다.
libFuzzer(LLVM)는 in-process, 코드 커버리지 기반 퍼저로, 퍼즈 타겟 함수 하나를 정해 두면 그 함수에 바이트 배열을 넘겨 반복 실행합니다. ASan·UBSan과 함께 쓰면 오버플로·정수 오버플로 등도 함께 검출할 수 있습니다.

이 글에서 다루는 것:

  • 퍼즈 테스트 개념: 퍼저가 입력을 생성·변형해 타겟 반복 호출
  • libFuzzer: LLVMFuzzerTestOneInput·빌드 옵션·코퍼스
  • 완전한 퍼즈 테스트 예제: JSON 파서, URL 파서, 프로토콜 처리
  • 자주 발생하는 에러와 해결법
  • 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 이면 위험!
}

왜 이런 일이 발생할까요?

개발자는 정상적인 입력만 생각하고 테스트합니다. 하지만 공격자실수한 클라이언트비정형 데이터, 극단적 값, 경계 조건을 보냅니다. 수동 테스트로는 이런 입력을 모두 커버할 수 없습니다. 퍼즈 테스트는 이 공백을 자동으로 채웁니다.

개념을 잡는 비유

빌드·검사·배포 파이프라인은 공장 검수 라인과 비슷합니다. 같은 입력이면 같은 산출물이 나오게 고정하고, Sanitizer·정적 분석은 출하 전 불량 검사 역할을 합니다.


목차

  1. Fuzz Testing 개념
  2. libFuzzer 사용하기
  3. 완전한 퍼즈 테스트 예제
  4. 자주 발생하는 에러와 해결법
  5. CI 연동
  6. 프로덕션 패턴
  7. 코퍼스와 회귀 테스트
  8. 정리

1. Fuzz Testing 개념

자동화된 잘못된 입력 주입

  • 퍼저타겟 함수입력 바이트(또는 구조화된 데이터)를 넘겨 반복 호출합니다. 입력은 무작위 또는 기존 코퍼스변형해서 만들어, “이전에 크래시를 유발한 입력”을 기반으로 더 많은 경로를 탐색합니다.
  • 목표: 크래시, assert 실패, Sanitizer 감지를 유발하는 입력을 찾는 것입니다. 찾으면 해당 입력을 코퍼스에 저장해 회귀 테스트에 활용합니다.
  • 적합한 코드: 파싱, 디코딩, 직렬화/역직렬화, 파일 포맷 처리, 네트워크 프로토콜 처리 등 바이트 스트림을 해석하는 코드.

퍼즈 테스트 흐름

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

항목libFuzzerAFL/AFL++
모드in-process (단일 프로세스)out-of-process (fork)
속도매우 빠름 (fork 없음)상대적으로 느림
커버리지LLVM SanitizerCoverage바이너리 계측
CI 적합성짧은 시간에 효과적장시간 권장
플랫폼Clang 전용GCC/Clang

2. libFuzzer 사용하기

LLVMFuzzerTestOneInput

  • 타겟: extern “C” int LLVMFuzzerTestOneInput(const uint8_t data, size_t size)* 를 정의합니다. 퍼저가 data/size를 생성해 이 함수를 반복 호출합니다. 이 안에서 파서·디코더 등을 호출하면 됩니다.

퍼저가 datasize 로 임의(또는 코퍼스 기반 변형) 바이트를 넘기므로, 이 안에서 p.parse(data, size) 처럼 실제 파서·디코더를 호출합니다. 여기서 크래시나 assert·ASan 감지가 나면 libFuzzer가 그 입력을 저장하고 종료합니다. size < 4 같은 최소 길이 체크로 의미 없는 짧은 입력은 빨리 건너뛸 수 있습니다. 반환값은 보통 0이고, 퍼저는 이 함수를 무한히 반복 호출합니다.

#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 < crash_input

주요 libFuzzer 옵션

옵션설명예시
-runs=N실행 횟수 제한-runs=100000
-max_total_time=N총 실행 시간(초)-max_total_time=60
-timeout=N입력당 타임아웃(초)-timeout=1
-rss_limit_mb=N메모리 제한(MB)-rss_limit_mb=2048
-dict=file토큰 사전 파일-dict=keywords.dict
-artifact_prefix=path크래시/타임아웃 저장 경로-artifact_prefix=crash_
-minimize_crash=1크래시 입력 최소화./fuzz < crash
-print_final_stats=1종료 시 통계 출력커버리지, 실행 횟수 등

3. 완전한 퍼즈 테스트 예제

예제 1: 간단한 정수 파서

길이 접두어가 있는 정수 배열을 파싱하는 코드를 퍼징합니다.

// simple_int_parser.hpp
#pragma once
#include <cstdint>
#include <vector>
#include <stdexcept>

// 위험한 파서: 경계 검사 부족
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 = (data[0] << 24) | (data[1] << 16) |
                         (data[2] << 8) | data[3];
        // ❌ count 검증 없음! size와 비교해야 함
        size_t needed = 4 + count * 4;
        if (size < needed) return result;  // ✅ 수정: 경계 검사

        for (uint32_t i = 0; i < count; ++i) {
            size_t offset = 4 + i * 4;
            int32_t val = (data[offset] << 24) | (data[offset+1] << 16) |
                          (data[offset+2] << 8) | data[offset+3];
            result.push_back(val);
        }
        return result;
    }
};
// 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 simple_int_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)

    out.type = (data[0] << 24) | (data[1] << 16) |
               (data[2] << 8) | data[3];
    uint32_t len = (data[4] << 24) | (data[5] << 16) |
                   (data[6] << 8) | data[7];

    // ✅ 핵심: 길이 검증 - 공격자가 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)

libFuzzer의 FuzzedDataProvider를 사용하면 바이트 스트림을 정수, 문자열, 벡터 등으로 쉽게 분해할 수 있습니다. 헤더 경로: Clang 설치 시 compiler-rt에 포함되며, #include <fuzzer/FuzzedDataProvider.h> 로 사용합니다.

// fuzz_with_provider.cpp
#include <stddef.h>
#include <stdint.h>
#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는 compiler-rt에 포함 (Clang 기본)
clang++ -std=c++17 -g -fsanitize=fuzzer,address \
    -I$(dirname $(clang++ -print-file-name=libclang_rt.fuzzer.a))/../include \
    -o fuzz_provider fuzz_with_provider.cpp

4. 자주 발생하는 에러와 해결법

문제 1: “undefined reference to LLVMFuzzerTestOneInput”

원인: 링크 시 -fsanitize=fuzzer를 누락했거나, 함수 시그니처가 잘못됨.

해결법:

// ✅ 올바른 시그니처 (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; 로 짧은 입력 조기 스킵
  • 입력 크기 제한: if (size > 64*1024) return 0;
  • 타임아웃: -timeout=1 (입력당 1초)
  • I/O 제거: 파일/네트워크 대신 메모리 버퍼 사용
// ❌ 느림: 매번 파일 쓰기
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).

해결법:

  • 길이/크기 상한 검증
  • -rss_limit_mb=N 으로 메모리 제한
// ✅ 파서 내부에서 상한 검사
if (count > 1024 * 1024) return {};  // 100만 개 초과 시 거부
# 2GB 메모리 제한
./fuzz_target -rss_limit_mb=2048 corpus

문제 4: 무한 루프 / 타임아웃

원인: 특정 입력이 파서를 무한 루프에 빠뜨림 (예: 정규식 ReDoS, 깊은 재귀).

해결법:

  • -timeout=1: 입력당 1초 제한
  • 재귀 깊이 제한 파서에 추가
  • -ignore_timeouts=0: 타임아웃 입력도 저장 (기본은 무시)
./fuzz_target -timeout=1 -ignore_timeouts=0 corpus

문제 5: ASan과 libFuzzer 충돌

원인: 일부 환경에서 -fsanitize=fuzzer,address 조합이 링크 에러.

해결법:

  • Clang 최신 버전 사용 (10+)
  • -fsanitize=fuzzer-fsanitize=address 를 함께 지정할 때 순서 확인
# ✅ 일반적으로 동작하는 조합
clang++ -fsanitize=address,undefined,fuzzer -o fuzz fuzz.cpp

문제 6: 코퍼스가 비어 있거나 효과 없음

원인: 시드가 없어 퍼저가 순수 무작위만 사용. 구조화된 포맷은 무작위로는 경로 탐색이 어려움.

해결법:

  • 의미 있는 시드 추가: corpus/ 에 유효한 JSON, URL, 패킷 샘플 넣기
  • -dict=file 사용: 키워드, 토큰 사전 제공
# url.dict - URL 파서용
http
https
ftp
://
/
?
#
%
./fuzz_target -dict=url.dict corpus

문제 7: FuzzedDataProvider 헤더를 찾을 수 없음

원인: Clang 버전에 따라 FuzzedDataProvider.h 경로가 다름.

해결법:

# 헤더 경로 확인 (Clang 14+)
clang++ -print-resource-dir
# 출력: /usr/lib/llvm-14/lib/clang/14.0.0
# 헤더: /usr/lib/llvm-14/lib/clang/14.0.0/include/fuzzer/FuzzedDataProvider.h

# CMake에서 include 경로 추가
target_include_directories(fuzz_target PRIVATE
  ${CMAKE_CXX_COMPILER_ID}-${CMAKE_CXX_COMPILER_VERSION}/include
)

또는 FuzzedDataProvider.h를 프로젝트에 복사해 사용할 수 있습니다.

문제 8: 퍼징 중 “LeakSanitizer” 메모리 누수 보고

원인: 타겟 코드가 명시적으로 해제하지 않는 메모리를 할당. 퍼저는 반복 호출하므로 누적되면 OOM.

해결법:

  • -detect_leaks=0: 퍼징 시에는 누수 검사 비활성화 (크래시 찾기에 집중)
  • 또는 타겟 코드에서 할당한 리소스를 명시적으로 해제
# 퍼징 시 누수 검사 끄기 (ASan은 유지)
LSAN_OPTIONS=detect_leaks=0 ./fuzz_target corpus

5. 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-15

      - name: Build fuzz target
        run: |
          export CC=clang-15
          export CXX=clang++-15
          mkdir build && cd build
          cmake .. -DCMAKE_CXX_FLAGS="-fsanitize=fuzzer,address,undefined"
          cmake --build . --target fuzz_target

      - name: Run fuzz (60 seconds)
        run: |
          cd build
          mkdir -p corpus
          # 기존 코퍼스 있으면 사용
          if [ -d ../corpus ]; then cp -r ../corpus .; fi
          timeout 60 ./fuzz_target corpus -runs=100000 || true
          # 실패 시 corpus에 크래시 입력 저장됨

      - name: Upload corpus on failure
        if: failure()
        uses: actions/upload-artifact@v4
        with:
          name: fuzz-corpus-crash
          path: build/corpus/

  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-15
    CXX: clang++-15
  before_script:
    - apt-get update && apt-get install -y clang-15 cmake
  script:
    - mkdir build && cd build
    - cmake .. -DFUZZ=ON
    - cmake --build .
    - mkdir -p corpus
    - timeout 120 ./fuzz_target corpus
  artifacts:
    when: on_failure
    paths:
      - build/corpus/
    expire_in: 7 days

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()

6. 프로덕션 패턴

패턴 1: 시드 코퍼스 관리

# corpus/ 디렉터리 구조
corpus/
├── seed_001.json      # 유효한 JSON 샘플
├── seed_002.json
├── url_001.txt        # 다양한 URL 형식
├── packet_001.bin     # 유효한 패킷
└── crash_abc123       # 이전에 발견한 크래시 입력 (회귀용)
  • 시드: 퍼저가 변형의 출발점으로 사용. 유효한 형식일수록 깊은 경로 탐색에 유리.
  • 크래시 입력: 버그 수정 후 회귀 테스트에 사용. git add corpus/ 로 버전 관리.

패턴 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: 크래시 입력 최소화

# 최소화된 입력으로 버그 수정 확인
./fuzz_target -minimize_crash=1 < crash_input
# 출력: 최소화된 입력 (같은 버그 유발, 더 짧음)

패턴 6: AFL++와 libFuzzer 병행

구조가 복잡한 포맷은 AFL++의 변이 전략이 더 효과적일 수 있습니다. CI에서는 libFuzzer를, 야간 장시간 퍼징에서는 AFL++를 사용하는 패턴이 있습니다.

# AFL++ 빌드 (afl-clang-fast 사용)
export CC=afl-clang-fast
export CXX=afl-clang-fast++
afl-fuzz -i corpus -o afl_output -m none -- ./fuzz_target @@

# AFL 출력을 libFuzzer 코퍼스로 변환
# afl_output/default/crashes/ 의 파일을 corpus/에 복사

패턴 7: OSS-Fuzz 스타일 통합

Google의 OSS-Fuzz는 오픈소스 프로젝트에 무료 퍼징 인프라를 제공합니다. 비슷한 구조로 자체 퍼징 파이프라인을 구성할 수 있습니다.

# Dockerfile.fuzz (간소화된 예시)
FROM gcr.io/oss-fuzz-base/base-builder
RUN apt-get update && apt-get install -y clang cmake
COPY . /src
WORKDIR /src
RUN ./build_fuzz.sh
# build_fuzz.sh
mkdir build && cd build
cmake .. -DFUZZ=ON -DCMAKE_C_COMPILER=clang -DCMAKE_CXX_COMPILER=clang++
cmake --build . --target fuzz_target
cp fuzz_target $OUT/

7. 코퍼스와 회귀 테스트

시드와 회귀 테스트

  • 코퍼스: 크래시·새 경로를 유발한 입력 파일들이 모인 디렉터리. git으로 관리하면 회귀 시 “이 입력으로 다시 터지는지” 확인할 수 있습니다. 최소화(-minimize_crash=1 등)로 같은 버그를 유발하는 더 작은 입력을 만들 수 있습니다.
  • CI: 퍼즈 타겟을 Sanitizer + fuzzer 옵션으로 빌드하고, 짧은 시간(예: 60초)만 돌리거나, 기존 코퍼스만 재실행해 회귀을 검사합니다. 장시간 퍼징은 별도 스케줄(야간 등)으로 돌리는 경우가 많습니다.
  • 타임아웃: 한 입력에서 타임아웃이 나면 퍼저가 그 입력을 “느린 입력”으로 기록해 다음부터 제한 시간을 두고 실행할 수 있습니다. -timeout=1 등으로 설정합니다.

코퍼스 디렉터리 전략

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

8. 정리

항목요약
퍼즈 테스트무작위·변형 입력으로 타겟 반복 호출 → 크래시·오류 유발 입력 발견
libFuzzerLLVMFuzzerTestOneInput + -fsanitize=fuzzer, ASan·UBSan과 병행
완전한 예제정수 파서, URL 파서, 패킷 파서, FuzzedDataProvider
자주 발생하는 에러링크 에러, 느린 퍼징, OOM, 타임아웃, 시드 부족
CI 연동GitHub Actions, GitLab CI, 60초 퍼징 + 코퍼스 회귀
프로덕션 패턴시드 관리, 회귀 스크립트, 장시간 퍼징, 크래시 최소화
코퍼스·CI시드 저장·회귀 테스트, CI에서는 짧은 실행 또는 코퍼스만 재실행

41번 시리즈는 정적 분석(Clang-Tidy, Cppcheck)런타임 검증(ASan, TSan)퍼즈 테스트(libFuzzer)로 “버그가 발생하기 전에 차단하는” 체계를 마쳤습니다.


실전 적용 순서

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. 장시간 퍼징 스케줄 설정]
  1. 타겟 식별: 외부 입력을 받는 파서·디코더·프로토콜 처리 함수
  2. 래퍼 작성: data/size를 타겟에 전달하는 LLVMFuzzerTestOneInput
  3. 빌드: -fsanitize=fuzzer,address,undefined
  4. 로컬 테스트: 1분 실행해 크래시·에러 확인
  5. 버그 수정: 발견 시 수정 후 코퍼스에 크래시 입력 저장
  6. 시드 추가: 유효한 형식 샘플을 corpus/에 넣기
  7. CI 연동: 푸시마다 60초 이상 퍼징
  8. 코퍼스 관리: 크래시 입력을 git으로 버전 관리
  9. 장시간 퍼징: 야간/주말에 수 시간~24시간 실행

구현 체크리스트

  • LLVMFuzzerTestOneInput 타겟 함수 정의
  • -fsanitize=fuzzer,address,undefined 빌드 옵션
  • 최소/최대 입력 크기 필터
  • 의미 있는 시드 코퍼스 준비
  • CI에 퍼즈 job 추가 (60초 이상)
  • 회귀 테스트 스크립트 (코퍼스만 재실행)
  • 크래시 입력 git 관리
  • -timeout, -rss_limit_mb 설정

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ WebAssembly(Wasm)와 Emscripten | C++을 브라우저에서 돌리기 [#35-2]
  • C++ 직접적인 하드웨어 제어: volatile, 메모리 맵 I/O, 인터럽트 서비스 루틴 [#42-2]
  • C++ Segmentation fault | core dump

실전 체크리스트

실무에서 이 개념을 적용할 때 확인해야 할 사항입니다.

코드 작성 전

  • 이 기법이 현재 문제를 해결하는 최선의 방법인가?
  • 팀원들이 이 코드를 이해하고 유지보수할 수 있는가?
  • 성능 요구사항을 만족하는가?

코드 작성 중

  • 컴파일러 경고를 모두 해결했는가?
  • 엣지 케이스를 고려했는가?
  • 에러 처리가 적절한가?

코드 리뷰 시

  • 코드의 의도가 명확한가?
  • 테스트 케이스가 충분한가?
  • 문서화가 되어 있는가?

이 체크리스트를 활용하여 실수를 줄이고 코드 품질을 높이세요.


이 글에서 다루는 키워드 (관련 검색어)

퍼징, fuzz testing, C++ 퍼징, libFuzzer, AFL, 푸즈 테스트 등으로 검색하시면 이 글이 도움이 됩니다.

자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. 파서, 디코더, 프로토콜 처리, 파일 포맷 처리 등 외부 입력을 해석하는 C++ 코드가 있다면 퍼즈 테스트를 적용하세요. 정적·동적 분석으로 못 잡는 “예상치 못한 입력”에 의한 버그를 자동으로 찾을 수 있습니다.

Q. 퍼즈 테스트와 단위 테스트의 차이는?

A. 단위 테스트는 개발자가 작성한 입력으로 의도한 동작을 검증합니다. 퍼즈 테스트는 퍼저가 생성한 무작위·변형 입력으로 의도하지 않은 동작(크래시, 오류)을 찾습니다. 둘 다 필요합니다.

Q. CI에서 퍼징 시간은 얼마나?

A. PR마다 60~120초 정도로 짧게 돌리고, main/develop 에 머지 후 또는 야간 스케줄으로 수 시간~24시간 장시간 퍼징을 권장합니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. libFuzzer 공식 문서, OSS-Fuzz 프로젝트, cppreference를 참고하세요.

Q. 퍼즈 테스트 효과는 어떻게 측정하나요?

A. 실행 통계(초당 실행 횟수, 총 실행 횟수), 코드 커버리지(libFuzzer는 -print_coverage=1로 경로 수 확인), 발견한 크래시 수로 효과를 측정합니다. 코퍼스 크기가 시간에 따라 늘어나면 더 많은 경로를 탐색한 것입니다.

Q. 프로덕션 빌드에 퍼즈 타겟을 포함해도 되나요?

A. 권장하지 않습니다. 퍼즈 타겟은 -fsanitize=fuzzer,address 등으로 빌드되어 오버헤드가 크고, LLVMFuzzerTestOneInput는 일반 사용자 코드에서 호출할 일이 없습니다. 별도 테스트 타겟으로만 빌드하고, CI/개발 환경에서만 실행하세요.

한 줄 요약: 퍼징으로 예상 못 한 입력에 대한 견고성을 자동으로 검증할 수 있습니다. 다음으로 임베디드·No Exception/RTTI(#42-1)를 읽어보면 좋습니다.

이전 글: 안정성 확보 #41-2: ASan·TSan

다음 글: [실전 도메인 #42-1] 제약된 환경에서의 C++: Exception과 RTTI 없이 안전한 코드 짜기


관련 글

  • C++ 정적 분석 도구 통합: Clang-Tidy와 Cppcheck로 코드 퀄리티 강제하기 [#41-1]
  • C++ volatile 완벽 가이드 | MMIO·ISR·메모리 맵 레지스터·atomic과의 차이 [실전]
  • C++ 런타임 검증: AddressSanitizer와 ThreadSanitizer 완벽 가이드 [#41-2]
  • C++ [[nodiscard]] 완벽 가이드 | 반환값 무시 방지·에러 코드·RAII·사유 메시지 [실전]
  • C++23 핵심 기능 완벽 가이드 | std::expected·mdspan