The risks of loading models by id while the game runs

A model loaded at runtime brings whatever scripts its latest version holds. How to keep maps in the place file or load them sandboxed.

warningLuaudit check: runtime-loaded-models

What it looks like

The server loads a model by asset id while the game runs, with InsertService:LoadAsset or AssetService:LoadAssetAsync, and puts it in the game. The id is often hardcoded, or read from a data store, a web request or a settings value so the map or event can be changed without updating the game. Whatever scripts the published model holds at that moment come with it.

Luau
local map = InsertService:LoadAsset(eventMapId)
map.Parent = workspace

How to detect it

  • Find every InsertService:LoadAsset, InsertService:LoadAssetVersion and AssetService:LoadAssetAsync call, and see where the result is parented.
  • Look at where the id comes from. A hardcoded id loads whatever that asset holds now, not what was tested. An id from HttpService, a data store, a memory store or MessagingService lets anyone who controls that source choose what loads.
  • Check whether the loaded model may contain scripts, and whether they can run. AssetService:LoadAssetAsync returns the model sandboxed with no script capabilities by default. Look for code that turns Sandboxed off or grants it Capabilities such as Network, DataStore, AssetRequire or LoadString, which Roblox's third-party assets guide says backdoors commonly use. Treat scripts in a model from InsertService:LoadAsset as able to run once it's parented somewhere scripts run.
  • AllowInsertFreeAssets, set in Studio's **Allow Loading Third Party Assets**, lets LoadAssetAsync also load any public free asset on the Creator Store, not just assets the creator owns or that were shared with them. If the code loads ids it doesn't control, check whether that setting is on.
  • If the client chooses the id, report Assets loaded by an id the client chose instead.

What an exploiter could do

Anyone who can publish a new version of the model, or change the data store entry, web response or message that names it, puts their own scripts into every server that loads it. That is the same reach as Module loaded by asset id: admin for themselves, hidden remotes, or content that gets the game moderated. A compromised account on the team is enough.

How to fix it

  • Keep maps and models that ship with scripts in the place file, in ServerStorage, and clone them from there.
  • If a model must be loaded at runtime, load it with AssetService:LoadAssetAsync, keep it sandboxed, and grant only the capabilities it needs.
  • Only load ids from a fixed list in the server code, and strip scripts from loaded models you only need for their parts.

Vulnerable example

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

local config = DataStoreService:GetDataStore("LiveConfig")

local ok, mapId = pcall(config.GetAsync, config, "EventMap")
if ok and typeof(mapId) == "number" then
	local loaded, map = pcall(InsertService.LoadAsset, InsertService, mapId)
	if loaded and map then
		map.Parent = Workspace
	end
end

Fixed example

Fixed
local DataStoreService = game:GetService("DataStoreService")
local ServerStorage = game:GetService("ServerStorage")
local Workspace = game:GetService("Workspace")

local config = DataStoreService:GetDataStore("LiveConfig")
local maps = ServerStorage:WaitForChild("EventMaps")

local ok, mapName = pcall(config.GetAsync, config, "EventMap")
local map = ok and typeof(mapName) == "string" and maps:FindFirstChild(mapName)
if map then
	map:Clone().Parent = Workspace
end

Common false positives

  • Loading avatar items, decals, sounds or meshes, which can't hold scripts, isn't this check.
  • A model loaded with LoadAssetAsync and left sandboxed with no capabilities can't run its scripts.
  • Loading in Studio only, behind RunService:IsStudio(), for build tools doesn't reach live servers.