Architecture
Pre-flight check. Confirms 1P's wrappers are in place and the bridge is what we expect.
(click Pre-flight)
Vector 1 — spoof create-credential through the bridge
Forges a SYN/SYN-ACK/DIRECT-REQUEST sequence to the content script with attacker-chosen
publicKey options. Content script forwards to the SW → 1P save-prompt UI fires → if the
user clicks Save, an attacker-influenced credential is registered to their vault.
The private key stays in the native helper, but the registration happens without the
page ever calling navigator.credentials.create().
Vector 2 — disable 1P interception via update-settings
Main-world stub's update-settings handler sets
R = body.authenticatePasskeys. With R = false,
p() returns false, and subsequent create()/get()
calls fall through to the native authenticator. Chains with Pattern 4a/4b: disable 1P,
then synthesize a credential entirely in JS, register against the user's account.
Vector 3 — race a forged create-credential-success response
Listens for in-flight DIRECT-REQUEST messages, captures the msgId, and immediately races
a synthetic DIRECT-RESPONSE before the legitimate one arrives. If we win the race, the
page's await navigator.credentials.create() resolves with our forged
attestation. Requires landing the response message before 1P's matching pending
synRequests/directRequests entry is consumed by the legitimate response.
The "End-to-end" button does the full attacker flow against the demo backend: it calls
/passkeys/api/register/begin.php?demo=5 to get a server challenge, fires
navigator.credentials.create() (race wins, page receives forged credential),
then POSTs the forged credential to /passkeys/api/register/finish.php?demo=5.
Open DevTools → Network and you'll see the POST carrying the attacker-controlled
attestationObject + the server returning 200 OK.
Demo 5 has its own per-demo session under the
__Host-report_uri_demo_passkeys_5 cookie — credentials registered here are isolated
from /passkeys/1/ and other demos. The panel
below polls /passkeys/api/credentials.php?demo=5 so you can see the credential
appear in this session immediately after the POST returns 200.
Credentials registered in demo-5 session
Live — polls /passkeys/api/credentials.php?demo=5 every second.
- Loading…
After the race resolves, paste this into DevTools to byte-compare the credential you
received against what you'd expect from real 1P vs the forgery:
console.log(new TextDecoder().decode(window.__lastCred?.response?.clientDataJSON))
Empty challenge: "" = forgery won. Real challenge: "<b64url>" = 1P won.
Live trace
All bridge messages this page sends or receives. Includes 1P's traffic when 1P doesn't stopImmediatePropagation (i.e. when the message is for a route 1P doesn't own).