Skip to content

Never size bundle entry decompression from the manifest's declared size - #4048

Merged
christophwille merged 3 commits into
masterfrom
fix/bundle-entry-decompression-bound
Aug 25, 2026
Merged

Never size bundle entry decompression from the manifest's declared size#4048
christophwille merged 3 commits into
masterfrom
fix/bundle-entry-decompression-bound

Conversation

@christophwille

@christophwille christophwille commented Aug 25, 2026

Copy link
Copy Markdown
Member

Summary

Opening a compressed entry of a .NET single-file bundle no longer allocates based on the size the manifest declares, and stops inflating as soon as the data exceeds that size. A small crafted bundle can no longer turn into a multi-gigabyte allocation, and a declared size that does not fit in memory is reported as corrupt bundle data instead of surfacing as an argument exception from a truncated cast.

The vulnerability (CWE-789 excessive allocation, CWE-197 numeric truncation)

ICSharpCode.ILSpyX/LoadedPackage.cs, BundleEntry.TryOpenStream, decompressed entries like this:

Stream decompressedStream = new MemoryStream((int)entry.Size);   // entry.Size: attacker-controlled long
deflateStream.CopyTo(decompressedStream);
if (decompressedStream.Length != entry.Size) throw new InvalidDataException(...);

entry.Size and entry.CompressedSize are read verbatim from the bundle manifest. Two problems:

  1. Pre-allocation from the declared size. new MemoryStream((int)entry.Size) commits the declared decompressed size up front, so a manifest declaring ~2 GB for an entry whose compressed payload is a few bytes forces a ~2 GB allocation before a single byte is inflated. The long -> int cast is unchecked: sizes at or above 2 GB wrap to a negative capacity and fail with ArgumentOutOfRangeException rather than a bundle-format error.
  2. Unbounded inflation. CopyTo inflates the entire deflate stream, however large, and only afterwards compares the result to the declared size. A decompression bomb (a few kilobytes that inflate to gigabytes) is therefore fully expanded into memory before the mismatch is detected.

The bundle's FileCount was already bounded against the remaining manifest bytes; the per-entry sizes were the remaining unbounded values.

Reachability

Opening any single-file bundle in ILSpy (BundleFileLoader -> LoadedPackage.FromBundle) and then expanding an entry, or ilspycmd extracting bundle contents. No action beyond opening a file and clicking an entry is needed.

What was re-verified and found not to be a problem

  • Entry offsets and stored sizes cannot read outside the file. Both the uncompressed path and the compressed source are UnmanagedMemoryStream(SafeBuffer, offset, length), and that constructor validates offset + length against the mapped view's ByteLength and rejects negatives. An out-of-range manifest entry fails on open with an argument exception; it is not a memory-safety issue and did not need a separate bounds check.
  • The uncompressed path does not allocate. It returns a stream over the mapping; Size is only used as the view length, which the SafeBuffer check covers.

The fix

TryOpenStream for compressed entries now:

  • rejects entry.Size outside [0, int.MaxValue] with InvalidDataException, since such an entry cannot be held in a MemoryStream (and the wrap-around cast is gone);
  • allocates an empty MemoryStream that grows only with the bytes the deflate stream actually produces, so memory follows real output, never the declared number;
  • reads at most entry.Size + 1 bytes from the deflate stream; the first byte past the declared size proves the entry is corrupt, so inflation stops there. The existing length comparison still catches entries that come up short.

Net effect: the memory an entry can force is bounded by min(actual inflated bytes, declared size + 1), and the declared size is capped at what a single in-memory buffer can hold.

Considered and rejected

  • Validating Size against CompressedSize by DEFLATE's maximum ratio (~1032:1). This only rejects mismatched declarations, which the bounded copy already handles at negligible cost, and does nothing against a bomb whose declaration is consistent with its payload. Not worth a magic constant.
  • Pre-sizing to a capped value. Growing from empty costs a handful of reallocations for the assemblies real bundles carry; not worth carrying an untrusted number into an allocation at all.

Also fixed: IsBundle missed a signature ending exactly at the end of the view

Running the new tests on Linux and macOS CI surfaced a latent off-by-one in SingleFileBundle.IsBundle: the scan loop ran ptr < end with end = data + size - 32, so the last position at which a full signature still fits was never compared. Windows hid it because a memory-mapped view there reports the page-rounded region size (VirtualQuery RegionSize), leaving trailing zero bytes after the file; on Unix the view length is the exact file length. Real bundles keep apphost code after the signature, which is why it never bit in practice, but a synthetic bundle ending with the signature was not detected at all on Unix (FromBundle returned null). The loop now runs ptr <= end; reading at ptr == end covers [size - 32, size), which is in bounds. Covered by IsBundle_SignatureAtEndOfBuffer_DetectsBundle in SingleFileBundleTests (red on all platforms before, since the byte* overload takes the exact size).

Tests

ILSpy.Tests/LoadedPackageBundleTests.cs (new), with a small in-test bundle writer (stored bytes, v6 manifest, header offset, signature):

  • Compressed entry with a matching declared size round-trips and reports the right length.
  • Uncompressed entry round-trips.
  • Declared size of 3 GB -> InvalidDataException (was ArgumentOutOfRangeException from the truncated negative capacity).
  • Decompression bomb (32 MB inflating from a few KB, declared as 16 bytes) -> InvalidDataException, and the bytes allocated on the calling thread while opening it stay under 4 MB (was the full 32 MB plus MemoryStream growth).

The two failure cases were confirmed red before the change and green after.


Generated with Claude Code

A single-file bundle manifest is attacker-controlled. Opening a compressed
entry pre-allocated a MemoryStream of the declared decompressed size
through an unchecked long-to-int cast, then inflated the whole deflate
stream before comparing lengths. A few-byte payload declaring ~2 GB thus
forced a ~2 GB allocation up front, sizes at or above 2 GB wrapped to a
negative capacity, and a decompression bomb was expanded in full before
the mismatch was noticed (CWE-789, CWE-197).

Grow the buffer only with bytes the deflate stream actually produces and
stop reading one byte past the declared size, which already proves the
entry corrupt. Reject declared sizes that cannot fit a single in-memory
buffer as invalid bundle data. Entry offsets need no extra check: the
UnmanagedMemoryStream over the mapping already validates them against the
view length.

Assisted-by: Claude:claude-fable-5:Claude Code
…view

IsBundle scanned up to but excluding the last position at which a full
signature fits, so a signature occupying the final 32 bytes of the region
was never compared. Windows hid this: a memory-mapped view there reports
the page-rounded region size, leaving trailing zero bytes after the file.
On Linux and macOS the view length is the exact file length, and the
LoadedPackage bundle tests, whose synthetic bundles end with the
signature, failed there with FromBundle returning null. Real bundles keep
apphost code after the signature, which is why this stayed latent.

Assisted-by: Claude:claude-fable-5:Claude Code
Comment thread ILSpy.Tests/LoadedPackageBundleTests.cs Outdated
Two test fixtures carried their own copy of the 32-byte signature, which
would silently drift from the real one. The signature is now an internal
member of SingleFileBundle and ILSpy.Tests gets internals access to the
decompiler assembly, matching what ILSpyX already grants it.

Assisted-by: Claude:claude-fable-5:Claude Code
@christophwille
christophwille merged commit 45470df into master Aug 25, 2026
15 checks passed
@christophwille
christophwille deleted the fix/bundle-entry-decompression-bound branch August 25, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants