perchance
Missing trailing slash in a `startsWith` check (userinfo bypass), then a hardcoded extension UUID and an origin-less postMessage handler to steal `activateOn`
NNS{PerH4p5_y0U_Migh7_p05siBlY_3NJ0y_c7f5_P3RCH4nce} FLAG: NNS{PerH4p5_y0U_Migh7_p05siBlY_3NJ0y_c7f5_P3RCH4nce}
Setup
POST /perchance with perchance=<url>; a headless Firefox (playwright + playwright-webextext)
loads a bundled extension, gets a cookie flag on doc.rust-lang.org, path /stable/std/,
httpOnly: false, SameSite=Strict, then page.goto(url) and waits 40s.
The extension injects assets/cs.js into any tab whose completed-navigation URL both
startsWith(activateOn) (default https://doc.rust-lang.org/) and
includes('https://doc.rust-lang.org/').
The key misread in the earlier pass
Previous notes assumed the innerHTML sink lived in options.js (a page nothing ever opens)
and that the challenge was a js-xss sanitizer bypass. Both were wrong:
-
The sink is in
cs.jsitself (lines 3-5), rendering into the page DOM of doc.rust-lang.org — and that site sends no CSP, so inline handlers execute. -
No sanitizer bypass is needed.
cs.jsdeliberately runs the “sanitizer” in untrusted page context and then trusts its return value:const nonce = 'a' + crypto.randomUUID().replaceAll('-',''); scr.textContent = `import ${nonce} from 'http://localhost:3000/jsxss.js'; window['${nonce}']=${nonce}`; document.body.appendChild(scr); // runs in PAGE context ... window.wrappedJSObject[nonce](location.href) // calls whatever the page put thereWhoever controls the page controls
window[nonce], so the “sanitized” value is arbitrary. (The file comment even says: “do not load dependencies in trusted context of the extension”.)
Chain
- Server-side prefix bug —
url.startsWith('https://doc.rust-lang.org')has no trailing slash and no domain-boundary check.https://doc.rust-lang.org@<our-host>/passes it, and Firefox navigates to our host (userinfo trick — no lookalike TLS cert needed). Verified: the bot fetched our page from an AWS-Ireland IP. web_accessible_resources: ["*"]+ hardcoded extension UUID (pinned inbrowser.tsviaextensions.webextensions.uuids) — our page iframesmoz-extension://09a6c422-a354-447d-b4ea-185cb10be869/options.html.options.jspostMessage handler has no origin check →updateConfigsetsactivateOn = u.origin. Itsincludes('https://doc.rust-lang.org/')gate is satisfied by putting that literal in our own URL’s query string. We repointactivateOnto our origin.- Add an iframe to
<our-origin>/?sub=1&u=https://doc.rust-lang.org/→onCompletedfires (subframe URLs count) → background injectscs.jsinto the top frame = our page. - Hijack the sanitizer.
Object.setPrototypeOf(Object.prototype, proxy)does not work — Object.prototype is an immutable-prototype exotic object (TypeError: can't set prototype of this object). Instead, aMutationObservercatches the<script>cs.js appends, reads the nonce straight out of itstextContent, and assignswindow[nonce] = () => payload. cs.js then stores our raw HTML asprevious. - postMessage again to restore
activateOn = https://doc.rust-lang.org, then navigate the top frame tohttps://doc.rust-lang.org/stable/std/index.html. cs.jsruns there:elm.innerHTML = \Previous: ${prev}`→ ourexecutes in doc.rust-lang.org page context →document.cookie` (httpOnly:false, path matches) → exfil.
Why the two navigations are required
previous is read at the start of cs.js and written at the end, so the payload must be
stored by one cs.js run and rendered by a later one. Step 4 supplies the first run (on our
page, where we control the sanitizer) and step 6-7 the second (on the flag origin).
Exploit
exploit/site.html (served over a cloudflared tunnel), fired with:
curl -X POST https://<instance>/perchance \
-F "perchance=https://doc.rust-lang.org@<our-host>/"
Instrumented run (all stages logged): alive → hook armed → optionsframe loaded → pm1 →
trigger → nonce a9a324c2... → sanitizer_called → csjs_ran=true → pm2 → nav →
/FLAG?c=flag%3DNNS%7B...%7D.