Why the server should never load an asset id the client sends

LoadAsset with a client's id can pull any model the creator owns into the live game. How to use server-side lists and AssetTypeVerification.

criticalLuaudit check: client-chosen-assets

What it looks like

The client sends an asset id, and the server loads it: a model for a showroom, a decoration, a pet or an avatar item. The server passes the id to InsertService:LoadAsset, AssetService:LoadAssetAsync, or a HumanoidDescription it applies to a character.

Luau
remotes.Preview.OnServerEvent:Connect(function(player, assetId)
	local model = InsertService:LoadAsset(assetId)
	model.Parent = workspace
end)

How to detect it

  • Find every call to InsertService:LoadAsset, InsertService:LoadAssetVersion, AssetService:LoadAssetAsync, Humanoid:ApplyDescriptionAsync, Humanoid:ApplyDescriptionResetAsync, Player:LoadCharacterWithHumanoidDescriptionAsync and Players:CreateHumanoidModelFromDescriptionAsync, plus the older versions of those four without Async. Trace each asset id back to its source.
  • An id from a remote argument, or a HumanoidDescription built from ids the client sent, is the finding unless the server matches every id against its own list first.
  • LoadAsset can load anything the game's creator owns or has been shared, as well as Roblox-owned assets and clothing and avatar accessories, which Roblox marks as open to any game. That includes models the creator never meant to ship: test builds, admin tools, unreleased maps. A client that picks the id picks from all of them.
  • For avatar loading, check the AssetTypeVerification argument. Roblox's reference for it says loading models can be insecure if malicious users can trick the game into loading unexpected models the creator owns, which may include malicious scripts. It recommends Enum.AssetTypeVerification.Always unless the game needs non-catalog assets. Missing or Default with client ids is a finding.
  • AssetService:LoadAssetAsync returns the model sandboxed with no script capabilities by default. Treat scripts in a model from InsertService:LoadAsset as able to run once it's parented somewhere scripts run.
  • Check what happens to the loaded model: parented to Workspace or a character, cloned for everyone, or kept on the server.

What an exploiter could do

They send the id of any model the developer owns and it appears in the live server, with whatever scripts, tools or unreleased content it holds. Scripts in an admin or test model can give them power over the server. Avatar items they choose skip the game's own shop and unlocks.

How to fix it

  • Let the client send a key from the game's own list, such as "Sofa", and look up the asset id on the server.
  • Use AssetService:LoadAssetAsync and leave the loaded model sandboxed unless it's one you trust.
  • Pass Enum.AssetTypeVerification.Always when applying descriptions, and build the description on the server from checked ids.

Vulnerable example

Vulnerable
local InsertService = game:GetService("InsertService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local stage = Workspace:WaitForChild("Showroom")

ReplicatedStorage.Remotes.ShowModel.OnServerEvent:Connect(function(player: Player, assetId: unknown)
	if typeof(assetId) ~= "number" then
		return
	end
	local ok, model = pcall(InsertService.LoadAsset, InsertService, assetId)
	if ok and model then
		stage:ClearAllChildren()
		model.Parent = stage
	end
end)

Fixed example

Fixed
local AssetService = game:GetService("AssetService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Workspace = game:GetService("Workspace")

local stage = Workspace:WaitForChild("Showroom")
local models: { [string]: number } = {
	Sofa = 9100101,
	Lamp = 9100102,
}

ReplicatedStorage.Remotes.ShowModel.OnServerEvent:Connect(function(player: Player, name: unknown)
	local assetId = typeof(name) == "string" and models[name]
	if not assetId then
		return
	end
	local ok, model = pcall(AssetService.LoadAssetAsync, AssetService, assetId)
	if ok and model then
		stage:ClearAllChildren()
		model.Parent = stage
	end
end)

Common false positives

  • Ids the server picks from its own table, even when the client chooses which entry, like the fixed example, are fine.
  • GetHumanoidDescriptionFromUserIdAsync for a real player's own avatar loads only what that player wears.
  • Anyone can load any number of things, so a showroom still needs a rate limit. Report that as Remote with no rate limit, not here.