Workers IO workers.io
skills docs blog login
← blog

april 14, 2026 · security

Finding a Bug in Google's Brotli Using Our Fuzzing Skill

We built a fuzzing skill for Claude, pointed it at Google's Brotli, and watched it find a real correctness bug — autonomously, We reported it. It got fixed.

by Shadan · engineer, workers io


We found a signed integer overflow in Google’s compression library. It is a genuine bug in code that ships inside Chrome, Firefox, nginx, and most CDNs. No existing test was catching it. An agent found it without any help.

The interesting part isn’t the bug. It’s what the agent had to do to find it.

The Harness Is the Hard Part

Fuzzing, at its core, is simple: feed a program garbage and see if it breaks. The modern version — coverage-guided fuzzing, as AFL++ does — is considerably smarter. The fuzzer instruments your code at compile time to track which branches execute. When a new input triggers a previously unseen branch, the fuzzer saves it and further mutates it. Over many iterations, it evolves inputs toward unexplored code, guided by the program’s actual behavior. It isn’t guessing. It’s searching.

But fuzzing only finds what the harness can reach. Writing a harness that hits the right place means you have to understand the code first. That’s the hard part. That’s where the agent earns its keep.

A Signed Overflow in Brotli’s Decoder

Here’s the bug. Brotli’s decoder supports compound dictionaries — pre-shared data the decompressor uses when reconstructing content. The BrotliDecoderAttachDictionary function accepts a dictionary of a given size and attaches it to the decoder state. Inside that function, at decode.c:1545, the accumulated total size is stored as an int — a signed, 32-bit integer. But each individual size is represented as a size_t — an unsigned, 64-bit integer. The code casts one to the other with (int)size and adds it to a running total. No bounds check. No assertion. No contract.

The full picture:

static BROTLI_BOOL AttachCompoundDictionary(
    BrotliDecoderState* state, const uint8_t* data, size_t size) {
  BrotliDecoderCompoundDictionary* addon = state->compound_dictionary;
  if (state->state != BROTLI_STATE_UNINITED) return BROTLI_FALSE;
  if (!addon) {
    addon = (BrotliDecoderCompoundDictionary*)BROTLI_DECODER_ALLOC(
        state, sizeof(BrotliDecoderCompoundDictionary));
    if (!addon) return BROTLI_FALSE;
    addon->num_chunks = 0;
    addon->total_size = 0;  // <-- int, not size_t
    ...
  }
  if (addon->num_chunks == 15) return BROTLI_FALSE;
  addon->chunks[addon->num_chunks] = data;
  addon->num_chunks++;
  addon->total_size += (int)size;  // <-- BUG: size_t cast to int
  addon->chunk_offsets[addon->num_chunks] = addon->total_size;
  return BROTLI_TRUE;
}

Call the function a few times with carefully chosen sizes, and the sum wraps past INT_MAX. That’s signed integer overflow — undefined behavior in C. The code is wrong by construction. It just hadn’t been shown the input that proves it.

decode.c:1545 — integer overflow
total_size (int32)INT_MAX
0
addon->total_size += (int)size;

Click the buttons to attach dictionary chunks. Watch total_size accumulate toward INT_MAX.

The fix replaced the unchecked addition with a checked one:

if (!BROTLI_SAFE_ADD(int, addon->total_size, new_size, &new_size)) {
  return BROTLI_FAILURE(BROTLI_DECODER_ERROR_INVALID_ARGUMENTS);
}

Commit 4792c8e

The bug was reported with @0xazanul and accepted by Google’s VRP.

What the Agent Actually Did

Now here’s what the agent actually did, and why I think it matters.

The fuzzing skill is a four-step pipeline. The first step — audit-context-building — is the one that does the real intellectual work. Before writing any harness code, the agent reads through the target codebase line by line, hunting for specific classes of problems: size_t-to-int narrowing casts, unchecked length arithmetic, and public API functions that accept attacker-controlled sizes. It produces a ranked list of suspicious locations. In the Brotli case, it surfaced decode.c:1545 as the top candidate. The exact line.

The second step is writing the harness. This is where most fuzzing efforts succeed or fail. A bad harness either crashes on its own seed inputs or never reaches the interesting code paths. The agent used a split-input pattern: the first few bytes of the fuzzer’s input control configuration parameters, like dictionary sizes, and the rest feed into the actual payload. This gives AFL++ a clean structure to evolve against.

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    if (size < 5) return 0;

    // First byte: number of dictionary chunks to attach
    uint8_t n_chunks = data[0];
    if (n_chunks == 0 || n_chunks > 5) return 0;

    // Next 4*n_chunks bytes: claimed sizes for each chunk
    // Rest: brotli-compressed stream
    ...
    for (int i = 0; i < n_chunks; i++) {
        BrotliDecoderAttachDictionary(state, BROTLI_SHARED_DICTIONARY_RAW,
                                      claimed_size[i], stub);
    }
    ...
    return 0;
}

Because the overflow is purely arithmetic — it’s all inside the integer accumulation — the harness doesn’t need to allocate real dictionary-sized buffers. A one-byte stub is enough. AFL++ just keeps increasing the size values toward large values until the sum wraps.

The third step is the build. AddressSanitizer and UndefinedBehaviorSanitizer are both mandatory. Without UBSan, a signed integer overflow is completely silent — the process keeps running with a corrupted state, and you’d never notice. This is a detail that matters enormously and is easy to skip.

/tmp/AFLplusplus/afl-clang-fast \
  -g -O1 -fsanitize=address,undefined -fno-omit-frame-pointer \
  harness.c libbrotlidec.a libbrotlicommon.a \
  /tmp/AFLplusplus/libAFLDriver.a \
  -o build/fuzzer

The fourth step is just running and reporting. AFL++ found the crash in 43 executions. Three seconds.

AFL++ — crash found in 43 executions
press run ↓

The Audit Is Where the Leverage Is

I keep coming back to the audit step, because that’s where the leverage is.

Unit tests check what you already thought to test. Fuzzing finds the things you didn’t think to test. But “the things you didn’t think to test” is an enormous space, and a fuzzer running a generic harness will spend most of its time on the happy path. The audit narrows the search. It tells the harness where to aim. Without it, you get coverage without insight.

The agent read through unfamiliar code, spotted the exact line where a type assumption quietly fails, built a harness that could actually reach that path with the right input structure, compiled everything with the right sanitizers, and ran the fuzzer. No hints, no prior knowledge of Brotli. The whole chain had to work, and it did.

size_t-to-int narrowing isn’t rare. You see it anywhere C code accumulates lengths or sizes using old integer types, which is most C code. There are a lot of libraries that were never built to handle this kind of scrutiny, and they’re everywhere — in your browser, your web server, your CDN. The cost of applying that scrutiny is dropping fast.


Try It Yourself

To use the skill yourself:

npx skills add workersio/spec

Then run /fuzzer <target>. The audit runs first — there’s no skipping it. The harness gets written, built, and run automatically. If a crash happens, you get the crash file, the sanitizer stack trace, the root cause with file and line, and a reproduction command.


more posts