| | | 1 | | // Licensed to the .NET Foundation under one or more agreements. |
| | | 2 | | // The .NET Foundation licenses this file to you under the MIT license. |
| | | 3 | | |
| | | 4 | | using System.Buffers; |
| | | 5 | | using System.IO.Compression; |
| | | 6 | | using System.Runtime.InteropServices; |
| | | 7 | | using System.Runtime.Versioning; |
| | | 8 | | using System.Threading.Tasks; |
| | | 9 | | |
| | | 10 | | namespace DotnetFuzzing.Fuzzers; |
| | | 11 | | |
| | | 12 | | // Exercises every ZIP entry-encryption method through a single round-trip: legacy ZipCrypto |
| | | 13 | | // (ZipCryptoStream) and WinZip AES-128/192/256 (WinZipAesStream). The two ciphers share the same |
| | | 14 | | // ZipArchiveEntry entry points and stream shape, so one fuzzer covers both encryption paths. |
| | | 15 | | [UnsupportedOSPlatform("browser")] |
| | | 16 | | internal sealed class ZipEncryptionStreamFuzzer : IFuzzer |
| | | 17 | | { |
| | 0 | 18 | | public string[] TargetAssemblies { get; } = ["System.IO.Compression"]; |
| | 0 | 19 | | public string[] TargetCoreLibPrefixes => []; |
| | | 20 | | |
| | | 21 | | // The entry name is not part of the encryption-stream surface this fuzzer targets: it only feeds |
| | | 22 | | // the generic ZIP name-encoding logic (ASCII-vs-UTF8 detection and the UTF-8 general-purpose-bit |
| | | 23 | | // flag), which is independent of the AES/ZipCrypto encryption path. Fuzzing it here would add |
| | | 24 | | // coverage outside the target, so it is hardcoded; entry-name encoding belongs in a ZIP fuzzer. |
| | | 25 | | private const string EntryName = "entry"; |
| | | 26 | | |
| | | 27 | | // Used only when the fuzzed password slice is empty; a non-empty password is required to encrypt. |
| | | 28 | | private const string FallbackPassword = "fuzz-password"; |
| | | 29 | | |
| | 0 | 30 | | private static readonly ZipEncryptionMethod[] s_methods = |
| | 0 | 31 | | [ |
| | 0 | 32 | | ZipEncryptionMethod.ZipCrypto, |
| | 0 | 33 | | ZipEncryptionMethod.Aes128, |
| | 0 | 34 | | ZipEncryptionMethod.Aes192, |
| | 0 | 35 | | ZipEncryptionMethod.Aes256, |
| | 0 | 36 | | ]; |
| | | 37 | | |
| | | 38 | | public void FuzzTarget(ReadOnlySpan<byte> bytes) |
| | 0 | 39 | | { |
| | | 40 | | // The first byte is consumed below as the encryption-method selector, so an empty |
| | | 41 | | // input has no byte to interpret and there is nothing to fuzz. |
| | 0 | 42 | | if (bytes.Length < 2) |
| | 0 | 43 | | { |
| | 0 | 44 | | return; |
| | | 45 | | } |
| | | 46 | | |
| | | 47 | | // Use the first byte to select the encryption method so all variants get exercised. |
| | 0 | 48 | | ZipEncryptionMethod method = s_methods[bytes[0] % s_methods.Length]; |
| | 0 | 49 | | ReadOnlySpan<byte> payload = bytes.Slice(2); // Slice 2 to keep MemoryMarshal.Cast<byte, char> below aligned |
| | | 50 | | |
| | | 51 | | // Split the rest of the input into a password and the entry content: everything up to the |
| | | 52 | | // first newline is the password, the remainder is the content. Deriving the password from the |
| | | 53 | | // input drives the key derivation and the UTF-8 password-encoding path instead of always using |
| | | 54 | | // a fixed key. The content — the remainder — is what drives the cipher chunking (and, for AES, |
| | | 55 | | // the trailing HMAC), so it stays the bulk of the input. |
| | 0 | 56 | | int newlineIndex = payload.IndexOf((byte)'\n'); |
| | 0 | 57 | | ReadOnlySpan<byte> passwordBytes = newlineIndex < 0 ? ReadOnlySpan<byte>.Empty : payload.Slice(0, newlineIndex); |
| | 0 | 58 | | ReadOnlySpan<byte> contentBytes = newlineIndex < 0 ? payload : payload.Slice(newlineIndex + 1); |
| | | 59 | | |
| | | 60 | | // An empty entry is stored unencrypted (no ciphertext is produced and IsEncrypted is false on |
| | | 61 | | // read-back), so it does not exercise the encryption stream this fuzzer targets. Skip it. |
| | 0 | 62 | | if (contentBytes.IsEmpty) |
| | 0 | 63 | | { |
| | 0 | 64 | | return; |
| | | 65 | | } |
| | | 66 | | |
| | | 67 | | // Interpret the password bytes as UTF-16 so arbitrary char values (including lone surrogates) |
| | | 68 | | // exercise the UTF-8 encoding of the password during key derivation. |
| | 0 | 69 | | string password = new string(MemoryMarshal.Cast<byte, char>(passwordBytes)); |
| | | 70 | | |
| | | 71 | | // A non-empty password is required to create an encrypted entry (CreateEntry throws otherwise). |
| | | 72 | | // The fuzzed slice may yield an empty string (no newline, or a single byte that casts to zero |
| | | 73 | | // chars), so fall back to a fixed non-empty password to keep exercising the key-derivation path. |
| | 0 | 74 | | if (password.Length == 0) |
| | 0 | 75 | | { |
| | 0 | 76 | | password = FallbackPassword; |
| | 0 | 77 | | } |
| | | 78 | | |
| | | 79 | | // Poison the page immediately after the content so any over-read while encrypting or |
| | | 80 | | // decrypting faults immediately instead of silently reading adjacent memory. |
| | 0 | 81 | | using PooledBoundedMemory<byte> buffer = PooledBoundedMemory<byte>.Rent(contentBytes, PoisonPagePlacement.After) |
| | 0 | 82 | | RoundTrip(buffer.Memory, password, method, async: false).GetAwaiter().GetResult(); |
| | 0 | 83 | | RoundTrip(buffer.Memory, password, method, async: true).GetAwaiter().GetResult(); |
| | 0 | 84 | | } |
| | | 85 | | |
| | | 86 | | private static async Task RoundTrip(ReadOnlyMemory<byte> content, string password, ZipEncryptionMethod method, bool |
| | 0 | 87 | | { |
| | 0 | 88 | | using var archiveStream = new MemoryStream(); |
| | | 89 | | |
| | | 90 | | // Encrypt the fuzz input, exercising the encryption stream (and HMAC path for AES). |
| | 0 | 91 | | ZipArchive writeArchive = async |
| | 0 | 92 | | ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: nul |
| | 0 | 93 | | : new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null); |
| | | 94 | | |
| | 0 | 95 | | ZipArchiveEntry writeEntry = writeArchive.CreateEntry(EntryName, password.AsSpan(), method); |
| | | 96 | | |
| | 0 | 97 | | Stream entryWriteStream = async ? await writeEntry.OpenAsync() : writeEntry.Open(); |
| | 0 | 98 | | if (async) |
| | 0 | 99 | | { |
| | 0 | 100 | | await entryWriteStream.WriteAsync(content); |
| | 0 | 101 | | await entryWriteStream.DisposeAsync(); |
| | 0 | 102 | | await writeArchive.DisposeAsync(); |
| | 0 | 103 | | } |
| | | 104 | | else |
| | 0 | 105 | | { |
| | 0 | 106 | | entryWriteStream.Write(content.Span); |
| | 0 | 107 | | entryWriteStream.Dispose(); |
| | 0 | 108 | | writeArchive.Dispose(); |
| | 0 | 109 | | } |
| | | 110 | | |
| | 0 | 111 | | archiveStream.Position = 0; |
| | | 112 | | |
| | | 113 | | // Decrypt with the correct password and verify the round-trip is lossless. |
| | 0 | 114 | | ZipArchive readArchive = async |
| | 0 | 115 | | ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null) |
| | 0 | 116 | | : new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null); |
| | | 117 | | |
| | 0 | 118 | | ZipArchiveEntry readEntry = readArchive.GetEntry(EntryName)!; |
| | 0 | 119 | | Assert.True(readEntry.IsEncrypted); |
| | 0 | 120 | | Assert.Equal(method, readEntry.EncryptionMethod); |
| | | 121 | | |
| | 0 | 122 | | using (var decrypted = new MemoryStream()) |
| | 0 | 123 | | { |
| | 0 | 124 | | Stream entryReadStream = async ? await readEntry.OpenAsync(password.AsSpan()) : readEntry.Open(password.AsSp |
| | 0 | 125 | | if (async) |
| | 0 | 126 | | { |
| | 0 | 127 | | await entryReadStream.CopyToAsync(decrypted); |
| | 0 | 128 | | await entryReadStream.DisposeAsync(); |
| | 0 | 129 | | } |
| | | 130 | | else |
| | 0 | 131 | | { |
| | 0 | 132 | | entryReadStream.CopyTo(decrypted); |
| | 0 | 133 | | entryReadStream.Dispose(); |
| | 0 | 134 | | } |
| | | 135 | | |
| | 0 | 136 | | Assert.SequenceEqual(content.Span, decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length)); |
| | 0 | 137 | | } |
| | | 138 | | |
| | | 139 | | // The AES variant of ZIP encryption uses a form of HMAC that makes accidentally accepting the wrong key |
| | | 140 | | // statistically unlikely, so a wrong password must fail with InvalidDataException. |
| | | 141 | | // ZipCrypto only has a weak 1-byte verifier over a stream cipher, so a wrong key is rejected |
| | | 142 | | // with roughly 255/256 probability and may occasionally decrypt to garbage without throwing; |
| | | 143 | | // both outcomes are acceptable and cannot be distinguished reliably on fuzzed input. Any |
| | | 144 | | // other exception type would indicate a real bug and is intentionally left to propagate. |
| | | 145 | | // Append a byte so the wrong password is guaranteed to differ from the fuzzed one above. |
| | 0 | 146 | | bool authenticated = method != ZipEncryptionMethod.ZipCrypto; |
| | 0 | 147 | | string wrongPassword = password + "!"; |
| | 0 | 148 | | bool wrongPasswordRejected = false; |
| | | 149 | | try |
| | 0 | 150 | | { |
| | 0 | 151 | | if (async) |
| | 0 | 152 | | { |
| | 0 | 153 | | await using Stream wrongStream = await readEntry.OpenAsync(wrongPassword.AsSpan()); |
| | 0 | 154 | | await wrongStream.CopyToAsync(Stream.Null); |
| | 0 | 155 | | } |
| | | 156 | | else |
| | 0 | 157 | | { |
| | 0 | 158 | | using Stream wrongStream = readEntry.Open(wrongPassword.AsSpan()); |
| | 0 | 159 | | wrongStream.CopyTo(Stream.Null); |
| | 0 | 160 | | } |
| | 0 | 161 | | } |
| | 0 | 162 | | catch (InvalidDataException) |
| | 0 | 163 | | { |
| | | 164 | | // Expected: the AES password verifier / HMAC (or the ZipCrypto check byte) rejects the wrong key. |
| | 0 | 165 | | wrongPasswordRejected = true; |
| | 0 | 166 | | } |
| | | 167 | | |
| | 0 | 168 | | if (authenticated) |
| | 0 | 169 | | { |
| | 0 | 170 | | Assert.True(wrongPasswordRejected); |
| | 0 | 171 | | } |
| | | 172 | | |
| | 0 | 173 | | if (async) |
| | 0 | 174 | | { |
| | 0 | 175 | | await readArchive.DisposeAsync(); |
| | 0 | 176 | | } |
| | | 177 | | else |
| | 0 | 178 | | { |
| | 0 | 179 | | readArchive.Dispose(); |
| | 0 | 180 | | } |
| | 0 | 181 | | } |
| | | 182 | | } |