Why tycoon collectors need an owner check

A pad that pays whoever touches it lets anyone take another player's earnings. How to check the plot's owner on the server.

criticalLuaudit check: unowned-plot-touches

What it looks like

A tycoon or plot has pads and buttons: a collector that pays out the plot's earnings, buy buttons that build the next upgrade, or a door only the owner should open. The Touched handler works out which player touched it, then pays or charges that player, without checking they own the plot.

Luau
collector.Touched:Connect(function(hit)
	local player = Players:GetPlayerFromCharacter(hit.Parent)
	if player then
		pay(player, plot:GetAttribute("Stored"))
	end
end)

How to detect it

  • Find Touched connections on parts inside plots, tycoons, houses and bases, and on prompts or click detectors there too.
  • For each one, find the plot's owner as the server recorded it, such as an OwnerId attribute the server set or a table kept by a plots module.
  • Check that the handler compares the touching player with that owner before it pays, charges, builds, opens or changes anything.
  • Watch for handlers that use the toucher for one part and the owner for another, such as paying the toucher from the owner's stored earnings, or charging the toucher to build on someone else's plot.
  • The touch itself still needs the usual checks: distance and a debounce, as Rewards paid on touch with no distance check describes.

What an exploiter could do

They walk onto, or fire a touch on, other players' collectors and take their earnings, over and over across the server. They press buy buttons on other plots to waste the owner's money or build things in the wrong order, and open doors meant only for the owner. Owners lose what they earned while they were away.

How to fix it

  • Look up the plot's owner on the server and return straight away if the touching player isn't them.
  • Keep the owner in server-side state, set only by the server when the plot is claimed. Don't trust a name or value on the plot that a client could have influenced.
  • For shared features, such as friends being allowed to build, check against a list the owner manages through the server.

Vulnerable example

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

local tycoon = workspace:WaitForChild("Tycoons"):WaitForChild("Tycoon1")
local collector = tycoon:WaitForChild("Collector") :: BasePart
local paid: { [Player]: number } = {}

collector.Touched:Connect(function(hit)
	local character = hit:FindFirstAncestorOfClass("Model")
	local player = character and Players:GetPlayerFromCharacter(character)
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not player or not root or not root:IsA("BasePart") then
		return
	end
	if (root.Position - collector.Position).Magnitude > 10 or os.clock() - (paid[player] or 0) < 1 then
		return
	end
	local profile = PlayerData.get(player)
	local stored = tycoon:GetAttribute("Stored")
	if profile and typeof(stored) == "number" and stored > 0 then
		paid[player] = os.clock()
		tycoon:SetAttribute("Stored", 0)
		profile.Coins += stored
	end
end)

Fixed example

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

local tycoon = workspace:WaitForChild("Tycoons"):WaitForChild("Tycoon1")
local collector = tycoon:WaitForChild("Collector") :: BasePart
local paid: { [Player]: number } = {}

collector.Touched:Connect(function(hit)
	local character = hit:FindFirstAncestorOfClass("Model")
	local player = character and Players:GetPlayerFromCharacter(character)
	local root = character and character:FindFirstChild("HumanoidRootPart")
	if not player or not root or not root:IsA("BasePart") then
		return
	end
	if tycoon:GetAttribute("OwnerId") ~= player.UserId then
		return
	end
	if (root.Position - collector.Position).Magnitude > 10 or os.clock() - (paid[player] or 0) < 1 then
		return
	end
	local profile = PlayerData.get(player)
	local stored = tycoon:GetAttribute("Stored")
	if profile and typeof(stored) == "number" and stored > 0 then
		paid[player] = os.clock()
		tycoon:SetAttribute("Stored", 0)
		profile.Coins += stored
	end
end)

OwnerId is set by the server when a player claims the tycoon.

Common false positives