포스트

OpenEXR libOpenEXRCore 취약점 발견 및 분석 with Fuzzing

HSPACE Knights Frontier의 OpenEXR libOpenEXRCore AFL++ 퍼징 하네스 설계 및 취약점 분석 연구 내용입니다.

목차

  1. Target
  2. Strategy
  3. Build
  4. Binary / Component Overview
  5. Code Auditing and Harness Design
  6. Start Fuzzing
  7. Crash Analysis

이번 글에서는 AFL++를 이용하여 OpenEXR libOpenEXRCore 라이브러리를 대상으로 퍼징을 수행한 과정과 발견한 취약점을 정리해 보겠습니다.

해당 프로젝트는 HSPACE Knights Frontier 분들이 진행한 연구이며, 원우진님께서 본 글을 작성해 주셨습니다.

OpenEXR C Core 라이브러리(libOpenEXRCore)의 공격 표면을 분석하고, 디코딩 파이프라인 전체를 커버하는 AFL++ 퍼징 하네스를 설계한 과정을 정리합니다.


1. Target

OpenEXR란?

OpenEXR는 ILM(Industrial Light & Magic)이 개발하고 현재 Academy Software Foundation(ASWF)이 관리하는 높은 동적 범위(HDR) 이미지 파일 포맷 및 라이브러리입니다.

  • 공식 저장소: https://github.com/AcademySoftwareFoundation/openexr
  • 라이선스: BSD-3-Clause

영화, VFX, 게임, 3D 렌더링 파이프라인 전반에서 표준 이미지 포맷으로 사용되며, Blender, Nuke, Houdini, Maya 등 업계 주요 소프트웨어가 모두 EXR을 기본 포맷으로 지원합니다.

한마디로 정리하면, 영화·VFX 업계에서 쓰는 고품질 이미지 파일을 읽고 쓰는 라이브러리입니다.

왜 OpenEXR인가? — 공격 표면 분석

OpenEXR는 구조적으로 취약점이 발생하기 쉬운 여러 조건을 동시에 갖추고 있습니다.

복잡한 파일 포맷 구조

EXR 파일은 헤더 → 속성(attribute) 테이블 → 청크 오프셋 테이블 → 압축된 픽셀 데이터로 구성됩니다. 각 레이어가 다음 레이어의 크기와 오프셋을 결정하기 때문에, 헤더를 조작하면 파서가 임의 주소를 읽거나 쓰도록 유도할 수 있습니다. 특히 청크 오프셋 테이블은 파일 내 절대 오프셋을 담고 있어, 이 값을 조작하면 OOB read/write로 이어질 수 있습니다.

12개 압축 방식

각 압축 방식(NONE, RLE, ZIPS, ZIP, PIZ, PXR24, B44, B44A, DWAA, DWAB, EXR_COMPRESSION_HTJ2K(lossless), EXR_COMPRESSION_HTJ2K_LOSSY(lossy))은 개별 구현을 가집니다. 압축 해제 루프는 packed_sizeunpacked_size를 헤더에서 읽어 사용하는데, 이 값이 조작되면 heap buffer overread 또는 정수 오버플로우로 이어질 수 있습니다. 압축 방식마다 서로 다른 메모리 접근 패턴을 가지기 때문에 한 구현에서 안전해도 다른 구현에서 취약할 수 있습니다.

4가지 스토리지 타입의 독립된 코드 경로

SCANLINE, TILED, DEEP_SCANLINE, DEEP_TILED는 각각 별도의 파싱 및 디코딩 코드 경로를 사용합니다. 특히 DEEP 타입은 픽셀당 샘플 수가 가변적이라 샘플 카운트 테이블을 먼저 읽고 그 값에 따라 버퍼 크기를 결정하는 2-pass 구조입니다. 샘플 카운트 값이 조작되면 뒤따르는 버퍼 할당 크기 계산에서 정수 오버플로우가 발생할 수 있습니다.

멀티파트 구조

하나의 파일에 여러 파트가 포함될 수 있으며, 파트마다 스토리지 타입과 압축 방식이 달라집니다. 파트 인덱스 검증이 미흡하면 파트 간 메타데이터 혼용, OOB 접근으로 이어질 수 있습니다.

C Core API의 낮은 추상화 수준

libOpenEXRCore는 OpenEXR 3.x에서 새로 추가된 순수 C API입니다. C++ 래퍼가 제공하는 RAII, 예외 처리, 경계 검사 등의 안전망이 없으며, 모든 버퍼 관리와 오류 처리가 호출자 책임입니다. 이는 기존의 C++ 레이어 중심 검증 경로와는 완전히 다른 코드 경로입니다.

이번 분석의 타겟은 C Core API(libOpenEXRCore)의 직접 호출 경로, 특히 압축 해제 → 픽셀 언팩 → 포맷 변환 전 과정입니다.

분석 대상 버전: OpenEXR v3.4.7


2. Strategy

Fuzzer

이번 분석에서는 AFL++를 선택했습니다.

Coverage-guided가 중요한 이유

EXR 파서는 헤더 검증 → 속성 파싱 → 청크 검증 → 압축 해제 → 픽셀 언팩 순으로 진행되며, 앞 단계가 실패하면 뒤 단계 코드는 실행되지 않습니다. 무작위 변이(black-box) 퍼저로는 헤더 검증조차 통과하기 어렵습니다. AFL++는 엣지 커버리지를 추적해 새로운 코드 경로에 도달한 입력만 코퍼스에 추가하기 때문에, 점진적으로 더 깊은 파서 경로를 탐색할 수 있습니다.

Persistent mode가 중요한 이유

EXR 디코딩은 파일 크기에 비례해 처리 시간이 늘어납니다. fork 기반 실행 모델이면 프로세스 생성 비용이 누적되어 실행 속도가 크게 떨어집니다. __AFL_LOOP()를 사용하면 하나의 프로세스가 수천 회 반복 실행하므로 fork 비용이 제거됩니다. ASan이 활성화된 환경에서는 이 차이가 더 크게 나타납니다.

CMPLOG가 필요한 이유

EXR 포맷에는 파서가 검사하는 고정 바이트 패턴이 다수 존재합니다.

  • 파일 매직: 0x762f3101 (리틀엔디언 4바이트)
  • 압축 타입: EXR_COMPRESSION_NONE(0) ~ EXR_COMPRESSION_HTJ2K_LOSSY(11) enum 값
  • 속성 타입 문자열: "float", "int", "string", "chlist", "preview"
  • 속성 이름 문자열: "channels", "compression", "dataWindow" 등 필수 속성명

이 값들 중 하나라도 맞지 않으면 파서가 즉시 반환합니다. CMPLOG 없이 순수 비트 변이로 이 패턴들을 맞추는 것은 확률적으로 매우 낮습니다. CMPLOG는 바이너리 내부의 strcmp, memcmp, 정수 비교 명령어를 추적해 Input-to-State(I2S) 변이 전략으로 이 패턴들을 자동으로 삽입합니다.

LTO instrumentation

afl-clang-lto는 Link-Time Optimization 단계에서 계측을 삽입하기 때문에 인라인 함수, 라이브러리 경계를 넘는 호출 경로까지 에지 커버리지를 수집합니다. OpenEXRCore는 코덱별 함수 포인터를 통해 실행되므로 일반 afl-clang보다 LTO 계측의 커버리지 정확도가 높습니다.

병렬 퍼징 구조: Master + Slave

이번 퍼징에서는 1 master + 14 slave 구조로 15개의 AFL++ 인스턴스를 병렬로 실행합니다.

1
2
3
4
5
6
7
8
9
master (CMPLOG)
  └─ CMPLOG 계측 바이너리 사용 (-c 옵션)
  └─ 비교 연산 추적 → Input-to-State 변이 전략 담당
  └─ 큐를 공유하며 slave들과 동기화

slave × 14 (일반 AFL 계측)
  └─ 일반 AFL 계측 바이너리 사용
  └─ 각자 독립적인 변이 전략으로 탐색
  └─ master 큐에서 흥미로운 입력을 가져와 추가 변이

이 구조를 선택한 이유는 CMPLOG의 비용 때문입니다. CMPLOG는 비교 연산마다 추가 처리를 수행하므로 단독으로 실행하면 처리량이 낮아집니다. master 하나만 CMPLOG를 담당하고 나머지 slave들은 순수 커버리지 탐색에 집중하면, CMPLOG의 I2S 변이 효과를 유지하면서 전체 처리량은 slave 수에 비례해 확보할 수 있습니다.

EXR 포맷처럼 고정 패턴이 많은 타겟에서는 master가 생성한 유효한 입력 구조를 slave들이 변이해 더 깊은 경로를 탐색하는 분업이 효과적입니다.


3. Build

의존성 설치

1
2
3
4
5
sudo apt-get install -y \
    cmake \
    zlib1g-dev \
    libssl-dev \
    clang

AFL++는 소스에서 직접 빌드하거나 패키지로 설치합니다.

OpenEXR 빌드 (AFL 계측 빌드)

퍼징에는 두 가지 빌드가 필요합니다.

  • build_afl: 실제 퍼징에 사용할 AFL++ 계측 바이너리
  • build_cmplog: master 인스턴스에 사용할 CMPLOG 계측 바이너리 (비교문 추적용)

build_afl

1
2
3
4
5
6
7
8
9
mkdir build_afl && cd build_afl
cmake ../openexr \
    -DCMAKE_C_COMPILER=afl-clang-lto \
    -DCMAKE_CXX_COMPILER=afl-clang-lto++ \
    -DCMAKE_C_FLAGS="-fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer" \
    -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer" \
    -DBUILD_TESTING=ON \
    -DCMAKE_BUILD_TYPE=RelWithDebInfo
make -j$(nproc)

build_cmplog

1
2
3
4
5
6
7
8
9
mkdir build_cmplog && cd build_cmplog
AFL_LLVM_CMPLOG=1 cmake ../openexr \
    -DCMAKE_C_COMPILER=afl-clang-lto \
    -DCMAKE_CXX_COMPILER=afl-clang-lto++ \
    -DCMAKE_C_FLAGS="-fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer" \
    -DCMAKE_CXX_FLAGS="-fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer" \
    -DBUILD_TESTING=ON \
    -DCMAKE_BUILD_TYPE=RelWithDebInfo
AFL_LLVM_CMPLOG=1 make -j$(nproc)

빌드 옵션 설명:

  • afl-clang-lto / afl-clang-lto++: LTO 기반 AFL++ 계측기로 최고 수준의 에지 커버리지 수집
  • fsanitize=address,undefined: ASan + UBSan 동시 적용, 메모리 오류 및 미정의 동작 탐지
  • g -O1 -fno-omit-frame-pointer: 디버그 심볼 유지, 낮은 최적화로 스택 트레이스 보존
  • AFL_LLVM_CMPLOG=1: 조건 비교 명령어 추적 계측 활성화 (CMPLOG 빌드 전용)

빌드 완료 후 생성되는 주요 정적 라이브러리:

1
2
3
build_afl/src/lib/OpenEXRCore/libOpenEXRCore-4_0.a
build_afl/external/OpenJPH/src/core/libopenjph.a
build_afl/_deps/imath-build/src/Imath/libImath-3_2.a

4. Binary / Component Overview

하네스 컴파일 후 최종적으로 생성되는 바이너리는 두 개입니다.

fuzz_exr_pipeline

실제 퍼징에 사용하는 메인 하네스 바이너리입니다. AFL++ persistent mode로 동작하며, EXR 파일을 in-memory 스트림으로 파싱하여 헤더 읽기 → 속성 파싱 → 청크 디코딩 → 픽셀 언팩/변환 전 과정을 실행합니다. slave 인스턴스 14개에서 사용합니다.

fuzz_exr_pipeline_cmplog

CMPLOG 계측이 적용된 하네스입니다. AFL++ master 인스턴스에서 -c 옵션으로 사용하며, 바이너리 내부의 비교 연산(strcmp, memcmp, 숫자 비교 등)을 추적하여 AFL++의 Input-to-State(I2S) 변이 전략을 지원합니다. 이를 통해 EXR 매직 바이트, 속성 이름, 압축 타입 고정값 등을 더 빠르게 발견할 수 있습니다.


5. Code Auditing and Harness Design

코드 오디팅 목적

본격적인 퍼징에 앞서 코드 오디팅을 진행했습니다. 목적은 다음과 같습니다.

  • 공격 표면 매핑: 퍼저가 실제로 도달해야 하는 코드 경로 파악. 헤더 파싱만 커버하는 하네스는 압축 해제, 픽셀 언팩 경로의 취약점을 전혀 발견할 수 없습니다.
  • 기존 하네스의 사각지대 확인: 헤더 검증이나 고수준 래퍼 중심 하네스가 실행하지 않는 코드 경로 식별. 이미 발견된 취약점이 없는 경로가 실제로 커버되지 않는 것인지 확인합니다.
  • 취약점 발생 조건 이해: 각 API 함수가 어떤 헤더 값을 신뢰하고 어떤 값을 검증하는지 파악해 퍼저가 집중해야 할 변이 대상을 결정합니다.

디코딩 파이프라인 흐름과 취약점 표면

OpenEXR C Core API의 읽기 파이프라인은 다음과 같이 구성됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
exr_start_read()                          ← 헤더 파싱 (parse_header.c, chunk.c)
  └─ exr_get_count()                      ← 파트 수
  └─ exr_get_storage()                    ← SCANLINE / TILED / DEEP_SCANLINE / DEEP_TILED
  └─ exr_get_chunk_count()                ← 청크 수 (오프셋 테이블)
  └─ exr_get_attribute_by_index()         ← 속성 파서 (string, float_vector, preview 등)

exr_read_scanline_chunk_info()            ← 청크 오프셋 및 크기 검증
exr_read_tile_chunk_info()

exr_decoding_initialize()                 ← 디코딩 파이프라인 초기화
exr_decoding_choose_default_routines()    ← 채널 타입에 따른 언팩 함수 선택
exr_decoding_run()                        ← 압축 해제 + 픽셀 언팩 + 포맷 변환
exr_decoding_destroy()                    ← 리소스 해제

각 단계는 독립적인 취약점 표면을 가집니다.

단계취약점 표면
exr_start_read()헤더 필드 길이 조작 → 속성 파서 OOB read, 청크 오프셋 테이블 크기 조작 → 대형 할당
exr_get_attribute_by_index()string, float_vector, preview 등 타입별 파서 각각 독립 경로 — 타입마다 별도 버퍼 할당
exr_read_*_chunk_info()청크 offset + data_len이 파일 크기 초과 여부 검증 로직 — OOB read 가능성
exr_decoding_run()decompress_fn코덱별 압축 해제 루프 — packed_size/unpacked_size 조작 시 heap overread/overwrite
exr_decoding_run()unpack_and_convert_fn채널 폭·높이·포맷 조합에 따른 픽셀 변환 — stride 계산 오류 시 OOB write

exr_decoding_run()은 내부적으로 세 단계를 순차 실행합니다.

1
2
3
read_fn               : 압축된 청크 데이터를 packed_buffer로 읽기
decompress_fn         : packed_buffer → unpacked_buffer  (각 코덱별 구현)
unpack_and_convert_fn : unpacked_buffer → 채널별 출력 버퍼  (픽셀 포맷 변환)

세 번째 단계(unpack_and_convert_fn)는 출력 버퍼(decode_to_ptr)가 NULL이면 실행되지 않습니다. 헤더 검증 중심 하네스가 이 단계를 놓치기 쉬운 근본 원인입니다. 하네스가 세 단계를 모두 실행해야 픽셀 언팩 경로의 취약점을 발견할 수 있습니다.

Corpus

EXR 포맷은 헤더 구조가 엄격합니다. 완전히 무작위 바이트로는 매직 넘버 검증 단계조차 통과하지 못하므로 유효한 EXR 파일을 시드 코퍼스로 사용해야 합니다.

시드 코퍼스 수집 기준은 두 가지입니다.

  1. 정상 EXR 파일: OpenEXR 공식 테스트 이미지에서 수집. 다양한 압축 방식, 4가지 스토리지 타입, 멀티파트 구조를 골고루 포함시켜 각 코덱·타입별 디코딩 경로를 초기부터 활성화합니다.
  2. 경계 조건 파일: 잘못된 데이터 윈도우 bounds, early EOF, 알 수 없는 압축 타입, 비정상 청크 오프셋 등 오류 케이스 파일. 이 파일들은 파서의 오류 처리 코드 경로를 초기부터 커버하게 해 줍니다. 오류 처리 코드는 정상 경로보다 테스트되지 않는 경우가 많아 취약점이 숨어있을 가능성이 높습니다.

초기 수집된 코퍼스는 126개 파일, 152MB였습니다. 퍼징 효율을 위해 두 단계 최소화를 진행했습니다. ASan 환경에서는 파일 크기가 클수록 실행당 처리 시간이 선형적으로 늘어나고, 동일한 커버리지를 가진 중복 파일은 퍼저의 코퍼스 탐색 속도를 떨어뜨리기 때문입니다.

1
2
3
4
5
6
7
8
9
10
11
# Step 1: afl-cmin으로 커버리지 중복 제거 (126 → 95개)
afl-cmin \
    -i ./corpus \
    -o ./corpus_min \
    -- ./harness/fuzz_exr_pipeline

# Step 2: 500KB 초과 파일 제외 (95 → 40개, 145MB → 5.5MB)
# AFL은 소형 파일을 변이해 대형 파일과 동일한 코드 경로에 도달할 수 있으므로
# 처음부터 대형 파일을 시드로 쓸 필요가 없음
find ./corpus_min -maxdepth 1 -type f -size -500k \
    -exec cp {} ./corpus_small/ \;

최종 코퍼스 구성:

항목수치
파일 수40개
총 크기5.5MB
최대 파일454KB (WavyLinesLatLong.exr)
최소 파일231B (comp_bad_pos_bounds_pxr24.exr)
커버하는 압축 타입NONE, RLE, ZIPS, ZIP, PIZ, PXR24, B44, B44A, DWAA, DWAB
비고이번 최소 코퍼스에는 EXR_COMPRESSION_HTJ2K / EXR_COMPRESSION_HTJ2K_LOSSY 샘플이 포함되지 않음
커버하는 스토리지SCANLINE, TILED, DEEP_SCANLINE, DEEP_TILED

하네스 설계 방향

코드 오디팅을 통해 파악한 취약점 표면을 기준으로 하네스 설계 목표를 정했습니다.

  1. 전체 디코딩 파이프라인 실행: read → decompress → unpack/convert 세 단계 모두 커버. 세 번째 단계를 실행하지 않으면 픽셀 변환 코드의 취약점은 영원히 발견되지 않습니다.
  2. 4가지 스토리지 타입 전부 독립 처리: 타입마다 코드 경로가 다르므로 하나의 함수로 묶으면 커버리지가 섞입니다. 각각 별도 함수로 분리해 AFL이 타입별 코드 경로를 독립적으로 탐색하도록 합니다.
  3. 속성 파서 전 타입 커버: 헤더 속성은 타입별로 별도 파서를 사용합니다. string, float_vector, preview, matrix 등 각 타입의 파서가 퍼징 대상이 되어야 합니다.
  4. Deep 이미지 2-pass 구조 준수: Pass 1(샘플 카운트)을 건너뛰면 Pass 2의 버퍼 크기 결정 로직이 실행되지 않습니다. 정수 오버플로우 등 취약점이 숨어있을 수 있는 경로입니다.
  5. 퍼저 안정성 확보: 정적 스크래치 버퍼로 출력 공간을 제한해 OOM으로 인한 퍼저 종료를 방지하고, MAX_CHUNKS 상한으로 무한 루프성 hang을 차단합니다.

핵심 설계 1: 출력 버퍼 할당 + 언팩 함수 활성화

코드 오디팅 결과, exr_decoding_choose_default_routines()는 각 채널의 decode_to_ptrNULL이 아닐 때만 unpack_and_convert_fn을 설정한다는 것을 확인했습니다. 출력 버퍼를 할당하지 않으면 세 번째 단계가 통째로 스킵되고, 픽셀 변환 코드 전체가 퍼징 대상에서 제외됩니다.

malloc/free를 반복하면 ASan의 shadow memory 갱신 비용이 누적되어 실행 속도가 크게 떨어집니다. 또한 heap 할당은 AFL의 fork-server 모델에서 메모리 상태 오염의 원인이 될 수 있습니다. 16MB 정적 스크래치 버퍼를 사용해 두 문제를 모두 해결합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define SCRATCH_BYTES (16u * 1024u * 1024u)
static uint8_t g_scratch[SCRATCH_BYTES];

static void
setup_output_buffers(exr_decode_pipeline_t *pipeline)
{
    size_t offset = 0;

    for (int c = 0; c < pipeline->channel_count; ++c)
    {
        exr_coding_channel_info_t *ch = &pipeline->channels[c];

        int    bpe    = (ch->bytes_per_element > 0) ? ch->bytes_per_element : 4;
        int    w      = (ch->width  > 0) ? ch->width  : 1;
        int    h      = (ch->height > 0) ? ch->height : 1;
        size_t needed = (size_t)w * (size_t)h * (size_t)bpe;

        if (offset + needed > SCRATCH_BYTES)
        {
            ch->decode_to_ptr = NULL;   /* 스크래치 초과 시 해당 채널 스킵 */
            continue;
        }

        ch->decode_to_ptr          = g_scratch + offset;
        ch->user_bytes_per_element = (int16_t)bpe;
        ch->user_data_type         = ch->data_type;
        ch->user_pixel_stride      = bpe;
        ch->user_line_stride       = w * bpe;
        offset += needed;
    }
}

이후 exr_decoding_choose_default_routines()를 호출하면 unpack_and_convert_fn이 설정되어 세 번째 단계까지 실행됩니다.

1
2
3
4
5
exr_decoding_initialize(ctxt, part_index, &cinfo, &pipeline);
setup_output_buffers(&pipeline);
exr_decoding_choose_default_routines(ctxt, part_index, &pipeline);
exr_decoding_run(ctxt, part_index, &pipeline);
// read → decompress → unpack_and_convert 세 단계 전부 실행

핵심 설계 2: 정확한 스캔라인 청크 반복

EXR의 lines_per_chunk는 압축 방식마다 고정된 값을 가집니다.

압축 방식lines_per_chunk
NONE, RLE, ZIPS1
ZIP, PXR2416
PIZ, B44, B44A, DWAA, EXR_COMPRESSION_HTJ2K_LOSSY32
DWAB, EXR_COMPRESSION_HTJ2K256

이 값을 하네스에서 수동으로 계산하면 압축 방식이 바뀔 때 청크 경계와 어긋나 exr_read_scanline_chunk_info()가 일찍 실패하고 뒤따르는 디코딩 경로가 실행되지 않습니다. exr_get_scanlines_per_chunk() API로 값을 가져오면 변이된 입력에서 압축 타입이 바뀌어도 올바른 경계로 청크를 순회합니다. 이는 더 많은 압축 코덱 경로를 커버하는 데 직접 영향을 줍니다.

1
2
3
4
5
6
7
8
9
int32_t lines_per_chunk = 1;
exr_get_scanlines_per_chunk(ctxt, part_index, &lines_per_chunk);

for (int y = dw.min.y; y <= dw.max.y; y += lines_per_chunk)
{
    exr_chunk_info_t cinfo;
    exr_read_scanline_chunk_info(ctxt, part_index, y, &cinfo);
    // ...
}

핵심 설계 3: 속성 파서 커버리지

EXR 헤더 속성은 타입별로 완전히 별도의 파서를 사용합니다. string 타입은 4바이트 길이 필드 후 데이터를 읽고, float_vector는 원소 수 × 4바이트를 할당하며, preview는 너비 × 높이 × 4바이트를 할당합니다. 퍼저가 이 필드들을 조작하면 각 파서의 길이 계산 로직이 시험됩니다. 속성 타입을 순회하지 않으면 string 파서의 취약점이 발견되어도 preview 파서의 취약점은 영원히 발견되지 않습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
static void
scan_attributes(exr_const_context_t ctxt, int part_index)
{
    int32_t attr_count = 0;
    exr_get_attribute_count(ctxt, part_index, &attr_count);

    for (int i = 0; i < attr_count; ++i)
    {
        const exr_attribute_t *attr = NULL;
        exr_get_attribute_by_index(ctxt, part_index,
                                   EXR_ATTR_LIST_FILE_ORDER, i, &attr);
        switch (attr->type)
        {
            case EXR_ATTR_STRING: {
                int32_t len = 0; const char *s = NULL;
                exr_attr_get_string(ctxt, part_index, attr->name, &len, &s);
                break;
            }
            case EXR_ATTR_FLOAT_VECTOR: {
                int32_t n = 0; const float *fv = NULL;
                exr_attr_get_float_vector(ctxt, part_index, attr->name, &n, &fv);
                break;
            }
            case EXR_ATTR_PREVIEW: {
                exr_attr_preview_t pv;
                exr_attr_get_preview(ctxt, part_index, attr->name, &pv);
                break;
            }
            case EXR_ATTR_M33F: { exr_attr_m33f_t v;
                exr_attr_get_m33f(ctxt, part_index, attr->name, &v); break; }
            case EXR_ATTR_M44F: { exr_attr_m44f_t v;
                exr_attr_get_m44f(ctxt, part_index, attr->name, &v); break; }
            case EXR_ATTR_CHLIST: {
                const exr_attr_chlist_t *cl = NULL;
                exr_attr_get_channels(ctxt, part_index, attr->name, &cl); break; }
            /* BOX2I, BOX2F, CHROMATICITIES, RATIONAL, KEYCODE, TIMECODE ... */
        }
    }
}

핵심 설계 4: Deep 이미지 2-pass 디코딩

Deep 이미지는 픽셀당 샘플 수가 가변적이기 때문에 2단계로 나누어 읽어야 합니다. Pass 1에서 샘플 카운트 테이블을 읽고, 그 합계를 기반으로 Pass 2의 버퍼 크기를 결정합니다. 이 구조가 취약점 관점에서 흥미로운 이유는, Pass 1에서 읽은 샘플 카운트 값들이 조작될 경우 Pass 2에서 버퍼 크기를 계산하는 정수 연산에서 오버플로우가 발생할 수 있기 때문입니다. Pass 1을 건너뛰면 이 경로 자체가 실행되지 않습니다.

1
2
3
4
5
6
7
8
9
/* Pass 1: 샘플 카운트 테이블만 읽기 */
pipeline.decode_flags =
    EXR_DECODE_SAMPLE_DATA_ONLY | EXR_DECODE_SAMPLE_COUNTS_AS_INDIVIDUAL;
if (exr_decoding_run(ctxt, part_index, &pipeline) == EXR_ERR_SUCCESS)
{
    /* Pass 2: 실제 픽셀 데이터 읽기 */
    pipeline.decode_flags = EXR_DECODE_SAMPLE_COUNTS_AS_INDIVIDUAL;
    exr_decoding_run(ctxt, part_index, &pipeline);
}

최종 하네스 흐름

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
exr_start_read()                                   ← 헤더 파싱
파트별 순회 (최대 MAX_PARTS=32):
  scan_attributes()                                ← 속성 타입별 getter 호출
  exr_get_storage()                                ← 스토리지 타입 판별
      │
      ├─ [SCANLINE] decode_scanline_part()
      │    ├─ exr_get_scanlines_per_chunk()         ← 압축 방식별 청크 경계
      │    └─ 청크 순회:
      │         ├─ (첫 청크) exr_decoding_initialize()
      │         │            setup_output_buffers()
      │         │            exr_decoding_choose_default_routines()
      │         ├─ (이후 청크) exr_decoding_update()
      │         │              setup_output_buffers()
      │         └─ exr_decoding_run()               ← read + decompress + unpack
      │
      ├─ [TILED] decode_tiled_part()
      │    ├─ exr_get_tile_descriptor()
      │    ├─ exr_get_tile_levels()                 ← 반환값 검증, 상한 MAX_TILE_LEVELS=16
      │    └─ 레벨 × 타일 순회:
      │         ├─ (첫 타일) exr_decoding_initialize()
      │         │            setup_output_buffers()
      │         │            exr_decoding_choose_default_routines()
      │         ├─ (이후 타일) exr_decoding_update()
      │         │              setup_output_buffers()
      │         └─ exr_decoding_run()
      │
      ├─ [DEEP_SCANLINE] decode_deep_scanline_part()
      │    └─ 청크 순회:
      │         ├─ Pass 1: decode_flags = SAMPLE_DATA_ONLY | COUNTS_AS_INDIVIDUAL
      │         │          exr_decoding_run()        ← 샘플 카운트 테이블
      │         └─ Pass 2: decode_flags = COUNTS_AS_INDIVIDUAL
      │                    exr_decoding_run()        ← 실제 픽셀 데이터
      │
      └─ [DEEP_TILED] decode_deep_tiled_part()
           ├─ exr_get_tile_descriptor()
           ├─ exr_get_tile_levels()                 ← 반환값 검증, 상한 MAX_TILE_LEVELS=16
           └─ 레벨 × 타일 순회:
                ├─ Pass 1: decode_flags = SAMPLE_DATA_ONLY | COUNTS_AS_INDIVIDUAL
                │          exr_decoding_run()
                └─ Pass 2: decode_flags = COUNTS_AS_INDIVIDUAL
                           exr_decoding_run()
exr_finish()

헤더 검증 중심 하네스와의 차별점

기존의 헤더 검증 중심 하네스나 고수준 래퍼 기반 접근은 파일이 유효한지 확인하는 데는 유용하지만, C Core API의 디코딩 파이프라인을 끝까지 밀어 넣지는 못하는 경우가 많습니다. 결과적으로 압축 해제 코덱, 픽셀 언팩, 포맷 변환 코드 전체가 퍼징 대상에서 빠질 수 있습니다.

항목헤더 검증 중심 하네스이번 하네스
입력 방식파일 또는 고수준 래퍼 중심in-memory 스트림
API 수준고수준 검증/파싱 경로 중심C Core API 직접 호출
픽셀 언팩출력 버퍼가 없으면 스킵되기 쉬움출력 버퍼 할당 후 전체 실행
속성 파서일부 메타데이터 확인에 그치기 쉬움타입별 getter를 적극 호출
Deep 이미지별도 2-pass 처리가 없으면 누락 가능2-pass 완전 커버
스토리지 타입특정 경로에 편중되기 쉬움4가지 전부 독립 처리

이 차이는 단순한 설계 개선이 아니라, 어떤 코드 경로에 취약점이 있을 수 있는가에 대한 분석 결과가 반영된 것입니다.

핵심 코드 요약

앞서 설계 섹션에서 개별 스니펫으로 설명한 setup_output_buffers()scan_attributes()를 제외한 나머지 핵심 구조를 정리합니다.

In-memory 스트림

AFL++ __AFL_FUZZ_TESTCASE_BUF로 받은 바이트 배열을 OpenEXR의 커스텀 스트림 인터페이스에 연결하는 부분입니다. 파일 I/O 없이 메모리에서 직접 파싱하므로 퍼징 속도가 높아집니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
typedef struct { const uint8_t *data; size_t size; } mem_stream_t;

static int64_t
mem_read_fn(exr_const_context_t ctxt, void *userdata,
            void *buffer, uint64_t sz, uint64_t offset,
            exr_stream_error_func_ptr_t error_cb)
{
    (void)ctxt; (void)error_cb;
    mem_stream_t *ms = (mem_stream_t *)userdata;
    if (offset >= ms->size) return 0;
    uint64_t avail   = ms->size - offset;
    uint64_t to_read = (sz < avail) ? sz : avail;
    memcpy(buffer, ms->data + offset, (size_t)to_read);
    return (int64_t)to_read;
}

static int64_t
mem_size_fn(exr_const_context_t ctxt, void *userdata)
{
    (void)ctxt;
    return (int64_t)((mem_stream_t *)userdata)->size;
}

decode_scanline_part() — 파이프라인 전 단계 실행

스캔라인 파트 디코딩 함수입니다. 첫 번째 청크에서만 exr_decoding_initialize() + choose_default_routines()를 호출하고, 이후 청크는 exr_decoding_update()로 재사용합니다. setup_output_buffers()는 청크마다 호출해 width/height 변동에 대응합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
static void
decode_scanline_part(exr_const_context_t ctxt, int part_index)
{
    exr_attr_box2i_t dw;
    int32_t chunk_count = 0, lines_per_chunk = 1;

    if (exr_get_data_window(ctxt, part_index, &dw) != EXR_ERR_SUCCESS) return;
    if (exr_get_chunk_count(ctxt, part_index, &chunk_count) != EXR_ERR_SUCCESS) return;
    if (chunk_count <= 0 || chunk_count > MAX_CHUNKS) return;

    if (exr_get_scanlines_per_chunk(ctxt, part_index, &lines_per_chunk) != EXR_ERR_SUCCESS)
        lines_per_chunk = 1;

    exr_decode_pipeline_t pipeline = EXR_DECODE_PIPELINE_INITIALIZER;
    int inited = 0;

    for (int y = dw.min.y; y <= dw.max.y; y += lines_per_chunk)
    {
        exr_chunk_info_t cinfo;
        if (exr_read_scanline_chunk_info(ctxt, part_index, y, &cinfo) != EXR_ERR_SUCCESS)
            continue;

        if (!inited)
        {
            if (exr_decoding_initialize(ctxt, part_index, &cinfo, &pipeline) != EXR_ERR_SUCCESS)
                break;
            setup_output_buffers(&pipeline);
            exr_decoding_choose_default_routines(ctxt, part_index, &pipeline);
            inited = 1;
        }
        else
        {
            if (exr_decoding_update(ctxt, part_index, &cinfo, &pipeline) != EXR_ERR_SUCCESS)
                continue;
            setup_output_buffers(&pipeline);  /* width/height 변동 대응 */
        }

        exr_decoding_run(ctxt, part_index, &pipeline);
    }

    if (inited) exr_decoding_destroy(ctxt, &pipeline);
}

decode_tiled_part()는 이중 레벨 루프(MIP/RIP map)가 추가되고, decode_deep_scanline_part() / decode_deep_tiled_part()는 각 청크/타일마다 2-pass 플래그를 설정한다는 점을 제외하면 동일한 구조입니다.

main() — AFL++ persistent mode 루프 및 디스패치

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
__AFL_FUZZ_INIT();

int main(void)
{
    __AFL_INIT();
    uint8_t *data = __AFL_FUZZ_TESTCASE_BUF;

    while (__AFL_LOOP(10000))
    {
        size_t size = __AFL_FUZZ_TESTCASE_LEN;
        if (size < 8) continue;

        mem_stream_t ms = { data, size };

        exr_context_initializer_t init = EXR_DEFAULT_CONTEXT_INITIALIZER;
        init.error_handler_fn = silent_error_fn;
        init.read_fn          = mem_read_fn;
        init.size_fn          = mem_size_fn;
        init.user_data        = &ms;

        exr_context_t ctxt = NULL;
        if (exr_start_read(&ctxt, "<memory>", &init) != EXR_ERR_SUCCESS)
            continue;

        int part_count = 0;
        exr_get_count(ctxt, &part_count);
        if (part_count > MAX_PARTS) part_count = MAX_PARTS;

        for (int i = 0; i < part_count; ++i)
        {
            scan_attributes(ctxt, i);

            exr_storage_t storage;
            if (exr_get_storage(ctxt, i, &storage) != EXR_ERR_SUCCESS) continue;

            switch (storage)
            {
                case EXR_STORAGE_SCANLINE:      decode_scanline_part(ctxt, i);      break;
                case EXR_STORAGE_TILED:         decode_tiled_part(ctxt, i);         break;
                case EXR_STORAGE_DEEP_SCANLINE: decode_deep_scanline_part(ctxt, i); break;
                case EXR_STORAGE_DEEP_TILED:    decode_deep_tiled_part(ctxt, i);    break;
                default: break;
            }
        }

        exr_finish(&ctxt);
    }

    return 0;
}

__AFL_LOOP(10000)은 하나의 프로세스가 최대 10,000회 반복 실행하는 persistent mode 루프입니다. 반복마다 새 입력을 받아 처리하지만, 프로세스를 다시 띄우는 것은 아니므로 하네스가 매 반복마다 컨텍스트를 새로 만들고 exr_finish()로 정리해 상태 누수를 막아야 합니다. silent_error_fn으로 에러 출력을 억제해 AFL의 출력 파싱 노이즈를 제거합니다.


6. Start Fuzzing

하네스 컴파일

섹션 5에서 작성한 하네스 코드를 퍼징에 사용할 바이너리로 컴파일합니다. build_aflbuild_cmplog 두 빌드 결과물에 각각 링크해 바이너리를 두 벌 만듭니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# fuzz_exr_pipeline (slave용, 일반 AFL 계측 빌드)
afl-clang-lto \
    -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer \
    -I./openexr/src/lib/OpenEXRCore \
    -I./build_afl/cmake/OpenEXRCore \
    -I./build_afl/cmake \
    -I./build_afl/_deps/imath-build/config \
    harness/fuzz_exr_pipeline.c \
    -o harness/fuzz_exr_pipeline \
    -L./build_afl/src/lib/OpenEXRCore -lOpenEXRCore-4_0 \
    -L./build_afl/external/OpenJPH/src/core -lopenjph \
    -lz -lm -lstdc++

# fuzz_exr_pipeline_cmplog (master용, CMPLOG 계측 빌드)
AFL_LLVM_CMPLOG=1 afl-clang-lto \
    -fsanitize=address,undefined -g -O1 -fno-omit-frame-pointer \
    -I./openexr/src/lib/OpenEXRCore \
    -I./build_cmplog/cmake/OpenEXRCore \
    -I./build_cmplog/cmake \
    -I./build_cmplog/_deps/imath-build/config \
    harness/fuzz_exr_pipeline.c \
    -o harness/fuzz_exr_pipeline_cmplog \
    -L./build_cmplog/src/lib/OpenEXRCore -lOpenEXRCore-4_0 \
    -L./build_cmplog/external/OpenJPH/src/core -lopenjph \
    -lz -lm -lstdc++

링크 옵션 설명:

  • lOpenEXRCore-4_0: C Core API 정적 라이브러리
  • lopenjph: HTJ2K 코덱 지원 라이브러리
  • lz: zlib (ZIP/ZIPS 압축)
  • lm -lstdc++: 수학 라이브러리, C++ 런타임 (OpenEXRCore 내부 의존성)

퍼징 실행 구성

15코어를 기준으로 master 1개 + slave 14개로 구성합니다.

  • master: CMPLOG 계측 바이너리(-c 옵션)를 사용하는 primary 인스턴스
  • slave 1~14: 일반 AFL 계측 바이너리를 사용하는 secondary 인스턴스, master와 큐를 동기화하며 독립적으로 변이
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#!/bin/bash
# run_fuzzing.sh

HARNESS="./harness/fuzz_exr_pipeline"
HARNESS_CMP="./harness/fuzz_exr_pipeline_cmplog"
CORPUS="./corpus_small"
OUTPUT="./output"

mkdir -p "$OUTPUT"

SESSION="fuzzing"
tmux kill-session -t "$SESSION" 2>/dev/null

# Master (CMPLOG)
tmux new-session -s "$SESSION" -d -x 220 -y 50
tmux send-keys -t "$SESSION:0" \
    "afl-fuzz -M master -i$CORPUS -o$OUTPUT -c$HARNESS_CMP --$HARNESS" Enter
tmux rename-window -t "$SESSION:0" "master"

sleep 3

# Slave 1~14
for i in $(seq 1 14); do
    tmux new-window -t "$SESSION" -n "slave${i}"
    tmux send-keys -t "$SESSION:slave${i}" \
        "afl-fuzz -S slave${i} -i$CORPUS -o$OUTPUT --$HARNESS" Enter
    sleep 1
done

tmux select-window -t "$SESSION:master"

옵션 설명:

  • -M master: primary 인스턴스, 다른 fuzzer와의 동기화 및 CMPLOG 사용
  • -S slave${i}: secondary 인스턴스, 독립적인 변이 전략 사용
  • -i $CORPUS: 시드 코퍼스 경로, 모든 인스턴스가 동일한 원본 코퍼스에서 시작
  • -o $OUTPUT: 큐, 크래시, hang 저장 경로
  • -c $HARNESS_CMP: CMPLOG 바이너리 경로 (master 전용)

퍼징 시작

1
bash ./run_fuzzing.sh

초기 계측 결과

퍼징 시작 직후 약 15분 경과 시점의 상태입니다.

1
2
3
4
5
6
7
8
9
Fuzzers alive       : 15
Total execs         : 117 thousands
Cumulative speed    : 1,950 execs/sec
Coverage reached    : 6.21%
Pending items       : 2,104
Cycles w/o finds    : 0/0/0/0/0/0/0/0/0/0/0/0/0/0/0
Time without finds  : 27 seconds
Crashes saved       : 0
Hangs saved         : 0

커버리지 해석

초기 6.21%는 libOpenEXRCore 전체 코드베이스 기준입니다. 헤더 파싱 경로는 시드 코퍼스에 유효한 EXR 파일이 포함되어 있어 초기부터 대부분 커버됩니다. 반면 각 코덱의 압축 해제 루프 내부, 특히 경계 조건 처리 분기나 에러 복구 코드는 커버리지가 낮은 상태입니다.

Pending items: 2,104는 시드 코퍼스 40개에서 출발해 이미 2,000개 이상의 새로운 코드 경로가 발견되었음을 의미합니다. Time without finds: 27 seconds는 15분 경과 시점에서도 27초 간격으로 새 경로를 지속 발견하고 있다는 뜻으로, 하네스가 깊은 코드 경로에 도달하고 있음을 나타냅니다.

실행 속도(~1,950 exec/s)는 같은 환경에서 픽셀 언팩을 스킵하는 하네스 대비 낮습니다. 압축 해제와 픽셀 변환 경로를 실제로 실행하기 때문이며, 이 비용은 더 넓은 코드 경로 커버리지를 얻는 트레이드오프입니다.


7. Crash Analysis

퍼징 결과 요약

퍼징 결과 수집된 크래시를 AFL++의 크래시 중복 제거 과정을 거쳐 유니크 크래시 4개를 확인했습니다. 이 중 3개는 독립된 취약점으로 인정받아 CVE가 발급됐으며, 아래 표에서 확인하실 수 있습니다.

#CVEGHSA심각도취약점 유형
1CVE-2026-34378GHSA-v76p-4qvv-vh4gModerategeneric_unpack() 부호 있는 정수 오버플로우
2CVE-2026-34379GHSA-w88v-vqhq-5p24HighDWA/DWAB 비정렬 포인터 역참조
3CVE-2026-34380GHSA-q3v8-hw4m-59w5ModeratePXR24 경계 검사 우회 정수 오버플로우

세 취약점 모두 조작된 EXR 파일을 파싱하는 것만으로 트리거되며, 인증이나 특별한 권한이 필요하지 않습니다. 영향 범위는 libOpenEXRCore를 사용하는 모든 애플리케이션입니다.


CVE-2026-34378 — generic_unpack() 부호 있는 정수 오버플로우

취약점 유형: CWE-190 (Integer Overflow), CWE-20 (Improper Input Validation) 영향 버전: OpenEXR 3.4.0 ~ 3.4.8 패치 버전: 3.4.9

발생 경로

이 취약점은 EXR 헤더의 dataWindow 속성을 파싱한 직후, 픽셀 데이터 언팩 함수(generic_unpack())에서 발생합니다. 하네스가 scan_attributes() 이후 decode_scanline_part()exr_decoding_run()unpack_and_convert_fn 경로를 실행하기 때문에 도달 가능한 경로입니다.

소스 코드 문제

unpack.c:1278 에서 픽셀 행의 폭(width)을 계산할 때 dataWindow 값을 직접 사용합니다.

1
2
3
/* unpack.c - 문제가 된 코드 (단순화) */
int32_t width = dataWindow.max.x - dataWindow.min.x + 1;
size_t  row_bytes = (size_t)width * bytes_per_pixel;   /* ← 오버플로우 지점 */

dataWindow.min.x에 극단적 음수값(예: -1,073,741,804)이 들어오면 width 계산 결과가 10억을 넘는 값이 됩니다. 이 값이 bytes_per_pixel과 곱해져 row_bytes 계산에서 부호 있는 32비트 정수 오버플로우가 발생하고, 뒤따르는 메모리 접근이 UBSan 크래시를 유발합니다.

핵심: dataWindow 속성은 헤더에서 그대로 읽혀 검증 없이 크기 계산에 사용됩니다. 공격자는 EXR 파일의 헤더 필드 값만 조작하면 됩니다.

패치 방향

dataWindow 값을 크기 계산에 사용하기 전에 합리적인 범위 검증을 추가합니다.


CVE-2026-34379 — DWA/DWAB LossyDctDecoder_execute 비정렬 포인터 역참조

취약점 유형: CWE-704 (Incorrect Type Conversion), CWE-787 (Out-of-bounds Write), CWE-843 (Type Confusion) 영향 버전: OpenEXR 3.2.0~3.2.6, 3.3.0~3.3.8, 3.4.0~3.4.8 패치 버전: 3.2.7, 3.3.9, 3.4.9

발생 경로

DWA/DWAB 압축 EXR 파일을 디코딩할 때 exr_decoding_run() 내부의 decompress_fnLossyDctDecoder_execute() 경로에서 발생합니다. 이 경로는 하네스에서 decode_scanline_part() 또는 decode_tiled_part()를 통해 도달합니다.

소스 코드 문제

internal_dwa_decoder.h:749에서 FLOAT 타입 채널을 처리할 때, 정렬이 보장되지 않는 uint8_t* 포인터를 float*로 직접 캐스트하여 역참조합니다.

1
2
3
4
/* internal_dwa_decoder.h:749 - 문제가 된 코드 (단순화) */
float *dst = (float *)output_ptr;   /* ← uint8_t* → float* 직접 캐스트 */
*dst = half_to_float(*src);          /* ← *src: 1바이트만 읽음(HALF=2바이트), *dst: 비정렬 4바이트 쓰기 */
output_ptr += sizeof(float);

채널 버퍼 사이에 정렬 패딩이 삽입되지 않기 때문에, 두 번째 이후 FLOAT 채널의 출력 포인터는 4바이트 정렬이 보장되지 않습니다. 정렬을 강제하는 아키텍처에서는 즉시 크래시가 발생하고, x86에서는 정의되지 않은 동작으로 메모리 손상 가능성이 있습니다.

핵심: 코덱 내부에서 포인터 정렬을 가정하고 있지만, 이를 보장하는 코드가 없습니다. 채널 수가 2개 이상이고 FLOAT 타입이 포함된 DWA/DWAB 파일이면 트리거됩니다.

패치 방향

직접 포인터 캐스트 대신 memcpy 기반 비정렬 접근 함수(unaligned_load16, unaligned_store32)를 사용합니다. 이 패턴은 이미 동일 코드베이스의 unpack.c에서 사용 중입니다.

1
2
3
4
5
6
/* 패치 후 */
uint16_t half_val;
memcpy(&half_val, src, sizeof(uint16_t));
float float_val = half_to_float(half_val);
memcpy(output_ptr, &float_val, sizeof(float));
output_ptr += sizeof(float);

CVE-2026-34380 — PXR24 undo_pxr24_impl 경계 검사 우회

취약점 유형: CWE-190 (Integer Overflow), CWE-787 (Out-of-bounds Write) 영향 버전: OpenEXR 3.2.0~3.2.6, 3.3.0~3.3.8, 3.4.0~3.4.8 패치 버전: 3.2.7, 3.3.9, 3.4.9

발생 경로

PXR24 압축 EXR 파일을 디코딩할 때 exr_decoding_run()decompress_fnundo_pxr24_impl() 경로에서 발생합니다. 하네스의 decode_scanline_part() 또는 decode_tiled_part()를 통해 도달합니다.

소스 코드 문제

internal_pxr24.c:377, 389에서 출력 버퍼 경계를 검사하는 조건문이 부호 있는 32비트 정수 오버플로우로 인해 무력화됩니다.

1
2
3
4
5
6
/* internal_pxr24.c:377 - 문제가 된 코드 */
if (nDec + (uint64_t)(w * 3) > outSize)   /* ← w * 3 이 먼저 signed 32-bit로 계산 */
    return EXR_ERR_CORRUPT_CHUNK;

/* ... */
nDec += (uint64_t)(w * 3);                /* ← 389번째 줄, 동일한 패턴 */

w * 3 연산은 캐스트 이전에 부호 있는 32비트 정수로 먼저 계산됩니다. w = 0x55555556 (1,431,655,766)일 때 w * 3은 두의 보수 오버플로우로 2가 되어 경계 검사를 통과합니다. 이후 실제 쓰기는 4 * w 바이트(약 5.4GB)를 출력 버퍼 너머에 씁니다.

핵심: (uint64_t)(w * 3)에서 캐스트의 우선순위 오해가 원인입니다. C에서 캐스트는 곱셈 이후에 적용되므로 오버플로우가 먼저 발생합니다. 의도한 동작은 (uint64_t)w * 3입니다.

패치 방향

캐스트를 피연산자에 먼저 적용해 64비트 연산으로 승격합니다.

1
2
3
4
5
/* 패치 후 */
if (nDec + (uint64_t)w * 3 > outSize)
    return EXR_ERR_CORRUPT_CHUNK;

nDec += (uint64_t)w * 3;

동일 파일 내 HALF, UINT 픽셀 타입 브랜치에도 동일 패턴이 존재해 함께 수정이 필요합니다.


분석 정리

세 취약점의 공통된 근본 원인은 헤더에서 읽은 값을 신뢰하고 크기 계산에 직접 사용하는 패턴입니다.

취약점신뢰한 헤더 값취약한 연산결과
CVE-2026-34378dataWindow.min.xmax.x - min.x + 1오버플로우 → OOB
CVE-2026-34379채널 수, 채널 타입(float *)output_ptr 직접 캐스트비정렬 쓰기
CVE-2026-34380청크 내 w (픽셀 폭)(uint64_t)(w * 3)경계 검사 우회 → OOB write

이 취약점들이 헤더 검증 중심 하네스에서 놓치기 쉬운 이유는 명확합니다. CVE-2026-34378은 generic_unpack() 경로가, CVE-2026-34379·34380은 각각 DWA/PXR24 코덱의 decompress_fn 경로가 픽셀 언팩 단계까지 도달해야 트리거됩니다. 출력 버퍼 없이 파이프라인을 실행했다면 세 크래시 모두 재현되지 않았을 것입니다.

자세한 내용은 아래 링크에서 확인하실 수 있습니다.

  • https://github.com/AcademySoftwareFoundation/openexr/security/advisories/GHSA-q3v8-hw4m-59w5
  • https://github.com/AcademySoftwareFoundation/openexr/security/advisories/GHSA-w88v-vqhq-5p24
  • https://github.com/AcademySoftwareFoundation/openexr/security/advisories/GHSA-v76p-4qvv-vh4g
이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.