From 819edb21bb71564d2febdd669f1ac140f8758e5c Mon Sep 17 00:00:00 2001 From: evanpelle Date: Sat, 2 May 2026 13:47:19 -0600 Subject: [PATCH] Fix winner stats spoofing exploit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, winnerVotes was keyed only on winner, so a client could send the correct winner with spoofed allPlayersStats and have their vote count toward the majority. The key is now a SHA-256 hash of both winner and allPlayersStats together, so any stats divergence produces a distinct key that must independently reach majority. When the winner is confirmed, the log now includes votesByKey — a summary of every distinct key, its vote count, and winner value — making stat manipulation visible in the logs. --- src/server/GameServer.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/server/GameServer.ts b/src/server/GameServer.ts index 2f6175776..7689fcaf6 100644 --- a/src/server/GameServer.ts +++ b/src/server/GameServer.ts @@ -1,3 +1,4 @@ +import { createHash } from "crypto"; import ipAnonymize from "ip-anonymize"; import { Logger } from "winston"; import WebSocket from "ws"; @@ -1200,7 +1201,14 @@ export class GameServer { client.reportedWinner = clientMsg.winner; // Add client vote - const winnerKey = JSON.stringify(clientMsg.winner); + const winnerKey = createHash("sha256") + .update( + JSON.stringify({ + winner: clientMsg.winner, + allPlayersStats: clientMsg.allPlayersStats, + }), + ) + .digest("hex"); if (!this.winnerVotes.has(winnerKey)) { this.winnerVotes.set(winnerKey, { ips: new Set(), winner: clientMsg }); } @@ -1227,6 +1235,12 @@ export class GameServer { `Winner determined by ${potentialWinner.ips.size}/${activeUniqueIPs.size} active IPs`, { winnerKey: winnerKey, + numKeys: this.winnerVotes.size, + votesByKey: [...this.winnerVotes.entries()].map(([key, v]) => ({ + key, + voteCount: v.ips.size, + winner: v.winner.winner, + })), }, ); this.archiveGame();