ABC 미래내일 프로젝트

[2026 ABC 프로젝트 멘토링 3기] 프로젝트 4주차

mysticprayer 2026. 4. 26. 21:44

[미래내일 일경험] 테크노트 - 4주차

뮤테이터 고도화 및 ONNX 퍼징 본격 가동 | 2026.04.20 ~ 04.26

프로젝트명 AI 기반 퍼징을 활용한 취약점 탐지 및 검증 자동화 시스템
팀명 The First 작성자 이우곤
담당 역할 하네스 개발, 퍼징 파이프라인 구축, 포맷 연동 구현

📌 1. 금주 학습 목표

  • GGUF 뮤테이터에 필드 간 상호 의존성(cross-field) 변형 전략 추가 구현
  • ONNX 심층 하네스의 세션 로딩 속도 최적화 (세션 풀링, 초기화 비용 절감)
  • ONNX 대상 24시간 장시간 퍼징 1차 실행 및 결과 분석
  • Triage 모듈(전선정 팀원)과 첫 E2E 인터페이스 연동 테스트 수행
  • 하네스 빌드 프로세스 자동화 (CMake + Cargo build.rs 통합)
  • 3주차에 수집한 GGUF 크래시 6~8건의 PoC 최소화 작업 완료

📖 2. 학습 및 수행 내용

2-1. Cross-Field 뮤테이션 전략의 필요성

3주차에 구현한 단일 필드 변형 뮤테이터는 엣지 커버리지를 크게 끌어올렸지만, 24시간 퍼징 후반부에 들어서면서 커버리지 증가가 정체되는 현상이 관찰되었다. 12시간 시점 4,780이었던 엣지 커버리지가 24시간 시점에는 5,103에서 멈추었으며, 이는 단일 필드만 변형해서는 더 깊은 검증 로직에 도달하기 어렵다는 것을 의미한다.

실제 GGUF 파서의 코드를 분석해 보면, 헤더 필드들은 서로 강한 의존 관계를 가진다. 예컨대 tensor_count와 텐서 정보 영역의 실제 엔트리 수가 일치해야 하며, 텐서의 n_dims와 차원 배열의 길이가 맞물려야 한다. 단일 필드만 변형하면 대부분 초기 sanity check 단계에서 걸러지지만, 두 필드를 동시에 조작하여 의도적으로 모순을 유지하면 검증 로직의 더 깊은 분기까지 도달할 수 있다.

💡 Cross-Field 변형의 4가지 핵심 패턴

  • 일관성 위반(Consistency Break): tensor_count = 100이지만 실제 텐서 엔트리는 3개만 존재 → 파서가 100개를 읽으려다 OOB Read 유발
  • 크기-오프셋 모순(Size-Offset Mismatch): 텐서의 offset은 파일 끝 너머를 가리키지만 size는 정상값 → 매핑된 메모리 외부 접근 유도
  • 차원-데이터 불일치(Dim-Data Mismatch): n_dims = 4이지만 차원 배열에는 2개 값만 존재 → 차원 곱셈 시 미초기화 메모리 참조
  • 타입-크기 모순(Type-Size Mismatch): 텐서 타입은 F32(4바이트/원소)인데 데이터 크기는 홀수 → 정렬 가정 위반에 따른 정수 오버플로우

2-2. Cross-Field 뮤테이터 구현

3주차에 구현한 단일 필드 뮤테이터에 cross-field 전략을 추가 구현하였다. 핵심은 두 필드를 함께 조작하되, 그 모순이 의미 있는 방향이 되도록 설계한 것이다.

// 텐서 개수와 실제 엔트리 수 사이의 모순을 유도
size_t mutate_count_entry_mismatch(uint8_t *data, size_t size,
                                   size_t max_size, struct gguf_view *view) {
    // 1) tensor_count는 의도적으로 부풀려 기록
    uint64_t declared_count = view->tensor_count + (rand() % 100 + 50);
    memcpy(data + view->tensor_count_offset, &declared_count, sizeof(uint64_t));

    // 2) 실제 텐서 엔트리는 그대로 두어 모순 발생
    // 파서는 declared_count만큼 루프 돌다가 파일 끝을 넘어감
    return size;
}

// 텐서 차원 수와 차원 배열 길이의 불일치 유도
size_t mutate_dim_array_mismatch(uint8_t *data, size_t size,
                                 size_t max_size, struct gguf_view *view) {
    if (view->tensor_count == 0) return size;
    
    struct tensor_info *t = &view->tensors[0];
    // n_dims를 4로 선언하지만 실제 차원 배열에는 1개만 기록되도록
    uint32_t fake_ndims = 4;
    memcpy(data + t->ndims_offset, &fake_ndims, sizeof(uint32_t));
    // dim 배열 영역을 늘리지 않고 그대로 둠 → 파서는 미초기화 메모리 참조
    return size;
}

// 메인 뮤테이터에 cross-field 전략 추가 (기존 4종 + 신규 4종)
size_t LLVMFuzzerCustomMutator(uint8_t *data, size_t size,
                                size_t max_size, unsigned int seed) {
    struct gguf_view view;
    if (!parse_gguf_layout(data, size, &view))
        return LLVMFuzzerMutate(data, size, max_size);

    int strategy = seed % 10;
    switch (strategy) {
        // 단일 필드 (3주차 구현, 0~3)
        case 0: return mutate_tensor_count(data, size, max_size, &view);
        case 1: return mutate_kv_string_length(data, size, max_size, &view);
        case 2: return mutate_tensor_offset(data, size, max_size, &view);
        case 3: return mutate_tensor_dimensions(data, size, max_size, &view);
        // Cross-field (4주차 신규, 4~7)
        case 4: return mutate_count_entry_mismatch(data, size, max_size, &view);
        case 5: return mutate_offset_size_mismatch(data, size, max_size, &view);
        case 6: return mutate_dim_array_mismatch(data, size, max_size, &view);
        case 7: return mutate_type_size_mismatch(data, size, max_size, &view);
        default: return LLVMFuzzerMutate(data, size, max_size);
    }
}

Cross-field 뮤테이터를 적용한 1시간 단위 비교 퍼징을 수행한 결과는 다음과 같다.

지표 3주차 (단일 필드) 4주차 (단일 + Cross-field) 증감
엣지 커버리지 (1h) 3,412 4,287 +25.6%
신규 코드 분기 도달 메타데이터 파싱 위주 텐서 검증, 정렬 계산 분기 도달 심층 도달
예비 크래시 (1h) 7건 12건 +71.4%

2-3. ONNX 심층 하네스 세션 로딩 최적화

3주차에 측정한 ONNX 심층 하네스의 실행 속도는 초당 약 35회에 불과했다. 이는 GGUF 하네스(140회/초)에 비해 4배 가까이 느리며, 24시간 퍼징을 돌려도 GGUF 6시간 분량밖에 되지 않는다. 병목 분석 결과, 한 회당 실행 시간의 약 78%가 Ort::Session 생성 자체에 소비되고 있었다.

⚠️ 세션 로딩 병목의 정체

  • Ort::Env 초기화: 로깅 시스템, 스레드 풀, 메모리 아레나 등을 매번 초기화 (약 80ms/회)
  • SessionOptions 구성: 디폴트 값 채우기, allocator 등록 (약 15ms/회)
  • Graph 검증: 입력 모델별로 매번 수행 — 이 단계는 퍼징 타깃이므로 그대로 유지

해결 전략은 변하지 않는 부분은 한 번만 초기화하여 재사용하는 것이다. Ort::EnvSessionOptions는 정적 변수로 한 번만 생성하고, Ort::Session만 매 반복마다 새로 만들도록 변경하였다.

// fuzz_onnx_session.cc - 최적화 버전
static Ort::Env& get_env() {
    static Ort::Env env(ORT_LOGGING_LEVEL_FATAL, "fuzz");
    return env;
}

static Ort::SessionOptions& get_session_opts() {
    static Ort::SessionOptions opts = []() {
        Ort::SessionOptions o;
        o.SetIntraOpNumThreads(1);
        o.SetInterOpNumThreads(1);
        o.SetGraphOptimizationLevel(ORT_DISABLE_ALL);
        o.DisableMemPattern();
        o.DisableCpuMemArena();
        return o;
    }();
    return opts;
}

extern "C" int LLVMFuzzerTestOneInput(const uint8_t* data, size_t size) {
    if (size > 1024 * 512) return 0;
    try {
        // Env, SessionOptions는 재사용 — Session만 새로 생성
        Ort::Session session(get_env(), data, size, get_session_opts());
    } catch (const Ort::Exception&) {
    } catch (const std::exception&) {}
    return 0;
}
측정 항목 최적화 전 최적화 후 비고
실행 속도 약 35회/초 약 142회/초 약 4배 향상
평균 1회 시간 약 28.5ms 약 7.0ms Env/Opts 초기화 비용 제거
메모리 누적 선형 증가 (의심) 안정 (250MB ±10MB) MemArena 비활성화 효과

2-4. ONNX 24시간 장시간 퍼징 1차 실행

최적화된 심층 하네스와 경량 하네스(LPM 기반)를 모두 24시간 가동하였다. 각 하네스는 별도 Docker 컨테이너에서 4 워커 병렬로 실행되었으며, 시드 코퍼스는 ONNX 공식 모델 동물원(Model Zoo)에서 추출한 최소 모델 8종으로 구성하였다.

하네스 총 실행 엣지 커버리지 예비 크래시
경량 (LPM) 약 2.1억 회 7,840 (protobuf 파싱 영역) 9건
심층 (Session) 약 4,900만 회 12,420 (그래프 검증 영역) 17건

🔍 ONNX 퍼징에서 발견한 흥미로운 패턴

심층 하네스에서 발견된 17건의 예비 크래시 중 절반 이상이 그래프 노드 간 텐서 shape이 일치하지 않을 때 발생하는 차원 추론(shape inference) 단계에서 나왔다. 특히 동적 차원(dynamic dimension)을 가진 입력 텐서가 후속 노드의 정적 가정과 충돌하는 케이스가 빈번했다. 이는 ONNX 런타임의 shape inference 로직이 사용자 입력 모델에 대해 충분히 방어적이지 않을 수 있음을 시사한다.

2-5. Triage 모듈과의 첫 E2E 인터페이스 연동

이번 주의 가장 큰 성과는 하네스에서 발견된 크래시가 Triage 모듈로 전달되어 분류·정규화되는 첫 종단 간(End-to-End) 플로우를 완성한 것이다. 이는 전선정 팀원과의 협업으로 이루어졌다.

3주차에 정의한 공통 산출물 구조체 CrashArtifact를 실제로 직렬화/역직렬화하는 코드를 작성하고, 하네스 측에서는 크래시 발생 시 다음 정보를 JSON 형태로 디스크에 기록하도록 변경하였다.

// crashes/crash_20260427_001.json (하네스가 생성)
{
  "crash_id": "crash_20260427_001",
  "format": "gguf",
  "harness_version": "0.4.0",
  "input_path": "crashes/inputs/crash_20260427_001.bin",
  "input_sha256": "3f9a...",
  "input_size": 2412,
  "asan_log": "crashes/logs/crash_20260427_001.asan.log",
  "crash_type": "heap-buffer-overflow",
  "discovered_at": "2026-04-27T03:14:22Z",
  "mutation_strategy": "count_entry_mismatch"
}

Triage 모듈은 이 JSON과 ASan 로그를 읽어 스택 트레이스를 정규화하고 중복 제거를 수행한다. 첫 E2E 테스트에서 GGUF 하네스의 23건 예비 크래시를 Triage에 흘려보낸 결과, 고유 크래시 7건으로 정규화되어 출력되었다. 이는 3주차 종료 시 추정했던 6~8건과 일치하여, 인터페이스가 의도대로 동작함을 확인하였다.

✓ E2E 연동 성공의 의미

"하네스 → 크래시 산출 → Triage → 고유 크래시 분류"라는 파이프라인의 절반이 동작하기 시작했다. 다음 주부터 Triage 결과가 다시 Crash Reproducer(전선정), Report Generator(신숙우·황희주)로 흘러가면서 전체 파이프라인이 완성될 예정이다.

2-6. 빌드 프로세스 자동화

지금까지 GGUF 하네스, ONNX 하네스 2종, Rust 코어를 각각 수동으로 빌드하고 있었으나, 팀 규모가 커지고 모듈이 늘어남에 따라 자동화가 필수가 되었다. CMake와 Cargo의 build.rs를 통합하여 단일 명령으로 전체 시스템이 빌드되도록 구성하였다.

// fuzzer-bridge/build.rs - 하네스 빌드를 Cargo가 자동 트리거
use std::process::Command;

fn main() {
    // 1. CMake로 C/C++ 하네스 빌드 디렉토리 구성
    Command::new("cmake")
        .args(&["-S", "../../harnesses",
               "-B", "../../build/harnesses",
               "-DCMAKE_C_COMPILER=clang",
               "-DCMAKE_CXX_COMPILER=clang++",
               "-DENABLE_FUZZING=ON",
               "-DENABLE_ASAN=ON"])
        .status().expect("cmake configure failed");

    // 2. 하네스 컴파일
    Command::new("cmake")
        .args(&["--build", "../../build/harnesses", "--parallel"])
        .status().expect("harness build failed");

    // 3. 빌드 산출물을 Rust 코어가 찾을 수 있도록 link 경로 출력
    println!("cargo:rustc-link-search=native=../../build/harnesses");
    println!("cargo:rerun-if-changed=../../harnesses");

    // 4. bindgen으로 C 헤더 → Rust 바인딩 생성
    let bindings = bindgen::Builder::default()
        .header("../../harnesses/include/harness_interface.h")
        .generate()
        .expect("bindgen failed");
    bindings.write_to_file("src/bindings.rs").unwrap();
}

이제 팀원 누구나 cargo build --workspace 한 번으로 전체 시스템을 빌드할 수 있다. CI(GitHub Actions)에서도 이 명령 하나로 통합 검증이 이루어진다.

2-7. GGUF 크래시 PoC 최소화 작업

3주차 마지막에 수집한 GGUF 크래시 23건은 Triage를 거쳐 고유 7건으로 정리되었다. 이 7건 각각에 대해 LibFuzzer의 -minimize_crash=1 옵션을 사용하여 PoC를 최소화하는 작업을 수행하였다. 최소화는 ASan 로그의 동일성을 유지하면서 입력 크기를 줄이는 과정으로, 리포트의 가독성과 분석 효율을 크게 향상시킨다.

크래시 ID 유형 원본 크기 최소화 후
GGUF-001 Heap Buffer Overflow 2,412 B 108 B
GGUF-002 OOB Read (메타데이터) 3,856 B 142 B
GGUF-003 Integer Overflow 1,920 B 96 B
GGUF-004 Use-After-Free (의심) 5,312 B 218 B
GGUF-005~007 Stack Overflow (재귀 의존) 평균 4.2 KB 평균 312 B

특히 GGUF-004(Use-After-Free 의심) 케이스는 매우 흥미로운데, ASan 로그상으로는 UAF로 보이지만 동일 입력을 재실행했을 때 일부 환경에서는 재현되지 않는 플레이키(flaky) 특성을 보였다. 메모리 할당기의 동작이 환경 의존적인 결과를 낳는 것으로 보이며, 이는 다음 주 Crash Reproducer 모듈(전선정 팀원)에서 정밀히 검증할 예정이다.

🛠️ 3. 수행 활동

3-1. 개인 수행 활동

  • GGUF cross-field 뮤테이터 4종(count-entry, offset-size, dim-array, type-size 모순) 신규 구현 및 통합
  • ONNX 심층 하네스 프로파일링 및 세션 로딩 병목 분석 (perf record 활용)
  • Ort::Env·SessionOptions 정적화 적용으로 ONNX 심층 하네스 속도 4배 향상
  • ONNX 경량/심층 하네스 24시간 장시간 퍼징 1차 실행 및 결과 수집
  • CrashArtifact JSON 직렬화 포맷 확정 및 하네스 측 출력 코드 구현
  • CMake + Cargo build.rs 통합 빌드 시스템 구축, GitHub Actions CI 워크플로 작성
  • GGUF 고유 크래시 7건의 PoC 최소화 작업 수행 (평균 95% 이상 크기 감소)

3-2. 팀 활동

  • 온라인 정기 미팅 (04.26, Discord): cross-field 뮤테이션 적용 결과와 ONNX 24시간 퍼징 결과를 공유, 발견된 크래시 패턴에 대한 토론 진행
  • 전선정 팀원과 페어 작업 (04.24~04.25): Triage 모듈과의 인터페이스 연동을 직접 함께 디버깅하며 CrashArtifact 필드를 최종 확정. 특히 ASan 로그 경로 표현 방식(절대 경로 vs 상대 경로)을 두고 논의 후 컨테이너 내부 기준 상대 경로로 통일
  • 멘토링 세션 (04.27): 멘토로부터 cross-field 모순 패턴이 실무 CVE에서도 자주 발견되는 패턴이라는 피드백을 받음. 또한 ONNX shape inference 단계에서 발견된 크래시들이 RCE 가능성이 있을 수 있어 레지스터 분석을 우선적으로 진행하라는 조언 수령
  • GitHub Actions CI 도입: 빌드 자동화에 이어 PR마다 자동 빌드 + 단위 테스트 실행되도록 구성. 팀 전체의 코드 품질 유지에 기여
  • 중간점검 자료 준비 회의: 5주차에 예정된 중간점검 발표회를 위해 팀원별 진행 상황을 정리하고, 본인은 하네스 및 퍼징 엔진 부분의 슬라이드를 담당하기로 분담

📋 4. 다음 주 계획 (5주차)

계획서상 5주차 키워드: "재현 및 중복 제거 구현"

  • ONNX 크래시 26건(경량 9 + 심층 17)에 대한 PoC 최소화 및 Triage 입력 작업
  • Crash Reproducer 모듈(전선정 팀원)과 함께 격리 컨테이너 3회 반복 재현 시스템 검증
  • GGUF-004(플레이키 UAF) 케이스의 재현 환경 변수 분석 및 안정 재현 조건 도출
  • 하네스 측에서 재현 검증 자동 트리거를 위한 watchdog/timeout 로직 보강
  • 중간점검 발표회 자료 준비 (하네스 및 퍼징 엔진 섹션 담당)
  • SafeTensors 포맷 사전 조사 (다음 단계 확장 대비)

💬 5. 느낀점 및 회고

4주차는 "퍼징 엔진의 절반"을 완성한 한 주였다. 3주차까지가 단일 하네스의 동작을 검증하는 단계였다면, 4주차는 그 결과물이 다른 모듈로 넘어가 의미 있게 소비되는 첫 순간을 만들어낸 주였다. 내가 만든 하네스에서 나온 23건의 크래시가 Triage 모듈을 거쳐 고유 7건으로 정리되어 출력되는 것을 처음 봤을 때, 그동안 따로따로 개발하던 모듈들이 비로소 하나의 시스템으로 작동하기 시작했다는 실감이 들었다.

Cross-field 뮤테이션을 구현하면서 가장 흥미로웠던 점은, 결국 퍼징의 깊이는 "타깃을 얼마나 잘 이해하느냐"에 비례한다는 것이었다. 단순 바이트 → 단일 필드 → cross-field로 발전할수록 변형의 의미가 풍부해지고, 그만큼 깊은 코드 영역에 도달했다. 이건 단순히 더 많은 코드를 쓴 결과가 아니라, GGUF 포맷의 의존 관계를 더 깊이 이해한 결과였다. 멘토님 말씀처럼 실무 CVE에서 cross-field 모순 패턴이 자주 발견된다는 것이 우연이 아니라는 생각이 들었다.

ONNX 심층 하네스 최적화는 성능 엔지니어링의 즐거움을 다시 느낀 작업이었다. perf record로 병목을 찾아내고, 정확히 그 부분만 정적화하니 성능이 4배 뛰었다. 처음에는 "ONNX는 무거우니 어쩔 수 없다"고 생각했는데, 정량적으로 측정해 보니 변하지 않는 부분(Ort::Env)이 매번 다시 만들어지고 있었던 것이 진짜 원인이었다. "느린 것 같다"는 직관 대신 측정 데이터로 결정하는 습관의 중요성을 다시 한 번 체감했다.

빌드 자동화는 사실 가장 "재미없어 보이는" 작업이었지만, 그 효과는 즉각적으로 체감되었다. 팀원들이 "어떻게 빌드하는지 모르겠다"는 질문을 하던 것이 사라지고, GitHub Actions가 PR마다 자동으로 검증해 주니 코드 리뷰의 부담도 줄었다. 인프라 작업이 결국 팀의 속도를 좌우하는 곱셈 인자라는 것을 느꼈다.

다음 주는 중간점검이 있다. 그동안 만든 하네스와 뮤테이터, 그리고 처음으로 동작한 E2E 플로우를 발표 자료로 정리하면서, 4주간의 진행을 객관화해 볼 수 있을 것 같다. 동시에 5주차는 "재현 및 중복 제거 구현" 단계이므로, 내가 만든 크래시들이 실제로 안정 재현되는지를 전선정 팀원과 함께 확정하는 것이 핵심 과제가 될 것이다.

📚 6. 참고 자료