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
- Obtain any legitimately signed Google JWT (e.g., the attacker's own login).
- Place it in
storage[0..len]. SHA-256 and RSA verification pass on the
honest signed bytes.
- Plant
. at storage[len]. This byte is past len, so the hash does not
cover it.
- Hint returns
dot_index = len. assert(storage[dot_index] == '.') passes.
from_index = len + 1. Loop guard j < len - 1 is false for every i.
The payload-binding assertion is skipped on every iteration.
payload_json_padded is now unbound from the signed bytes. Attacker
provides a hand-crafted JSON with arbitrary sub/aud/nonce/iat.
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.
Summary
assert_payloads_matchcalls the unconstrained hintfind_dotto locate the.separator between the base64url-encoded header and payload. The hint'sreturn value is only constrained by
assert(storage[dot_index] == '.')andnot by
dot_index < header_and_payload.len(). BecauseBoundedVec.storageis sized to the maximum JWT length while real JWTs are~877 bytes, ~400 bytes of
storage[len..]are prover-controlled and notcovered by
sha256_var(storage, len). A malicious prover plants a.pastlen, causing the payload-binding loop's guard to be uniformly false andunbinding
payload_json_paddedfrom the RSA-signed bytes. The prover can thensubstitute any
sub,aud,nonce, andiatin the parsed JSON, forgingarbitrary JWT identities while passing the signature check on a real Google
JWT.
Vulnerable Code
Exploit
storage[0..len]. SHA-256 and RSA verification pass on thehonest signed bytes.
.atstorage[len]. This byte is pastlen, so the hash does notcover it.
dot_index = len.assert(storage[dot_index] == '.')passes.from_index = len + 1. Loop guardj < len - 1is false for everyi.The payload-binding assertion is skipped on every iteration.
payload_json_paddedis now unbound from the signed bytes. Attackerprovides a hand-crafted JSON with arbitrary
sub/aud/nonce/iat.assert_verify_jwt_payloadderivesaccount_idfrom the attacker-chosensub/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 meaningfuldomain (
dot_index < len). The constraint has multiple satisfying witnessesbecause the prover controls
storage[len..N].Suggested Fix
Constrain the hint to the signed region:
Additionally:
j < header_and_payload.len() - 1, which appears to beoff-by-one and skips the final payload byte on the honest path. The intent
is presumably
j < header_and_payload.len().dot_indexto be the first dot, not merely adot, to defend against future inputs where the prover-controlled region
could be inside
len.storage[len..N](or otherwise constrain it) so post-lenbytescannot influence any subsequent logic.