diff --git a/ICSharpCode.Decompiler.Tests/XamarinCompressedFileLoaderTests.cs b/ICSharpCode.Decompiler.Tests/XamarinCompressedFileLoaderTests.cs new file mode 100644 index 000000000..0637a3f51 --- /dev/null +++ b/ICSharpCode.Decompiler.Tests/XamarinCompressedFileLoaderTests.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2026 Siegfried Pammer +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this +// software and associated documentation files (the "Software"), to deal in the Software +// without restriction, including without limitation the rights to use, copy, modify, merge, +// publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons +// to whom the Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR +// PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE +// FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR +// OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +// DEALINGS IN THE SOFTWARE. + +using System.IO; +using System.Threading.Tasks; + +using ICSharpCode.ILSpyX.FileLoaders; + +using K4os.Compression.LZ4; + +using NUnit.Framework; + +namespace ICSharpCode.Decompiler.Tests +{ + [TestFixture] + public class XamarinCompressedFileLoaderTests + { + // Magic used for the Xamarin compressed module header ('XALZ', little-endian). + const uint CompressedDataMagic = 0x5A4C4158; + + // Builds an XALZ blob: 4-byte magic, 4-byte descriptor index, 4-byte declared + // uncompressed length, followed by the raw payload bytes. + static MemoryStream BuildBlob(uint magic, uint declaredUncompressedLength, byte[] payload) + { + var ms = new MemoryStream(); + using (var writer = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) + { + writer.Write(magic); + writer.Write((uint)0); // descriptor table index, unused by the loader + writer.Write(declaredUncompressedLength); + writer.Write(payload); + } + ms.Position = 0; + return ms; + } + + static Task Load(MemoryStream blob) + { + return new XamarinCompressedFileLoader().Load("test.dll", blob, new FileLoadContext(false, null)); + } + + [Test] + public void NonXalzMagic_ReturnsNull() + { + // A stream that does not start with the XALZ magic is not ours; pass it through. + using var blob = BuildBlob(0x12345678, 16, new byte[16]); + + Assert.That(Load(blob).GetAwaiter().GetResult(), Is.Null); + } + + [Test] + public void TooShortForMagic_ReturnsNull() + { + // A stream shorter than the 4-byte magic cannot be an XALZ module; pass it through + // rather than letting the magic read throw EndOfStreamException. + using var blob = new MemoryStream(new byte[] { 0x58, 0x41 }); + + Assert.That(Load(blob).GetAwaiter().GetResult(), Is.Null); + } + + [Test] + public void TruncatedHeader_IsRejected() + { + // The magic is present but the stream ends before the full 12-byte header. This is a + // corrupt module and must fail as a catchable InvalidDataException, not an + // EndOfStreamException from a partial header read. + var ms = new MemoryStream(); + using (var writer = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) + { + writer.Write(CompressedDataMagic); + writer.Write((uint)0); // only 8 of the required 12 header bytes + } + ms.Position = 0; + + Assert.ThrowsAsync(() => Load(ms)); + ms.Dispose(); + } + + [Test] + public void NegativeDeclaredLength_DoesNotThrowArgumentOutOfRange() + { + // A declared length with the high bit set would become negative when cast to int and + // make ArrayPool.Rent throw ArgumentOutOfRangeException. It must be rejected as + // malformed input (a catchable InvalidDataException) before any allocation. + using var blob = BuildBlob(CompressedDataMagic, 0xFFFFFFFF, new byte[] { 1, 2, 3, 4 }); + + Assert.ThrowsAsync(() => Load(blob)); + } + + [Test] + public void ImplausiblyHugeDeclaredLength_IsRejectedWithoutAllocating() + { + // A tiny payload declaring a ~2 GB decompressed size is a decompression-bomb header: + // no LZ4 block that small can expand that far. Reject it instead of renting ~2 GB. + using var blob = BuildBlob(CompressedDataMagic, 0x7FFFFFFF, new byte[] { 1, 2, 3, 4 }); + + Assert.ThrowsAsync(() => Load(blob)); + } + + [Test] + public void CorruptPayload_IsRejected() + { + // Garbage that is not a valid LZ4 block, with an otherwise plausible declared length, + // must surface as a catchable InvalidDataException rather than producing a buffer of + // stale/partial bytes. + byte[] garbage = new byte[64]; + for (int i = 0; i < garbage.Length; i++) + garbage[i] = (byte)(i * 7 + 1); + using var blob = BuildBlob(CompressedDataMagic, 4096, garbage); + + Assert.ThrowsAsync(() => Load(blob)); + } + + [Test] + public void ValidXalz_LoadsDecompressedAssembly() + { + // A genuine XALZ wrapper around a real, LZ4-compressed assembly must still load. This + // also exercises the decoded-length slice: ArrayPool.Rent may hand back a buffer larger + // than the decompressed data, and the PE parser must see only the real bytes. + byte[] original = File.ReadAllBytes(typeof(XamarinCompressedFileLoader).Assembly.Location); + byte[] compressed = new byte[LZ4Codec.MaximumOutputSize(original.Length)]; + int compressedLength = LZ4Codec.Encode(original, 0, original.Length, compressed, 0, compressed.Length); + byte[] payload = new byte[compressedLength]; + System.Array.Copy(compressed, payload, compressedLength); + + using var blob = BuildBlob(CompressedDataMagic, (uint)original.Length, payload); + + var result = Load(blob).GetAwaiter().GetResult(); + + Assert.That(result, Is.Not.Null); + Assert.That(result!.IsSuccess, Is.True); + Assert.That(result.MetadataFile, Is.Not.Null); + } + } +} diff --git a/ICSharpCode.ILSpyX/FileLoaders/XamarinCompressedFileLoader.cs b/ICSharpCode.ILSpyX/FileLoaders/XamarinCompressedFileLoader.cs index 157788fd8..257dee52c 100644 --- a/ICSharpCode.ILSpyX/FileLoaders/XamarinCompressedFileLoader.cs +++ b/ICSharpCode.ILSpyX/FileLoaders/XamarinCompressedFileLoader.cs @@ -36,23 +36,55 @@ namespace ICSharpCode.ILSpyX.FileLoaders const uint CompressedDataMagic = 0x5A4C4158; // Magic used for Xamarin compressed module header ('XALZ', little-endian) using var fileReader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: true); // Read compressed file header + if (stream.Length < sizeof(uint)) + return null; var magic = fileReader.ReadUInt32(); if (magic != CompressedDataMagic) return null; + // The magic identifies this as an XALZ module, so it must carry the full 12-byte + // header: magic, descriptor table index, and uncompressed length. A shorter stream is + // a truncated/corrupt module; fail consistently instead of letting a later read throw + // EndOfStreamException. + if (stream.Length < 3 * sizeof(uint)) + throw new InvalidDataException("Invalid Xamarin compressed module: truncated header."); _ = fileReader.ReadUInt32(); // skip index into descriptor table, unused - int uncompressedLength = (int)fileReader.ReadUInt32(); - int compressedLength = (int)stream.Length; // Ensure we read all of compressed data + uint declaredUncompressedLength = fileReader.ReadUInt32(); + // The compressed payload is whatever follows the 12-byte header, not the whole file. + long compressedLength = stream.Length - stream.Position; + // The declared uncompressed length is attacker-controlled. Reject implausible values + // before renting buffers: a negative/oversized size (both lengths are sized for + // int-based ArrayPool and MemoryStream), or one larger than any LZ4 block of this + // payload could possibly produce. An LZ4 block expands by at most 255x, so a smaller + // payload claiming a larger output is a malformed or decompression-bomb header. + const long MaxLZ4ExpansionRatio = 255; + if (compressedLength <= 0 || compressedLength > int.MaxValue + || declaredUncompressedLength == 0 + || declaredUncompressedLength > int.MaxValue + || declaredUncompressedLength > compressedLength * MaxLZ4ExpansionRatio) + { + throw new InvalidDataException("Invalid Xamarin compressed module: declared length is out of range."); + } + int uncompressedLength = (int)declaredUncompressedLength; + int compressed = (int)compressedLength; ArrayPool pool = ArrayPool.Shared; - var src = pool.Rent(compressedLength); + var src = pool.Rent(compressed); var dst = pool.Rent(uncompressedLength); try { // fileReader stream position is now at compressed module data - await stream.ReadAsync(src, 0, compressedLength).ConfigureAwait(false); - // Decompress - LZ4Codec.Decode(src, 0, compressedLength, dst, 0, uncompressedLength); - // Load module from decompressed data buffer - using (var uncompressedStream = new MemoryStream(dst, writable: false)) + await stream.ReadExactlyAsync(src, 0, compressed).ConfigureAwait(false); + // Decompress; Decode returns the number of bytes written, or negative on failure. + // The header declares the exact decompressed size, so anything other than an exact + // match (a negative error code or a short, truncated decode) means the payload is + // corrupt and must not be parsed as a partial module. + int decodedLength = LZ4Codec.Decode(src, 0, compressed, dst, 0, uncompressedLength); + if (decodedLength != uncompressedLength) + { + throw new InvalidDataException("Invalid Xamarin compressed module: decompressed size does not match the header."); + } + // Load module from the decompressed data buffer, sliced to the declared length (the + // rented buffer may be larger than the decompressed data). + using (var uncompressedStream = new MemoryStream(dst, 0, uncompressedLength, writable: false)) { MetadataReaderOptions options = context.ApplyWinRTProjections ? MetadataReaderOptions.ApplyWindowsRuntimeProjections