Skip to content

Soundness bug: Account impersonation: extracted JWT fields aren't bound to the signed token #20

Description

@anon-researchers-123

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:

  1. 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.
  2. 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:

  1. Build the JSON marker that precedes a value: "sub" → the byte-string "sub":".
  2. Ask the prover where "sub":" occurs, the position is an unconstrained hint, supplied freely (flaw 2).
  3. 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.
  4. Read the bytes after the marker until the closing ", that is the extracted value.
  5. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions