Never size bundle entry decompression from the manifest's declared size - #4048
Merged
Conversation
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
siegfriedpammer
requested changes
Aug 25, 2026
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
siegfriedpammer
approved these changes
Aug 25, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:entry.Sizeandentry.CompressedSizeare read verbatim from the bundle manifest. Two problems: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. Thelong -> intcast is unchecked: sizes at or above 2 GB wrap to a negative capacity and fail withArgumentOutOfRangeExceptionrather than a bundle-format error.CopyToinflates 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
FileCountwas 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, orilspycmdextracting 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
UnmanagedMemoryStream(SafeBuffer, offset, length), and that constructor validatesoffset + lengthagainst the mapped view'sByteLengthand 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.Sizeis only used as the view length, which theSafeBuffercheck covers.The fix
TryOpenStreamfor compressed entries now:entry.Sizeoutside[0, int.MaxValue]withInvalidDataException, since such an entry cannot be held in aMemoryStream(and the wrap-around cast is gone);MemoryStreamthat grows only with the bytes the deflate stream actually produces, so memory follows real output, never the declared number;entry.Size + 1bytes 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
SizeagainstCompressedSizeby 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.Also fixed:
IsBundlemissed a signature ending exactly at the end of the viewRunning the new tests on Linux and macOS CI surfaced a latent off-by-one in
SingleFileBundle.IsBundle: the scan loop ranptr < endwithend = 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 (VirtualQueryRegionSize), 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 (FromBundlereturned null). The loop now runsptr <= end; reading atptr == endcovers[size - 32, size), which is in bounds. Covered byIsBundle_SignatureAtEndOfBuffer_DetectsBundleinSingleFileBundleTests(red on all platforms before, since thebyte*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):InvalidDataException(wasArgumentOutOfRangeExceptionfrom the truncated negative capacity).InvalidDataException, and the bytes allocated on the calling thread while opening it stay under 4 MB (was the full 32 MB plusMemoryStreamgrowth).The two failure cases were confirmed red before the change and green after.
Generated with Claude Code