文章总结: 本文介绍了BlackHatUSA2026上关于编译后V8字节码恶意软件静态反混淆的研究。攻击者利用V8字节码格式绕过检测,作者以加密货币窃密木马jsceal为例,展示了通过扩展view8反编译器并构建四阶段反混淆流水线(字符串解码、控制流反扁平化、代理与运算包装剥离、LLM辅助重命名)成功恢复恶意代码完整能力。工具已开源,为恶意软件分析师提供了有效方法论。 综合评分: 96 文章分类: 恶意软件,逆向分析,漏洞分析,安全工具,实战经验
破封:编译后 V8 字节码恶意软件的静态反混淆
原创
AIxSec69 AIxSec69
AIxSec69
2026年8月24日 21:57 中国香港
在小说阅读器读本章
去阅读
破封:编译后 V8 字节码恶意软件的静态反混淆
来源:Black Hat USA 2026 Briefings
标题:Breaking the Seal: Static Deobfuscation of Compiled V8 JavaScript Bytecode Malware
作者:Aleksandra “Hasherezade” Doniec(Check Point Research)
适合读者:恶意软件分析师、逆向工程师、JavaScript 安全研究者、安全工具开发者
简介:编译后的 V8 JavaScript 字节码(.jsc)正在成为攻击者的一项不对称优势:攻击者可以用 Node.js 生态轻松组装功能齐全的恶意软件,套一层现成的 JS 混淆器,再编译成字节码。落到防御方手里,它既高于原生层插桩、又低于标准 JS 分析工具——正好卡在一个没人管的缝里。Hasherezade 带来了一个真实案例 JSCeal(加密货币窃密木马)的完整逆向方法论:扩展 View8 反编译器拿到可读伪代码,再用一条四阶段反混淆流水线逐层剥掉字符串加密、控制流扁平化、调用代理间接和运算包装。在 23 个样本上全部产出可分析结果,工具已开源。
◆ ◆ ◆
威胁情报里有一个安静的策略:选一个工具生态薄弱的格式。恶意代码只要落进”我们的工具够不着”的地方,就白赚了一分摩擦。这个模式不是第一次出现——Go 的 AOT 二进制、Rust 的剥离二进制、.NET 都各自制造过逆向摩擦,工具跟上是迟早的事。
但 Hasherezade 指出 V8 字节码有些不一样:它卡在一个谁都不太舒服的缝隙里——比原生层插桩高一层,比标准 JavaScript 分析工具低一层。
▲ 格式摩擦:攻击者选择工具生态薄弱的格式——Go AOT、Rust、.NET 都制造过逆向摩擦,V8 字节码是新的一例(来源:Black Hat USA 2026 Slides)
为什么是 JavaScript + Node.js
JavaScript 本是浏览器语言,Node.js 把它变成了通用平台。对攻击者来说,这意味着 npm ≈ pip——拉现成的模块,用高层语言从已有零件里快速拼装功能完整的软件。写恶意代码的门槛没有变,变的是交付格式。
编译技巧:源码消失在哪一步
V8 的编译链是这样的:
JavaScript 源码 → V8 引擎(parse + compile)→ Ignition 字节码 → 代码缓存(.jsc)
关键点有两个:
1. Ignition 字节码不是稳定公开格式——它是 V8 内部、随版本变化的产物;
- TurboFan 会把热路径 JIT 成原生代码,但代码缓存存的是 Ignition 字节码——而这正是被分发的东西。
于是攻击者得到了一种”编译过的程序”:它不完全是原生二进制,但源码、变量名、函数名、格式化信息在编译后全部消失了。文本 JS 检测绕过,源码级分析工具也够不着。
案例:JSCeal
研究对象是一个真实的窃密木马 JSCeal,名字来自最终 payload 的扩展名 .jsc。它是一个专注加密货币窃取的木马,带监视和流量拦截能力,Check Point 从 2025 年初就开始跟踪。
交付链路:Brotli 压缩的字节码 blob → V8 代码缓存 → 由捆绑的 Node.js 运行时执行。编译前,JavaScript 还套了一层混淆。
分析师的目标很朴素:恢复足够理解行为的代码、对比样本、追踪这个家族的演化。但起点并不友好。
先试最直接的办法——运行它。结果只有一段远日志遥测信标:
events: [{ "name": "session\_start", "domain": "node" }]
然后进程就死了:getaddrinfo ENOTFOUND faro.vertical-scaling.com——命令端点的服务器已经不在了。运行只能暴露表面,恶意逻辑藏得深不见底。
▲ 运行暴露的只有表面:一段 session_start 遥测信标,然后因端点已失效而死亡(来源:Black Hat USA 2026 Slides)
工具现状:薄得可怜
对 .jsc 的工具生态几乎为空:
– JSC Decompiler:在线反编译,需要上传样本,按文件收费(500KB 以下 $200,2MB 以下 $500);
– ghidra_nodejs:开源插件,无人维护;
– View8:开源 Python 反编译器,作者是 Check Point Research 的 Moshe Marelus,本来就用于编译后的 V8 JavaScript 恶意软件。
直接拿 LLM 啃原始反汇编也不可行——每个 payload 的反汇编高达 330–500MB,抽象层级还不对。当时的 LLM 上下文窗口根本装不下这么大的文件。
拿到干净反汇编的路径
naive 的路径直接失败:node --print-bytecode 输出的东西既嘈杂(运行时和 loader 字节码混在一起),又会因为 Node 把缓存 blob 当脚本解析而报语法错误。
正确的解法是绕开 Node,直接在 V8 API 上构建反汇编器:构建匹配版本的 V8、应用作者补丁、链接成静态库、直接消费代码缓存:
V8::SetFlagsFromString("--no-lazy --no-flush-bytecode");
V8::Initialize();
isolate = Isolate::New(create\_params);
auto\* cached = new ScriptCompiler::CachedData(buf, len);
ScriptCompiler::Source source(dummySource, origin, cached);
ScriptCompiler::CompileUnboundScript(isolate, &source,
ScriptCompiler::kConsumeCodeCache);
这就是 View8 的路线,也是从 .jsc 到可读伪代码的第一个可靠里程碑。View8 恢复的是低层的东西:常量、寄存器、作用域、函数边界、控制流——不是原始 JavaScript,不能直接运行,但可以解析和理解。
假的山顶
但真正打开反编译输出时,是”假的山顶”:
-
34–55MB 缓存 → 330–500MB 反汇编 → 约 50MB 反编译;
-
大到无法线性阅读——数万个函数,大部分是捆绑的依赖代码;
– JavaScript 在编译前就混淆过了,恶意逻辑依然隐藏。
一个里程碑,还不是终点。
识别混淆器:四层叠加
研究者识别出 JSCeal 用的混淆器是 javascript-obfuscator(开源、文档齐全、可配置)。所有真实字符串都从一个编码数组里经 \_0x1d('0xN') 取出来,块顺序藏在一个 while/switch 分发器后面。
它叠了四层:
1. 字符串混淆——重要字符串全部隐藏;
2. 控制流扁平化——函数变成分发器后面的状态机;
3. 代理间接——链式转发层,不是真实调用;
4. 运算包装——连加减比较都变成函数。
单独看每一层都不惊人,难在它们叠加起来、互相依赖。
▲ 四层混淆叠加:字符串混淆、控制流扁平化、代理间接、运算包装——单独看都不惊人,叠加且互相依赖才难(来源:Black Hat USA 2026 Slides)
反混淆流水线:顺序由依赖决定
反混淆的顺序不是随意的,每一层都依赖前面的输出:
值/作用域传播 → 字符串 → 反扁平化 → 代理与包装 → 全局属性+可见性 → LLM 重命名 → 分析与对比
字符串必须最先做——后面每一层都依赖它。
架构上,研究者修改了 View8 输出序列化 IR(pickle 格式),每个 filter 独立消费 IR → 转换 → 保存。反混淆器是独立工具,不是和反编译器紧耦合的 fork。
▲ 反混淆流水线架构:值传播 → 字符串 → 反扁平化 → 代理/包装 → 全局属性 → LLM 重命名 → 分析,顺序由依赖决定(来源:Black Hat USA 2026 Slides)
Pass 1 · 字符串
字符串解码函数的调用形态很典型:func\_n(60787, "Bz&S")——一个数字和一个字符串,结果再拼下一段。这是分块字符串 + RC4 逐块密钥 + Base64 + 一个大数组的结构:解密函数返回的每一段都只是明文的一部分。
已知算法和密钥,难点在于找到正确的块——一个函数持有数千个字符串的数组,调用时的数字是 RC4 密钥、字符串是待变换的索引。过滤器把每一块解码回明文、合并回正确的位置、隐藏掉解码器本身,并单独导出一份全部已解码字符串的清单。
这份清单本身就是情报:隐藏的 PowerShell 执行命令(Add-MpPreference -ExclusionPath 关杀软)、加密货币余额的字段名(totalBalanceInUSDT、customer\_account\_USDT\_balance\_available)、浏览器 cookie/OAuth 读取函数、以及攻击者嵌入的公钥。
Pass 2 · 控制流反扁平化
最重要的函数都被扁平化了:代码拆成编号块,由状态机按顺序执行。它们的预期顺序存在一个字符串里——比如 "3|2|1|0|4"。
分发器循环的结构是:
r6["jGBGz"] = "3|2|1|0|4"
r6 = r6["jGBGz"]["split"]("|")
while (true) {
r6 = r3[Number(r4)]
if (!r6 === "0") { [block0]; continue; }
if (!r6 === "1") { [block1]; continue; }
// ...
}
反扁平化就是把 "3|2|1|0|4" 这个顺序串提出来,把块按顺序线性排列。一个块里有多个条件 continue 时,要重建为嵌套的 if/else。
▲ Pass 2 反扁平化:分发器循环 + 顺序字符串 “3|2|1|0|4″,还原为线性的块顺序(来源:Black Hat USA 2026 Slides)
Pass 3 · 间接层:代理与运算包装
代理只做一件事——转发调用。它们链成兔穴,把真实的调用树埋起来。过滤器的动作是:标记 → 解析目标 → 替换 → 隐藏。func\_INBzN(func\_k, , null, null) 被折叠成 func\_k(, null, null)。
运算包装更夸张:连 in、减法、除法、调用都各自变成一个函数,且大量重复、多层嵌套。一次运行里藏着约 18500 个包装函数——纯粹的噪声,不是特征。
分层剥离是环环相扣的:一个恢复的字符串 → 揭示一个字段名 → 指向字典条目 → 解析一个代理 → 还原一个包装 → 变成一个运算。比如 if (r1["SeAyf"](a0, r1["yHrsY"])) 最终还原成 if (a0 === "https")——SeAyf 是比较包装,yHrsY 存着 “https”。
Pass 4 · LLM 辅助命名
剥离完成后,代码可读但函数名还是 func\_xxx\_0x…。第四阶段用 LLM 辅助重命名:从入口点构建依赖图(basic 模式只走直接调用,greedy 模式走所有引用),叶子优先——先命名依赖最少的函数,再把每个新名字传播进剩余的函数体。批量模式返回一个 CSV,把每个旧标识符映射到建议名,同一个文件兼作可恢复缓存——中断的 run 可以从中断处继续。
命名验证实验很有意思:同一个清理后的 payload 分别喂给两个模型(Claude Sonnet 4.6 vs GPT-5.4-mini),21154 个函数都被命名,只有 9.3% 选了完全相同的名字。但研究者强调:精确措辞 ≠ 含义,不同的名字可能表示同一功能。案例也展示了名字的层次:
-
bootKeyvsinitBootKeyModule——都可用,后者还说明了它做什么; -
findCertificatevsremoveCertificate——不是修饰性的差别,函数体确实在删除证书库条目; -
decryptLocalStateFile——自信但错了,它实际解析的是 DPAPI 主密钥文件。
结论是明确的原则:模型帮我们找到和导航逻辑,代码永远是证据。 名字是假设,不是证明。
恢复出来的完整能力
经过四阶段流水线,JSCeal 的能力全景浮现:
– 加密货币窃取规模化:50+ 平台专属 handler、30+ 纯加密货币交易所(Binance、Bybit、OKX、Kraken 等),付款和 P2P 平台也在其中;
– 钱包与扩展:10 个钱包扩展初始化器 + 硬件钱包路径(MetaMask、Phantom、Rabby、Trust Wallet 等)——扩展 ID、弹窗路径、补丁配置直接写在代码体里;
– 浏览器与凭据窃取:100+ cookie 相关函数(捆绑解析器 + JSCeal 专属读取路径,含 Facebook 和 OAuth)、Windows 凭据(DPAPI 结构 + 主密钥文件、LSA registry、派生密钥解密)、Windows Hello + passkey 收集、加密货币种子短语、Puppeteer + stealth 插件(重放被盗 cookie、自动化 Google 登录验证、抓 Android OAuth token);
– Telegram 会话劫持:找到 Telegram Desktop → 进入 tdata → 读取会话文件 → 交给存储 handler;
– 键盘记录与截屏:键盘捕获组件(keydown 订阅而非 Windows hook)+ 屏幕捕获与窗口枚举控制;
– 本地 MITM 代理:生成密钥对 → 创建证书(subject+issuer)→ 签名 → certutil -addstore -f root 把攻击者控制的证书装进受信根存储,然后代理就能拦截 HTTPS。
静态意图,运行时证明
恢复出的入口点把整个程序的开场完整还原:设置 DNS 到 1.1.1.1/8.8.8.8、按文件名判断是主进程还是 worker、分别初始化应用。运行时只暴露一次 DNS 查询,恢复的代码展示的是产生它的完整入口点——静态代码显示意图,运行时轨迹确认行为。
23 个样本验证
流水线在数月内收集的 23 个 JSCeal 样本上全部跑通:
– 23/23 产出可分析输出;
-
解析 232k 个字符串解码器配置;
-
导出 178 万条解码字符串;
-
47.9 万次最终清理重写;
-
每样本平均 4.6 分钟(最短 1.4,最长 7.6),无需缓存。
▲ 23 个样本全部产出可分析输出:232k 解码器配置解析、1.78M 解码字符串导出、平均每样本 4.6 分钟(来源:Black Hat USA 2026 Slides)
诚实地面对局限
研究者明确划出了边界:
– V8 反汇编版本敏感——不同的 Node 版本需要匹配的反汇编器;
– 过滤器基于模式——换混淆器设置或输出可能需要调参;
– 原生 .node 模块在流水线之外;
– LLM 名字是导航辅助,不是证明。
这不是完美的源码恢复,而是一条可读、可验证、可对比的实用路径。
一个更有洞察力的点是:“按设计过时”。这项研究进行的一年里,上下文窗口和存储容量大幅扩张,一些当时的约束现在已经不那么严重;模型对比也是一张快照,不是排名。但不变的是:这些层靠计算剥掉,不靠推断——近 300 万个编码块用同一种方式解码,可检查、可验证。Pass 给了我们控制权,模型相关的部分按设计过时——控制权才是持久的那部分。
开源工具
整套成果开源:github.com/hasherezade/jsc\_deobfuscator——View8 集成、输出分割、反混淆过滤器、依赖感知的 LLM 重命名器。View8 本身是 Moshe Marelus 的独立项目(github.com/suleram/View8),研究者为它贡献了改进。
◆ ◆ ◆
从密封盒到可读代码:.jsc → 字节码 → 伪代码 → 反混淆输出。编译后的 V8 字节码不必是密封的盒子——用匹配的反汇编器、View8、专门的 pass 和 LLM 辅助导航就能跨过这道缝隙。得到的不是原始 JavaScript,但足以支撑实际的威胁分析,也足以追踪这个家族随时间的演化。
◆ ◆ ◆
原文链接:https://www.blackhat.com/us-26/briefings/schedule/#breaking-the-seal-static-deobfuscation-of-compiled-v8-javascript-bytecode-malware-53041
免责声明:
本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。
任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。
本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我。
本文转载自:AIxSec69 AIxSec69 AIxSec69《破封:编译后 V8 字节码恶意软件的静态反混淆》
版权声明
本站仅做备份收录,仅供研究与教学参考之用。
读者将信息用于其他用途的,全部法律及连带责任由读者自行承担,本站不承担任何责任。









评论