perchance — Browser Extension Origin and Context Confusion
Platform: NNS CTF 2026
Category: Web / Browser Extension
1. Components
The challenge combines a bot, a browser extension, an attacker-controlled page, and the trusted Rust documentation origin. The goal is to make code execute in the Rust documentation origin, where a non-HttpOnly flag cookie is available.
The exploit is not one isolated bug. It is a chain of trust failures: weak URL validation, an unvalidated cross-origin message, an attacker-observable imported function, and unsafe HTML insertion.
2. Weak URL validation
The options page accepts a URL only if it contains https://doc.rust-lang.org/. This is a substring check, not an origin check. A URL using userinfo can satisfy the check while loading an attacker host, for example:
https://anything@ATTACKER/?https://doc.rust-lang.org/
The browser interprets ATTACKER as the real host; the Rust string appears only in the userinfo/query portion. The code then stores u.origin as activateOn, allowing the attacker origin to become the extension’s activation origin.
3. Cross-origin postMessage bug
The options script listens for messages and calls updateConfig whenever the data begins with http. It never checks event.origin or that event.source is the expected window:
window.addEventListener('message', function (e) {
if (e.data.match(/^https?/)) updateConfig(e.data);
});
An attacker page can frame the options page and post the crafted URL. This bypasses the need for a manual user entry and sets the extension configuration from an untrusted origin.
4. Replacing the sanitizer function
On an activated page, the content script creates a random name and injects a module import into the page context:
const nonce = 'a' + crypto.randomUUID().replaceAll('-', '');
scr.textContent = `import ${nonce} from 'http://localhost:3000/jsxss.js';
window['${nonce}']=${nonce}`;
The script waits until window.wrappedJSObject[nonce] exists and then calls it on the current URL. The random name is not secret from page observers: a MutationObserver can watch newly inserted script elements, extract the nonce, and define the corresponding global before the import completes. The attacker-controlled replacement returns HTML instead of sanitized text.
5. Reaching the trusted origin
The content script stores the return value as previous. On the final visit it creates a paragraph and assigns:
elm.innerHTML = `Previous: ${prev}`;
Because the stored value can contain markup, an image payload such as an onerror handler executes when inserted. The final navigation is to https://doc.rust-lang.org/stable/std/, so the payload runs where the flag cookie is readable and sends it to the attacker-controlled endpoint.
6. Exploit sequence
- Frame the extension options page.
- Send an attacker URL containing the required Rust substring to set
activateOn. - Reload the attacker page so the content script executes.
- Observe the imported script, recover its random global name, and replace the sanitizer function.
- Return an HTML payload that exfiltrates
document.cookie. - Reset the activation origin to Rust documentation.
- Navigate to the Rust standard-library page and read the exfiltrated cookie.
7. Verified result
The bot requested an /exfil URL containing the URL-encoded flag cookie. Decoding that value produced:
flag=NNS{p3RH4ps_y0u_M16h7_P0551b1Y_3Nj0Y_c7Fs_P3RCH4nc3}
8. Defensive lessons
- Compare parsed URL origins, not substrings.
- Validate both
event.originandevent.sourcefor postMessage. - Do not expose security-sensitive functions through page globals.
- Keep extension data isolated from page scripts.
- Use text insertion or a trusted sanitizer instead of
innerHTML. - Mark sensitive cookies HttpOnly and Secure where possible.