|
| 1 | +# Critical Security Vulnerability: Session Hijacking via Reconnect System |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +A critical authentication bypass vulnerability exists in the player reconnection system that allows an attacker to hijack any logged-in user's active session using only their MongoDB `accountId` (which is not a secret). |
| 6 | + |
| 7 | +## Severity |
| 8 | + |
| 9 | +**CRITICAL** - Complete session takeover, no secret/password required |
| 10 | + |
| 11 | +## Affected File |
| 12 | + |
| 13 | +`ws/classes/Player.js` - `verify()` function |
| 14 | + |
| 15 | +## Vulnerability Details |
| 16 | + |
| 17 | +### Root Cause |
| 18 | + |
| 19 | +The `disconnectedPlayers` map uses two different types of keys depending on whether the user is logged in or a guest: |
| 20 | + |
| 21 | +**Line 382:** |
| 22 | +```javascript |
| 23 | +disconnectedPlayers.set(this.accountId||this.rejoinCode, this.id); |
| 24 | +``` |
| 25 | + |
| 26 | +- For **logged-in users**: key = `accountId` (MongoDB `_id`, e.g., `"507f1f77bcf86cd799439011"`) |
| 27 | +- For **guests**: key = `rejoinCode` (secure UUID, e.g., `"a1b2c3d4-e5f6-7890-abcd-ef1234567890"`) |
| 28 | + |
| 29 | +However, the guest reconnection path at **lines 164-169** accepts any user-provided `rejoinCode` without validating its format: |
| 30 | + |
| 31 | +```javascript |
| 32 | +if(json.rejoinCode) { |
| 33 | + const dcPlayerId = disconnectedPlayers.get(json.rejoinCode); |
| 34 | + if(dcPlayerId) { |
| 35 | + handleReconnect(dcPlayerId, json.rejoinCode); |
| 36 | + return; |
| 37 | + } |
| 38 | +} |
| 39 | +``` |
| 40 | + |
| 41 | +Since both key types are stored in the same map, an attacker can provide a victim's `accountId` as their `rejoinCode` and hijack their session. |
| 42 | + |
| 43 | +### Attack Flow |
| 44 | + |
| 45 | +``` |
| 46 | +1. Victim (logged-in user) connects to the game |
| 47 | + → Server creates Player with accountId = "507f1f77bcf86cd799439011" |
| 48 | +
|
| 49 | +2. Victim disconnects (network issue, closes tab, etc.) |
| 50 | + → Server executes: disconnectedPlayers.set("507f1f77bcf86cd799439011", victimPlayerId) |
| 51 | +
|
| 52 | +3. Attacker connects as GUEST (no secret) |
| 53 | + → Sends: { type: "verify", secret: "not_logged_in", rejoinCode: "507f1f77bcf86cd799439011" } |
| 54 | +
|
| 55 | +4. Server processes guest reconnection (lines 164-169): |
| 56 | + → disconnectedPlayers.get("507f1f77bcf86cd799439011") returns victimPlayerId |
| 57 | + → handleReconnect() is called |
| 58 | + → Attacker's websocket is assigned to victim's Player object |
| 59 | +
|
| 60 | +5. Attacker now controls victim's session: |
| 61 | + - Has victim's username |
| 62 | + - Has victim's ELO rating |
| 63 | + - Has victim's friends list |
| 64 | + - Can play ranked games as victim |
| 65 | + - Can perform any action as victim |
| 66 | +``` |
| 67 | + |
| 68 | +### Why accountId is Not Secret |
| 69 | + |
| 70 | +MongoDB `_id` values can be leaked through: |
| 71 | +- API responses (friend lists, leaderboards, game results) |
| 72 | +- Browser network inspection |
| 73 | +- The `_id` format is predictable (ObjectId contains timestamp + counter) |
| 74 | +- Social engineering |
| 75 | + |
| 76 | +## Proof of Concept |
| 77 | + |
| 78 | +```javascript |
| 79 | +// Attacker's malicious client code |
| 80 | +const ws = new WebSocket("wss://game-server-url"); |
| 81 | + |
| 82 | +ws.onopen = () => { |
| 83 | + // Send verify as guest, but with victim's accountId as rejoinCode |
| 84 | + ws.send(JSON.stringify({ |
| 85 | + type: "verify", |
| 86 | + secret: "not_logged_in", |
| 87 | + rejoinCode: "VICTIM_ACCOUNT_ID_HERE" // e.g., "507f1f77bcf86cd799439011" |
| 88 | + })); |
| 89 | +}; |
| 90 | + |
| 91 | +ws.onmessage = (msg) => { |
| 92 | + const data = JSON.parse(msg.data); |
| 93 | + if (data.type === "verify") { |
| 94 | + console.log("Successfully hijacked session!"); |
| 95 | + // Attacker is now playing as victim |
| 96 | + } |
| 97 | +}; |
| 98 | +``` |
| 99 | + |
| 100 | +## Impact |
| 101 | + |
| 102 | +- **Complete account takeover** for any user who disconnects |
| 103 | +- **ELO manipulation** - attacker can intentionally lose games to tank victim's rating |
| 104 | +- **Reputation damage** - attacker can send offensive messages as victim |
| 105 | +- **Friend list access** - attacker sees victim's friends, can remove friends |
| 106 | +- **No trace** - victim may not realize their session was hijacked |
| 107 | + |
| 108 | +## Recommended Fix |
| 109 | + |
| 110 | +### Option 1: Always Use Secure rejoinCode (Recommended) |
| 111 | + |
| 112 | +Change line 382 to always use the UUID-based `rejoinCode`: |
| 113 | + |
| 114 | +```javascript |
| 115 | +// Before (VULNERABLE): |
| 116 | +disconnectedPlayers.set(this.accountId||this.rejoinCode, this.id); |
| 117 | + |
| 118 | +// After (FIXED): |
| 119 | +disconnectedPlayers.set(this.rejoinCode, this.id); |
| 120 | +``` |
| 121 | + |
| 122 | +Also update line 196 to look up by `rejoinCode` instead of `accountId`, and send `rejoinCode` to logged-in users in the verify response (line 266-268): |
| 123 | + |
| 124 | +```javascript |
| 125 | +this.send({ |
| 126 | + type: 'verify', |
| 127 | + rejoinCode: this.rejoinCode // Add this |
| 128 | +}); |
| 129 | +``` |
| 130 | + |
| 131 | +And validate that reconnecting session belongs to the same account: |
| 132 | + |
| 133 | +```javascript |
| 134 | +// Line 196-199 updated: |
| 135 | +if(json.rejoinCode) { |
| 136 | + const dcPlayerId = disconnectedPlayers.get(json.rejoinCode); |
| 137 | + if(dcPlayerId) { |
| 138 | + const dcPlayer = players.get(dcPlayerId); |
| 139 | + // Verify the session belongs to this account |
| 140 | + if(dcPlayer && dcPlayer.accountId === valid._id.toString()) { |
| 141 | + await handleReconnect(dcPlayerId, json.rejoinCode, valid._id.toString()); |
| 142 | + return; |
| 143 | + } |
| 144 | + } |
| 145 | +} |
| 146 | +``` |
| 147 | + |
| 148 | +### Option 2: Namespace the Keys |
| 149 | + |
| 150 | +Prefix keys to prevent collision: |
| 151 | + |
| 152 | +```javascript |
| 153 | +// Line 382: |
| 154 | +const key = this.accountId ? `account:${this.accountId}` : `guest:${this.rejoinCode}`; |
| 155 | +disconnectedPlayers.set(key, this.id); |
| 156 | + |
| 157 | +// Line 165 (guest path): |
| 158 | +const dcPlayerId = disconnectedPlayers.get(`guest:${json.rejoinCode}`); |
| 159 | + |
| 160 | +// Line 196 (logged-in path): |
| 161 | +const dcPlayerId = disconnectedPlayers.get(`account:${valid._id.toString()}`); |
| 162 | +``` |
| 163 | + |
| 164 | +### Option 3: Validate rejoinCode Format |
| 165 | + |
| 166 | +Add validation to ensure guest rejoinCodes are UUIDs: |
| 167 | + |
| 168 | +```javascript |
| 169 | +// Line 164-169: |
| 170 | +if(json.rejoinCode) { |
| 171 | + // UUID v4 format check |
| 172 | + const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; |
| 173 | + if(uuidRegex.test(json.rejoinCode)) { |
| 174 | + const dcPlayerId = disconnectedPlayers.get(json.rejoinCode); |
| 175 | + // ... rest of reconnection logic |
| 176 | + } |
| 177 | +} |
| 178 | +``` |
| 179 | + |
| 180 | +## Timeline |
| 181 | + |
| 182 | +- **Discovered:** 2026-02-01 |
| 183 | +- **Status:** Unfixed |
| 184 | + |
| 185 | +## Additional Notes |
| 186 | + |
| 187 | +The secure `rejoinCode` UUID is already being generated for every player at line 34: |
| 188 | + |
| 189 | +```javascript |
| 190 | +this.rejoinCode = createUUID(); |
| 191 | +``` |
| 192 | + |
| 193 | +It's just not being used for logged-in users, making this a relatively easy fix. |
0 commit comments