Repository navigation
Description
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
0makes 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:
- Keep validating the uncompressed size for AE-2 entries even though the CRC cannot be checked — the length half of
CrcValidatingReadStreamdoes not depend on the CRC. - Require the
0x9901extra field in the local file header to match the central directory copy, which is what 7-Zip does.
Activity
dotnet-policy-service commentedon Nov 1, 2026
Tagging subscribers to this area: @karelz, @dotnet/area-system-io-compression
See info in area-owners.md if you want to be subscribed.
earbona23 commentedon Nov 1, 2026
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.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:data.bin65536 bytes, content identical1024data.bin1024 bytes, a truncated prefix, no exception0data.bin0 bytes, no exceptionThe path guard is not affected and I want to be clear about that: an entry named
../escaped.txtin an encrypted archive is still rejected withIOException: 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 againstentry.Length. A caller usingExtractToDirectorygets 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.