Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions src/libraries/Common/src/System/IO/SubReadStream.cs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,27 @@ public override long Length
}
}

// Returns the number of bytes actually available for this stream's window in the super stream,
// i.e. how many bytes remain in the super stream, starting from this window's start position,
// before hitting the actual end of the super stream. Returns null when the super stream is not
// seekable, since its total length cannot be determined without consuming it.
// This can be smaller than Length when the window's declared length extends past the actual data
// available in the super stream (for example, a corrupted or maliciously crafted length field).
internal long? AvailableLengthInSuperStream
{
get
{
ThrowIfDisposed();
if (!_superStream.CanSeek)
{
return null;
}

long superStreamLength = _superStream.Length;
return superStreamLength <= _startInSuperStream ? 0 : superStreamLength - _startInSuperStream;
}
}

public override long Position
{
get
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -739,7 +739,7 @@ private FileStreamOptions CreateFileStreamOptions(bool isAsync)
// (real) size, while the archive only contains the much smaller packed data.
// Preallocating to the expanded size would reserve disk space that bears no
// relation to the archive contents and can fail surprisingly on small volumes.
PreallocationSize = _header._gnuSparseDataStream is null ? Length : 0,
PreallocationSize = _header._gnuSparseDataStream is null ? GetPreallocationSize() : 0,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can put the _gnuSparseDataStream check also into GetPreallocationSize so that all logic is in one place

Options = isAsync ? FileOptions.Asynchronous : FileOptions.None
};

Expand All @@ -757,5 +757,39 @@ private FileStreamOptions CreateFileStreamOptions(bool isAsync)

return fileStreamOptions;
}

// Determines the number of bytes to preallocate for the destination file.
// The entry's declared Length comes directly from the (potentially attacker-controlled) tar
// header's size field, so when the data section is backed by a SubReadStream over the archive
// stream, cap the preallocation to the number of bytes actually remaining in the archive stream.
// This prevents a crafted entry that declares a huge size but provides little or no actual data
// from causing an excessive up-front file preallocation (which can exhaust disk space or hang).
// Since extraction writes from the stream's current position, the remaining declared bytes
// (rather than the full Length) are compared against the remaining available archive data.
// When the archive stream isn't seekable, its true remaining length can't be determined without
// consuming it, so preallocation is skipped entirely rather than trusting the declared size.
// Non-SubReadStream data sources (e.g. a user-provided DataStream) are preallocated using the
// full remaining reported Length, since there is no archive stream to validate against.
private long GetPreallocationSize()
{
long length = Length;

if (length > 0 && _header._dataStream is SubReadStream subReadStream)
{
long remainingDeclared = length - subReadStream.Position;

long? availableLength = subReadStream.AvailableLengthInSuperStream;
if (!availableLength.HasValue)
{
// Unseekable archive stream: the declared size cannot be validated against the
// actual remaining data, so don't preallocate based on it at all.
return 0;
Comment on lines +784 to +786

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might regress perf because it disables preallocation in cases where it was allowed before.

The application already authorized writing Length amount of data to the disk by calling the ExtractToFile or ExtractToDirectory method, so verifying that there is enough data in the archive does not introduce any meaningful protection.

}

return Math.Min(remainingDeclared, availableLength.Value);
}

return length;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,98 @@ namespace System.Formats.Tar.Tests
{
public class TarEntry_ExtractToFile_Tests : TarTestsBase
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public void ExtractToFile_MismatchedSizeField_DoesNotPreallocateDeclaredSize(bool seekableArchive)
{
// Craft an entry whose header declares a size much larger than the archive
// actually contains. If the declared size were used verbatim for preallocation,
// extraction could attempt to reserve an arbitrarily large amount of disk space
// (disk-exhaustion) even though almost no real data backs it. Instead, extraction
// should only preallocate/write as much data as is actually available, producing
// a truncated file rather than reserving the bogus declared size.
// Covers both a seekable archive stream (where the real remaining length can be
// determined) and an unseekable one (where preallocation must be skipped entirely).
const long HugeDeclaredSize = 500_000_000; // 500 MB declared, far exceeding the tiny real archive.
byte[] actualData = "small"u8.ToArray();

byte[] archive = BuildRawPaxArchiveWithSizeOverride("file.bin", "file.bin", actualData, HugeDeclaredSize, HugeDeclaredSize);

long apiLength;
using (var scanStream = new MemoryStream(archive))
using (var reader = new TarReader(scanStream))
{
TarEntry entry = reader.GetNextEntry(copyData: false);
Assert.NotNull(entry);
apiLength = entry.Length;
Assert.Equal(HugeDeclaredSize, apiLength);
}

using TempDirectory root = new TempDirectory();
string destination = Path.Join(root.Path, "file.bin");

Stream extractStream = seekableArchive
? new MemoryStream(archive)
: new WrappedStream(new MemoryStream(archive), canRead: true, canWrite: false, canSeek: false);
using (extractStream)
using (var extractReader = new TarReader(extractStream))
{
TarEntry entryToExtract = extractReader.GetNextEntry(copyData: false);
Assert.NotNull(entryToExtract);

// Should complete without hanging or exhausting disk trying to preallocate 500 MB
// for a few bytes of real data; the resulting file is truncated to the real data available.
entryToExtract.ExtractToFile(destination, overwrite: false);
}

long extractedSize = new FileInfo(destination).Length;
Assert.True(extractedSize < HugeDeclaredSize);
}

[Theory]
[InlineData(10, 100_000, true)] // declared size far larger than the entire remaining archive: extraction is bounded by available data, not the bogus declared size
[InlineData(100, 25, true)] // declared size smaller than actual data: gets truncated to the declared size
[InlineData(10, 100_000, false)] // same as above, but the archive stream is unseekable
[InlineData(100, 25, false)]
public void ExtractToFile_MismatchedSizeField_MatchesAvailableData(int dataSize, long headerSizeField, bool seekableArchive)
{
byte[] actualData = new byte[dataSize];
Array.Fill<byte>(actualData, (byte)'X');

byte[] archive = BuildRawPaxArchiveWithSizeOverride("file.bin", "file.bin", actualData, headerSizeField, headerSizeField);

using TempDirectory root = new TempDirectory();
string destination = Path.Join(root.Path, "file.bin");

Stream extractStream = seekableArchive
? new MemoryStream(archive)
: new WrappedStream(new MemoryStream(archive), canRead: true, canWrite: false, canSeek: false);
using (extractStream)
using (var reader = new TarReader(extractStream))
{
TarEntry entry = reader.GetNextEntry(copyData: false);
Assert.NotNull(entry);

entry.ExtractToFile(destination, overwrite: false);
}

long extractedSize = new FileInfo(destination).Length;

if (headerSizeField <= dataSize)
{
Assert.Equal(headerSizeField, extractedSize);
}
else
{
// Not enough real data to satisfy the declared size: extraction stops once the
// underlying archive stream is exhausted, and never reaches the huge declared size.
Assert.True(extractedSize < headerSizeField);
Assert.True(extractedSize <= archive.Length);
}
}


[Theory]
[InlineData(TarEntryFormat.V7)]
[InlineData(TarEntryFormat.Ustar)]
Expand Down