Web
Medium
250 points
BP88 Level 2
Recuite 2025 - HCMUS
6 tháng 10, 2025
PRNG
Dynamic Seed
Timestamp
Socket.IO

Web
BP88 Level 2 - Write-up
Challenge Information
- Category: Web
- Difficulty: Medium
- Vulnerability: Predictable PRNG (Dynamic Seed - Exposed)
- Requirement: Complete Level 1 (50 coins)
Overview
Level 2 increases difficulty by using a dynamic seed based on time.time(). However, the server broadcasts the seed value via Socket.IO event gameData, allowing attackers to predict dice results.
Game Mechanics
Win Condition
- Level 2: Reach 1,000 coins to unlock Level 3
- Start from 50 coins (from Level 1)
Vulnerability: Exposed Dynamic Seed
Source Code Analysis
def prepare_seed(level):
""" Create seed for `random` module """
if level == 1:
random.seed(13371337)
elif level == 2:
random.seed(round(time.time())) # ⚠️ Seed based on timestamp
else:
random.seed()
Server broadcasts seed via Socket.IO:
// Client-side event
socket.on('gameData', function(data) {
# data.id_level2 = round(time.time())
console.log('Seed for Level 2:', data.id_level2);
});
The Problem:
- Seed value is exposed via WebSocket events
- Attacker can use seed to predict dice results
- Server-side and client-side share the same seed value
Exploitation
Step 1: Capture Seed Value
Method 1: Browser DevTools
- Open DevTools (F12) → Console
- Listen for Socket.IO events:
// Intercept gameData event
let level2_seed = null;
socket.on('gameData', function(data) {
console.log('Game Data:', data);
if (data.id_level2) {
level2_seed = data.id_level2;
console.log('🎯 Seed Level 2:', level2_seed);
}
});
- Seed will be displayed on page (under countdown timer) or in console
Method 2: Network Tab
- DevTools → Network → WS (WebSocket)
- Click on Socket.IO connection
- View Messages tab
- Find
gameDataevent withid_level2field
Step 2: Predict Result
import random
def predict_result_from_seed(seed):
random.seed(seed)
dice = [random.randint(1, 6) for _ in range(3)]
total = sum(dice)
result = 'tai' if total >= 11 else 'xiu'
return result, dice, total
# Example: Seed captured from gameData
seed_level2 = 1760086470 # From Socket.IO event
result, dice, total = predict_result_from_seed(seed_level2)
print(f"Bet: {result}")
print(f"Dice: {dice}, Total: {total}")
Step 3: Automated Exploitation
import socketio
import random
import requests
import re
import time
class Level2Solver:
def __init__(self, base_url):
self.base_url = base_url
self.http = requests.Session()
self.sio = socketio.Client()
self.latest_seed = None
# Setup event handlers
self.sio.on('gameData', self.on_game_data)
self.sio.on('gameStart', self.on_game_start)
self.sio.on('gameOver', self.on_game_over)
def on_game_data(self, data):
print(f"[*] gameData: {data}")
if 'id_level2' in data:
self.latest_seed = data['id_level2']
print(f"[+] Captured seed: {self.latest_seed}")
def on_game_start(self, data):
print("[*] Round starting...")
self.place_bet()
def on_game_over(self, data):
print(f"[*] Round over: {data}")
def predict_result(self, seed):
random.seed(seed)
dice = [random.randint(1, 6) for _ in range(3)]
return 'tai' if sum(dice) >= 11 else 'xiu'
def place_bet(self):
if not self.latest_seed:
print("[!] Seed not captured yet")
return
# Predict result
prediction = self.predict_result(self.latest_seed)
print(f"[+] Prediction: {prediction}")
# Get current money
money = self.get_current_money()
bet_amount = min(money, 50) # Bet 50 or all-in
# Place bet via Socket.IO
self.sio.emit('pull', {
'id': self.sio.sid,
'dice': prediction,
'level': 'level2',
'name': self.username,
'money': bet_amount
})
print(f"[+] Bet {bet_amount} on {prediction}")
def get_current_money(self):
resp = self.http.get(f"{self.base_url}/")
match = re.search(r"You have\s+(\d+)\s+coins", resp.text)
return int(match.group(1)) if match else 0
def solve(self):
# Login
self.http.post(f"{self.base_url}/signup", data={
'username': 'solver2',
'password': 'pass123'
})
# Connect Socket.IO
cookies = self.http.cookies.get_dict()
cookie_str = '; '.join(f"{k}={v}" for k, v in cookies.items())
self.sio.connect(self.base_url, headers={'Cookie': cookie_str})
# Navigate to level2
self.http.get(f"{self.base_url}/level2")
# Wait and let auto-betting work
while self.get_current_money() < 1000:
time.sleep(2)
print("[+] Finished Level 2!")
print(f"[+] Final Balance: {self.get_current_money()}")
# Run solver
solver = Level2Solver("http://target.com")
solver.solve()
Real-Time Exploitation
Quick Manual Method
- Open Level 2 page
- Open DevTools Console
- Paste this code:
// Auto-predict and display
socket.on('gameData', function(data) {
if (!data.id_level2) return;
# Simulate Python's random
let seed = data.id_level2;
console.log('Seed:', seed);
# You need to run separate Python script to get prediction
# Or implement Mersenne Twister in JS
console.log('🎲 Use this seed in Python script!');
});
- Run Python prediction:
# In terminal
python3 -c "
import random
seed = int(input('Enter seed from browser: '))
random.seed(seed)
dice = [random.randint(1,6) for _ in range(3)]
result = 'tai' if sum(dice) >= 11 else 'xiu'
print(f'Bet on: {result.upper()}')
"
- Place bet on predicted result
Understanding Timing
Seed Generation
# Server creates seed when round starts
current_time = round(time.time()) # Unix timestamp (seconds)
random.seed(current_time)
# Example timestamps:
# 2025-10-12 09:30:00 → 1760086200
# 2025-10-12 09:30:01 → 1760086201
# 2025-10-12 09:30:02 → 1760086202
Race Condition?
Q: Can I bet before receiving the seed?
A: No! Server broadcasts seed before accepting bets:
1. Round starts → Create seed
2. Broadcast gameData event (include seed)
3. Accept bets (5-10 second window)
4. Round ends → Show dice
Timing Attack (Not needed here)
If seed wasn't broadcasted, you could brute-force timestamp:
import time
import random
def brute_force_seed():
current_time = round(time.time())
# Try range ±5 seconds
for offset in range(-5, 6):
seed = current_time + offset
random.seed(seed)
dice = [random.randint(1, 6) for _ in range(3)]
result = 'tai' if sum(dice) >= 11 else 'xiu'
print(f"Seed {seed}: {result} {dice}")
Mitigation
❌ Vulnerable Code
# Seed exposed to client
seed = round(time.time())
random.seed(seed)
socket.emit('gameData', {'id_level2': seed}) # ⚠️ Leaked!
✅ Secure Code
import secrets
# DO NOT expose seed to client
def generate_secure_round():
# Use cryptographically secure random
dice = [secrets.randbelow(6) + 1 for _ in range(3)]
# Optional: Commitment scheme for provably fair
import hashlib
server_seed = secrets.token_hex(32)
client_seed = request.get('client_seed', '')
# Hash commitment
combined = f"{server_seed}{client_seed}"
commitment = hashlib.sha256(combined.encode()).hexdigest()
# Reveal after betting
return dice, commitment, server_seed
Implementing Provably Fair
def provably_fair_dice(server_seed_hash, client_seed, nonce):
# Client can verify after round
import hashlib
# Combine seeds
combined = f"{server_seed_hash}{client_seed}{nonce}"
hash_output = hashlib.sha256(combined.encode()).hexdigest()
# Create dice from hash
dice = []
for i in range(3):
byte_val = int(hash_output[i*2:(i+1)*2], 16)
dice_val = (byte_val % 6) + 1
dice.append(dice_val)
return dice
Key Takeaways
- Never expose PRNG seeds to clients
- Time-based seeds can be predictable even if not exposed
- Socket.IO events can leak sensitive data
- Use secure random (
secretsmodule) for games - Implement provably fair if transparency is needed
Tools Used
- Browser DevTools - Capture seed
- Python socketio - Automated exploitation
- Python random - Predict PRNG
Flag
HCMUS{t1m3st4mp_s33d_3xp0s3d}
References
250
Points
Medium
Difficulty
Web
Category