< Summary

Information
Class: DotnetFuzzing.Fuzzers.ZipEncryptionStreamFuzzer
Assembly: DotnetFuzzing
File(s): D:\runner\runtime\src\libraries\Fuzzing\DotnetFuzzing\Fuzzers\ZipEncryptionStreamFuzzer.cs
Line coverage
0%
Covered lines: 0
Uncovered lines: 99
Coverable lines: 99
Total lines: 182
Line coverage: 0%
Branch coverage
0%
Covered branches: 0
Total branches: 28
Branch coverage: 0%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Cyclomatic complexity NPath complexity Sequence coverage
.cctor()100%110%
FuzzTarget(...)0%10100%
RoundTrip()0%18180%

File(s)

D:\runner\runtime\src\libraries\Fuzzing\DotnetFuzzing\Fuzzers\ZipEncryptionStreamFuzzer.cs

#LineLine coverage
 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
 4using System.Buffers;
 5using System.IO.Compression;
 6using System.Runtime.InteropServices;
 7using System.Runtime.Versioning;
 8using System.Threading.Tasks;
 9
 10namespace 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")]
 16internal sealed class ZipEncryptionStreamFuzzer : IFuzzer
 17{
 018    public string[] TargetAssemblies { get; } = ["System.IO.Compression"];
 019    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
 030    private static readonly ZipEncryptionMethod[] s_methods =
 031    [
 032        ZipEncryptionMethod.ZipCrypto,
 033        ZipEncryptionMethod.Aes128,
 034        ZipEncryptionMethod.Aes192,
 035        ZipEncryptionMethod.Aes256,
 036    ];
 37
 38    public void FuzzTarget(ReadOnlySpan<byte> bytes)
 039    {
 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.
 042        if (bytes.Length < 2)
 043        {
 044            return;
 45        }
 46
 47        // Use the first byte to select the encryption method so all variants get exercised.
 048        ZipEncryptionMethod method = s_methods[bytes[0] % s_methods.Length];
 049        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.
 056        int newlineIndex = payload.IndexOf((byte)'\n');
 057        ReadOnlySpan<byte> passwordBytes = newlineIndex < 0 ? ReadOnlySpan<byte>.Empty : payload.Slice(0, newlineIndex);
 058        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.
 062        if (contentBytes.IsEmpty)
 063        {
 064            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.
 069        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.
 074        if (password.Length == 0)
 075        {
 076            password = FallbackPassword;
 077        }
 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.
 081        using PooledBoundedMemory<byte> buffer = PooledBoundedMemory<byte>.Rent(contentBytes, PoisonPagePlacement.After)
 082        RoundTrip(buffer.Memory, password, method, async: false).GetAwaiter().GetResult();
 083        RoundTrip(buffer.Memory, password, method, async: true).GetAwaiter().GetResult();
 084    }
 85
 86    private static async Task RoundTrip(ReadOnlyMemory<byte> content, string password, ZipEncryptionMethod method, bool 
 087    {
 088        using var archiveStream = new MemoryStream();
 89
 90        // Encrypt the fuzz input, exercising the encryption stream (and HMAC path for AES).
 091        ZipArchive writeArchive = async
 092            ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: nul
 093            : new ZipArchive(archiveStream, ZipArchiveMode.Create, leaveOpen: true, entryNameEncoding: null);
 94
 095        ZipArchiveEntry writeEntry = writeArchive.CreateEntry(EntryName, password.AsSpan(), method);
 96
 097        Stream entryWriteStream = async ? await writeEntry.OpenAsync() : writeEntry.Open();
 098        if (async)
 099        {
 0100            await entryWriteStream.WriteAsync(content);
 0101            await entryWriteStream.DisposeAsync();
 0102            await writeArchive.DisposeAsync();
 0103        }
 104        else
 0105        {
 0106            entryWriteStream.Write(content.Span);
 0107            entryWriteStream.Dispose();
 0108            writeArchive.Dispose();
 0109        }
 110
 0111        archiveStream.Position = 0;
 112
 113        // Decrypt with the correct password and verify the round-trip is lossless.
 0114        ZipArchive readArchive = async
 0115            ? await ZipArchive.CreateAsync(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null)
 0116            : new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true, entryNameEncoding: null);
 117
 0118        ZipArchiveEntry readEntry = readArchive.GetEntry(EntryName)!;
 0119        Assert.True(readEntry.IsEncrypted);
 0120        Assert.Equal(method, readEntry.EncryptionMethod);
 121
 0122        using (var decrypted = new MemoryStream())
 0123        {
 0124            Stream entryReadStream = async ? await readEntry.OpenAsync(password.AsSpan()) : readEntry.Open(password.AsSp
 0125            if (async)
 0126            {
 0127                await entryReadStream.CopyToAsync(decrypted);
 0128                await entryReadStream.DisposeAsync();
 0129            }
 130            else
 0131            {
 0132                entryReadStream.CopyTo(decrypted);
 0133                entryReadStream.Dispose();
 0134            }
 135
 0136            Assert.SequenceEqual(content.Span, decrypted.GetBuffer().AsSpan(0, (int)decrypted.Length));
 0137        }
 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.
 0146        bool authenticated = method != ZipEncryptionMethod.ZipCrypto;
 0147        string wrongPassword = password + "!";
 0148        bool wrongPasswordRejected = false;
 149        try
 0150        {
 0151            if (async)
 0152            {
 0153                await using Stream wrongStream = await readEntry.OpenAsync(wrongPassword.AsSpan());
 0154                await wrongStream.CopyToAsync(Stream.Null);
 0155            }
 156            else
 0157            {
 0158                using Stream wrongStream = readEntry.Open(wrongPassword.AsSpan());
 0159                wrongStream.CopyTo(Stream.Null);
 0160            }
 0161        }
 0162        catch (InvalidDataException)
 0163        {
 164            // Expected: the AES password verifier / HMAC (or the ZipCrypto check byte) rejects the wrong key.
 0165            wrongPasswordRejected = true;
 0166        }
 167
 0168        if (authenticated)
 0169        {
 0170            Assert.True(wrongPasswordRejected);
 0171        }
 172
 0173        if (async)
 0174        {
 0175            await readArchive.DisposeAsync();
 0176        }
 177        else
 0178        {
 0179            readArchive.Dispose();
 0180        }
 0181    }
 182}