How to validate area attacks that send a list of hits
A list of victims from the client can be any length and repeat the same player. How to cap it, remove duplicates and range-check every target.
What it looks like
An area attack, a shotgun, a cleave or a combo sends the server a list of characters it hit, and the server damages each one. The list isn't capped, duplicates aren't removed, or each victim isn't checked against the attack's area.
Luau
remote.OnServerEvent:Connect(function(player, victims) for _, victim in victims do victim.Humanoid:TakeDamage(30) end end)
How to detect it
- Find remote handlers that loop over a table from the client and apply damage, knockback, stuns or kill credit to each entry.
- Check that the table is really a list of the expected type, and that its length is capped at what the attack can hit, such as the number of pellets.
- Check for duplicates. The same character, or two parts of the same character, listed twice should count once. Look for a set keyed by the victim's
Humanoidor model. - Check each victim on its own: alive, not the attacker, and inside the attack's range or area measured from the attacker's character on the server. Measuring the victim's own part, as the server sees it, is fine. A check on only the first entry, or on the attack's centre, doesn't cover the rest.
- The attack's own cooldown is Fire rate, ammo or reloads kept on the client. Report a missing one there.
What an exploiter could do
One attack can hit every player in the server, or the same player dozens of times. A single swing becomes an instant kill for anyone they choose, at the normal cooldown.
How to fix it
- Check the argument is a table, stop after a fixed number of entries, and check each entry's type.
- Turn each entry into its character model and
Humanoidon the server and skip any you've already hit this attack. - Check every victim's distance from the attacker's character against the attack's radius plus a small margin.
- Better still, find the victims on the server with
Workspace:GetPartBoundsInRadiusorGetPartsInPartaround the attacker's character, and ignore the client's list.
Vulnerable example
Vulnerable
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local damage = 30 local cooldown = 4 local lastSlam: { [Player]: number } = {} ReplicatedStorage.Remotes.Slam.OnServerEvent:Connect(function(player: Player, victims: unknown) if typeof(victims) ~= "table" then return end local character = player.Character local root = character and character:FindFirstChild("HumanoidRootPart") if not character or not root or not root:IsA("BasePart") then return end local now = os.clock() if now - (lastSlam[player] or -math.huge) < cooldown then return end lastSlam[player] = now for _, victim in victims :: { unknown } do if typeof(victim) == "Instance" and victim:IsA("Model") and victim ~= character then local humanoid = victim:FindFirstChildOfClass("Humanoid") if humanoid then humanoid:TakeDamage(damage) end end end end) Players.PlayerRemoving:Connect(function(player) lastSlam[player] = nil end)
Fixed example
Fixed
local Players = game:GetService("Players") local ReplicatedStorage = game:GetService("ReplicatedStorage") local damage = 30 local radius = 12 local margin = 3 local maxTargets = 8 local cooldown = 4 local lastSlam: { [Player]: number } = {} ReplicatedStorage.Remotes.Slam.OnServerEvent:Connect(function(player: Player, victims: unknown) if typeof(victims) ~= "table" then return end local character = player.Character local root = character and character:FindFirstChild("HumanoidRootPart") if not character or not root or not root:IsA("BasePart") then return end local now = os.clock() if now - (lastSlam[player] or -math.huge) < cooldown then return end lastSlam[player] = now local hit: { [Humanoid]: boolean } = {} for index = 1, maxTargets do local victim = rawget(victims :: { unknown }, index) if victim == nil then break end if typeof(victim) == "Instance" and victim:IsA("Model") and victim ~= character then local humanoid = victim:FindFirstChildOfClass("Humanoid") local targetRoot = victim:FindFirstChild("HumanoidRootPart") if humanoid and not hit[humanoid] and humanoid.Health > 0 and targetRoot and targetRoot:IsA("BasePart") then if (targetRoot.Position - root.Position).Magnitude <= radius + margin then hit[humanoid] = true humanoid:TakeDamage(damage) end end end end end) Players.PlayerRemoving:Connect(function(player) lastSlam[player] = nil end)
Common false positives
- A list the server builds itself, such as from
GetPartBoundsInRadius, isn't the client's. Check that the query is centred on the attacker's character, not on a position the client sent. - Lists of things that aren't targets, such as the keys of a combo the client pressed, are only input. Check they're validated as input.
- A single-target hit with no range check is Hits the server doesn't check were possible, not this check.
- A list that's only a problem because of its size, with every entry checked and nothing gained per entry, is Client decides how much work a call does in rate-limits.