Why tonumber isn't enough in chat commands
tonumber accepts inf, nan, negatives and exponents, and math.clamp lets NaN through. How to read numbers from commands safely before they reach saved data.
What it looks like
A chat or admin command reads a number from typed text with tonumber and uses it with no proper range check: an amount of currency, a level, a walk speed, a ban length. tonumber accepts much more than digits, including inf, nan, -5, 1e999, 5.5 and 0x10.
local amount = tonumber(args[2]) if amount then profile.Coins += amount end
How to detect it
- Find every command that turns text into a number, usually with
tonumber, and follow the number to where it's used. - Check for a lower bound, an upper bound and, where it matters, a whole-number check. A missing bound lets in negative or huge values.
- Check how the bounds are written. NaN fails every comparison, so
if amount > 1000 then return endlets it through, andmath.clampreturns NaN unchanged. Checks written as "continue only ifamount >= 1 and amount <= 1000" reject it. - Look hardest at numbers that reach saved data, leaderboards or other players.
What an exploiter could do
Commands are typed by staff, so this is mostly about mistakes and stolen staff accounts. A typo like 1e9, or inf, puts a value into saved data that no normal play can produce, and a negative amount takes currency away. NaN is worse: once it's in a balance, every later comparison on it is false, so checks like "has enough coins" stop working as intended. If any command is open to ordinary players, such as a ;pay command, anyone can do this on purpose.
How to fix it
- Accept only the characters you expect, for example
string.match(text, "^%d+$"), before callingtonumber. - Check the result with comparisons that NaN fails: continue only if
amount >= 1 and amount <= limit. - Give each command its own limit, sized for what staff really need.
Vulnerable example
local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local staff: { [number]: boolean } = { [123456789] = true, } local function onChatted(player: Player, message: string) local name, text = string.match(message, "^;coins (%S+) (%S+)$") if not name or not text then return end local target = Players:FindFirstChild(name) local amount = tonumber(text) if not target or not target:IsA("Player") or not amount then return end local profile = PlayerData.get(target) if profile then profile.Coins += amount end end Players.PlayerAdded:Connect(function(player) if staff[player.UserId] then player.Chatted:Connect(function(message) onChatted(player, message) end) end end)
Fixed example
local Players = game:GetService("Players") local PlayerData = require(script.Parent.PlayerData) local staff: { [number]: boolean } = { [123456789] = true, } local limit = 100000 local function onChatted(player: Player, message: string) local name, text = string.match(message, "^;coins (%S+) (%d+)$") if not name or not text then return end local target = Players:FindFirstChild(name) local amount = tonumber(text) if not target or not target:IsA("Player") or not amount then return end if not (amount >= 1 and amount <= limit) then return end local profile = PlayerData.get(target) if profile then profile.Coins += amount end end Players.PlayerAdded:Connect(function(player) if staff[player.UserId] then player.Chatted:Connect(function(message) onChatted(player, message) end) end end)
Common false positives
- Numbers that only pick from a fixed list, like
;map 2indexing a table of maps, fail safely when the entry isnil. Check the lookup handlesnil. - Purely cosmetic values that end with the server, like the time of day, matter less. Mention them only if NaN or infinity would break something.
- Numbers that come through a remote rather than chat are covered by Numbers from the client without range checks in remote-events.