How network ownership lets clients move reward parts

An unanchored part near a player is simulated by their client. How to stop balls, crates and carts deciding rewards from client physics.

criticalLuaudit check: client-owned-physics

What it looks like

An unanchored part decides a reward: a ball that scores when it reaches a goal, a crate that pays when it lands in a drop zone, a cart that finishes a race. The server reads where the part is, or listens to its Touched event, but a player's client is simulating that part's physics.

Luau
goal.Touched:Connect(function(hit)
	if hit == ball then
		awardGoal(lastKicker)
	end
end)

How to detect it

  • Find rewards, scores and progress that depend on an unanchored part that isn't a character: its Position or CFrame, its velocity, Touched or TouchEnded, GetPartsInPart or a region check around it.
  • Check who owns the part. The server always owns anchored parts. An unanchored part near a player is handed to that player's client automatically unless the server has called SetNetworkOwner(nil) on it. A call to SetNetworkOwner(player) hands it over on purpose.
  • SetNetworkOwner(nil) has to be called again after the part is anchored and unanchored, since unanchoring resets ownership to automatic. A later SetNetworkOwnershipAuto() call undoes it too.
  • Parts welded to a player's character are simulated by that player's client too.
  • Characters themselves are always owned by their player. Rewards from a character touching something are covered by Rewards paid on touch with no distance check in the anti-exploit category.

What an exploiter could do

Roblox's documentation says it can't verify physics on a part a client owns: the client can teleport the part, push it through walls or make it fly, and can fire Touched for it even when the server never saw a touch. They can put the ball in the goal from the halfway line or drop every crate in the delivery zone the moment they pick it up.

How to fix it

  • Call SetNetworkOwner(nil) on parts that decide rewards, once they're in Workspace and unanchored, so the server simulates them. Roblox notes this can make physics feel less smooth for the player near the part, so keep it for parts that matter.
  • A touch between a server-owned part and a character can still be reported by that character's client, so check the character is really close before giving it credit.
  • If the part has to stay client-owned for feel, don't reward from its position. Track the action on the server instead: who picked it up and when, where their character is, and whether the time taken was possible.
  • Server ownership stops the client moving the part directly, but characters can still push it, and a client controls its own character. Server ownership raises the bar, but still check the play was plausible, such as the distance and time since the last real touch, and see Rewards paid on touch with no distance check.
  • Anchored parts moved by the server, for example with TweenService or PivotTo, are always server-owned.

Vulnerable example

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

local pitch = Workspace:WaitForChild("Pitch")
local goal = pitch:WaitForChild("Goal") :: BasePart
local centre = pitch:WaitForChild("Centre") :: BasePart
local reward = 25
local lastKicker: Player? = nil

local ball = (ServerStorage:WaitForChild("Ball") :: BasePart):Clone()
ball.Parent = pitch

ball.Touched:Connect(function(hit)
	local character = hit.Parent
	local player = if character and character:IsA("Model") then Players:GetPlayerFromCharacter(character) else nil
	if player then
		lastKicker = player
	end
end)

goal.Touched:Connect(function(hit)
	if hit ~= ball or not lastKicker then
		return
	end
	local profile = PlayerData.get(lastKicker)
	if profile then
		profile.Coins += reward
	end
	ball.CFrame = centre.CFrame
	ball.AssemblyLinearVelocity = Vector3.zero
	lastKicker = nil
end)

Fixed example

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

local pitch = Workspace:WaitForChild("Pitch")
local goal = pitch:WaitForChild("Goal") :: BasePart
local centre = pitch:WaitForChild("Centre") :: BasePart
local reward = 25
local reach = 8
local lastKicker: Player? = nil

local ball = (ServerStorage:WaitForChild("Ball") :: BasePart):Clone()
ball.Anchored = false
ball.Parent = pitch
ball:SetNetworkOwner(nil)

ball.Touched:Connect(function(hit)
	local character = hit.Parent
	local player = if character and character:IsA("Model") then Players:GetPlayerFromCharacter(character) else nil
	local root = player and character and character:FindFirstChild("HumanoidRootPart")
	if player and root and root:IsA("BasePart") and (root.Position - ball.Position).Magnitude <= reach then
		lastKicker = player
	end
end)

goal.Touched:Connect(function(hit)
	if hit ~= ball or not lastKicker then
		return
	end
	local profile = PlayerData.get(lastKicker)
	if profile then
		profile.Coins += reward
	end
	ball.CFrame = centre.CFrame
	ball.AssemblyLinearVelocity = Vector3.zero
	lastKicker = nil
end)

Common false positives

  • Anchored parts are always owned by the server.
  • Unanchored parts that give nothing, such as debris or props players can knock over, don't matter.
  • A part the server has called SetNetworkOwner(nil) on, and doesn't anchor and unanchor afterwards, is simulated by the server.
  • Vehicles and tools handed to a player for driving or holding are fine as long as nothing is paid out from where they end up.