diff --git a/src/SharpCompress/Compressors/LZMA/LzmaDecoder.Fast.cs b/src/SharpCompress/Compressors/LZMA/LzmaDecoder.Fast.cs index 81ad80d8..9c774be3 100644 --- a/src/SharpCompress/Compressors/LZMA/LzmaDecoder.Fast.cs +++ b/src/SharpCompress/Compressors/LZMA/LzmaDecoder.Fast.cs @@ -151,6 +151,12 @@ public partial class Decoder [MethodImpl(MethodImplOptions.AggressiveInlining)] public readonly byte GetByte(int distance) { + // A previous decoding failure can leave a rep distance outside the dictionary. + // Unlike a managed array access, this pointer read has no implicit bounds check. + if ((uint)distance >= (uint)WindowSize) + { + throw new DataErrorException(); + } var p = Pos - distance - 1; if (p < 0) { @@ -178,6 +184,10 @@ public partial class Decoder public void CopyBlock(int distance, int len) { + if ((uint)distance >= (uint)WindowSize || (uint)distance >= Total) + { + throw new DataErrorException(); + } var rem = len; var p = (distance < Pos ? Pos : Pos + WindowSize) - distance - 1; var targetSize = @@ -284,6 +294,12 @@ public partial class Decoder // on every iteration, including non-literal ones, outweighs the latency-hiding // benefit; kept lazy, computed only once IsMatch resolves to 0.) var isCharState = _state.IsCharState(); + // Validate before the speculative matched-literal read, not only when a + // newly decoded match is copied. In particular, retries can retain _rep0. + if (!isCharState && (_rep0 >= os.Total || _rep0 >= dictionarySizeCheck)) + { + throw new DataErrorException(); + } var prevByte = os.GetByte(0); var matchByte = isCharState ? (byte)0 : os.GetByte((int)_rep0); @@ -326,6 +342,10 @@ public partial class Decoder { if (DecodeBitFast(ref rs, pIsRep0Long, matchIndex) == 0) { + if (_rep0 >= os.Total || _rep0 >= dictionarySizeCheck) + { + throw new DataErrorException(); + } _state.UpdateShortRep(); os.PutByte(os.GetByte((int)_rep0)); continue; diff --git a/tests/SharpCompress.Test/Streams/LzmaDecoderSafetyTests.cs b/tests/SharpCompress.Test/Streams/LzmaDecoderSafetyTests.cs new file mode 100644 index 00000000..fcc55cef --- /dev/null +++ b/tests/SharpCompress.Test/Streams/LzmaDecoderSafetyTests.cs @@ -0,0 +1,319 @@ +using System; +using System.IO; +using System.IO.Compression; +using System.Reflection; +using System.Threading; +using System.Threading.Tasks; +using SharpCompress.Common; +using SharpCompress.Compressors.LZMA; +using SharpCompress.Compressors.LZMA.LZ; +using SharpCompress.Compressors.Xz; +using SharpCompress.Readers; +using Xunit; + +namespace SharpCompress.Test.Streams; + +public class LzmaDecoderSafetyTests +{ + // Corruption followed by another read, including the implicit retry during entry skipping. + private const string RereadHex = + "fd377a585a0000016922de360200210100000000372797d6010011616c7068610a616c7068610a616c70" + + "10000afff3003f0e7b9e00012600100000109042990d010000000001595a"; + + private const string TarGzipBase64 = + "H4sIAAAAAAACCvtrXhURxcDAmKl0z4yJQZGRAQjM1adfY5T8r6cfkFjhkZqYklpUrGdoZmJkqK/HQGVgAARmJiZgGgjQaQMD" + + "UyM09ebmJuYMChUMdAClxSWJRUArSdDyHwgosZIy3dQFhiYKBYklGbaJeiUVJVxGFgq5JZm5qbaG5pZGBiYGFoaWeqbGxsam" + + "xhZcDEMY+DGMAuwAHO80toNg/jcyYjA0NTIzMTA1NzUzBoqbAQsABgWDQZr/h1X85xRkJHIhk/hUsww5/30azeIDD/AWAIag" + + "BoAhZgPADKUBQLt2CoUFQMOgDnnzqj3r1WWIaAYYQ5oBKZlF+jhbAeZGhlyjiXlYAlC806MUMDc1xd0MMFBHbwaYmgO7Aab0" + + "bwY8pVvAMw6O+B8c3UAzjFrA1JiW3UA9Pf2h1xIM8GZkoklP0AJRBSTh7Q1amJiP1gMQ8IzAUIDS0KoCkmjcGyTcEzRBqwKA" + + "FYbpSOsJBnizslHLLGHSLGZmSVRISi1BIoZGTk8ZSYVOA7zQERuKzn/KMAqoAXiHprPrB8ba/99A5NfRZIMK7qNytX/dEWRg" + + "nGLSYMLAsDPV4r6dAW83uLXJGBkFAFQcy31GGgAA"; + + [Theory] + [InlineData(false, 1)] + [InlineData(false, 65536)] + [InlineData(true, 1)] + [InlineData(true, 65536)] + public void CorruptInputThrowsDataError(bool raw, int bufferSize) + { + using var input = CreateInput(raw); + using var stream = CreateStream(input, raw); + var buffer = new byte[bufferSize]; + Assert.Throws(() => Drain(stream, buffer)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void ReadByteCorruptInputThrowsDataError(bool raw) + { + using var input = CreateInput(raw); + using var stream = CreateStream(input, raw); + Assert.Throws(() => + { + for (var count = 0; count < 65536; count++) + { + Assert.NotEqual(-1, stream.ReadByte()); + } + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AsyncCorruptInputThrowsDataError(bool raw) + { + using var input = CreateInput(raw); + using var stream = await CreateStreamAsync(input, raw); + var buffer = new byte[65536]; + await Assert.ThrowsAsync(async () => + { + for (var attempt = 0; attempt < 16; attempt++) + { + Assert.NotEqual( + 0, + await stream.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None) + ); + } + }); + } + +#if !LEGACY_DOTNET + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task AsyncMemoryCorruptInputThrowsDataError(bool raw) + { + using var input = CreateInput(raw); + using var stream = await CreateStreamAsync(input, raw); + var buffer = new byte[65536]; + await Assert.ThrowsAsync(async () => + { + for (var attempt = 0; attempt < 16; attempt++) + { + Assert.NotEqual( + 0, + await stream.ReadAsync(buffer.AsMemory(), CancellationToken.None) + ); + } + }); + } + + [Fact] + public void FastDecoderRetryAfterCorruptionRejectsInvalidDistance() + { + using var input = CreateInput(true); + using var stream = LzmaStream.Create(new byte[] { 0 }, input, leaveOpen: true); + Assert.Throws(() => Drain(stream, new byte[65536])); + + // Exercise the unsafe decoder directly after corruption to verify its bounds checks. + var decoder = GetField(stream, "_decoder"); + var window = GetField(stream, "_outWindow"); + var range = GetField( + stream, + "_rangeDecoder" + ); + try + { + Assert.Throws(() => decoder.CodeFast(4096, window, range)); + } + finally + { + range.ReleaseStream(); + } + } + + [Theory] + [InlineData(1u)] // Inside the window, but outside the available history. + [InlineData(4096u)] + [InlineData(0x80000000u)] + [InlineData(uint.MaxValue)] + public void FastDecoderRejectsInvalidMatchedLiteralDistance(uint distance) + { + using var decoder = new Decoder(); + decoder.SetDecoderProperties(new byte[] { 0x5D, 0, 0x10, 0, 0 }); + var state = new Base.State(); + state.UpdateMatch(); + SetField(decoder, "_state", state); + SetField(decoder, "_rep0", distance); + using var window = new OutWindow(); + window.Create(4096); + window.SetLimit(2); + window.PutByte(0); + var range = new SharpCompress.Compressors.LZMA.RangeCoder.Decoder(); + try + { + Assert.Throws(() => decoder.CodeFast(4096, window, range)); + } + finally + { + range.ReleaseStream(); + } + } + + private static T GetField(object instance, string name) => + (T) + instance + .GetType() + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .GetValue(instance)!; + + [Theory] + [InlineData(0u)] + [InlineData(4095u)] + public void FastDecoderAcceptsTrainedHistoryAtDictionaryWrap(uint distance) + { + using var decoder = new Decoder(); + decoder.SetDecoderProperties(new byte[] { 0x5D, 0, 0x10, 0, 0 }); + var state = new Base.State(); + state.UpdateMatch(); + SetField(decoder, "_state", state); + SetField(decoder, "_rep0", distance); + using var window = new OutWindow(); + window.Create(4096); + using var dictionary = new MemoryStream(new byte[4096]); + window.Train(dictionary); + window.SetLimit(1); + using var input = new MemoryStream(new byte[16]); + var range = new SharpCompress.Compressors.LZMA.RangeCoder.Decoder(); + range.Init(input); + try + { + Assert.False(decoder.CodeFast(4096, window, range)); + Assert.Equal(4097, window.Total); + Assert.Equal(0, window.ReadByte()); + } + finally + { + range.ReleaseStream(); + } + } + + private static void SetField(object instance, string name, T value) => + instance + .GetType() + .GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)! + .SetValue(instance, value); +#endif + + [Fact] + public void ReaderSkippingCorruptTarXzThrowsManagedException() + { + using var packed = new MemoryStream(Convert.FromBase64String(TarGzipBase64)); + using var gzip = new GZipStream(packed, CompressionMode.Decompress); + using var input = new MemoryStream(); + gzip.CopyTo(input); + input.Position = 0; + Assert.ThrowsAny(() => + { + using var reader = ReaderFactory.OpenReader(input, ReaderOptions.ForExternalStream); + for (var entry = 0; entry < 32; entry++) + { + Assert.True(reader.MoveToNextEntry()); + } + }); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void FaultedStreamDisposalPreservesOwnership(bool leaveOpen) + { + using var input = CreateInput(true); + var stream = LzmaStream.Create(new byte[] { 0 }, input, leaveOpen: leaveOpen); + Assert.Throws(() => Drain(stream, new byte[65536])); + stream.Dispose(); + stream.Dispose(); + Assert.Equal(leaveOpen, input.CanRead); + Assert.Throws(() => stream.ReadByte()); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FaultedStreamAsyncDisposalPreservesOwnership(bool leaveOpen) + { + using var input = CreateInput(true); + var stream = await LzmaStream.CreateAsync( + new byte[] { 0 }, + input, + -1, + -1, + leaveOpen: leaveOpen + ); + var buffer = new byte[65536]; + await Assert.ThrowsAsync(() => + stream.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None) + ); + await stream.DisposeAsync(); + await stream.DisposeAsync(); + Assert.Equal(leaveOpen, input.CanRead); + await Assert.ThrowsAsync(() => + stream.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None) + ); + } + + [Fact] + public async Task CancellationBeforeDecodingDoesNotFaultStream() + { + using var input = new MemoryStream(new byte[] { 1, 0, 0, (byte)'X', 0 }); + await using var stream = await LzmaStream.CreateAsync( + new byte[] { 0 }, + input, + -1, + -1, + leaveOpen: true + ); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + var buffer = new byte[2]; + await Assert.ThrowsAsync(() => + stream.ReadAsync(buffer, 0, buffer.Length, cancellation.Token) + ); + Assert.Equal(0, input.Position); + Assert.Equal(1, await stream.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None)); + Assert.Equal((byte)'X', buffer[0]); + } + +#if !LEGACY_DOTNET + [Fact] + public void RawLzmaRejectsShortRepeatWithoutHistory() + { + // Initial range code selects IsMatch=1, IsRep=1, IsRepG0=0, IsRep0Long=0. + using var input = new MemoryStream(new byte[] { 0, 0xC0, 0, 0, 0 }); + using var stream = LzmaStream.Create( + new byte[] { 0x5D, 0, 0x10, 0, 0 }, + input, + leaveOpen: true + ); + Assert.Throws(() => stream.ReadByte()); + Assert.Equal(5, input.Position); + } +#endif + + private static MemoryStream CreateInput(bool raw) + { + var bytes = new byte[RereadHex.Length / 2]; + for (var i = 0; i < bytes.Length; i++) + { + bytes[i] = Convert.ToByte(RereadHex.Substring(i * 2, 2), 16); + } + return new MemoryStream(raw ? bytes.AsSpan(24).ToArray() : bytes); + } + + private static Stream CreateStream(Stream input, bool raw) => + raw ? LzmaStream.Create(new byte[] { 0 }, input, leaveOpen: true) : new XZStream(input); + + private static async Task CreateStreamAsync(Stream input, bool raw) => + raw + ? await LzmaStream.CreateAsync(new byte[] { 0 }, input, -1, -1, leaveOpen: true) + : new XZStream(input); + + private static void Drain(Stream stream, byte[] buffer) + { + for (var attempt = 0; attempt < 65536; attempt++) + { + Assert.NotEqual(0, stream.Read(buffer, 0, buffer.Length)); + } + } +}