04 /ExperimentsArticle · 2026-07-03
← Back to experiments

What a Crash Oracle Can't See: Fuzzing Real CPython Parsers for Exception-Contract Bugs

TL;DR

  • What we did: coverage-guided fuzzing of real, owned, local CPython parsers (zipfile, gzip, and a length-trust frame parser), 60k iterations each, deterministic.
  • What we found: three exception-contract robustness classes, where a parser lets a lower-level exception (zlib.error, UnicodeDecodeError, ValueError) escape past its own declared error type, bypassing a caller's except.
  • The bigger lesson: a crash oracle is blind to silent corruption. It found zero crashes against a parser with a known info-leak over-read, which is the correct and instructive result.
  • Not memory safety, possibly known to CPython. The durable value is the reusable class and fix pattern, and the method notes.
  • Sanitized: inputs are malformed archive/gzip bytes (safe regression seeds), not weaponized payloads.

The contract a parser is supposed to keep

When you hand a parser untrusted bytes, you want one promise: anything malformed raises the parser's own declared error, so you can wrap a single except BadZipFile around it and reject cleanly. That promise is what makes try/except around untrusted input safe.

We fuzzed real CPython parsers to test that promise, and found three places it breaks. In each, the parser raises something the caller was never told to expect, so the untrusted-input handler is bypassed and the exception propagates somewhere it shouldn't, which in a real service is a 500 or a crash loop from a single malformed upload.

The three classes

  1. A codec error leaks past the format's error type. The wrapper raises BadGzipFile / BadZipFile for header and structural problems, but on a corrupt compressed body it lets the raw zlib.error from the C decompressor escape. One root cause, hit in two independent stdlib parsers in the same run. Fix: wrap the inner codec call, re-raise as the format's declared error.
  2. A charset decode with no error handler. A filename is decoded as UTF-8 with no errors= argument, on the path taken when the UTF-8 flag bit is set. A non-UTF-8 filename with that bit set raises UnicodeDecodeError. Fix: errors="replace", or catch and re-raise.
  3. An untrusted offset becomes a negative seek. A crafted offset drives an internal position negative, and the backing seek(-n) raises ValueError. Fix: validate offsets against stream bounds before seeking.

The reviewer's takeaway: "the library has a BadXFile exception" does not mean every path is covered by it. The body path, the decode path, and the seek path each needed their own guarantee.

The part worth publishing: what the fuzzer couldn't see

This experiment is as much about the method as the three bugs.

A crash oracle is blind to silent corruption. One target, a length-trust frame parser, has a known over-read that leaks memory past the intended bounds. The crash fuzzer found zero crashes against it. That is correct: an over-read raises nothing, so a harness that only watches for exceptions cannot see it. The lesson is that a clean crash-fuzz report is not a clean-parser report. Length-trust, logic, and silent-corruption bugs need a differential or property oracle, not a crash harness, and the two should always run together.

Triage is the deliverable, not the crash count. The fuzzer captured an encrypted-entry RuntimeError that is documented and expected, and the real work was dismissing it with a reason. A crash oracle over-reports; judgment is what turns raw output into a finding.

Negative coverage is the map. A single-path campaign covers little of a big module (zipfile 27%, gzip 13%). The lines it never reached name exactly where the next seeds should aim: zip64, the BZIP2/LZMA members, the write path.

Verification

  • Every crasher reproduced under the tracer (zipfile 5/5, gzip 1/1, sandbox 0/0).
  • Two runs at seed 1337 produced identical crash ids.
  • The engine's unit tests still pass.

What we are not publishing

The inputs are malformed archive and gzip bytes, safe regression seeds, not weaponized payloads. The minimized crashers stay in the lab. This post carries the classes, the fixes, and the method.

Reproduce

cd agents/cipher/labs/fuzzing
python3 harness_stdlib.py     # 60k iters/target, seed 1337, deterministic
python3 triage_stdlib.py      # reproduce + minimize each crasher under the tracer

More security experiments and build notes live in the experiments index.