From fa3dff056a5b55ba168fe27fcfd18f79ca43f822 Mon Sep 17 00:00:00 2001 From: Adam Hathcock Date: Mon, 9 Nov 2026 11:16:14 +0000 Subject: [PATCH] Reject stalled BZip2 decoder states after corruption --- .../BZip2/CBZip2InputStream.Async.cs | 12 +- .../Compressors/BZip2/CBZip2InputStream.cs | 13 +- .../BZip2/BZip2StreamAsyncTests.cs | 37 ++++ .../BZip2/BZip2StreamTests.cs | 2 + .../Zip/ZipBZip2CorruptionTests.cs | 167 ++++++++++++++++++ 5 files changed, 217 insertions(+), 14 deletions(-) create mode 100644 tests/SharpCompress.Test/Zip/ZipBZip2CorruptionTests.cs diff --git a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs index c1053216..6fcbcdbd 100644 --- a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs +++ b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.Async.cs @@ -21,26 +21,24 @@ internal partial class CBZip2InputStream var retChar = currentChar; switch (currentState) { - case START_BLOCK_STATE: - break; - case RAND_PART_A_STATE: - break; case RAND_PART_B_STATE: await SetupRandPartBAsync(cancellationToken).ConfigureAwait(false); break; case RAND_PART_C_STATE: await SetupRandPartCAsync(cancellationToken).ConfigureAwait(false); break; - case NO_RAND_PART_A_STATE: - break; case NO_RAND_PART_B_STATE: await SetupNoRandPartBAsync(cancellationToken).ConfigureAwait(false); break; case NO_RAND_PART_C_STATE: await SetupNoRandPartCAsync(cancellationToken).ConfigureAwait(false); break; + case START_BLOCK_STATE: + case RAND_PART_A_STATE: + case NO_RAND_PART_A_STATE: default: - break; + // Match ReadByte(): retries after a decoding error must not generate endless output. + throw new InvalidFormatException("BZip2: invalid decoder state"); } return retChar; } diff --git a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs index bcc0c73a..7759bb12 100644 --- a/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs +++ b/src/SharpCompress/Compressors/BZip2/CBZip2InputStream.cs @@ -263,26 +263,25 @@ internal partial class CBZip2InputStream : Stream var retChar = currentChar; switch (currentState) { - case START_BLOCK_STATE: - break; - case RAND_PART_A_STATE: - break; case RAND_PART_B_STATE: SetupRandPartB(); break; case RAND_PART_C_STATE: SetupRandPartC(); break; - case NO_RAND_PART_A_STATE: - break; case NO_RAND_PART_B_STATE: SetupNoRandPartB(); break; case NO_RAND_PART_C_STATE: SetupNoRandPartC(); break; + case START_BLOCK_STATE: + case RAND_PART_A_STATE: + case NO_RAND_PART_A_STATE: default: - break; + // These transitional states can remain after a decoding error. A retry (including + // entry disposal) must fail rather than return the same byte indefinitely. + throw new InvalidFormatException("BZip2: invalid decoder state"); } return retChar; } diff --git a/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs b/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs index d5b2ef78..9c3c65f4 100644 --- a/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs +++ b/tests/SharpCompress.Test/BZip2/BZip2StreamAsyncTests.cs @@ -2,7 +2,9 @@ using System; using System.Buffers; using System.IO; using System.Text; +using System.Threading; using System.Threading.Tasks; +using SharpCompress.Common; using SharpCompress.Compressors.BZip2; using SharpCompress.Test.Mocks; using Xunit; @@ -11,6 +13,41 @@ namespace SharpCompress.Test.BZip2; public class BZip2StreamAsyncTests { + [Fact] + public async Task BZip2Stream_Throws_On_Read_After_Corrupt_Checksum_Async() + { + using var compressed = new MemoryStream(); + await using ( + var writer = await BZip2Stream.CreateAsync( + new AsyncOnlyStream(compressed), + SharpCompress.Compressors.CompressionMode.Compress, + false, + leaveOpen: true, + cancellationToken: CancellationToken.None + ) + ) + { + var data = Encoding.ASCII.GetBytes("BZip2 checksum validation test data."); + await writer.WriteAsync(data, 0, data.Length, CancellationToken.None); + } + + var bytes = compressed.ToArray(); + bytes[^5] ^= 1; + await using var stream = await BZip2Stream.CreateAsync( + new AsyncOnlyStream(new MemoryStream(bytes)), + SharpCompress.Compressors.CompressionMode.Decompress, + false, + cancellationToken: CancellationToken.None + ); + + await Assert.ThrowsAsync(() => + stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None) + ); + await Assert.ThrowsAsync(() => + stream.ReadAsync(new byte[1], 0, 1, CancellationToken.None) + ); + } + private byte[] CreateTestData(int size) { var data = new byte[size]; diff --git a/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs b/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs index dc603878..920a89f2 100644 --- a/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs +++ b/tests/SharpCompress.Test/BZip2/BZip2StreamTests.cs @@ -24,6 +24,8 @@ public class BZip2StreamTests using var output = new MemoryStream(); Assert.Throws(() => stream.CopyTo(output)); + Assert.Throws(() => stream.ReadByte()); + Assert.Throws(() => stream.Read(new byte[1], 0, 1)); } // A stream that ends exactly where a block header is expected (no stream footer) is the shape a caller diff --git a/tests/SharpCompress.Test/Zip/ZipBZip2CorruptionTests.cs b/tests/SharpCompress.Test/Zip/ZipBZip2CorruptionTests.cs new file mode 100644 index 00000000..d9990ec7 --- /dev/null +++ b/tests/SharpCompress.Test/Zip/ZipBZip2CorruptionTests.cs @@ -0,0 +1,167 @@ +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Archives; +using SharpCompress.Common; +using SharpCompress.Readers; +using SharpCompress.Test.Mocks; +using Xunit; + +namespace SharpCompress.Test.Zip; + +public class ZipBZip2CorruptionTests : TestBase +{ + [Theory] + [InlineData(240, 0x8d, 0x5a, false)] + [InlineData(11318, 0x35, 0xa8, false)] + [InlineData(41884, 0x12, 0x89, false)] + [InlineData(240, 0x8d, 0x5a, true)] + [InlineData(11318, 0x35, 0xa8, true)] + [InlineData(41884, 0x12, 0x89, true)] + public void Zip_BZip2_Reader_Throws_On_Corruption( + int offset, + byte original, + byte replacement, + bool skipEntry + ) + { + using var stream = new ForwardOnlyStream( + CreateCorruptArchive(offset, original, replacement) + ); + using var reader = ReaderFactory.OpenReader(stream, ReaderOptions.ForExternalStream); + + var exception = Assert.ThrowsAny(() => + { + while (reader.MoveToNextEntry()) + { + // Disposal must also terminate when a failed read leaves the decoder between states. + using var entry = reader.OpenEntryStream(); + if (!skipEntry) + { + entry.CopyTo(Stream.Null); + } + } + }); + + Assert.StartsWith("BZip2", exception.Message); + if (!skipEntry) + { + Assert.IsType(exception); + } + } + + [Theory] + [InlineData(240, 0x8d, 0x5a, false)] + [InlineData(11318, 0x35, 0xa8, false)] + [InlineData(41884, 0x12, 0x89, false)] + [InlineData(240, 0x8d, 0x5a, true)] + [InlineData(11318, 0x35, 0xa8, true)] + [InlineData(41884, 0x12, 0x89, true)] + public async Task Zip_BZip2_Reader_Throws_On_Corruption_Async( + int offset, + byte original, + byte replacement, + bool skipEntry + ) + { + using var stream = new AsyncOnlyStream( + new ForwardOnlyStream(CreateCorruptArchive(offset, original, replacement)) + ); + await using var reader = await ReaderFactory.OpenAsyncReader( + stream, + ReaderOptions.ForExternalStream, + cancellationToken: CancellationToken.None + ); + + var exception = await Assert.ThrowsAnyAsync(async () => + { + while (await reader.MoveToNextEntryAsync(CancellationToken.None)) + { +#if LEGACY_DOTNET + using var entry = await reader.OpenEntryStreamAsync(CancellationToken.None); +#else + await using var entry = await reader.OpenEntryStreamAsync(CancellationToken.None); +#endif + if (!skipEntry) + { + await entry.CopyToAsync(Stream.Null, 81920, CancellationToken.None); + } + } + }); + + Assert.StartsWith("BZip2", exception.Message); + if (!skipEntry) + { + Assert.IsType(exception); + } + } + + [Theory] + [InlineData(240, 0x8d, 0x5a)] + [InlineData(11318, 0x35, 0xa8)] + [InlineData(41884, 0x12, 0x89)] + public void Zip_BZip2_Archive_Throws_On_Corruption(int offset, byte original, byte replacement) + { + using var stream = CreateCorruptArchive(offset, original, replacement); + using var archive = ArchiveFactory.OpenArchive(stream, ReaderOptions.ForExternalStream); + + Assert.ThrowsAny(() => + { + foreach (var entry in archive.Entries) + { + if (!entry.IsDirectory) + { + using var entryStream = entry.OpenEntryStream(); + entryStream.CopyTo(Stream.Null); + } + } + }); + } + + [Theory] + [InlineData(240, 0x8d, 0x5a)] + [InlineData(11318, 0x35, 0xa8)] + [InlineData(41884, 0x12, 0x89)] + public async Task Zip_BZip2_Archive_Throws_On_Corruption_Async( + int offset, + byte original, + byte replacement + ) + { + using var stream = new AsyncOnlyStream(CreateCorruptArchive(offset, original, replacement)); + await using var archive = await ArchiveFactory.OpenAsyncArchive( + stream, + ReaderOptions.ForExternalStream, + cancellationToken: CancellationToken.None + ); + + await Assert.ThrowsAnyAsync(async () => + { + await foreach (var entry in archive.EntriesAsync) + { + if (!entry.IsDirectory) + { +#if LEGACY_DOTNET + using var entryStream = await entry.OpenEntryStreamAsync( + CancellationToken.None + ); +#else + await using var entryStream = await entry.OpenEntryStreamAsync( + CancellationToken.None + ); +#endif + await entryStream.CopyToAsync(Stream.Null, 81920, CancellationToken.None); + } + } + }); + } + + private static MemoryStream CreateCorruptArchive(int offset, byte original, byte replacement) + { + // Mutate the existing fixture in memory so each regression differs by exactly one byte. + var bytes = File.ReadAllBytes(Path.Combine(TEST_ARCHIVES_PATH, "Zip.bzip2.dd.zip")); + Assert.Equal(original, bytes[offset]); + bytes[offset] = replacement; + return new MemoryStream(bytes); + } +}