Reject truncated LZMA2 headers and prevent stalled decoding

This commit is contained in:
Adam Hathcock
2026-10-15 10:44:50 +01:00
parent e4193af83b
commit bc1ade2bbc
3 changed files with 201 additions and 10 deletions

View File

@@ -201,6 +201,11 @@ public partial class LzmaStream
}
}
if (_availableBytes < 0)
{
throw new DataErrorException();
}
var toProcess = count - total;
if (toProcess > _availableBytes)
{
@@ -224,6 +229,10 @@ public partial class LzmaStream
}
var read = _outWindow.Read(buffer, offset, toProcess);
if (read == 0 && _availableBytes > 0)
{
throw new DataErrorException();
}
total += read;
offset += read;
_position += read;
@@ -317,6 +326,11 @@ public partial class LzmaStream
}
}
if (_availableBytes < 0)
{
throw new DataErrorException();
}
var toProcess = count - total;
if (toProcess > _availableBytes)
{
@@ -340,6 +354,10 @@ public partial class LzmaStream
}
var read = _outWindow.Read(buffer, offset, toProcess);
if (read == 0 && _availableBytes > 0)
{
throw new DataErrorException();
}
total += read;
offset += read;
_position += read;

View File

@@ -266,6 +266,11 @@ public partial class LzmaStream : Stream, IStreamStack, IAsyncDisposable
}
}
if (_availableBytes < 0)
{
throw new DataErrorException();
}
var toProcess = count - total;
if (toProcess > _availableBytes)
{
@@ -283,6 +288,11 @@ public partial class LzmaStream : Stream, IStreamStack, IAsyncDisposable
}
var read = _outWindow.Read(buffer, offset, toProcess);
// Unknown-size raw LZMA can end without output; otherwise no progress is corruption.
if (read == 0 && _availableBytes > 0)
{
throw new DataErrorException();
}
total += read;
offset += read;
_position += read;
@@ -416,10 +426,21 @@ public partial class LzmaStream : Stream, IStreamStack, IAsyncDisposable
return value;
}
private int ReadChunkHeaderByte()
{
var value = _inputStream!.ReadByte();
// EOF is not an LZMA2 end marker and must never enter chunk-size arithmetic.
if (value < 0)
{
throw new DataErrorException();
}
_inputPosition++;
return value;
}
private void DecodeChunkHeader()
{
var control = _inputStream!.ReadByte();
_inputPosition++;
var control = ReadChunkHeaderByte();
if (control == 0x00)
{
@@ -448,17 +469,14 @@ public partial class LzmaStream : Stream, IStreamStack, IAsyncDisposable
_uncompressedChunk = false;
_availableBytes = (control & 0x1F) << 16;
_availableBytes += (_inputStream.ReadByte() << 8) + _inputStream.ReadByte() + 1;
_inputPosition += 2;
_availableBytes += (ReadChunkHeaderByte() << 8) + ReadChunkHeaderByte() + 1;
_rangeDecoderLimit = (_inputStream.ReadByte() << 8) + _inputStream.ReadByte() + 1;
_inputPosition += 2;
_rangeDecoderLimit = (ReadChunkHeaderByte() << 8) + ReadChunkHeaderByte() + 1;
if (control >= 0xC0)
{
_needProps = false;
Properties[0] = (byte)_inputStream.ReadByte();
_inputPosition++;
Properties[0] = (byte)ReadChunkHeaderByte();
_decoder = new Decoder();
_decoder.SetDecoderProperties(Properties);
@@ -482,8 +500,7 @@ public partial class LzmaStream : Stream, IStreamStack, IAsyncDisposable
else
{
_uncompressedChunk = true;
_availableBytes = (_inputStream.ReadByte() << 8) + _inputStream.ReadByte() + 1;
_inputPosition += 2;
_availableBytes = (ReadChunkHeaderByte() << 8) + ReadChunkHeaderByte() + 1;
}
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using SharpCompress.Archives;
using SharpCompress.Common;
using SharpCompress.Compressors.LZMA;
using SharpCompress.Compressors.Xz;
using Xunit;
namespace SharpCompress.Test.Streams;
public class Lzma2TruncationTests
{
private const string TruncatedXzHex =
"FD377A585A0000016922DE360200210100000000372797D6010011616C7068610A616C7068610A616C7068610A";
private const string MutatedSevenZipBase64 =
"N3q8ryccAATrtqxIDgAAAAAAAABaAAAAAAAAAKyqq98BAApoZWxsbyB3b3JsZAEEBgABCQ4ABwsBAAEhIQEADAwACAoBhRFKDQAABQEZDAAAAAAAAAAAAAAAABETAHQAZQBzAHQALgB0AHgAdAAAABkAFAoBAI6gjYxMV90BFQYBACAAAAAAAA==";
public static IEnumerable<object[]> PartialHeaders()
{
// Start with a complete dictionary-reset chunk so a missing control byte
// cannot be rejected merely because the dictionary still needs resetting.
byte[] prefix = [0x01, 0x00, 0x00, 0x58];
yield return [prefix];
byte[][] headers =
[
[0x01, 0x00, 0x00],
[0x02, 0x00, 0x00],
[0xE0, 0x00, 0x00, 0x00, 0x04, 0x00],
];
foreach (var header in headers)
{
for (var length = 1; length < header.Length; length++)
{
var data = new byte[prefix.Length + length];
prefix.CopyTo(data, 0);
Array.Copy(header, 0, data, prefix.Length, length);
yield return [data];
}
}
}
[Theory]
[MemberData(nameof(PartialHeaders))]
public void Lzma2_Rejects_Partial_Header(byte[] data)
{
using var input = new MemoryStream(data);
using var decoder = LzmaStream.Create([0x00], input);
Assert.ThrowsAny<SharpCompressException>(() => decoder.CopyTo(Stream.Null));
}
[Theory]
[MemberData(nameof(PartialHeaders))]
public void Lzma2_ReadByte_Rejects_Partial_Header(byte[] data)
{
using var input = new MemoryStream(data);
using var decoder = LzmaStream.Create([0x00], input);
Assert.Equal(0x58, decoder.ReadByte());
Assert.ThrowsAny<SharpCompressException>(() => decoder.ReadByte());
}
[Theory]
[MemberData(nameof(PartialHeaders))]
public async Task Lzma2_Async_Rejects_Partial_Header(byte[] data)
{
using var input = new MemoryStream(data);
await using var decoder = await LzmaStream.CreateAsync([0x00], input, -1, -1);
var buffer = new byte[64];
await Assert.ThrowsAnyAsync<SharpCompressException>(async () =>
await decoder.ReadAsync(buffer, 0, buffer.Length, CancellationToken.None)
);
#if !LEGACY_DOTNET
// Use a fresh decoder because parser state is undefined after a failed read.
using var memoryInput = new MemoryStream(data);
await using var memoryDecoder = await LzmaStream.CreateAsync([0x00], memoryInput, -1, -1);
await Assert.ThrowsAnyAsync<SharpCompressException>(async () =>
await memoryDecoder.ReadAsync(buffer.AsMemory(), CancellationToken.None)
);
#endif
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Xz_Rejects_Missing_Lzma2_End_Marker(bool useAsync)
{
var data = new byte[TruncatedXzHex.Length / 2];
for (var i = 0; i < data.Length; i++)
{
data[i] = Convert.ToByte(TruncatedXzHex.Substring(i * 2, 2), 16);
}
using var input = new MemoryStream(data);
await using var decoder = new XZStream(input);
if (useAsync)
{
await Assert.ThrowsAnyAsync<SharpCompressException>(() =>
decoder.CopyToAsync(Stream.Null, 81920, CancellationToken.None)
);
}
else
{
Assert.ThrowsAny<SharpCompressException>(() => decoder.CopyTo(Stream.Null));
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task SevenZip_Rejects_Missing_Lzma2_End_Marker(bool useAsync)
{
using var input = new MemoryStream(Convert.FromBase64String(MutatedSevenZipBase64));
if (useAsync)
{
await using var archive = await ArchiveFactory.OpenAsyncArchive(input);
await foreach (var entry in archive.EntriesAsync)
{
await using var stream = await entry.OpenEntryStreamAsync(CancellationToken.None);
await Assert.ThrowsAnyAsync<SharpCompressException>(() =>
stream.CopyToAsync(Stream.Null, 81920, CancellationToken.None)
);
}
}
else
{
using var archive = ArchiveFactory.OpenArchive(input);
foreach (var entry in archive.Entries)
{
using var stream = entry.OpenEntryStream();
Assert.ThrowsAny<SharpCompressException>(() => stream.CopyTo(Stream.Null));
}
}
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Lzma2_Accepts_End_Marker_After_Uncompressed_Chunks(bool useAsync)
{
byte[] data = [0x01, 0x00, 0x00, 0x58, 0x02, 0x00, 0x00, 0x59, 0x00];
using var input = new MemoryStream(data);
using var output = new MemoryStream();
if (useAsync)
{
await using var decoder = await LzmaStream.CreateAsync([0x00], input, data.Length, 2);
await decoder.CopyToAsync(output, 1, CancellationToken.None);
}
else
{
using var decoder = LzmaStream.Create([0x00], input, data.Length, 2);
decoder.CopyTo(output, 1);
}
Assert.Equal(new byte[] { 0x58, 0x59 }, output.ToArray());
}
}