Fix winner stats spoofing exploit

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.
This commit is contained in:
evanpelle
2026-05-02 13:47:19 -06:00
parent bf74028200
commit 819edb21bb
+15 -1
View File
@@ -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();