The new EXR-ZSTD decode path (internal_exr_undo_zstd /
exr_undo_zstd_v1) sized its scratch/decompression buffer directly
from ZSTD_getFrameContentSize(), a value read out of the untrusted
compressed chunk payload itself. A hand-crafted zstd frame can
declare an arbitrary content size there while its real body decodes
to something much smaller, letting a malicious EXR file drive the
destination buffer capacity (and thus the capacity handed to
ZSTD_decompressDCtx) far beyond what the chunk's already-validated
uncompressed_size allows.
Combined with this, ensure_tls_resources() computed its 25% buffer
overshoot (required_size + (required_size >> 2)) and 64-byte
alignment step with no overflow checking, and did not reset its
cached buffer/size on allocation failure. With a sufficiently large
untrusted required_size this arithmetic wraps around, so a
small/tiny buffer would be allocated while the code believed it was
large enough, and the same undersized-but-successfully-allocated
buffer could be reused with a stale, incorrect belief of its size on
a later call.
Fixes:
- Bound the ZSTD frame's declared content size to at most
uncompressed_size + 16 (the maximum possible pre-ZSTD inner-stream
overhead: up to two 8-byte segment length prefixes), rather than
trusting it unconditionally.
- Make ensure_tls_resources() overflow-safe: check for wraparound in
both the 25% overshoot and the 64-byte alignment step, return a
success/failure status, and reset shuffle_buf/shuffle_buf_size to
a known-empty state on any failure so a later, smaller request
cannot mistake a stale or invalid buffer for a valid one.
- Update both call sites (encode and decode) to check the new return
value and fail cleanly instead of proceeding with a NULL or
undersized buffer.
- Add a guard against unsigned underflow in the comp_buf_size -
ZSTD_EXR_V1_HEADER computation in exr_undo_zstd_v1(), in case the
caller's existing size guarantee is ever relaxed by future
refactoring.
Assisted-by: GitHub Copilot CLI (claude-sonnet-5)
Signed-off-by: Cary Phillips <seabeepea@gmail.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
[repeat of #2586 from the main repo's zstd-beta, not from vlazar-ilm:zstd-beta]
This is the merge to main from: #2458
Some more details can be found here:
Proposal_ Support for Zstd Compression in OpenEXR.pdf
This PR adds a new compression method based on Zstd with some preprocessing. While the method was heavily tested with scanline Deep data, it supports both flat and tiled formats.
It introduces a new dependency on the Zstd library. I also added a vendored copy inside the "external" directory.
I have tested the method with scanline deep renders totalling about 2.5TB ( 45k files ), a large majority being deep alpha only. Over this corpus, using exrmetric, it achieved (size weighted):
By default Zstd compresses with level 5, very low effort ( valid range is between 1-22). Higher levels achieve better compression but for considerably low write speed - decompression speed is almost unaffected.
Weighted Size Ratio vs Zips: 0.671
Weighted Write Speed Ratio vs Zips: 0.661
Weighted Read Speed Ratio vs Zips: 0.771
For flat scanlines comparison with ZIPS:
Weighted Size ratio: 0.975
Write time ratio: 0.72
Weighted Read time ratio: 1.01
Compression pipeline description:
The incoming data is in a "row major" planar format: within a chunk, every row channels are contiguous. The first step is to convert it to a full planar format: a single channel is contiguous across all rows inside a chunk.
sort the data into 2 blocks: 2 bytes and 4 bytes wide. This makes the byteshuffle and delta easier
3.(optional, currently disabled) is a neighboring pixel delta encoding (performed in in the appropriate int arithmetic). This is currently disabled as it helps certain sequences but hurts others. I did an experiment where I recompress the scanline twice and pick the best, which yielded about 5% size improvement, but compression time was doubled (might get some of that time back by doing some smarter heuristic). Also the payoff and penalty was asymmetric (it hurts a lot more than it helps on certain images).
A byte shuffle is performed on each 2 and 4 byte block. This takes advantage of the fact that exponents and high order mantissa bits are often similar across pixels.
The two blocks are then compressed with Zstd.
1-2 are performed in 2 step with a computed lookup table.
The Shuffle and delta were implemented with scalar code for compression but for decompression with AVX2 intrinsics for GCC (mostly as a proof of correctness).
A few quirks:
For this method, I need the sample count table. I was unable to get access to it inside the compressor. When using the C++ API, the data was constantly wiped so I added a new version of the C++ compressor functions that passes this point and added guards inside EXRCore to protect it from getting wiped.
The C++ Deep Tiled path was calling the wrong compressor function that was causing issues with my codec.
Fixed an overflow issue with exrmetric when passing very large files.
The Zstd context is saved as a TLS that gets dellocated only when a thread dies.
Possible areas of improvement (all do not require invalidating the current implementation):
Add a fastpath to skip channel sorting if channels are already grouped , the sort is NOOP
Add a multi scanline variant: in some tests i got about 5% more with 16 scanlines
Add cheap heuristic and enable delta compression for certain scanlines
Tweak the ZStd compression settings: Zstd has a few settings that could make it generate slightly smaller files with higher compression speed