diff --git a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml index 85c92f292323f9..ff5db8cf02512c 100644 --- a/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml +++ b/eng/pipelines/libraries/fuzzing/deploy-to-onefuzz.yml @@ -200,6 +200,14 @@ 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' @@ -207,4 +215,12 @@ extends: onefuzzDropDirectory: $(fuzzerProject)/deployment/ZipArchiveFuzzer SYSTEM_ACCESSTOKEN: $(System.AccessToken) displayName: Send ZipArchiveFuzzer to OneFuzz + + - task: onefuzz-task@0 + inputs: + onefuzzOSes: 'Windows' + env: + onefuzzDropDirectory: $(fuzzerProject)/deployment/ZipCryptoStreamFuzzer + SYSTEM_ACCESSTOKEN: $(System.AccessToken) + displayName: Send ZipCryptoStreamFuzzer to OneFuzz # ONEFUZZ_TASK_WORKAROUND_END diff --git a/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs new file mode 100644 index 00000000000000..28e2eb845a8825 --- /dev/null +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/WinZipAesStreamFuzzer.cs @@ -0,0 +1,131 @@ +// 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.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) + { + // Expected: the AES password verifier / HMAC 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/ZipCryptoStreamFuzzer.cs b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs new file mode 100644 index 00000000000000..99c5b6767a44df --- /dev/null +++ b/src/libraries/Fuzzing/DotnetFuzzing/Fuzzers/ZipCryptoStreamFuzzer.cs @@ -0,0 +1,119 @@ +// 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.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) + { + // Expected: the header password verifier 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; + } +}