How to keep impossible scores off your global leaderboards

Writing raw stats to an ordered data store puts every exploit on show. How to check times and rates of gain before a score is saved.

warningLuaudit check: unchecked-leaderboard-scores

What it looks like

The server writes players' scores to an OrderedDataStore that feeds a global leaderboard: fastest times, total coins, wins or levels. It writes whatever the player's stats say, with no check that the value could have been reached legitimately.

Luau
scores:SetAsync(tostring(player.UserId), profile.Coins)

How to detect it

  • Find every GetOrderedDataStore and every write to it: SetAsync, UpdateAsync and IncrementAsync.
  • Roblox's leaderboard guide says to protect score writes with server-side validation, and to review suspicious or impossible values before they affect rankings. Its server-side detection guide describes the checks to use: the fastest possible completion time, and the fastest legitimate rate of gain.
  • For times, check that the server measured the time itself and refuses times below what the course allows. A time sent by the client is Server trusts values the client decides as well.
  • For totals, check that the server compares the gain since the last write with what's possible in that time, and holds back or flags values that jump.
  • Check that flagged scores aren't written, or are written to a separate review store, and that there's a way to remove a bad score afterwards.
  • A score that can be raised through another bug, such as a duplication or an unchecked reward, will reach the leaderboard. Note this in the finding for that bug.

What an exploiter could do

Once they've found any way to raise their numbers, the global leaderboard shows it to everyone: impossible times, billions of coins, thousands of wins. Real players can't compete, and a leaderboard shown in the player list makes the game look broken.

How to fix it

  • Measure times on the server from the start to the finish it saw, and refuse anything faster than the fastest possible run.
  • Compare each write with the previous one and the time between them. Hold back gains beyond the fastest legitimate rate, and log them for review.
  • Keep a way to remove or reset a player's leaderboard entry.

Vulnerable example

Vulnerable
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local PlayerData = require(script.Parent.PlayerData)

local richest = DataStoreService:GetOrderedDataStore("RichestPlayers")

Players.PlayerRemoving:Connect(function(player)
	local profile = PlayerData.get(player)
	if profile then
		pcall(richest.SetAsync, richest, tostring(player.UserId), math.floor(profile.Coins))
	end
end)

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")
local Players = game:GetService("Players")
local PlayerData = require(script.Parent.PlayerData)

local richest = DataStoreService:GetOrderedDataStore("RichestPlayers")
local maxGainPerMinute = 5000
local joined: { [Player]: { at: number, coins: number } } = {}

Players.PlayerAdded:Connect(function(player)
	local profile = PlayerData.waitFor(player)
	if profile then
		joined[player] = { at = os.time(), coins = profile.Coins }
	end
end)

Players.PlayerRemoving:Connect(function(player)
	local profile = PlayerData.get(player)
	local start = joined[player]
	joined[player] = nil
	if not profile or not start then
		return
	end
	local minutes = math.max((os.time() - start.at) / 60, 1)
	if profile.Coins - start.coins > maxGainPerMinute * minutes then
		warn(`Leaderboard score held back for review: {player.UserId}`)
		return
	end
	pcall(richest.SetAsync, richest, tostring(player.UserId), math.floor(profile.Coins))
end)

Common false positives

  • Leaderboards that only show the current server, from leaderstats, reset when the server closes. They're still worth checking, but impossible values there do far less harm.
  • Scores that only the server can change in small, fixed steps, such as wins counted by the server at the end of a round it ran, need little more checking.
  • A score held back or logged for review, like the fixed example, is the fix.