Skip to content

WinZip AES (AE-2): unverified metadata silently changes what ZipArchive returns, including reading an entry as empty #136787

Open

Description

@earbona23

Description

For a WinZip AES entry written as AE-2, two pieces of metadata that the authentication code does not cover are used but never verified: the declared uncompressed size, and the inner compression method in the 0x9901 extra field. Editing either one silently changes what ZipArchiveEntry.Open(password) returns, and the read completes without an error.

The encrypted bytes and the 10-byte authentication code are never touched in any of the cases below, so the HMAC still matches.

Two symptoms from the same cause:

  • Declared uncompressed size → the entry is silently truncated. Setting it to 0 makes the entry read as empty, with no error at all.
  • Inner compression method, Deflate (8) → Stored (0) → the raw Deflate stream is handed back undecompressed: 65558 bytes instead of 65536, different content, no error.

The same edits on an unencrypted entry are rejected, which is the comparison that makes this a gap rather than a property of the format.

This is new surface in .NET 11, so I am reporting it before GA.

Reproduction

Repro.cs is self-contained: net11.0, no arguments, no files on disk. It builds one archive in memory, edits only metadata, and reads it back.

using System;
using System.Buffers.Binary;
using System.IO;
using System.IO.Compression;
using System.Linq;

// net11.0, no arguments. Builds one AES-256 archive in memory, then reads it back
// three times: untouched, with the declared uncompressed size lowered, and with the
// inner compression method changed. Only metadata is edited; the encrypted bytes and
// the 10-byte authentication code are never touched.
class Repro
{
    const string Password = "correct horse battery staple";

    static byte[] s_plaintext;

    static void Main()
    {
        s_plaintext = new byte[64 * 1024];
        new Random(1234).NextBytes(s_plaintext);        // incompressible

        byte[] aes = Build(ZipEncryptionMethod.Aes256);
        byte[] plain = Build(ZipEncryptionMethod.None);

        Console.WriteLine("AES-256 entry, 65536 bytes of content:");
        Read("  untouched                    ", aes, true);
        Read("  declared size -> 1024        ", SetDeclaredSize(aes, 1024), true);
        Read("  declared size -> 0           ", SetDeclaredSize(aes, 0), true);
        Read("  inner method Deflate->Stored ", SetInnerMethod(aes, 0), true);

        Console.WriteLine("the same edits on an unencrypted entry, for comparison:");
        Read("  untouched                    ", plain, false);
        Read("  declared size -> 1024        ", SetDeclaredSize(plain, 1024), false);
        Read("  declared size -> 0           ", SetDeclaredSize(plain, 0), false);
    }

    static byte[] Build(ZipEncryptionMethod enc)
    {
        using MemoryStream ms = new MemoryStream();
        using (ZipArchive zip = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
        {
            ZipArchiveEntry e = enc == ZipEncryptionMethod.None
                ? zip.CreateEntry("data.bin", CompressionLevel.Optimal)
                : zip.CreateEntry("data.bin", CompressionLevel.Optimal, Password, enc);
            using Stream s = enc == ZipEncryptionMethod.None ? e.Open() : e.Open(Password);
            s.Write(s_plaintext);
        }
        return ms.ToArray();
    }

    // Uncompressed size, in the local file header and the central directory.
    static byte[] SetDeclaredSize(byte[] zip, uint value)
    {
        byte[] b = (byte[])zip.Clone();
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(22), value);
        BinaryPrimitives.WriteUInt32LittleEndian(b.AsSpan(CentralDirectory(b) + 24), value);
        return b;
    }

    // Inner compression method inside the 0x9901 AES extra field of the central directory:
    // tag(2) size(2) vendorVersion(2) "AE"(2) strength(1) method(2).
    static byte[] SetInnerMethod(byte[] zip, ushort method)
    {
        byte[] b = (byte[])zip.Clone();
        int cd = CentralDirectory(b);
        int nameLen = BinaryPrimitives.ReadUInt16LittleEndian(b.AsSpan(cd + 28));
        BinaryPrimitives.WriteUInt16LittleEndian(b.AsSpan(cd + 46 + nameLen + 4 + 5), method);
        return b;
    }

    static void Read(string label, byte[] zip, bool encrypted)
    {
        Console.Write(label);
        try
        {
            using MemoryStream src = new MemoryStream(zip);
            using ZipArchive z = new ZipArchive(src, ZipArchiveMode.Read);
            ZipArchiveEntry e = z.GetEntry("data.bin");
            long declared = e.Length;

            using MemoryStream dst = new MemoryStream();
            using (Stream s = encrypted ? e.Open(Password) : e.Open())
            {
                s.CopyTo(dst);
            }

            byte[] got = dst.ToArray();
            string what =
                got.AsSpan().SequenceEqual(s_plaintext) ? "the original content" :
                got.Length <= s_plaintext.Length && got.AsSpan().SequenceEqual(s_plaintext.AsSpan(0, got.Length))
                    ? $"a truncated prefix of the {s_plaintext.Length}-byte content" :
                    "different content";
            Console.WriteLine($"-> Length={declared,6}, read {got.Length,6} bytes with no error: {what}");
        }
        catch (Exception ex)
        {
            Console.WriteLine($"-> rejected: {ex.GetType().Name}: {ex.Message}");
        }
    }

    static int CentralDirectory(byte[] b)
    {
        byte[] eocd = "PK\u0005\u0006".Select(c => (byte)c).ToArray();
        for (int i = b.Length - eocd.Length; i >= 0; i--)
        {
            if (b.AsSpan(i, eocd.Length).SequenceEqual(eocd))
            {
                return BinaryPrimitives.ReadInt32LittleEndian(b.AsSpan(i + 16));
            }
        }
        throw new InvalidDataException("end of central directory not found");
    }
}

Output on 11.0.0-rc.3.26522.148, linux-x64:

AES-256 entry, 65536 bytes of content:
  untouched                    -> Length= 65536, read  65536 bytes with no error: the original content
  declared size -> 1024        -> Length=  1024, read   1024 bytes with no error: a truncated prefix of the 65536-byte content
  declared size -> 0           -> Length=     0, read      0 bytes with no error: a truncated prefix of the 65536-byte content
  inner method Deflate->Stored -> Length= 65536, read  65558 bytes with no error: different content
the same edits on an unencrypted entry, for comparison:
  untouched                    -> Length= 65536, read  65536 bytes with no error: the original content
  declared size -> 1024        -> rejected: InvalidDataException: The CRC32 checksum of the extracted data does not match the expected value from the archive.
  declared size -> 0           -> rejected: InvalidDataException: The CRC32 checksum of the extracted data does not match the expected value from the archive.

The 65558 bytes are the raw Deflate stream and not something corrupt: inflating them with raw Deflate returns the original 65536-byte plaintext exactly.

Mechanism

ZipArchiveEntry.BuildDecompressionPipeline returns the stream without CrcValidatingReadStream when the entry is AES-encrypted and _aeVersion == 2:

// AE-2 encrypted entries store CRC as 0 so skip CRC validation for those.
// AE-1 version entries store a valid CRC.
if (IsAesEncrypted && _aeVersion == 2)
{
    return decompressedStream;
}

return new CrcValidatingReadStream(decompressedStream, _crc32, _uncompressedSize);

Skipping the CRC is correct, since AE-2 stores CRC = 0. What goes with it is the uncompressed size check, and that size is still used as a hard output cap one call earlier, in GetDataDecompressor:

case ZipCompressionMethod.Deflate:
    uncompressedStream = new DeflateStream(compressedStreamToRead, CompressionMode.Decompress, _uncompressedSize);

So _uncompressedSize decides how many bytes the caller gets, and nothing afterwards checks that the stream actually produced that many. The Stored arm returns the stream unbounded, which is why the declared size has no effect on a Stored AES entry and a truncating effect on a Deflate one — the two are inconsistent with each other as well.

For the second symptom: the 0x9901 field is read from the central directory only (_aeVersion and the real compression method are both taken from cd.AesExtraField). The copy in the local file header is never consulted, so the two are never compared.

What the authentication code does still catch

These are controls. They all behave correctly, which is why I am describing this as missing cross-checks and not as a problem with the authentication code itself:

change result on 11.0.0-rc.3
10-byte authentication code corrupted InvalidDataException: The authentication code does not match.
64 KB inserted before the authentication code, code left intact InvalidDataException: The authentication code does not match.
inner method changed in the local header only reads, content identical — the field is ignored there
AES strength byte set to 0, 7 or 0xFF InvalidDataException (message says "unsupported compression method")
vendor version changed 2 → 1 in the central directory InvalidDataException, CRC32 mismatch — the AE-1 path does validate the CRC
inner method changed to 12, 14, 93 or 99 InvalidDataException: unsupported compression method
declared uncompressed size raised to 200000 reads, full content, no error

Only inner method 0 produces the content change; method 9 is accepted and treated as Deflate, which yields identical content.

Another implementation rejects the same bytes

7-Zip 26.03 on the same three archives, same password:

clean.zip     Everything is Ok                                              65536 bytes out
size0.zip     ERROR: Data Error in encrypted file. Wrong password? : f           0 bytes out
size1024.zip  ERROR: Data Error in encrypted file. Wrong password? : f        1024 bytes out

and on the inner-method edit:

ERROR: Headers Error : data.bin

It flags all three. That difference is the reason this seems worth reporting: archives that another implementation calls damaged are read by ZipArchive with no signal.

Why it matters

Someone who can modify the archive but does not know the password can change what a .NET application receives from an encrypted entry, and the application has no way to notice. Setting the declared size to zero makes an entry read as empty and successful. Code that treats a completed Open(password) and a completed read as "this is the authentic content" — a verification step, a scanner, anything that hashes what it extracted, anything that would have caught this on an unencrypted entry — gets different or missing bytes and no exception.

I am not claiming the authentication code is defeated. It covers the entry data and it detects changes to it, as the table above shows. What is absent is any check on the metadata that decides how the authenticated data is interpreted, for exactly the entries where the CRC and size checks were turned off together.

Possible directions

Either closes it, and the first is small:

  1. Keep validating the uncompressed size for AE-2 entries even though the CRC cannot be checked — the length half of CrcValidatingReadStream does not depend on the CRC.
  2. Require the 0x9901 extra field in the local file header to match the central directory copy, which is what 7-Zip does.

Activity

  1. dotnet-policy-service commented on Nov 1, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @karelz, @dotnet/area-system-io-compression
    See info in area-owners.md if you want to be subscribed.

  2. earbona23 commented on Nov 1, 2026

    @earbona23
    Author

    One more consequence of the same cause, which I had not measured when I filed this: the new ZipFile.ExtractToDirectory(..., ZipExtractionOptions) overload writes the truncated content to disk, so the caller gets a short or empty file and a successful return rather than a stream they could notice something about.

    ZipFile.ExtractToDirectory(archivePath, destination,
        new ZipExtractionOptions { Password = "pw".AsMemory() });

    Measured on 11.0.0-rc.3.26522.148, same archive as above (one AES-256 entry, 65536 bytes of incompressible content), editing only the declared uncompressed size in the local header and the central directory:

    declared uncompressed size file on disk after extraction
    untouched (control) data.bin 65536 bytes, content identical
    1024 data.bin 1024 bytes, a truncated prefix, no exception
    0 data.bin 0 bytes, no exception

    The path guard is not affected and I want to be clear about that: an entry named ../escaped.txt in an encrypted archive is still rejected with IOException: Extracting Zip entry would have resulted in a file outside..., so this is only the size and integrity half of the problem, not traversal.

    What changes with this overload is who notices. A caller using entry.Open(password) holds a stream and could in principle compare what it read against entry.Length. A caller using ExtractToDirectory gets a file and a void return, so an installer, an update unpacker or a restore step that extracts an encrypted archive ends up with an empty file and no indication that anything was modified.

    Reproducing it is the same edit as the repro in the issue, with the read replaced by the extraction call above. Both halves of the original suggestion still apply; validating the uncompressed size for AE-2 entries would close this path as well, since it is the same missing check.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions