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.

criticalLuaudit check: unbounded-hit-lists

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 Humanoid or 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 Humanoid on 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:GetPartBoundsInRadius or GetPartsInPart around 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.