From 2905b5b51213805d9f5a0ed3625e6518d78dfc26 Mon Sep 17 00:00:00 2001 From: 22314621 Date: Mon, 18 May 2026 01:46:44 +0300 Subject: [PATCH 1/2] fix(game): patch Winner Spoofing vulnerability with strict majority consensus --- src/server/GameServer.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 59673e2fda..6b2bbac102 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -606,6 +606,9 @@ export class GameServer { } this._hasEnded = true; } + } else { + // Check if remaining clients have reached a winner consensus + this.checkWinnerConsensus(); } }); client.ws.on("error", (error: Error) => { @@ -1238,7 +1241,7 @@ export class GameServer { }, ); - if (potentialWinner.ips.size * 2 < activeUniqueIPs.size) { + if (potentialWinner.ips.size * 2 <= activeUniqueIPs.size) { return; } @@ -1252,4 +1255,26 @@ export class GameServer { ); this.archiveGame(); } + + private checkWinnerConsensus() { + if (this.winner !== null) { + return; + } + + const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)); + + for (const [winnerKey, potentialWinner] of this.winnerVotes.entries()) { + if (potentialWinner.ips.size * 2 > activeUniqueIPs.size) { + this.winner = potentialWinner.winner; + this.log.info( + `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs after client disconnect`, + { + winnerKey: winnerKey, + }, + ); + this.archiveGame(); + return; + } + } + } } From cc11f879a1faa363d11e65958b324c51abd17821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berk=20Elmal=C4=B1?= Date: Sat, 23 May 2026 20:48:25 +0300 Subject: [PATCH 2/2] fix(security): count only active voter IPs in consensus calculation --- src/server/GameServer.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 6b2bbac102..8fdaf53714 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -606,7 +606,7 @@ export class GameServer { } this._hasEnded = true; } - } else { + } else if (!this._hasEnded) { // Check if remaining clients have reached a winner consensus this.checkWinnerConsensus(); } @@ -1232,8 +1232,11 @@ export class GameServer { potentialWinner.ips.add(client.ip); const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)); + const activeVotes = [...potentialWinner.ips].filter((ip) => + activeUniqueIPs.has(ip), + ).length; - const ratio = `${potentialWinner.ips.size}/${activeUniqueIPs.size}`; + const ratio = `${activeVotes}/${activeUniqueIPs.size}`; this.log.info( `received winner vote ${clientMsg.winner}, ${ratio} votes for this winner`, { @@ -1241,14 +1244,14 @@ export class GameServer { }, ); - if (potentialWinner.ips.size * 2 <= activeUniqueIPs.size) { + if (activeVotes * 2 <= activeUniqueIPs.size) { return; } // Vote succeeded this.winner = potentialWinner.winner; this.log.info( - `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`, + `Winner determined by ${activeVotes}/${activeUniqueIPs.size} active IPs`, { winnerKey: winnerKey, }, @@ -1257,17 +1260,23 @@ export class GameServer { } private checkWinnerConsensus() { - if (this.winner !== null) { + if (this.winner !== null || this._hasEnded) { return; } const activeUniqueIPs = new Set(this.activeClients.map((c) => c.ip)); + if (activeUniqueIPs.size === 0) { + return; + } for (const [winnerKey, potentialWinner] of this.winnerVotes.entries()) { - if (potentialWinner.ips.size * 2 > activeUniqueIPs.size) { + const activeVotes = [...potentialWinner.ips].filter((ip) => + activeUniqueIPs.has(ip), + ).length; + if (activeVotes * 2 > activeUniqueIPs.size) { this.winner = potentialWinner.winner; this.log.info( - `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs after client disconnect`, + `Winner determined by ${activeVotes}/${activeUniqueIPs.size} active IPs after client disconnect`, { winnerKey: winnerKey, },