Skip to content

Gzip TryDecompress/Decompress still return truncated data as success when the cut input's last 4 bytes read as a number below the output length #171

Closed

Description

@matt-edmondson

What's wrong

#80 made Gzip verify the CRC-32/ISIZE trailer so that cut-off input fails instead of returning partial data. To allow concatenated members, the check also accepts any input whose last 4 bytes, read as ISIZE, are smaller than the output produced so far (Shared/CompressedStreamEnd.cs:133-136):

uint trailerCrc = ReadUInt32LittleEndian(inputTail[^8..]);
uint trailerLength = ReadUInt32LittleEndian(inputTail[^4..]);
return (trailerCrc == ~crc && trailerLength == length) || trailerLength < length;

When the input is truncated, its last 8 bytes aren't a trailer. They're whatever compressed bytes happened to sit at the cut. The || trailerLength < length branch therefore decides on arbitrary data and never checks a CRC.

All three paths use this check: the span path through IsComplete (:53-63), and the stream and async paths through CopyTo/CopyToAsync (:87, :112). When it matches, TryDecompress returns true with a prefix of the data, and Decompress(byte[]) returns that prefix without throwing. That is the failure #80 set out to remove.

How often this happens depends on the data:

  • Compressible data: about outputLength / 2^32 per cut. That's small for small payloads, but roughly 1 in 4 at a 1 GiB output.
  • Data deflate leaves in stored blocks (already-compressed or encrypted content, images, archives, binaries): much more often. Stored blocks copy plaintext verbatim, so zero runs and small little-endian integers in the payload become the "trailer" when the input is cut there.

Repro (net10.0, HEAD 89fc373)

ICompressionProvider gzip = new GzipCompressionProvider();
byte[] plain = new byte[100_000];
new Random(42).NextBytes(plain);
plain.AsSpan(50_000, 4).Clear();              // a zeroed 4-byte field, as any binary file has
byte[] compressed = gzip.Compress(plain);      // 100053 bytes (stored blocks)
int at = 20 + compressed.AsSpan(20).IndexOf(new byte[4]);   // 50030
byte[] truncated = compressed[..(at + 4)];     // cut half way

gzip.TryDecompress(truncated, new byte[plain.Length], out int written);
gzip.TryDecompress(new MemoryStream(truncated), new MemoryStream());
await gzip.TryDecompressAsync(new MemoryStream(truncated), new MemoryStream());
gzip.Decompress(truncated);

Observed:

span   TryDecompress(truncated to 50034) = True, bytesWritten = 50004
stream TryDecompress = True, output = 50004 bytes
async  TryDecompressAsync = True
Decompress(byte[]) returned 50004 bytes, no exception

Real data shows the same thing. I gzipped the first 300 KB of a .nupkg (a zip) and truncated it every 11 bytes. 2 of the 27,202 cuts returned true; the first returned 10,643 of 300,000 bytes. TruncatedCompressionTests misses this because it uses only 14 fixed cut points over one pseudo-random payload (Essentials.Tests/TruncatedCompressionTests.cs:108-118).

Why it matters

  • A partial download or interrupted write of a .gz containing binary or already-compressed content "succeeds" as a short file.
  • Persistence and cache layers that gzip records hand back truncated data with no error to catch.
  • The remarks at CompressedStreamEnd.cs:22-27 and the CLAUDE.md entry for this file both promise that truncation is detected.

Suggested fix

Accept a multi-member stream only after verifying the last member's own CRC, instead of trusting trailerLength < length:

  • Span path: the whole output is available. When trailerLength < length, compute CRC-32 over output[^trailerLength..] and require it to equal trailerCrc.

  • Stream/async paths: the output isn't kept. On net10, GZipStream.Read returns at each member boundary; two members of 100000 and 50000 bytes gave read ends …,98264,100000,150000. So:

    1. Record (length, crc) at each Read return.
    2. At the end, find the checkpoint whose length equals total - trailerLength.
    3. Derive the suffix CRC with a CRC-32 combine and compare it with trailerCrc.
    4. If no checkpoint matches, report failure.

    If relying on read boundaries is too fragile, parse the member framing directly instead.

Acceptance criteria

  • The repro returns false on the span, stream and async paths, writes 0 bytes on the span path, and Decompress throws.
  • A test truncates a stored-block payload (random bytes with embedded zero runs) at every offset and asserts failure on all three paths.
  • Gzip_Accepts_Concatenated_Members (TruncatedCompressionTests.cs:98) still passes, and a concatenation whose last member is truncated is rejected.
  • ZLib and Brotli behaviour is unchanged.

Activity

  1. matt-edmondson commented on Nov 1, 2026

    @matt-edmondson
    ContributorAuthor

    Triage


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions