Bound XamarinCompressedFileLoader against crafted XALZ headers#3843
Bound XamarinCompressedFileLoader against crafted XALZ headers#3843christophwille wants to merge 2 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR hardens the Xamarin XALZ compressed-module loader to avoid attacker-controlled allocations, partial-read issues, and parsing beyond the actual decompressed bytes when opening crafted files.
Changes:
- Adds header/payload validation and uses
ReadExactlyAsyncto avoid partial reads of the compressed payload. - Uses the
LZ4Codec.Decodereturned length to bound theMemoryStreamexposed toPEFile. - Adds NUnit tests covering malformed headers/payloads and a valid round-trip XALZ load.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| ICSharpCode.ILSpyX/FileLoaders/XamarinCompressedFileLoader.cs | Adds validation, exact reads, and bounds the decompressed stream to mitigate crafted XALZ inputs. |
| ICSharpCode.Decompiler.Tests/XamarinCompressedFileLoaderTests.cs | Introduces regression tests for crafted XALZ headers/payloads plus a happy-path load test. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The Xamarin XALZ loader sized its buffer allocations from an attacker-controlled header field and ignored the partial-read length, so merely opening a crafted file (the loader is registered first and runs on any XALZ-magic input) could crash or over-allocate. The declared uncompressed length, a raw header uint cast to int, had no sanity bound: a tiny file claiming ~2 GB forced a giant ArrayPool.Rent (decompression-bomb amplification), and a high-bit value became negative and made Rent throw ArgumentOutOfRangeException. The compressed length was taken as the whole file (header included) and ReadAsync's return value was discarded, leaving stale pooled bytes in the tail fed to the decoder; the output MemoryStream then exposed the entire rented buffer, so PEFile parsed past the real decompressed data into leftover pool contents. Bound the declared length before renting (reject zero, > int.MaxValue, or more than an LZ4 block could expand from this payload at its 255x maximum ratio), read the payload that actually follows the header with ReadExactlyAsync, and slice the output to the length LZ4Codec.Decode reports. Malformed input now fails as a catchable InvalidDataException, consistent with the bundle and .rsrc hardening; well-formed Xamarin modules load exactly as before. Assisted-by: Claude:claude-opus-4-8:Claude Code
de49f8e to
3dee49a
Compare
| using var fileReader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); | ||
| // Read compressed file header | ||
| if (stream.Length < sizeof(uint)) | ||
| return null; |
There was a problem hiding this comment.
Useless check (TOCTOU).
Catching the exception is the only proper way of handling IO errors.
Also, some kinds of streams might not know the length ahead-of-time.
There was a problem hiding this comment.
Fixed in fc76fb5: the stream.Length pre-checks are gone; the loader now catches EndOfStreamException from each read instead (magic read returns null, truncated header or payload after a confirmed magic throws InvalidDataException). stream.Length is still used, but only as a sizing hint for the payload buffer; the exact read is guarded by the same exception handling.
(Written by an AI agent on behalf of @siegfriedpammer.)
Pre-checking stream.Length before reading is racy (the stream can shrink between check and read) and not every stream knows its length ahead of time. Instead, catch EndOfStreamException at each read: a stream too short for the magic is passed through as not-XALZ, and a truncated header or payload after a confirmed magic is rejected as InvalidDataException. stream.Length remains only a sizing hint for the payload buffer. Assisted-by: Claude:claude-fable-5:Claude Code
The Xamarin "XALZ" compressed-module loader (
XamarinCompressedFileLoader) sized its buffer allocations from an attacker-controlled header field and ignored the partial-read return value. Because the loader is registered first and runs on any file beginning with theXALZmagic, merely opening a crafted file could crash the process or force a huge allocation.Three concrete defects: the declared uncompressed length is a raw header
uintcast tointwith no sanity bound, so a tiny file claiming ~2 GB forced a giantArrayPool.Rent(a decompression-bomb amplification), while a value with the high bit set became negative after the cast and madeRentthrowArgumentOutOfRangeException. The compressed length was taken as the whole file length (header included) andReadAsync's return value was discarded, so a short read left the tail of the rented buffer as stale pooled bytes fed to the LZ4 decoder. Finally, the outputMemoryStreamexposed the entire rented buffer, whichArrayPool.Rentmay size larger than the data, soPEFileparsed past the real decompressed bytes into leftover pool contents.The fix bounds the declared length before any allocation (rejecting zero, values above
int.MaxValue, or more than an LZ4 block could possibly expand from the payload at its 255x maximum ratio), reads exactly the payload that follows the 12-byte header withReadExactlyAsync, and slices the outputMemoryStreamto the length thatLZ4Codec.Decodeactually reports. Malformed input now surfaces as a catchableInvalidDataException, consistent with the recent bundle-signature and.rsrcresource-tree hardening, instead of crashing or over-allocating. Well-formed Xamarin modules continue to load unchanged.New tests in
ICSharpCode.Decompiler.Tests/XamarinCompressedFileLoaderTests.cscover the crafted cases (high-bit/negative declared length, an implausibly huge declared length rejected without allocating, and a corrupt LZ4 payload) plus a happy-path round trip that LZ4-compresses a real assembly, wraps it in a valid XALZ header, and asserts it still loads.🤖 Generated with Claude Code