diff --git a/ICSharpCode.ILSpyX/LoadedPackage.cs b/ICSharpCode.ILSpyX/LoadedPackage.cs index dfeecf797..35cc5368f 100644 --- a/ICSharpCode.ILSpyX/LoadedPackage.cs +++ b/ICSharpCode.ILSpyX/LoadedPackage.cs @@ -239,10 +239,27 @@ namespace ICSharpCode.ILSpyX } else { + // entry.Size comes straight from the manifest and is not trusted: it must never + // size an allocation, and decompression must not run past it. A size that does + // not fit a single in-memory buffer cannot be a real entry either. + if (entry.Size < 0 || entry.Size > int.MaxValue) + { + throw new InvalidDataException($"Corrupted single-file entry '{entry.RelativePath}'. Declared decompressed size '{entry.Size}' is not valid."); + } Stream compressedStream = new UnmanagedMemoryStream(view.SafeMemoryMappedViewHandle, entry.Offset, entry.CompressedSize); using var deflateStream = new DeflateStream(compressedStream, CompressionMode.Decompress); - Stream decompressedStream = new MemoryStream((int)entry.Size); - deflateStream.CopyTo(decompressedStream); + // Grows only with the bytes that actually come out of the deflate stream. Reading + // one byte past the declared size is enough to prove the entry corrupt, so a + // decompression bomb stops there instead of being inflated in full. + Stream decompressedStream = new MemoryStream(); + byte[] buffer = new byte[81920]; + long remaining = entry.Size + 1; + int read; + while (remaining > 0 && (read = deflateStream.Read(buffer, 0, (int)Math.Min(buffer.Length, remaining))) > 0) + { + decompressedStream.Write(buffer, 0, read); + remaining -= read; + } if (decompressedStream.Length != entry.Size) { throw new InvalidDataException($"Corrupted single-file entry '{entry.RelativePath}'. Declared decompressed size '{entry.Size}' is not the same as actual decompressed size '{decompressedStream.Length}'."); diff --git a/ILSpy.Tests/LoadedPackageBundleTests.cs b/ILSpy.Tests/LoadedPackageBundleTests.cs new file mode 100644 index 000000000..3301f1037 --- /dev/null +++ b/ILSpy.Tests/LoadedPackageBundleTests.cs @@ -0,0 +1,192 @@ +// Copyright (c) 2026 Christoph Wille +// +// 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; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text; + +using ICSharpCode.ILSpyX; + +using NUnit.Framework; + +namespace ICSharpCode.ILSpy.Tests; + +/// +/// The sizes in a single-file bundle manifest are attacker-controlled. These tests pin down +/// that opening a compressed entry never allocates based on the declared size and stops +/// inflating as soon as the data exceeds it, so a small bundle cannot become a multi-gigabyte +/// allocation. +/// +[TestFixture] +public class LoadedPackageBundleTests +{ + // The 32-byte bundle signature (SHA-256 of ".net core bundle"), as in SingleFileBundle.IsBundle. + static readonly byte[] Signature = new byte[] { + 0x8b, 0x12, 0x02, 0xb9, 0x6a, 0x61, 0x20, 0x38, + 0x72, 0x7b, 0x93, 0x02, 0x14, 0xd7, 0xa0, 0x32, + 0x13, 0xf5, 0xb9, 0xe6, 0xef, 0xae, 0x33, 0x18, + 0xee, 0x3b, 0x2d, 0xce, 0x24, 0xb3, 0x6a, 0xae + }; + + readonly List tempFiles = new(); + + [TearDown] + public void DeleteTempFiles() + { + // The memory-mapped view stays alive as long as the bundle entries do, so the file + // can only be deleted once those have been collected. + GC.Collect(); + GC.WaitForPendingFinalizers(); + foreach (var file in tempFiles) + { + try + { + File.Delete(file); + } + catch (IOException) + { + // best effort; the temp directory is cleaned up by the OS eventually + } + } + } + + [Test] + public void CompressedEntry_WithMatchingDeclaredSize_RoundTrips() + { + byte[] content = Enumerable.Range(0, 10_000).Select(i => (byte)(i * 7)).ToArray(); + var bundle = WriteBundle(new BundleFile("lib.dll", Deflate(content), content.Length)); + + var package = LoadedPackage.FromBundle(bundle); + + Assert.That(package, Is.Not.Null); + var entry = package!.Entries.Single(); + Assert.That(entry.TryGetLength(), Is.EqualTo(content.Length)); + using var stream = entry.TryOpenStream(); + Assert.That(ReadAll(stream!), Is.EqualTo(content)); + } + + [Test] + public void UncompressedEntry_RoundTrips() + { + byte[] content = Encoding.ASCII.GetBytes("plain content"); + var bundle = WriteBundle(new BundleFile("plain.dll", content, content.Length, Compressed: false)); + + var package = LoadedPackage.FromBundle(bundle); + + using var stream = package!.Entries.Single().TryOpenStream(); + Assert.That(ReadAll(stream!), Is.EqualTo(content)); + } + + [Test] + public void CompressedEntry_DeclaredSizeBeyondInt32_IsRejectedAsInvalidData() + { + // A declared size that does not fit an int used to be truncated to a negative + // MemoryStream capacity; any size that cannot be held in memory must be reported as + // corrupt bundle data instead. + byte[] content = new byte[100]; + var bundle = WriteBundle(new BundleFile("huge.dll", Deflate(content), DeclaredSize: 3L * 1024 * 1024 * 1024)); + + var entry = LoadedPackage.FromBundle(bundle)!.Entries.Single(); + + Assert.Throws(() => entry.TryOpenStream()); + } + + [Test] + public void CompressedEntry_InflatingBeyondDeclaredSize_StopsEarly() + { + // A decompression bomb: a few kilobytes that inflate to 32 MB, declared as 16 bytes. + // Decompression must stop as soon as the declared size is exceeded rather than + // inflating everything first and comparing lengths afterwards, so the memory the + // attacker can force is bounded by what the manifest declares, not by the bomb. + const int inflatedSize = 32 * 1024 * 1024; + var bundle = WriteBundle(new BundleFile("bomb.dll", Deflate(new byte[inflatedSize]), DeclaredSize: 16)); + var entry = LoadedPackage.FromBundle(bundle)!.Entries.Single(); + + long allocatedBefore = GC.GetAllocatedBytesForCurrentThread(); + Assert.Throws(() => entry.TryOpenStream()); + long allocated = GC.GetAllocatedBytesForCurrentThread() - allocatedBefore; + + Assert.That(allocated, Is.LessThan(4 * 1024 * 1024), "opening the entry inflated far past its declared size"); + } + + sealed record BundleFile(string RelativePath, byte[] StoredBytes, long DeclaredSize, bool Compressed = true); + + static byte[] Deflate(byte[] content) + { + var ms = new MemoryStream(); + using (var deflate = new DeflateStream(ms, CompressionLevel.Optimal, leaveOpen: true)) + { + deflate.Write(content); + } + return ms.ToArray(); + } + + static byte[] ReadAll(Stream stream) + { + var ms = new MemoryStream(); + stream.CopyTo(ms); + return ms.ToArray(); + } + + /// + /// Writes a minimal version-6 single-file bundle: the stored file bytes, the manifest, + /// the 8-byte manifest offset and the bundle signature, in that order. + /// + string WriteBundle(params BundleFile[] files) + { + var ms = new MemoryStream(); + var writer = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true); + var offsets = new long[files.Length]; + for (int i = 0; i < files.Length; i++) + { + offsets[i] = ms.Position; + writer.Write(files[i].StoredBytes); + } + + long manifestOffset = ms.Position; + writer.Write(6u); // MajorVersion (v6 adds the compressed size field) + writer.Write(0u); // MinorVersion + writer.Write(files.Length); // FileCount + writer.Write("test-bundle"); // BundleID + writer.Write(0L); // DepsJsonOffset + writer.Write(0L); // DepsJsonSize + writer.Write(0L); // RuntimeConfigJsonOffset + writer.Write(0L); // RuntimeConfigJsonSize + writer.Write(0UL); // Flags + for (int i = 0; i < files.Length; i++) + { + writer.Write(offsets[i]); // Offset + writer.Write(files[i].DeclaredSize); // Size + writer.Write(files[i].Compressed ? files[i].StoredBytes.Length : 0L); // CompressedSize + writer.Write((byte)1); // FileType.Assembly + writer.Write(files[i].RelativePath); + } + + writer.Write(manifestOffset); + writer.Write(Signature); + writer.Flush(); + + string path = Path.Combine(Path.GetTempPath(), "ILSpyBundleTest-" + Guid.NewGuid().ToString("N") + ".exe"); + File.WriteAllBytes(path, ms.ToArray()); + tempFiles.Add(path); + return path; + } +}