Summary
To prove "Google signed a token that says I am account X," the circuit takes two inputs from the prover: the signed token (header_and_payload) and a decoded copy of the payload (payload_json) that it actually reads fields out of. It (a) checks the copy matches the signed token, and (b) extracts sub/aud/nonce/iat from the copy; the on-chain identity is account_id = pedersen(sub, aud, salt).
This process has the following bugs:
- The match is prefix-only.
assert_payloads_match compares payload_json to the signed token only over a prefix (its loop guard j < header_and_payload.len() - 1 skips the tail). The tail of payload_json is unconstrained, which is a free injection point.
- The field lookup trusts the prover for "where."
extract_jwt_payload_field_str finds a field with string_search::substring_match, whose match position comes from an unconstrained hint (utils::search). The circuit only checks that the needle matches at that position, never that it is the first occurrence.
So a malicious prover could inject a duplicate field into the unbound tail and points the lookup at it. The verifier reads the attacker's value, even though the genuine field is present.
The intuition. Searching for a field inside a circuit is expensive, so the circuit doesn't find sub itself, it asks the prover "where is it?" and only verifies that a sub really sits at the spot you pointed to, never that it is the first one. It is like a notary who, instead of reading the contract, asks you which line the signature is on and just confirms that line has a signature: sign twice and point at the second, and the second is what counts. Flaw 1 lets you add the second signature (inject a duplicate field into the unbound tail); flaw 2 lets you point the notary at it.
How it works
Google signs Alice's token, payload {"sub":"alice","aud":"app"}. Alice wants to log in as Bob. She submits:
header_and_payload = {"sub":"alice","aud":"app"} <- her real signed token
payload_json = {"sub":"alice","aud":"app","sub":"bob"}
└──────── prefix ────────┘└ injected tail ┘
- Check (a): the prefix of
payload_json equals the signed token → passes. The appended ,"sub":"bob" lives in the unchecked tail (flaw 1).
- Check (b): "where is
sub?" Alice answers with the position of the second one. The circuit verifies a sub exists there → reads "bob" (flaw 2). An honest prover would answer the first position and read "alice".
account_id = pedersen("bob", …) → Alice is authenticated as Bob, using a token Google signed for Alice.
Under the hood, the field lookup (extract_jwt_payload_field_str) runs five steps:
- Build the JSON marker that precedes a value:
"sub" → the byte-string "sub":".
- Ask the prover where
"sub":" occurs, the position is an unconstrained hint, supplied freely (flaw 2).
- Verify
"sub":" really sits at that position. It does at both copies, so any pointed-to copy passes; "is this the first occurrence?" is never asked.
- Read the bytes after the marker until the closing
", that is the extracted value.
- Identity is
account_id = pedersen(sub, …) over whatever step 4 returned.
Steps 2–4 are where it breaks: point step 2 at the injected copy and step 4 hands back the attacker's value.
Summary
To prove "Google signed a token that says I am account X," the circuit takes two inputs from the prover: the signed token (
header_and_payload) and a decoded copy of the payload (payload_json) that it actually reads fields out of. It (a) checks the copy matches the signed token, and (b) extractssub/aud/nonce/iatfrom the copy; the on-chain identity isaccount_id = pedersen(sub, aud, salt).This process has the following bugs:
assert_payloads_matchcomparespayload_jsonto the signed token only over a prefix (its loop guardj < header_and_payload.len() - 1skips the tail). The tail ofpayload_jsonis unconstrained, which is a free injection point.extract_jwt_payload_field_strfinds a field withstring_search::substring_match, whose match position comes from anunconstrainedhint (utils::search). The circuit only checks that the needle matches at that position, never that it is the first occurrence.So a malicious prover could inject a duplicate field into the unbound tail and points the lookup at it. The verifier reads the attacker's value, even though the genuine field is present.
The intuition. Searching for a field inside a circuit is expensive, so the circuit doesn't find
subitself, it asks the prover "where is it?" and only verifies that asubreally sits at the spot you pointed to, never that it is the first one. It is like a notary who, instead of reading the contract, asks you which line the signature is on and just confirms that line has a signature: sign twice and point at the second, and the second is what counts. Flaw 1 lets you add the second signature (inject a duplicate field into the unbound tail); flaw 2 lets you point the notary at it.How it works
Google signs Alice's token, payload
{"sub":"alice","aud":"app"}. Alice wants to log in as Bob. She submits:payload_jsonequals the signed token → passes. The appended,"sub":"bob"lives in the unchecked tail (flaw 1).sub?" Alice answers with the position of the second one. The circuit verifies asubexists there → reads"bob"(flaw 2). An honest prover would answer the first position and read"alice".account_id = pedersen("bob", …)→ Alice is authenticated as Bob, using a token Google signed for Alice.Under the hood, the field lookup (
extract_jwt_payload_field_str) runs five steps:"sub"→ the byte-string"sub":"."sub":"occurs, the position is anunconstrainedhint, supplied freely (flaw 2)."sub":"really sits at that position. It does at both copies, so any pointed-to copy passes; "is this the first occurrence?" is never asked.", that is the extracted value.account_id = pedersen(sub, …)over whatever step 4 returned.Steps 2–4 are where it breaks: point step 2 at the injected copy and step 4 hands back the attacker's value.