diff --git a/_layouts/post.html b/_layouts/post.html index 8eddc3e..383576a 100644 --- a/_layouts/post.html +++ b/_layouts/post.html @@ -27,14 +27,14 @@

{{ page.title }}

diff --git a/_posts/2026-03-26-fivem-web-surface.md b/_posts/2026-03-26-fivem-web-surface.md new file mode 100644 index 0000000..1625c85 --- /dev/null +++ b/_posts/2026-03-26-fivem-web-surface.md @@ -0,0 +1,415 @@ +--- +layout: post +title: "The web inside FiveM: From browser to full remote control" +date: 2026-03-26 +toc: true +series: "FiveM Security" +series_part: 2 +tags: [ + fivem, fivem-security, gta-roleplay, cfx, + lua, lua-security, server-events, event-validation, + game-server-security, game-server-hardening, + security-awareness, red-team, penetration-testing, + input-validation, client-server, roleplay-server +] +--- + +A player typed something into a text field. Now an attacker is reading files on another player's computer. +Your server didn't get hacked. You were never the target. But you are the one who let it happen. + +--- + +# Part 1 recap + +Part 1 was about the server. An attacker connected, fired events the server wasn't expecting, and +walked away with whatever the scripts handed over. The attacker was still a player — present on the +server, operating within the network stack. + +That stays true here. The attacker is still a connected player. What changes is the target. +In Part 1 the server was the victim. In this post, so are the other players. + +FiveM ships a Chromium browser inside every game client. Developers use it to build custom UIs — inventory menus, HUD +overlays, admin panels. Those UIs render data. Some of that data was written by other players. If it +is rendered without sanitisation, it executes. + +A payload stored in a player-controlled field — a name, an item description, a support ticket — sits +in the database and waits. Every client that opens the affected UI runs it. The attacker can be +offline. The payload keeps firing. + +That is the first shift. The second is where it leads. The Chromium instance running NUI is not +isolated from the host machine. It has a designed bridge to the game engine, and through that bridge, +to game functions and eventually to the operating system. A payload that starts as an `innerHTML` +injection can end on the victim's filesystem — outside the game, outside the server, on a real +machine. + +--- + +# The NUI / Web Layer + +FiveM's NUI system is a Chromium browser running inside the game client. Developers build custom UIs +with HTML, CSS, and JavaScript — inventory menus, HUDs, admin panels, ticket queues — exactly the +same way you build a website. The stack is standard. The attack surface is standard. If you have +done web security before, you already know the attack. If you haven't, a quick look at any bug +bounty leaderboard will show you how common this class of vulnerability is and how little it takes +to exploit it. The question is what the context makes possible. + +# Web security in a game is worse + +In a normal browser, XSS[^1] is bounded. The sandbox limits system access. Same-origin policy restricts +what an injected JavaScript can reach. Exfiltrating a session cookie is the ceiling for most web XSS, and you'll rarely if ever end up +with a fully compromised system from a web-vectored attack alone[^2]. + +NUI does not have that ceiling. The Chromium instance runs inside a process that has direct, designed +access to the game engine. There is no boundary between the web layer and the game layer — that +boundary was intentionally removed so that JavaScript can communicate with Lua and Lua can communicate +with JavaScript. The same design choice that makes custom UIs possible is what makes XSS here +categorically worse than XSS in a web app. + +# The bridge + +The communication mechanism is worth understanding before the escalation. Two functions carry traffic +across the boundary: + +- `SendNUIMessage` — Lua to JavaScript. Sends a JSON object into the browser, received by a + `window.addEventListener('message', ...)` handler in JS. +- `RegisterNUICallback` — JavaScript to Lua. JS makes an HTTP POST to + `https://${GetParentResourceName()}/callbackName`; the registered Lua handler receives the body. + +Data flows in both directions. A payload that lands in the browser can use `RegisterNUICallback` to +send data back to Lua — and client-side Lua has access to game state, player data, and game +functions. The bridge is the mechanism. Everything below is what happens when untrusted input reaches +the DOM on the wrong side of it. + +> **Note:** Also notice the similarity with the vulnerabilities mentioned in Part 1? They also apply here. If you missed it, read about it [here](/blog/2026/03/24/fivem-server-events/). + +# Case study + +## Step 1: Noticing + +We join a new server — it's got open police slots, great. Playing around we notice evidence bags: +placeable items like bullet casings that accept metadata comments, letting detectives add context +to evidence later in an investigation. Let's dig. + +## Step 2: Digging + +The script is paid — no public source, documentation behind a paywall. A bit of OSINT surfaces an +outdated leak: obfuscated code and a three-year-old user guide. The UI has changed and the feature +list is half of what the script offers today, but that doesn't matter. Core functions are rarely +rewritten from scratch. There's a good chance the internals are similar, if not identical. + +Digging through the docs, we find an example structure for item declarations using `filled_evidence_bag`. +Let's check if that item exists on the server. + +Back in FiveM we manipulate our inventory requests to request the `itemthatdoesnotexist` item. + +```bash +SYSTEM: Item does not exist... +``` + +Alright, let's try `filled_evidence_bag`: + +```bash +SYSTEM: User inventory already defined in database +``` + +Jackpot. The item exists in the resource files. All we need now is an item that accepts metadata. + +## Step 3: Proof of Concept (PoC) + +All items have a metadata attribute — it's just unused unless a script needs it. That means we can +assign metadata to the welcome guide handed out on character creation. + +```html + +``` + +Setting this as the item's metadata lets us test locally — no other player is affected. On inventory +open, a `Vulnerable` popup appears in the UI layer. The service is vulnerable. + +### Blind XSS + +The inventory is not the only injection point. Admin reports are NUI too — player reports, ban +requests, ticket queues. A payload stored in a report body won't visibly render as a script; the +staff member opens what looks like a normal ticket. The payload fires in their client, under their +permissions. They never see it execute. This is blind XSS: the attacker fires and goes offline. +The payload does the rest, sometimes years later. + +## Step 4: Persist via Stored XSS + +Several vectors are already in reach — one is already in place in our testing: the item's metadata. +What if I log in from a second machine, drop the infected welcome guide on the ground, and pick it +up with another character? + +`Vulnerable` popup on the receiving screen. The payload persists with object state. Going back to +the evidence bag: if we were to create an infected bag and store it in the police station, we could +specifically target police players. Or we could go wide — hiding the payload in the chat bar, item +names, vehicle descriptions, gang tags. Possibilities are endless. One stored injection, every +player who opens the affected UI, no further interaction required. + +## Step 5: Weaponize + +Now that we know we can hit anyone, anywhere, it's time to decide what to hit them with. The +simplest starting point is DOM manipulation — rewriting what the victim sees inside their own UI. + +```js +// html/app.js +var descriptionEl = document.querySelector('[data-component="evidence-description"]'); +if (descriptionEl) { + descriptionEl.innerText = SPOOFED_DESCRIPTION; +} + +fetch(CALLBACK_ENDPOINT, { + method: 'POST', + body: JSON.stringify({ + action: 'transferEvidence', + target: ATTACKER_INVENTORY, + item: EVIDENCE_BAG_ID + }) +}); +``` + +> **Note:** Specific payload syntax is intentionally omitted. This is a documented class of +> vulnerability — the goal is to make the attack surface legible, not to provide a tutorial. + +The first part rewrites the evidence description in the victim's UI — they see whatever we want +them to see. The second fires a `POST` to the inventory callback, requesting a transfer of the +item to our inventory. The victim opened their evidence bag. We took what was inside. + +## Step 6: Elevate + +Now that we have visibility into what players see, we can think about elevating — using our foothold +to perform more destructive actions. Our payload has full DOM interactivity. If it's sophisticated +enough it can scan, detect, and pivot autonomously. Let's see what's in the DOM. + +```html +
+
Payment Received
+
Unemployment check - $15
+
+``` + +Convenient: the moment I ran my recon payload was the same instant I received my 15-minute +in-game paycheck. That notification was loaded in my DOM[^3]. Oddly curious. + +After digging into how FiveM handles NUI, the picture becomes clear. FiveM isolates resources in +separate iframes — they shouldn't be able to see each other's DOM. But many servers run a +centralised notification or display system: a single third-party script that routes all UI +elements through one stack for a consistent look. When that's in place, all resources share the +same DOM, and any callback registered there is reachable from our payload. + +Scanning the DOM for registered callbacks, one stands out: + +```lua +-- server.lua +RegisterNUICallback('UIsystem:generalCallbacks', function(data, cb) + -- routes callback to the originating resource by name +end) +``` + +A generic pass-through — routes any callback to its originating resource without validation. That +is a wide pivot surface. Every resource on the server with a registered callback is now reachable +from our injection point. + +The opening we are looking for is `window.invokeNative`[^4], which exposes game engine functions +directly to JavaScript running in NUI. + +From injected JavaScript running in a victim's NUI: teleport the player, spawn or delete entities, +trigger animations, call commands that would normally require server-side authorisation. The attacker +is not sending a crafted server event anymore. They are calling game engine functions directly from +inside the victim's client, without touching the server at all. + +The anticheat has no visibility into this. It is not a game modification. It is JavaScript executing +inside a browser that the game provides. + +## Step 7: Exfiltrate + +> **Note:** This section is deliberately vague and leans toward speculation rather than demonstration, +> for obvious reasons. Keep an open mind. + +`window.invokeNative` is not the ceiling. + +The CEF instance running NUI is a browser. Browsers have APIs: clipboard read and write, microphone +access, camera access. In a standard browser these prompt for permission. Inside the game client, +the permission surface is different — prompts may not appear, or may appear in a context where the +player dismisses them without understanding what they are approving. + +Beyond that: the CEF remote debug interface runs on `localhost:13172` while the game is open.[^5] +Any process on the same machine can attach to it and inject code into the running browser context, +or inspect and modify anything currently loaded. This is not an attacker capability — it is a +developer tool. But it is exposed by default, and accessible to anything running locally. + +> **Note:** The activation and blocking of debug services on FiveM is not well documented. Most will +> argue that if you don't enable the tools, they aren't enabled — but some claim it is possible to +> force or bypass their activation. The documentation is thin; the threat model shouldn't assume the +> default is safe. + +With a payload executing in the NUI context and a foothold on the debug interface, the attacker can +operate at OS level — and they no longer need to be connected to the server. Reading local files via +`fetch` against `file://` paths, exfiltrating stored credentials, or dropping content to disk are +all within reach. The attacker has moved from manipulating a game UI to running arbitrary code on +the victim's operating system. + +--- + +# The end state + +A detective opened an evidence bag. + +The metadata field rendered without sanitisation. Our payload executed in their NUI context. It +rewrote the bag's description — they saw whatever we wanted them to see. It fired a callback and +transferred the bag to our inventory. It scanned the DOM, found the centralised notification system, +and mapped every registered callback on the server. It called `window.invokeNative` — directly, +without touching the server — and issued game engine commands under their identity. Then it reached +the debug interface and read files off their machine. + +They were just doing their job. Opening evidence, like every shift. + +The payload had been sitting in that bag for days. We were offline. It fires on every detective who +opens it. The server saw none of this — no unusual events, no suspicious connections, nothing to +flag. The only trace is a text field in a database, waiting for the next person to open the right +menu. + +This started with a developer using `innerHTML` instead of `textContent`. + +# Fixing the NUI layer + +The same three principles from Part 1 apply here. The bridge runs in both directions; the +obligation runs in both directions. + +**The render side.** + +The JavaScript that displays player-controlled data is the first gate. By default, it does nothing +unless the data passes. `textContent` is the default — it does not invoke the HTML parser, so +injected markup is inert. If HTML rendering is genuinely required, DOMPurify runs first. No +exceptions for "trusted" sources: if the data touched the database and a player wrote it, it is +untrusted. + +```js +// html/app.js +function renderDescription(data) { + var descEl = document.querySelector('[data-component="evidence-description"]'); + if (null !== descEl) { + + // Type check + if ('string' === typeof data.description) { + + // Render — textContent, never innerHTML + descEl.textContent = data.description; + } + } + + return; +} +``` + +Default to negative. The function does nothing unless the element exists and the data is a string. +No render, no side effect. + +**The callback side.** + +Data arriving from the NUI layer is untrusted. A payload executing in the browser can call any +registered callback with any body it constructs. The Lua handler is the second gate — same layered +pattern as Part 1. + +```lua +-- client.lua +RegisterNUICallback('inventory:transferEvidence', function(data, cb) + + -- Type check + if "string" == type(data.itemId) and "number" == type(data.target) then + + -- Sanity check + if 64 > #data.itemId and 1 <= data.target then + + -- Context check + if true == isValidItem(data.itemId) and true == DoesEntityExist(data.target) then + + -- Perform the action + TriggerServerEvent('inventory:transferEvidence', data.itemId, data.target) + cb({ success = true }) + return + end + end + end + + cb({ success = false }) + return +end) +``` + +Default to negative. Silent `cb({ success = false })` and `return` on any failed check. The action +— a server event — only fires when every layer passes. + +Three things worth naming explicitly: + +- **Default to negative.** Both sides of the bridge do nothing unless all conditions pass. No + render, no callback, no server event. Silent return. + +- **Layered validation.** Not a single guard — a sequence: type, sanity, context, then action. + Each layer is independent. A failure at any point drops the request. + +- **Minimal surface.** Only register the callbacks you need. The generic pass-through from Step 6 + — routing any callback to any resource without validation — is the opposite of this. Each + registered callback is a decision; treat it like one. + +A Content Security Policy on NUI HTML files adds a fourth layer: restrict `script-src` to your +own bundle and block inline execution. It does not prevent injection, but it severs the eval chain. +A payload that cannot execute inline and cannot reach an external endpoint is significantly less +useful, even if it lands. + +--- + +# Closing + +Part 1 was one attacker, one server, one payload at a time. This is worse. + +A single stored injection fires on every player who opens the affected UI — indefinitely, without +the attacker being present, without the server seeing anything unusual. The surface is not just +the server anymore. It is every client, every machine, every set of credentials sitting in a +browser profile on the same computer running the game. + +Most servers won't notice. The tell is not an alert or a spike — it is a detective who lost their +evidence bag and assumed it was a script bug. It is a staff member whose admin panel behaved +strangely for a moment. It is a player who saw a notification they didn't expect. Or it's nothing +at all. None of those get filed as security incidents. They get filed as bugs, or they don't get +filed at all. + +The attacker doesn't need to be skilled. They need to find one field that renders without +sanitisation and one developer who reached for `innerHTML` because it was easier. That field +exists on most servers. That developer made that choice on most resources. The gap between exposed +and defensible is `textContent`, a type check on a callback handler, and the decision to treat +the NUI layer as what it is: a browser running untrusted input. + +Two posts in, we have covered the server and the clients. There is a third layer we haven't +touched — the database. The same input that executes in a browser can execute in a query. Part 3 +is about what happens when untrusted data reaches SQL. + +If this reached someone running a server, send it to them. Their players' machines are not part +of the game — until they are. + +--- + +# Notes + +[^1]: Cross-Site Scripting. An attacker injects a script into a page viewed by another user. The + browser executes it as if it were part of the page. + +[^2]: A decade or two ago, browsers were a genuine vector for full system compromise. Systems were + less hardened, browser sandboxes were weaker, and exploitation toolkits made it routine. Modern + browsers have closed most of those paths — which is exactly what makes NUI's exposure notable: + it reopens them by design. + +[^3]: FiveM isolates resources in separate iframes — theoretically preventing cross-resource + communication. In this example the resources share the same NUI via a centralised display + script. Worth noting: even without a shared DOM, that doesn't prevent a payload from calling + another resource's registered callbacks directly. + +[^4]: `window.invokeNative` is a CEF-exposed function specific to the FiveM client. It is + undocumented officially but widely reverse-engineered by the community. Its availability and + scope may vary across client versions. + +[^5]: The CEF remote debugging port (`13172` by default) is a Chromium DevTools Protocol endpoint. + Any application on localhost can attach to it while the game is running. It is a standard + developer tool, not a vulnerability — but its exposure is worth understanding in a threat model.