Parser Differential Hunting
Every checkpoint in a system parses the input in order to decide about it, and
then hands the original bytes onward to something that parses them again.
The check is only as good as the assumption that both parses agree. They
frequently do not, and the gap is not a bug in either parser — each is locally
reasonable. The bug is in the composition, which means neither team owns it and
neither test suite covers it.
The question this skill asks, at every boundary:
Two components read these same bytes. Under what input do they disagree —
and is the disagreement between the component that decides and the
component that acts?
Only that second half makes a differential a vulnerability. A and B
disagreeing is a curiosity; A authorizing on its reading while B executes
on a different one is an authorization bypass with no missing check anywhere.
Composes with the library:
- invariant-hunting — this skill is the deep method for its family #4
(semantic boundary failure); the invariant here is
meaning_at_checkpoint == meaning_at_sink
- oracle-driven-fuzzing — two parsers are the differential oracle; this
skill says which pairs to point it at
- validate-at-the-boundary — the fix side: parse once, forward the parsed
form, never the bytes
- discriminating-proof — earns the verdict on a candidate disagreement
- reverse-engineering — when one of the two parsers is closed
Authorization: hunt systems you own or are authorized to test. The comparison
work — running two parsers over the same corpus offline — is safe anywhere; the
end-to-end confirmation is a live test and needs scope.
Part 1 — Find the parser pairs
Draw the byte's journey and mark every place it is interpreted. A pair exists
wherever the same bytes are interpreted twice by different code. High-density
habitats, in rough order of yield:
| Pair |
The bytes |
Classic disagreement |
| Proxy / origin server |
HTTP framing |
Content-Length vs Transfer-Encoding, duplicate headers, obsolete line folding |
| WAF or validator / handler |
body, query string |
one decodes %2e%2e, the other does not |
| Router / filesystem |
path |
.., ., //, trailing dot, \ on Windows, symlink resolution |
| Auth check / storage layer |
identifier |
case-insensitive lookup vs case-sensitive write; trailing whitespace |
| JSON parser A / JSON parser B |
document |
duplicate keys (first vs last wins), big integers, NaN, comments |
| Signature verifier / consumer |
signed blob |
signature over the canonical form, consumption of the raw form |
| Serializer / deserializer |
object graph |
round-trip is not identity (oracle-driven-fuzzing rung 3) |
| Allowlist matcher / HTTP client |
URL |
userinfo @, fragment, port, IPv6 brackets, DNS rebinding — the matcher's host is not the client's host |
| Template / renderer |
markup |
one escapes, the other re-parses the escaped form |
| Archive lister / extractor |
entry names |
absolute paths, .., symlinks — name shown is not name written |
| Client validation / server |
form data |
never a security boundary; verify the server repeats it |
Two parsers written in different languages, by different vendors, at different
times, for the same format are the ideal candidate. So is any format embedded
in another (JSON in a header, a URL in a query parameter, base64 wrapping
anything) — every layer of encoding is another chance to disagree.
Part 2 — The disagreement families
Name the family before testing; each has a known generator.
- Ambiguity in the format. The spec permits two readings, or is silent.
Duplicate fields, conflicting length indicators, optional whitespace.
- Different strictness. One parser rejects, the other repairs. The
permissive one usually sits behind the strict one — which means the strict
one is the checkpoint and never sees what the permissive one will make of
the input.
- Different canonicalization order. Decode-then-normalize versus
normalize-then-decode. Any pipeline that does both in a different order is a
candidate by construction.
- Different alphabet. Unicode normalization, case folding (the Kelvin sign
folds to
k), overlong UTF-8, homoglyphs, zero-width and bidirectional
characters, punycode. Case-insensitive comparison or NFKC normalization
applied at only one end is the recurring bug.
- Different truncation point. Null bytes, newlines, length limits: one
component sees
evil.example\0.trusted.example as one string, the other
stops at the null. A database column that silently truncates at 255 is the
same bug in slow motion.
- Different type coercion.
"1" versus 1, "false" truthiness, arrays
where scalars were expected, null versus absent. Two languages' coercion
rules are never the same.
- Sniffing over declaration. One component believes the declared
Content-Type, the other sniffs the content. Polyglot files exist because
of this.
Part 3 — The method
The comparison is cheap and offline; run it before any live testing.
- Extract both parsers into one harness. Import the library the proxy uses
and the one the app uses; or shell out to both. The harness takes bytes and
returns each parser's decision-relevant projection — the host it resolved,
the path it produced, the map it built, the length it framed.
- Compare the projection, not the whole output. Two JSON parsers differ in
a dozen irrelevant ways. Compare only what the checkpoint decides on.
- Seed with the format's known-hard corners (Part 2's families), then let
oracle-driven-fuzzing run the search with A(x) != B(x) as the oracle.
This is among the strongest oracles in the library: it needs no ground truth
and no notion of "correct".
- Triage each disagreement by position. For every differing input, ask
which parser is the decider and which is the actor. A disagreement in
which the actor is the stricter of the two is inert. Only
decider says safe → actor does something else is a finding.
- Confirm end to end. A harness-level disagreement is a plausible
hypothesis: the real pipeline may normalize between the two, or reject
earlier. Send the input through the real path and observe the actor's
behavior (
discriminating-proof — binary oracle, plus a negative control
that is byte-identical except for the ambiguity).
Part 4 — The fix is architectural, and this is the blue payoff
Point fixes ("also reject %2e%2e") lose, because the next encoding is always
one layer deeper. Rank the remediations you propose:
- Parse once, forward the parsed form. The checkpoint emits a structured,
unambiguous representation and the actor consumes that, never the original
bytes. This deletes the class rather than one instance.
- One parser, shared. Both components call the same library at the same
version. Cheap when both sides are yours.
- Reject ambiguity instead of resolving it. Duplicate header, two length
indicators, non-NFC input, a path containing
.. before resolution → 400.
A format's ambiguous corner is almost never legitimate traffic; refusing it
costs little and removes the disagreement's fuel.
- Canonicalize in exactly one place, then compare canonical to canonical —
and pin the order: decode to a fixpoint, then normalize, then validate, then
use the validated value and nothing else.
- Only then, filters and signatures over the raw bytes.
Also hand the pair to detection-engineering: "requests where the front-end
and back-end interpretations differ" is a high-signal, low-volume detection,
and one of the few that is nearly free of false positives.
Anti-patterns
- Comparing full parser outputs, drowning the real disagreement in noise.
- Reporting a harness-level disagreement as a vulnerability without tracing it
to a decider/actor split in the live pipeline.
- Assuming the strict parser protects the permissive one — it is usually in
front of it, deciding about bytes it will never execute.
- Fixing one encoding and declaring the class closed.
- Trusting client-side validation as one half of the pair. It is not a
checkpoint; it is a hint.
- Normalizing after the authorization check instead of before it.
- Treating "both are spec-compliant" as a reason not to file it. The
composition is the bug; compliance of the parts is why it survived.
1---2name: parser-differential-hunting3description: Hunt the bugs that live where two components read the same bytes and disagree about what they mean — the validator parses one way, the executor another, and the attacker writes input that is two things at once. Use whenever a value crosses a component, language, or process boundary and is re-interpreted — proxy in front of an app server, gateway before a backend, WAF before a handler, client validation before server storage, signature verified before content is re-parsed, or one format embedded in another. Trigger on "request smuggling", "path traversal", "canonicalization", "normalization", "Unicode/homoglyph/punycode/IDN", "duplicate keys", "content-type sniffing", "URL parsing", "the proxy and the backend disagree", "it validated but stored something else", "double decode", "polyglot", "type confusion", or two parsers of the same format appearing in one system. Expands invariant-hunting's semantic-boundary family into a full method; hands verdicts to discriminating-proof.4---56# Parser Differential Hunting78Every checkpoint in a system parses the input in order to decide about it, and9then hands the *original bytes* onward to something that parses them again.10The check is only as good as the assumption that both parses agree. They11frequently do not, and the gap is not a bug in either parser — each is locally12reasonable. The bug is in the composition, which means neither team owns it and13neither test suite covers it.1415The question this skill asks, at every boundary:1617> **Two components read these same bytes. Under what input do they disagree —18> and is the disagreement between the component that *decides* and the19> component that *acts*?**2021Only that second half makes a differential a vulnerability. `A` and `B`22disagreeing is a curiosity; `A` authorizing on its reading while `B` executes23on a different one is an authorization bypass with no missing check anywhere.2425Composes with the library:2627- **invariant-hunting** — this skill is the deep method for its family #428 (semantic boundary failure); the invariant here is29 `meaning_at_checkpoint == meaning_at_sink`30- **oracle-driven-fuzzing** — two parsers *are* the differential oracle; this31 skill says which pairs to point it at32- **validate-at-the-boundary** — the fix side: parse once, forward the parsed33 form, never the bytes34- **discriminating-proof** — earns the verdict on a candidate disagreement35- **reverse-engineering** — when one of the two parsers is closed3637Authorization: hunt systems you own or are authorized to test. The comparison38work — running two parsers over the same corpus offline — is safe anywhere; the39end-to-end confirmation is a live test and needs scope.4041---4243## Part 1 — Find the parser pairs4445Draw the byte's journey and mark every place it is *interpreted*. A pair exists46wherever the same bytes are interpreted twice by different code. High-density47habitats, in rough order of yield:4849| Pair | The bytes | Classic disagreement |50|---|---|---|51| Proxy / origin server | HTTP framing | `Content-Length` vs `Transfer-Encoding`, duplicate headers, obsolete line folding |52| WAF or validator / handler | body, query string | one decodes `%2e%2e`, the other does not |53| Router / filesystem | path | `..`, `.`, `//`, trailing dot, `\` on Windows, symlink resolution |54| Auth check / storage layer | identifier | case-insensitive lookup vs case-sensitive write; trailing whitespace |55| JSON parser A / JSON parser B | document | duplicate keys (first vs last wins), big integers, `NaN`, comments |56| Signature verifier / consumer | signed blob | signature over the canonical form, consumption of the raw form |57| Serializer / deserializer | object graph | round-trip is not identity (`oracle-driven-fuzzing` rung 3) |58| Allowlist matcher / HTTP client | URL | userinfo `@`, fragment, port, IPv6 brackets, DNS rebinding — the matcher's host is not the client's host |59| Template / renderer | markup | one escapes, the other re-parses the escaped form |60| Archive lister / extractor | entry names | absolute paths, `..`, symlinks — name shown is not name written |61| Client validation / server | form data | never a security boundary; verify the server repeats it |6263Two parsers written in different languages, by different vendors, at different64times, for the same format are the ideal candidate. So is any format embedded65in another (JSON in a header, a URL in a query parameter, base64 wrapping66anything) — every layer of encoding is another chance to disagree.6768## Part 2 — The disagreement families6970Name the family before testing; each has a known generator.71721. **Ambiguity in the format.** The spec permits two readings, or is silent.73 Duplicate fields, conflicting length indicators, optional whitespace.742. **Different strictness.** One parser rejects, the other repairs. The75 permissive one usually sits behind the strict one — which means the strict76 one is the checkpoint and never sees what the permissive one will make of77 the input.783. **Different canonicalization order.** Decode-then-normalize versus79 normalize-then-decode. Any pipeline that does both in a different order is a80 candidate by construction.814. **Different alphabet.** Unicode normalization, case folding (the Kelvin sign82 folds to `k`), overlong UTF-8, homoglyphs, zero-width and bidirectional83 characters, punycode. Case-insensitive comparison or NFKC normalization84 applied at only one end is the recurring bug.855. **Different truncation point.** Null bytes, newlines, length limits: one86 component sees `evil.example\0.trusted.example` as one string, the other87 stops at the null. A database column that silently truncates at 255 is the88 same bug in slow motion.896. **Different type coercion.** `"1"` versus `1`, `"false"` truthiness, arrays90 where scalars were expected, `null` versus absent. Two languages' coercion91 rules are never the same.927. **Sniffing over declaration.** One component believes the declared93 `Content-Type`, the other sniffs the content. Polyglot files exist because94 of this.9596## Part 3 — The method9798The comparison is cheap and offline; run it before any live testing.991001. **Extract both parsers into one harness.** Import the library the proxy uses101 and the one the app uses; or shell out to both. The harness takes bytes and102 returns each parser's *decision-relevant projection* — the host it resolved,103 the path it produced, the map it built, the length it framed.1042. **Compare the projection, not the whole output.** Two JSON parsers differ in105 a dozen irrelevant ways. Compare only what the checkpoint decides on.1063. **Seed with the format's known-hard corners** (Part 2's families), then let107 `oracle-driven-fuzzing` run the search with `A(x) != B(x)` as the oracle.108 This is among the strongest oracles in the library: it needs no ground truth109 and no notion of "correct".1104. **Triage each disagreement by position.** For every differing input, ask111 which parser is the *decider* and which is the *actor*. A disagreement in112 which the actor is the stricter of the two is inert. Only113 `decider says safe → actor does something else` is a finding.1145. **Confirm end to end.** A harness-level disagreement is a *plausible115 hypothesis*: the real pipeline may normalize between the two, or reject116 earlier. Send the input through the real path and observe the actor's117 behavior (`discriminating-proof` — binary oracle, plus a negative control118 that is byte-identical except for the ambiguity).119120## Part 4 — The fix is architectural, and this is the blue payoff121122Point fixes ("also reject `%2e%2e`") lose, because the next encoding is always123one layer deeper. Rank the remediations you propose:1241251. **Parse once, forward the parsed form.** The checkpoint emits a structured,126 unambiguous representation and the actor consumes *that*, never the original127 bytes. This deletes the class rather than one instance.1282. **One parser, shared.** Both components call the same library at the same129 version. Cheap when both sides are yours.1303. **Reject ambiguity instead of resolving it.** Duplicate header, two length131 indicators, non-NFC input, a path containing `..` before resolution → 400.132 A format's ambiguous corner is almost never legitimate traffic; refusing it133 costs little and removes the disagreement's fuel.1344. **Canonicalize in exactly one place, then compare canonical to canonical** —135 and pin the order: decode to a fixpoint, then normalize, then validate, then136 use the validated value and nothing else.1375. **Only then**, filters and signatures over the raw bytes.138139Also hand the pair to `detection-engineering`: "requests where the front-end140and back-end interpretations differ" is a high-signal, low-volume detection,141and one of the few that is nearly free of false positives.142143---144145## Anti-patterns146147- Comparing full parser outputs, drowning the real disagreement in noise.148- Reporting a harness-level disagreement as a vulnerability without tracing it149 to a decider/actor split in the live pipeline.150- Assuming the strict parser protects the permissive one — it is usually in151 front of it, deciding about bytes it will never execute.152- Fixing one encoding and declaring the class closed.153- Trusting client-side validation as one half of the pair. It is not a154 checkpoint; it is a hint.155- Normalizing after the authorization check instead of before it.156- Treating "both are spec-compliant" as a reason not to file it. The157 composition is the bug; compliance of the parts is why it survived.