From 4aec554a34c26ac733d39abe9748e7e619ed3c70 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Fri, 24 Jul 2026 11:12:00 +0200 Subject: [PATCH 1/6] correctly implement fuzzers fro new encryption streams --- .../Fuzzers/WinZipAesStreamFuzzer.cs | 204 +++++++----------- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 152 +++++++------ 2 files changed, 154 insertions(+), 202 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index 7632e9b6b33197..28e2eb845a8825 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -3,9 +3,7 @@ using System.Buffers; using System.IO.Compression; -using System.Reflection; using System.Runtime.Versioning; -using System.Security.Cryptography; using System.Threading.Tasks; namespace DotnetFuzzing.Fuzzers; @@ -15,157 +13,119 @@ internal sealed class WinZipAesStreamFuzzer : IFuzzer { public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; public string[] TargetCoreLibPrefixes => []; - public string Corpus => "winzipaesstream"; - // AES-256 key size in bits; salt size = keySizeBits / 16 = 16 bytes. - private const int KeySizeBits = 256; + private const string Password = "fuzz-password"; + private const string EntryName = "entry"; - // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, - // and CreateDelegate cannot handle struct-to-object return covariance. - // Use DynamicMethod to emit a wrapper that boxes the struct return value. - private delegate object CreateKeyDelegate(ReadOnlySpan password, byte[]? salt, int keySizeBits); + private static readonly ZipEncryptionMethod[] s_aesMethods = + [ + ZipEncryptionMethod.Aes128, + ZipEncryptionMethod.Aes192, + ZipEncryptionMethod.Aes256, + ]; - private static readonly CreateKeyDelegate _createKey; - private static readonly MethodInfo _createMethod; - - // The salt and password verifier properties are needed to prepend a valid header - // so the stream's ReadAndValidateHeaderCore succeeds and decryption logic is reached. - private static readonly PropertyInfo _saltProp; - private static readonly PropertyInfo _verifierProp; - - // Pre-derive key material once with a fixed password and no salt so the fuzzer focuses - // on the stream's decryption/HMAC logic rather than key derivation. - private static readonly object s_keyMaterial; + public void FuzzTarget(ReadOnlySpan bytes) + { + // The encryption streams only produce ciphertext for non-empty content; an empty + // entry is stored unencrypted, so it does not exercise the WinZipAesStream at all. + if (bytes.IsEmpty) + { + return; + } - // Cache the salt and password verifier bytes for prepending to the fuzz input. - private static readonly byte[] s_salt; - private static readonly byte[] s_verifier; + // Use the first byte to select the AES key strength so all three variants get exercised. + ZipEncryptionMethod method = s_aesMethods[bytes[0] % s_aesMethods.Length]; - static WinZipAesStreamFuzzer() - { - Type winZipAesStreamType = Type.GetType("System.IO.Compression.WinZipAesStream, System.IO.Compression")!; - Type winZipAesKeyMaterialType = Type.GetType("System.IO.Compression.WinZipAesKeyMaterial, System.IO.Compression")!; - -#pragma warning disable IL3050 // RequiresDynamicCode: DynamicMethod is not AOT-compatible; fuzzers run under CoreCLR only. - _createKey = CreateBoxingDelegate(winZipAesKeyMaterialType); -#pragma warning restore IL3050 - - _createMethod = winZipAesStreamType.GetMethod( - "Create", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(Stream), winZipAesKeyMaterialType, typeof(long), typeof(bool), typeof(bool)], - modifiers: null)!; - - _saltProp = winZipAesKeyMaterialType.GetProperty( - "Salt", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; - - _verifierProp = winZipAesKeyMaterialType.GetProperty( - "PasswordVerifier", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)!; - - s_keyMaterial = _createKey("fuzz", null, KeySizeBits); - s_salt = (byte[])_saltProp.GetValue(s_keyMaterial)!; - s_verifier = (byte[])_verifierProp.GetValue(s_keyMaterial)!; + byte[] content = CopyToRentedArray(bytes); + try + { + RoundTrip(content, bytes.Length, method, async: false).GetAwaiter().GetResult(); + RoundTrip(content, bytes.Length, method, async: true).GetAwaiter().GetResult(); + } + finally + { + ArrayPool.Shared.Return(content); + } } - private static CreateKeyDelegate CreateBoxingDelegate(Type winZipAesKeyMaterialType) + private static async Task RoundTrip(byte[] content, int length, ZipEncryptionMethod method, bool async) { - MethodInfo createKeyMethod = winZipAesKeyMaterialType.GetMethod( - "Create", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(ReadOnlySpan), typeof(byte[]), typeof(int)], - modifiers: null)!; - - var dm = new System.Reflection.Emit.DynamicMethod( - "CreateKeyWrapper", - typeof(object), - [typeof(ReadOnlySpan), typeof(byte[]), typeof(int)], - typeof(WinZipAesStreamFuzzer).Module, - skipVisibility: true); - var il = dm.GetILGenerator(); - il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); - il.Emit(System.Reflection.Emit.OpCodes.Ldarg_1); - il.Emit(System.Reflection.Emit.OpCodes.Ldarg_2); - il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); - il.Emit(System.Reflection.Emit.OpCodes.Box, winZipAesKeyMaterialType); - il.Emit(System.Reflection.Emit.OpCodes.Ret); - return dm.CreateDelegate(); - } + using var archiveStream = new MemoryStream(); - // Minimum fuzz input: at least 1 byte of encrypted data beyond the header. - // The header (salt + verifier) is prepended by CreateStream, so the fuzz input - // only needs to supply encrypted data + the 10-byte auth code. - private const int MinInputLength = 11; // 1 byte data + 10 bytes HMAC + // Encrypt the fuzz input using WinZip AES, exercising the WinZipAesStream encryption + HMAC path. + ZipArchive writeArchive = async + ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null) + : new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null); - public void FuzzTarget(ReadOnlySpan bytes) - { - if (bytes.Length < MinInputLength) + ZipArchiveEntry writeEntry = writeArchive.CreateEntry(EntryName, Password.AsSpan(), method); + + Stream entryWriteStream = async ? await writeEntry.OpenAsync() : writeEntry.Open(); + if (async) { - return; + await entryWriteStream.WriteAsync(content.AsMemory(0, length)); + await entryWriteStream.DisposeAsync(); + await writeArchive.DisposeAsync(); + } + else + { + entryWriteStream.Write(content, 0, length); + entryWriteStream.Dispose(); + writeArchive.Dispose(); } - TestStream(CopyToRentedArray(bytes), bytes.Length, async: false).GetAwaiter().GetResult(); - TestStream(CopyToRentedArray(bytes), bytes.Length, async: true).GetAwaiter().GetResult(); - } + archiveStream.Position = 0; - private static Stream CreateStream(byte[] bytes, int length) - { - // Prepend the valid salt + password verifier so ReadAndValidateHeaderCore passes, - // allowing the fuzzer to exercise the CTR decryption and HMAC validation paths. - int headerSize = s_salt.Length + s_verifier.Length; - int totalSize = headerSize + length; - byte[] combined = new byte[totalSize]; - s_salt.CopyTo(combined, 0); - s_verifier.CopyTo(combined, s_salt.Length); - Buffer.BlockCopy(bytes, 0, combined, headerSize, length); - -#pragma warning disable IL2072 // dynamic invocation - return (Stream)_createMethod.Invoke( - obj: null, - parameters: [new MemoryStream(combined), s_keyMaterial, (long)totalSize, /*encrypting*/ false, /*leaveOpen*/ false])!; -#pragma warning restore IL2072 - } + // Decrypt with the correct password and verify the round-trip is lossless. + ZipArchive readArchive = async + ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null) + : new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null); - private static byte[] CopyToRentedArray(ReadOnlySpan bytes) - { - byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); - bytes.CopyTo(buffer); - return buffer; - } + ZipArchiveEntry readEntry = readArchive.GetEntry(EntryName)!; + Assert.True(readEntry.IsEncrypted); + Assert.Equal(method, readEntry.EncryptionMethod); - private async Task TestStream(byte[] buffer, int length, bool async) - { - try + using (var decrypted = new MemoryStream()) { - using var stream = CreateStream(buffer, length); + Stream entryReadStream = async ? await readEntry.OpenAsync(Password.AsSpan()) : readEntry.Open(Password.AsSpan()); if (async) { - await stream.CopyToAsync(Stream.Null); + await entryReadStream.CopyToAsync(decrypted); + await entryReadStream.DisposeAsync(); } else { - stream.CopyTo(Stream.Null); + entryReadStream.CopyTo(decrypted); + entryReadStream.Dispose(); } + + Assert.SequenceEqual(content.AsSpan(0, length), decrypted.ToArray()); } - catch (InvalidDataException) + + // Decrypting with a wrong password must fail cleanly with InvalidDataException, never crash. + try { - // ignore, this exception is expected for invalid/corrupted data. + using Stream stream = readEntry.Open("wrong-password".AsSpan()); + stream.CopyTo(Stream.Null); } - catch (CryptographicException) + catch (InvalidDataException) { - // ignore, crypto failures are expected for random fuzz input. + // Expected: the AES password verifier / HMAC rejects the wrong key. } - catch (TargetInvocationException ex) when (ex.InnerException is InvalidDataException or CryptographicException) + + if (async) { - // The reflected WinZipAesStream.Create call wraps exceptions - // in TargetInvocationException when header validation fails. + await readArchive.DisposeAsync(); } - finally + else { - ArrayPool.Shared.Return(buffer); + readArchive.Dispose(); } } + + private static byte[] CopyToRentedArray(ReadOnlySpan bytes) + { + byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); + bytes.CopyTo(buffer); + return buffer; + } } diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 71a6851d37659e..99c5b6767a44df 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -3,7 +3,6 @@ using System.Buffers; using System.IO.Compression; -using System.Reflection; using System.Threading.Tasks; namespace DotnetFuzzing.Fuzzers; @@ -12,116 +11,109 @@ internal sealed class ZipCryptoStreamFuzzer : IFuzzer { public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; public string[] TargetCoreLibPrefixes => []; - public string Corpus => "zipcryptostream"; + + private const string Password = "fuzz-password"; + private const string EntryName = "entry"; public void FuzzTarget(ReadOnlySpan bytes) { - // ZipCryptoStream.Create reads a 12-byte header from the stream and validates the - // last decrypted byte against the expected check byte. Require at least 13 bytes - // (1 check byte + 12 header bytes) so the fuzzer can reach past the header. - if (bytes.Length < 13) + // The encryption streams only produce ciphertext for non-empty content; an empty + // entry is stored unencrypted, so it does not exercise the ZipCryptoStream at all. + if (bytes.IsEmpty) { return; } - TestStream(CopyToRentedArray(bytes), bytes.Length, async: false).GetAwaiter().GetResult(); - TestStream(CopyToRentedArray(bytes), bytes.Length, async: true).GetAwaiter().GetResult(); + byte[] content = CopyToRentedArray(bytes); + try + { + RoundTrip(content, bytes.Length, async: false).GetAwaiter().GetResult(); + RoundTrip(content, bytes.Length, async: true).GetAwaiter().GetResult(); + } + finally + { + ArrayPool.Shared.Return(content); + } } - // ReadOnlySpan is a ref struct and cannot be boxed for MethodInfo.Invoke, - // and CreateDelegate cannot handle struct-to-object return covariance. - // Use DynamicMethod to emit a wrapper that boxes the struct return value. - private delegate object CreateKeyDelegate(ReadOnlySpan password); - - private static readonly CreateKeyDelegate _createKey; - private static readonly MethodInfo _createMethod; - private static readonly object s_keys; - - static ZipCryptoStreamFuzzer() + private static async Task RoundTrip(byte[] content, int length, bool async) { - Type zipCryptoStreamType = Type.GetType("System.IO.Compression.ZipCryptoStream, System.IO.Compression")!; - Type zipCryptoKeysType = Type.GetType("System.IO.Compression.ZipCryptoKeys, System.IO.Compression")!; + using var archiveStream = new MemoryStream(); -#pragma warning disable IL3050 // RequiresDynamicCode: DynamicMethod is not AOT-compatible; fuzzers run under CoreCLR only. - _createKey = CreateBoxingDelegate(zipCryptoStreamType, zipCryptoKeysType); -#pragma warning restore IL3050 + // Encrypt the fuzz input using legacy ZipCrypto, exercising the ZipCryptoStream encryption path. + ZipArchive writeArchive = async + ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null) + : new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null); - _createMethod = zipCryptoStreamType.GetMethod( - "Create", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static, - binder: null, - types: [typeof(Stream), zipCryptoKeysType, typeof(byte), typeof(bool), typeof(bool)], - modifiers: null)!; + ZipArchiveEntry writeEntry = writeArchive.CreateEntry(EntryName, Password.AsSpan(), ZipEncryptionMethod.ZipCrypto); - s_keys = _createKey("fuzz"); - } + Stream entryWriteStream = async ? await writeEntry.OpenAsync() : writeEntry.Open(); + if (async) + { + await entryWriteStream.WriteAsync(content.AsMemory(0, length)); + await entryWriteStream.DisposeAsync(); + await writeArchive.DisposeAsync(); + } + else + { + entryWriteStream.Write(content, 0, length); + entryWriteStream.Dispose(); + writeArchive.Dispose(); + } - private static CreateKeyDelegate CreateBoxingDelegate(Type zipCryptoStreamType, Type zipCryptoKeysType) - { - MethodInfo createKeyMethod = zipCryptoStreamType.GetMethod( - "CreateKey", - BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static)!; - - var dm = new System.Reflection.Emit.DynamicMethod( - "CreateKeyWrapper", - typeof(object), - [typeof(ReadOnlySpan)], - typeof(ZipCryptoStreamFuzzer).Module, - skipVisibility: true); - var il = dm.GetILGenerator(); - il.Emit(System.Reflection.Emit.OpCodes.Ldarg_0); - il.Emit(System.Reflection.Emit.OpCodes.Call, createKeyMethod); - il.Emit(System.Reflection.Emit.OpCodes.Box, zipCryptoKeysType); - il.Emit(System.Reflection.Emit.OpCodes.Ret); - return dm.CreateDelegate(); - } + archiveStream.Position = 0; - private static Stream CreateStream(byte[] bytes, int length) - { - // Use the first byte of the input as the "expected check byte" so that the - // header validation path is exercised with varying values. - byte expectedCheckByte = bytes[0]; - var baseStream = new MemoryStream(bytes, 1, length - 1); -#pragma warning disable IL2072 // dynamic invocation - return (Stream)_createMethod.Invoke( - obj: null, - parameters: [baseStream, s_keys, expectedCheckByte, /*encrypting*/ false, /*leaveOpen*/ false])!; -#pragma warning restore IL2072 - } + // Decrypt with the correct password and verify the round-trip is lossless. + ZipArchive readArchive = async + ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null) + : new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null); - private static byte[] CopyToRentedArray(ReadOnlySpan bytes) - { - byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); - bytes.CopyTo(buffer); - return buffer; - } + ZipArchiveEntry readEntry = readArchive.GetEntry(EntryName)!; + Assert.True(readEntry.IsEncrypted); + Assert.Equal(ZipEncryptionMethod.ZipCrypto, readEntry.EncryptionMethod); - private async Task TestStream(byte[] buffer, int length, bool async) - { - try + using (var decrypted = new MemoryStream()) { - using var stream = CreateStream(buffer, length); + Stream entryReadStream = async ? await readEntry.OpenAsync(Password.AsSpan()) : readEntry.Open(Password.AsSpan()); if (async) { - await stream.CopyToAsync(Stream.Null); + await entryReadStream.CopyToAsync(decrypted); + await entryReadStream.DisposeAsync(); } else { - stream.CopyTo(Stream.Null); + entryReadStream.CopyTo(decrypted); + entryReadStream.Dispose(); } + + Assert.SequenceEqual(content.AsSpan(0, length), decrypted.ToArray()); + } + + // Decrypting with a wrong password must fail cleanly with InvalidDataException, never crash. + try + { + using Stream stream = readEntry.Open("wrong-password".AsSpan()); + stream.CopyTo(Stream.Null); } catch (InvalidDataException) { - // ignore, this exception is expected for invalid/corrupted data. + // Expected: the header password verifier rejects the wrong key. } - catch (TargetInvocationException ex) when (ex.InnerException is InvalidDataException) + + if (async) { - // The reflected ZipCryptoStream.Create call wraps InvalidDataException - // (e.g. password mismatch, truncated header) in TargetInvocationException. + await readArchive.DisposeAsync(); } - finally + else { - ArrayPool.Shared.Return(buffer); + readArchive.Dispose(); } } + + private static byte[] CopyToRentedArray(ReadOnlySpan bytes) + { + byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); + bytes.CopyTo(buffer); + return buffer; + } } From 9323cbca005e6c394a458cde39ede467e58b8e1c Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Mon, 27 Jul 2026 11:44:22 +0200 Subject: [PATCH 2/6] address copilot comments --- .../Fuzzers/WinZipAesStreamFuzzer.cs | 25 +++++++++++++++--- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 26 +++++++++++++++---- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index 28e2eb845a8825..cf5644e88f1cfd 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -98,20 +98,37 @@ private static async Task RoundTrip(byte[] content, int length, ZipEncryptionMet entryReadStream.Dispose(); } - Assert.SequenceEqual(content.AsSpan(0, length), decrypted.ToArray()); + Assert.SequenceEqual(content.AsSpan(0, length), decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length)); } - // Decrypting with a wrong password must fail cleanly with InvalidDataException, never crash. + // A wrong password must be rejected: the AES password verifier and HMAC make accepting a + // wrong key cryptographically infeasible, so decryption must fail with InvalidDataException. + bool wrongPasswordRejected = false; try { - using Stream stream = readEntry.Open("wrong-password".AsSpan()); - stream.CopyTo(Stream.Null); + Stream wrongStream = async + ? await readEntry.OpenAsync("wrong-password".AsSpan()) + : readEntry.Open("wrong-password".AsSpan()); + using (wrongStream) + { + if (async) + { + await wrongStream.CopyToAsync(Stream.Null); + } + else + { + wrongStream.CopyTo(Stream.Null); + } + } } catch (InvalidDataException) { // Expected: the AES password verifier / HMAC rejects the wrong key. + wrongPasswordRejected = true; } + Assert.True(wrongPasswordRejected); + if (async) { await readArchive.DisposeAsync(); diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 99c5b6767a44df..42d16a72e3c205 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -86,18 +86,34 @@ private static async Task RoundTrip(byte[] content, int length, bool async) entryReadStream.Dispose(); } - Assert.SequenceEqual(content.AsSpan(0, length), decrypted.ToArray()); + Assert.SequenceEqual(content.AsSpan(0, length), decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length)); } - // Decrypting with a wrong password must fail cleanly with InvalidDataException, never crash. + // Exercise the wrong-password failure path. ZipCrypto only has a weak 1-byte password + // verifier over a stream cipher, so a wrong key is rejected with InvalidDataException with + // roughly 255/256 probability and may occasionally decrypt to garbage without throwing; + // both outcomes are acceptable and cannot be distinguished reliably on fuzzed input. Any + // other exception type would indicate a real bug and is intentionally left to propagate. try { - using Stream stream = readEntry.Open("wrong-password".AsSpan()); - stream.CopyTo(Stream.Null); + Stream wrongStream = async + ? await readEntry.OpenAsync("wrong-password".AsSpan()) + : readEntry.Open("wrong-password".AsSpan()); + using (wrongStream) + { + if (async) + { + await wrongStream.CopyToAsync(Stream.Null); + } + else + { + wrongStream.CopyTo(Stream.Null); + } + } } catch (InvalidDataException) { - // Expected: the header password verifier rejects the wrong key. + // Expected: the check byte rejects the wrong key. } if (async) From c9d42c05c26048ac03144e02d0b9c06c22bd3501 Mon Sep 17 00:00:00 2001 From: Stefan-Alin Pahontu <56953855+alinpahontu2912@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:05:56 +0200 Subject: [PATCH 3/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs | 14 ++++++++++++-- .../DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs | 14 ++++++++++++-- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index cf5644e88f1cfd..be4eea2dcb4171 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -109,7 +109,7 @@ private static async Task RoundTrip(byte[] content, int length, ZipEncryptionMet Stream wrongStream = async ? await readEntry.OpenAsync("wrong-password".AsSpan()) : readEntry.Open("wrong-password".AsSpan()); - using (wrongStream) + try { if (async) { @@ -120,7 +120,17 @@ private static async Task RoundTrip(byte[] content, int length, ZipEncryptionMet wrongStream.CopyTo(Stream.Null); } } - } + finally + { + if (async) + { + await wrongStream.DisposeAsync(); + } + else + { + wrongStream.Dispose(); + } + } catch (InvalidDataException) { // Expected: the AES password verifier / HMAC rejects the wrong key. diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index 42d16a72e3c205..d67b75d5fef2f4 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -99,7 +99,7 @@ private static async Task RoundTrip(byte[] content, int length, bool async) Stream wrongStream = async ? await readEntry.OpenAsync("wrong-password".AsSpan()) : readEntry.Open("wrong-password".AsSpan()); - using (wrongStream) + try { if (async) { @@ -110,7 +110,17 @@ private static async Task RoundTrip(byte[] content, int length, bool async) wrongStream.CopyTo(Stream.Null); } } - } + finally + { + if (async) + { + await wrongStream.DisposeAsync(); + } + else + { + wrongStream.Dispose(); + } + } catch (InvalidDataException) { // Expected: the check byte rejects the wrong key. From 60a058e64a3217bfe52e0466940c92e0ecd6ee3b Mon Sep 17 00:00:00 2001 From: Stefan-Alin Pahontu <56953855+alinpahontu2912@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:36:35 +0200 Subject: [PATCH 4/6] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs | 1 + .../Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs | 1 + 2 files changed, 2 insertions(+) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs index be4eea2dcb4171..a6c83472417ee7 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -131,6 +131,7 @@ private static async Task RoundTrip(byte[] content, int length, ZipEncryptionMet wrongStream.Dispose(); } } + } catch (InvalidDataException) { // Expected: the AES password verifier / HMAC rejects the wrong key. diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs index d67b75d5fef2f4..6ef568b3841eec 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -121,6 +121,7 @@ private static async Task RoundTrip(byte[] content, int length, bool async) wrongStream.Dispose(); } } + } catch (InvalidDataException) { // Expected: the check byte rejects the wrong key. From b51636eb47782909b884cd1224e752c9b1ad0249 Mon Sep 17 00:00:00 2001 From: alinpahontu2912 Date: Thu, 30 Jul 2026 14:06:12 +0200 Subject: [PATCH 5/6] merge encryption streams --- .../libraries/fuzzing/deploy-to-onefuzz.yml | 12 +- .../Fuzzers/WinZipAesStreamFuzzer.cs | 159 -------------- .../Fuzzers/ZipCryptoStreamFuzzer.cs | 146 ------------- .../Fuzzers/ZipEncryptionStreamFuzzer.cs | 197 ++++++++++++++++++ 4 files changed, 199 insertions(+), 315 deletions(-) delete mode 100644 src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs delete mode 100644 src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs create mode 100644 src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs diff --git a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml index ff5db8cf02512c..7b3319c28f12cd 100644 --- a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml +++ b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml @@ -200,14 +200,6 @@ extends: SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Send Utf8JsonWriterFuzzer to OneFuzz - - task: onefuzz-task@0 - inputs: - onefuzzOSes: 'Windows' - env: - onefuzzDropDirectory: $(fuzzerProject)/deployment/WinZipAesStreamFuzzer - SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Send WinZipAesStreamFuzzer to OneFuzz - - task: onefuzz-task@0 inputs: onefuzzOSes: 'Windows' @@ -220,7 +212,7 @@ extends: inputs: onefuzzOSes: 'Windows' env: - onefuzzDropDirectory: $(fuzzerProject)/deployment/ZipCryptoStreamFuzzer + onefuzzDropDirectory: $(fuzzerProject)/deployment/ZipEncryptionStreamFuzzer SYSTEM_ACCESSTOKEN: $(System.AccessToken) - displayName: Send ZipCryptoStreamFuzzer to OneFuzz + displayName: Send ZipEncryptionStreamFuzzer to OneFuzz # ONEFUZZ_TASK_WORKAROUND_END diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs deleted file mode 100644 index a6c83472417ee7..00000000000000 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs +++ /dev/null @@ -1,159 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Buffers; -using System.IO.Compression; -using System.Runtime.Versioning; -using System.Threading.Tasks; - -namespace DotnetFuzzing.Fuzzers; - -[UnsupportedOSPlatform("browser")] -internal sealed class WinZipAesStreamFuzzer : IFuzzer -{ - public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; - public string[] TargetCoreLibPrefixes => []; - - private const string Password = "fuzz-password"; - private const string EntryName = "entry"; - - private static readonly ZipEncryptionMethod[] s_aesMethods = - [ - ZipEncryptionMethod.Aes128, - ZipEncryptionMethod.Aes192, - ZipEncryptionMethod.Aes256, - ]; - - public void FuzzTarget(ReadOnlySpan bytes) - { - // The encryption streams only produce ciphertext for non-empty content; an empty - // entry is stored unencrypted, so it does not exercise the WinZipAesStream at all. - if (bytes.IsEmpty) - { - return; - } - - // Use the first byte to select the AES key strength so all three variants get exercised. - ZipEncryptionMethod method = s_aesMethods[bytes[0] % s_aesMethods.Length]; - - byte[] content = CopyToRentedArray(bytes); - try - { - RoundTrip(content, bytes.Length, method, async: false).GetAwaiter().GetResult(); - RoundTrip(content, bytes.Length, method, async: true).GetAwaiter().GetResult(); - } - finally - { - ArrayPool.Shared.Return(content); - } - } - - private static async Task RoundTrip(byte[] content, int length, ZipEncryptionMethod method, bool async) - { - using var archiveStream = new MemoryStream(); - - // Encrypt the fuzz input using WinZip AES, exercising the WinZipAesStream encryption + HMAC path. - ZipArchive writeArchive = async - ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null) - : new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null); - - ZipArchiveEntry writeEntry = writeArchive.CreateEntry(EntryName, Password.AsSpan(), method); - - Stream entryWriteStream = async ? await writeEntry.OpenAsync() : writeEntry.Open(); - if (async) - { - await entryWriteStream.WriteAsync(content.AsMemory(0, length)); - await entryWriteStream.DisposeAsync(); - await writeArchive.DisposeAsync(); - } - else - { - entryWriteStream.Write(content, 0, length); - entryWriteStream.Dispose(); - writeArchive.Dispose(); - } - - archiveStream.Position = 0; - - // Decrypt with the correct password and verify the round-trip is lossless. - ZipArchive readArchive = async - ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null) - : new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null); - - ZipArchiveEntry readEntry = readArchive.GetEntry(EntryName)!; - Assert.True(readEntry.IsEncrypted); - Assert.Equal(method, readEntry.EncryptionMethod); - - using (var decrypted = new MemoryStream()) - { - Stream entryReadStream = async ? await readEntry.OpenAsync(Password.AsSpan()) : readEntry.Open(Password.AsSpan()); - if (async) - { - await entryReadStream.CopyToAsync(decrypted); - await entryReadStream.DisposeAsync(); - } - else - { - entryReadStream.CopyTo(decrypted); - entryReadStream.Dispose(); - } - - Assert.SequenceEqual(content.AsSpan(0, length), decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length)); - } - - // A wrong password must be rejected: the AES password verifier and HMAC make accepting a - // wrong key cryptographically infeasible, so decryption must fail with InvalidDataException. - bool wrongPasswordRejected = false; - try - { - Stream wrongStream = async - ? await readEntry.OpenAsync("wrong-password".AsSpan()) - : readEntry.Open("wrong-password".AsSpan()); - try - { - if (async) - { - await wrongStream.CopyToAsync(Stream.Null); - } - else - { - wrongStream.CopyTo(Stream.Null); - } - } - finally - { - if (async) - { - await wrongStream.DisposeAsync(); - } - else - { - wrongStream.Dispose(); - } - } - } - catch (InvalidDataException) - { - // Expected: the AES password verifier / HMAC rejects the wrong key. - wrongPasswordRejected = true; - } - - Assert.True(wrongPasswordRejected); - - if (async) - { - await readArchive.DisposeAsync(); - } - else - { - readArchive.Dispose(); - } - } - - private static byte[] CopyToRentedArray(ReadOnlySpan bytes) - { - byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); - bytes.CopyTo(buffer); - return buffer; - } -} diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs deleted file mode 100644 index 6ef568b3841eec..00000000000000 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs +++ /dev/null @@ -1,146 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System.Buffers; -using System.IO.Compression; -using System.Threading.Tasks; - -namespace DotnetFuzzing.Fuzzers; - -internal sealed class ZipCryptoStreamFuzzer : IFuzzer -{ - public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; - public string[] TargetCoreLibPrefixes => []; - - private const string Password = "fuzz-password"; - private const string EntryName = "entry"; - - public void FuzzTarget(ReadOnlySpan bytes) - { - // The encryption streams only produce ciphertext for non-empty content; an empty - // entry is stored unencrypted, so it does not exercise the ZipCryptoStream at all. - if (bytes.IsEmpty) - { - return; - } - - byte[] content = CopyToRentedArray(bytes); - try - { - RoundTrip(content, bytes.Length, async: false).GetAwaiter().GetResult(); - RoundTrip(content, bytes.Length, async: true).GetAwaiter().GetResult(); - } - finally - { - ArrayPool.Shared.Return(content); - } - } - - private static async Task RoundTrip(byte[] content, int length, bool async) - { - using var archiveStream = new MemoryStream(); - - // Encrypt the fuzz input using legacy ZipCrypto, exercising the ZipCryptoStream encryption path. - ZipArchive writeArchive = async - ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null) - : new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null); - - ZipArchiveEntry writeEntry = writeArchive.CreateEntry(EntryName, Password.AsSpan(), ZipEncryptionMethod.ZipCrypto); - - Stream entryWriteStream = async ? await writeEntry.OpenAsync() : writeEntry.Open(); - if (async) - { - await entryWriteStream.WriteAsync(content.AsMemory(0, length)); - await entryWriteStream.DisposeAsync(); - await writeArchive.DisposeAsync(); - } - else - { - entryWriteStream.Write(content, 0, length); - entryWriteStream.Dispose(); - writeArchive.Dispose(); - } - - archiveStream.Position = 0; - - // Decrypt with the correct password and verify the round-trip is lossless. - ZipArchive readArchive = async - ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null) - : new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null); - - ZipArchiveEntry readEntry = readArchive.GetEntry(EntryName)!; - Assert.True(readEntry.IsEncrypted); - Assert.Equal(ZipEncryptionMethod.ZipCrypto, readEntry.EncryptionMethod); - - using (var decrypted = new MemoryStream()) - { - Stream entryReadStream = async ? await readEntry.OpenAsync(Password.AsSpan()) : readEntry.Open(Password.AsSpan()); - if (async) - { - await entryReadStream.CopyToAsync(decrypted); - await entryReadStream.DisposeAsync(); - } - else - { - entryReadStream.CopyTo(decrypted); - entryReadStream.Dispose(); - } - - Assert.SequenceEqual(content.AsSpan(0, length), decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length)); - } - - // Exercise the wrong-password failure path. ZipCrypto only has a weak 1-byte password - // verifier over a stream cipher, so a wrong key is rejected with InvalidDataException with - // roughly 255/256 probability and may occasionally decrypt to garbage without throwing; - // both outcomes are acceptable and cannot be distinguished reliably on fuzzed input. Any - // other exception type would indicate a real bug and is intentionally left to propagate. - try - { - Stream wrongStream = async - ? await readEntry.OpenAsync("wrong-password".AsSpan()) - : readEntry.Open("wrong-password".AsSpan()); - try - { - if (async) - { - await wrongStream.CopyToAsync(Stream.Null); - } - else - { - wrongStream.CopyTo(Stream.Null); - } - } - finally - { - if (async) - { - await wrongStream.DisposeAsync(); - } - else - { - wrongStream.Dispose(); - } - } - } - catch (InvalidDataException) - { - // Expected: the check byte rejects the wrong key. - } - - if (async) - { - await readArchive.DisposeAsync(); - } - else - { - readArchive.Dispose(); - } - } - - private static byte[] CopyToRentedArray(ReadOnlySpan bytes) - { - byte[] buffer = ArrayPool.Shared.Rent(bytes.Length); - bytes.CopyTo(buffer); - return buffer; - } -} diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs new file mode 100644 index 00000000000000..b3414b743473e9 --- /dev/null +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs @@ -0,0 +1,197 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Buffers; +using System.IO.Compression; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using System.Threading.Tasks; + +namespace DotnetFuzzing.Fuzzers; + +// Exercises every ZIP entry-encryption method through a single round-trip: legacy ZipCrypto +// (ZipCryptoStream) and WinZip AES-128/192/256 (WinZipAesStream). The two ciphers share the same +// ZipArchiveEntry entry points and stream shape, so one fuzzer covers both encryption paths. +[UnsupportedOSPlatform("browser")] +internal sealed class ZipEncryptionStreamFuzzer : IFuzzer +{ + public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; + public string[] TargetCoreLibPrefixes => []; + + // The entry name is not part of the encryption-stream surface this fuzzer targets: it only feeds + // the generic ZIP name-encoding logic (ASCII-vs-UTF8 detection and the UTF-8 general-purpose-bit + // flag), which is independent of the AES/ZipCrypto encryption path. Fuzzing it here would add + // coverage outside the target, so it is hardcoded; entry-name encoding belongs in a ZIP fuzzer. + private const string EntryName = "entry"; + + // Used only when the fuzzed password slice is empty; a non-empty password is required to encrypt. + private const string FallbackPassword = "fuzz-password"; + + private static readonly ZipEncryptionMethod[] s_methods = + [ + ZipEncryptionMethod.ZipCrypto, + ZipEncryptionMethod.Aes128, + ZipEncryptionMethod.Aes192, + ZipEncryptionMethod.Aes256, + ]; + + public void FuzzTarget(ReadOnlySpan bytes) + { + // The first byte is consumed below as the encryption-method selector, so an empty + // input has no byte to interpret and there is nothing to fuzz. + if (bytes.IsEmpty) + { + return; + } + + // Use the first byte to select the encryption method so all variants get exercised. + ZipEncryptionMethod method = s_methods[bytes[0] % s_methods.Length]; + ReadOnlySpan payload = bytes.Slice(1); + + // Split the rest of the input into a password and the entry content: everything up to the + // first newline is the password, the remainder is the content. Deriving the password from the + // input drives the key derivation and the UTF-8 password-encoding path instead of always using + // a fixed key. The content — the remainder — is what drives the cipher chunking (and, for AES, + // the trailing HMAC), so it stays the bulk of the input. + int newlineIndex = payload.IndexOf((byte)'\n'); + ReadOnlySpan passwordBytes = newlineIndex < 0 ? ReadOnlySpan.Empty : payload.Slice(0, newlineIndex); + ReadOnlySpan contentBytes = newlineIndex < 0 ? payload : payload.Slice(newlineIndex + 1); + + // An empty entry is stored unencrypted (no ciphertext is produced and IsEncrypted is false on + // read-back), so it does not exercise the encryption stream this fuzzer targets. Skip it. + if (contentBytes.IsEmpty) + { + return; + } + + // Interpret the password bytes as UTF-16 so arbitrary char values (including lone surrogates) + // exercise the UTF-8 encoding of the password during key derivation. + string password = new string(MemoryMarshal.Cast(passwordBytes)); + + // A non-empty password is required to create an encrypted entry (CreateEntry throws otherwise). + // The fuzzed slice may yield an empty string (no newline, or a single byte that casts to zero + // chars), so fall back to a fixed non-empty password to keep exercising the key-derivation path. + if (password.Length == 0) + { + password = FallbackPassword; + } + + // Poison the page immediately after the content so any over-read while encrypting or + // decrypting faults immediately instead of silently reading adjacent memory. + using PooledBoundedMemory buffer = PooledBoundedMemory.Rent(contentBytes, PoisonPagePlacement.After); + RoundTrip(buffer.Memory, password, method, async: false).GetAwaiter().GetResult(); + RoundTrip(buffer.Memory, password, method, async: true).GetAwaiter().GetResult(); + } + + private static async Task RoundTrip(ReadOnlyMemory content, string password, ZipEncryptionMethod method, bool async) + { + using var archiveStream = new MemoryStream(); + + // Encrypt the fuzz input, exercising the encryption stream (and HMAC path for AES). + ZipArchive writeArchive = async + ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null) + : new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null); + + ZipArchiveEntry writeEntry = writeArchive.CreateEntry(EntryName, password.AsSpan(), method); + + Stream entryWriteStream = async ? await writeEntry.OpenAsync() : writeEntry.Open(); + if (async) + { + await entryWriteStream.WriteAsync(content); + await entryWriteStream.DisposeAsync(); + await writeArchive.DisposeAsync(); + } + else + { + entryWriteStream.Write(content.Span); + entryWriteStream.Dispose(); + writeArchive.Dispose(); + } + + archiveStream.Position = 0; + + // Decrypt with the correct password and verify the round-trip is lossless. + ZipArchive readArchive = async + ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null) + : new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null); + + ZipArchiveEntry readEntry = readArchive.GetEntry(EntryName)!; + Assert.True(readEntry.IsEncrypted); + Assert.Equal(method, readEntry.EncryptionMethod); + + using (var decrypted = new MemoryStream()) + { + Stream entryReadStream = async ? await readEntry.OpenAsync(password.AsSpan()) : readEntry.Open(password.AsSpan()); + if (async) + { + await entryReadStream.CopyToAsync(decrypted); + await entryReadStream.DisposeAsync(); + } + else + { + entryReadStream.CopyTo(decrypted); + entryReadStream.Dispose(); + } + + Assert.SequenceEqual(content.Span, decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length)); + } + + // AES is authenticated: the password verifier and HMAC make accepting a wrong key + // cryptographically infeasible, so a wrong password must fail with InvalidDataException. + // ZipCrypto only has a weak 1-byte verifier over a stream cipher, so a wrong key is rejected + // with roughly 255/256 probability and may occasionally decrypt to garbage without throwing; + // both outcomes are acceptable and cannot be distinguished reliably on fuzzed input. Any + // other exception type would indicate a real bug and is intentionally left to propagate. + // Append a byte so the wrong password is guaranteed to differ from the fuzzed one above. + bool authenticated = method != ZipEncryptionMethod.ZipCrypto; + string wrongPassword = password + "!"; + bool wrongPasswordRejected = false; + try + { + Stream wrongStream = async + ? await readEntry.OpenAsync(wrongPassword.AsSpan()) + : readEntry.Open(wrongPassword.AsSpan()); + try + { + if (async) + { + await wrongStream.CopyToAsync(Stream.Null); + } + else + { + wrongStream.CopyTo(Stream.Null); + } + } + finally + { + if (async) + { + await wrongStream.DisposeAsync(); + } + else + { + wrongStream.Dispose(); + } + } + } + catch (InvalidDataException) + { + // Expected: the AES password verifier / HMAC (or the ZipCrypto check byte) rejects the wrong key. + wrongPasswordRejected = true; + } + + if (authenticated) + { + Assert.True(wrongPasswordRejected); + } + + if (async) + { + await readArchive.DisposeAsync(); + } + else + { + readArchive.Dispose(); + } + } +} From e7b1bcff3dd76e641210bf35318c4a432268ebbe Mon Sep 17 00:00:00 2001 From: Stefan-Alin Pahontu <56953855+alinpahontu2912@users.noreply.github.com> Date: Fri, 31 Jul 2026 08:54:20 +0200 Subject: [PATCH 6/6] Apply suggestions from code review Co-authored-by: Miha Zupan --- .../Fuzzers/ZipEncryptionStreamFuzzer.cs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs index b3414b743473e9..a828029b716ca8 100644 --- a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipEncryptionStreamFuzzer.cs @@ -39,14 +39,14 @@ public void FuzzTarget(ReadOnlySpan bytes) { // The first byte is consumed below as the encryption-method selector, so an empty // input has no byte to interpret and there is nothing to fuzz. - if (bytes.IsEmpty) + if (bytes.Length < 2) { return; } // Use the first byte to select the encryption method so all variants get exercised. ZipEncryptionMethod method = s_methods[bytes[0] % s_methods.Length]; - ReadOnlySpan payload = bytes.Slice(1); + ReadOnlySpan payload = bytes.Slice(2); // Slice 2 to keep MemoryMarshal.Cast below aligned // Split the rest of the input into a password and the entry content: everything up to the // first newline is the password, the remainder is the content. Deriving the password from the @@ -136,8 +136,8 @@ private static async Task RoundTrip(ReadOnlyMemory content, string passwor Assert.SequenceEqual(content.Span, decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length)); } - // AES is authenticated: the password verifier and HMAC make accepting a wrong key - // cryptographically infeasible, so a wrong password must fail with InvalidDataException. + // The AES variant of ZIP encryption uses a form of HMAC that makes accidentally accepting the wrong key + // statistically unlikely, so a wrong password must fail with InvalidDataException. // ZipCrypto only has a weak 1-byte verifier over a stream cipher, so a wrong key is rejected // with roughly 255/256 probability and may occasionally decrypt to garbage without throwing; // both outcomes are acceptable and cannot be distinguished reliably on fuzzed input. Any @@ -148,30 +148,15 @@ private static async Task RoundTrip(ReadOnlyMemory content, string passwor bool wrongPasswordRejected = false; try { - Stream wrongStream = async - ? await readEntry.OpenAsync(wrongPassword.AsSpan()) - : readEntry.Open(wrongPassword.AsSpan()); - try + if (async) { - if (async) - { - await wrongStream.CopyToAsync(Stream.Null); - } - else - { - wrongStream.CopyTo(Stream.Null); - } + await using Stream wrongStream = await readEntry.OpenAsync(wrongPassword.AsSpan()); + await wrongStream.CopyToAsync(Stream.Null); } - finally + else { - if (async) - { - await wrongStream.DisposeAsync(); - } - else - { - wrongStream.Dispose(); - } + using Stream wrongStream = readEntry.Open(wrongPassword.AsSpan()); + wrongStream.CopyTo(Stream.Null); } } catch (InvalidDataException)