Skip to content

Unconstrained dot_index Enables Full JWT Spoofing #19

Description

@imxm

Summary

assert_payloads_match calls the unconstrained hint find_dot to locate the
. separator between the base64url-encoded header and payload. The hint's
return value is only constrained by assert(storage[dot_index] == '.') and
not by dot_index < header_and_payload.len(). Because
BoundedVec.storage is sized to the maximum JWT length while real JWTs are
~877 bytes, ~400 bytes of storage[len..] are prover-controlled and not
covered by sha256_var(storage, len). A malicious prover plants a . past
len, causing the payload-binding loop's guard to be uniformly false and
unbinding payload_json_padded from the RSA-signed bytes. The prover can then
substitute any sub, aud, nonce, and iat in the parsed JSON, forging
arbitrary JWT identities while passing the signature check on a real Google
JWT.

Vulnerable Code

fn assert_payloads_match<let N: u32, let M: u32, let M2: u32>(
    header_and_payload: BoundedVec<u8, N>,
    payload_json_padded: [u8; M],
) {
    let from_index = unsafe {
        let dot_index = find_dot(header_and_payload.storage);
        assert(header_and_payload.storage[dot_index] == ".".as_bytes()[0], "dot not found");
        dot_index + 1
    };

    let encoded: [u8; M2] = noir_base64::base64url_decode(payload_json_padded);

    for i in 0..M2 {
        let j = from_index + i;
        if j < header_and_payload.len() - 1 {  // <-- false for all i when dot_index >= len
            assert(header_and_payload.storage[j] == encoded[i], "payload mismatch");
        }
    }
}

unconstrained fn find_dot<let N: u32>(input: [u8; N]) -> u32 { /* ... */ }

Exploit

  1. Obtain any legitimately signed Google JWT (e.g., the attacker's own login).
  2. Place it in storage[0..len]. SHA-256 and RSA verification pass on the
    honest signed bytes.
  3. Plant . at storage[len]. This byte is past len, so the hash does not
    cover it.
  4. Hint returns dot_index = len. assert(storage[dot_index] == '.') passes.
  5. from_index = len + 1. Loop guard j < len - 1 is false for every i.
    The payload-binding assertion is skipped on every iteration.
  6. payload_json_padded is now unbound from the signed bytes. Attacker
    provides a hand-crafted JSON with arbitrary sub/aud/nonce/iat.
  7. assert_verify_jwt_payload derives account_id from the attacker-chosen
    sub/aud, completing the impersonation.

Impact

Critical. Full identity forgery: any user can be impersonated by anyone who
holds a single valid Google JWT for any account. The RSA signature check is
intact but provides no protection because nothing binds the verified bytes to
the parsed identity fields.

Root Cause

Standard unconstrained-hint pattern: the hint's output is checked locally
(storage[dot_index] == '.') but not range-checked against the meaningful
domain (dot_index < len). The constraint has multiple satisfying witnesses
because the prover controls storage[len..N].

Suggested Fix

Constrain the hint to the signed region:

let from_index = unsafe {
    let dot_index = find_dot(header_and_payload.storage);
    assert(dot_index < header_and_payload.len(), "dot index out of signed range");
    assert(header_and_payload.storage[dot_index] == ".".as_bytes()[0], "dot not found");
    dot_index + 1
};

Additionally:

  • Fix the loop guard j < header_and_payload.len() - 1, which appears to be
    off-by-one and skips the final payload byte on the honest path. The intent
    is presumably j < header_and_payload.len().
  • Consider constraining dot_index to be the first dot, not merely a
    dot, to defend against future inputs where the prover-controlled region
    could be inside len.
  • Zero out storage[len..N] (or otherwise constrain it) so post-len bytes
    cannot influence any subsequent logic.

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