Robust Parsing
Binary File Formats in Practice
Chapter 9 ยท Robust Parsing
Every parser in this course so far has been fed files it wrote itself. Real files are not so polite. They get cut short by a failed download, damaged by a bad disk, written by buggy programs, or built on purpose to break the reader. A parser that is correct for good input and unpredictable for bad input is only half finished. This chapter collects the defences that Binary File Formats in Practice 8 began: a single checked gate for reading, limits on counts and sizes, protection from decompression bombs and pointer loops, and a small fuzzer that proves the parser fails cleanly.
Every Number in the File Is a Claim
The core idea is simple: nothing read from a file is known to be true. A length, a count, an offset, a version, even a "this record is 12 bytes" are only statements the file makes about itself. Robust parsing means checking each statement against what you can actually see before acting on it. The failures fall into a few families:
| Problem | Example | Defence |
|---|---|---|
| Truncation | The file ends in the middle of a field | Check bytes remaining before every read |
| Lying length | A record claims more bytes than the file has | Compare the length with the bytes that remain |
| Lying count | "4 billion items" in a 20-byte file | A fixed limit, and a check that the items could fit |
| Expansion | A tiny compressed stream that inflates to gigabytes | Cap the output size while decompressing |
| Loops and depth | An offset that points back at itself; records nested thousands deep | Visited set, hop limit, depth limit |
| Bad content | Invalid UTF-8, an unknown version | Validate and reject with a clear message |
| Leftovers | Extra bytes after the last record | Decide: reject, or skip on purpose |
One Gate for Every Read
The easiest way to forget a check is to scatter reads through the code. Instead, put them behind one small class whose only job is to hand out bytes safely, and make it raise one error type for every kind of bad input. Callers then catch a single FormatError rather than a mixture of whatever Python happens to raise.
Three details earn their place here. The check is n > remaining before slicing, because Python slicing never complains: asking for bytes past the end quietly returns a shorter, even empty, result, and the parser carries on with garbage. A negative request is refused too, since buf[pos:pos + n] with a negative n would walk backwards. And the message carries the offset and the shortfall, which turns "invalid file" into something you can debug with a hex dump from Binary File Formats in Practice 1.
Indexing is the opposite trap: buf[5] past the end raises IndexError, which has nothing to do with your format and would escape a caller expecting FormatError. Routing everything through take avoids both.
Counts and Sizes Are Claims Too
A count field is the classic way to make a program allocate far more than the file justifies. Suppose a format starts with a 4-byte record count, and a hostile file says four billion. A parser that builds a list of that size, or loops that many times before noticing the data has run out, wastes huge amounts of memory or time. Two cheap checks stop it. First, a hard limit chosen by you. Second, a feasibility test: if each record needs at least a minimum number of bytes, the count cannot exceed remaining // minimum.
Notice that the same check appears in different clothes everywhere: a length against the bytes left, a count against the bytes left, a decode failure turned into the shared error type. The last one uses raise ... from e so the original exception is kept as the cause while callers still catch only FormatError.
Choosing Limits
A limit is a promise about the largest thing your program will accept. Pick them on purpose and write them down next to the format:
- Total file size. If you read the whole file into memory, refuse files over a size you can afford before reading them (check
os.path.getsizeor read at most the limit plus one byte). - Item counts and string lengths. Far below what the length field could express. A 4-byte length can say four gigabytes; a name field rarely needs more than a few hundred bytes.
- Nesting depth. A small number, such as the 8 used in Binary File Formats in Practice 8. Python's default recursion limit is 1000 frames (check), so a hostile file nested deeper than that would otherwise crash the parser with a
RecursionErrorrather than a clean rejection. - Total work. Loops over records should have an upper bound that does not depend only on the file's own claims.
- Decompressed size. See the next section.
Decompression Bombs
Compression makes a particularly nasty lie possible, because the input is tiny but the output is not. A run of zeros compresses extremely well: fifty million zero bytes squeeze into about 48 thousand, a ratio above a thousand to one. A parser that calls zlib.decompress(data) on untrusted input will happily build the full fifty million bytes (and a determined attacker can do far worse). The fix is to decompress with a cap using a decompressor object, which accepts a max_length and stops there:
Asking for max_out + 1 bytes is the trick: if more than max_out come back, the stream wanted to produce more than you allow, and you can refuse without ever having held the whole thing. The eof flag then separates a stream that genuinely ended from one that was cut off. The same care applies to the PNG chunk data from Binary File Formats in Practice 5, whose image data is compressed: a decoder that trusts the header's width and height to size its buffers inherits the same problem.
Offsets, Loops and Pointer Chains
Some formats do not store records one after another; they store offsets that say where the next thing lives. An offset is another claim, with a new way to fail: it can point outside the file, or back at something already visited, which makes a loop. A parser that follows "next" pointers until it sees an end marker will spin forever on a file whose chain has no end.
The visited set catches a true loop at once; the hop limit is a second line of defence that also bounds a long but finite chain. Both are cheap, and a loop-free format that "never" produces cycles still deserves them, because the reader cannot know the writer was honest.
Checksums Detect Accidents, Not Attackers
A CRC, like the one on each PNG chunk in Binary File Formats in Practice 5, is excellent at noticing accidental damage: a flipped bit, a truncated transfer. It does nothing against someone who changes the data and recomputes the checksum, because a CRC has no secret.
So a checksum is a good first filter for corruption, but it never replaces the structural checks above: a file with a valid CRC can still hold a lying length. Protection against deliberate tampering needs a keyed construction such as a message authentication code or a digital signature, which is outside this course.
Prove It: Break Your Own Files
Careful code is a belief; a test is evidence. The cheapest useful test for a parser is a small fuzzer: take a valid file, damage it in every simple way, and check that the parser either accepts the result or rejects it with FormatError. Anything else, whether struct.error, IndexError, RecursionError or a hang, is a bug. The simplest damage is to cut the file at every possible length and to overwrite each byte with a few extreme values:
Of the 108 damaged files, 55 still parse (a changed value byte is still a valid value, and some cuts land exactly between records) and 53 are rejected cleanly. None crash. Now the same test on a sloppy parser that reads lengths without checking them:
The sloppy parser has two failures, and the second is the worse. Four cases crash with struct.error (a header cut short). But it accepts 104 of 108, including many it should have refused: a record whose length overruns the data silently becomes a shorter record, so a damaged file produces plausible but wrong results with no warning. Silent acceptance of bad data is more dangerous than a crash, because nobody notices.
A Robust Parser Checklist
- One gate. All reads go through a helper that checks bytes remaining. No raw indexing of untrusted lengths.
- One error type. Convert
struct.error,UnicodeDecodeErrorandzlib.errorintoFormatErrorso callers handle one thing. - Check before you allocate or loop. Counts and sizes against a limit and against what could fit.
- Cap expansion. Decompress with an output limit, and compare the declared size with the real one.
- Bound the structure. Depth limit, hop limit, visited set.
- Validate content. Magic, version, text encoding, reserved fields that must be zero.
- Decide about leftovers. Trailing bytes are either an error or an intentional extension point; never an accident.
- Report the offset. Every error message says where in the file it happened.
- Fuzz it. Truncate and mutate a good file, and require that only
FormatErrorcomes out.
Challenges
Write a parser for a small inventory file: the magic bytes INV1, a 1-byte version (only 1 is accepted), a 2-byte little-endian item count, then that many items, each a 1-byte name length, the UTF-8 name (never empty), a 4-byte quantity and a 2-byte price. Build it on a checked reader. Enforce a file-size limit of 65,536 bytes, a limit of 100 items, a check that the count could fit in the bytes left, and a refusal of trailing bytes. Show a good file parsed, an empty list, and eleven bad inputs each rejected with its own clear message.
Write a safe loader for a blob made of a 4-byte little-endian declared size followed by a zlib stream. Refuse a declared size over 1,000,000 before decompressing, cap the decompressor's output, and check the real size equals the declared size. Show a good blob, a 50-million-byte bomb (with an honest size and with a lying one), a size that is too small, too large and absurd, and a cut stream, trailing bytes, corrupt data, non-zlib data and a blob too short for its size field. Every failure must be a FormatError.
Build a fuzz harness that cuts a seed file at every length and replaces each byte with 0x00, 0xFF and the byte with its top bit flipped (skipping replacements equal to the original), counting accepted, rejected and crashed cases and recording the first case for each crash type. Run it against a careful nested type-length-value parser and a sloppy one, and finish by feeding both a record nested 50 levels deep.
๐ View solutionQuick Reference
- Principle: every length, count, offset and size in a file is a claim to verify
- Slicing never fails:
buf[a:b]past the end returns less; indexing raisesIndexError; check first - Checked reader:
take(n)refusesn < 0orn > remainingand names the offset - One error type: wrap
struct.error,UnicodeDecodeError,zlib.errorwithraise FormatError(...) from e - Counts: hard limit, plus
count * min_size <= remaining - Bombs:
zlib.decompressobj().decompress(data, max_out + 1), then check length andeof - Loops and depth: visited set, hop limit, recursion depth limit
- CRC: catches accidents, not tampering
- Fuzz: truncate at every length, mutate every byte; only
FormatErroris acceptable