1. JSVMP为什么是硬骨头:先把虚拟机保护的老底揭了
如果你做过互联网逆向,大概率见过这种场景:某个站点的加密参数长得奇奇怪怪,testab、_signature、sign……关键字一搜,代码全是虚拟机字节码,不是几百KB的混淆大数组,就是动态生成的handler逻辑。没错,这就是JSVMP(JavaScript Virtual Machine Protection)。我在这两个月内连续接了x程系的testab参数和某头条的_signature参数两个任务,把传统的静态分析路线基本走了一遍,最后发现真正能打穿这两类保护的组合拳是:动态插桩 + 执行流还原 + AI辅助语义分析。这篇笔记就把完整思路和踩坑过程拆开聊聊,适合正在跟JSVMP较劲、或者准备系统学Web逆向的朋友参考。
先说清楚JSVMP到底干了什么。传统的JS混淆,不管怎么字符串加密、怎么控制流平坦化,最终代码还是在原生JS引擎里跑。只要你肯花时间,总能通过AST还原出大概逻辑。但JSVMP不一样——它把原始JS代码先编译成自定义的字节码,然后用自己的解释器(也叫VM调度器)去逐条执行。这意味着你在DevTools里看到的不是业务逻辑,而是一个巨大的while循环加switch分支,每一条字节码对应一个handler,handler从操作数栈上取数据、运算、再压回去。
举个例子,一段简单的 a = b + c,经过JSVMP编译后,可能变成这样:
javascript复制// 伪代码示意
opcodes = [0x12, 0x34, 0x56, 0x78, 0x01, 0x23];
// 0x12: push b的地址
// 0x34: push c的地址
// 0x56: add操作码
// 0x78: store到a的地址
然后VM主循环长这样:
javascript复制while (ip < opcodes.length) {
opcode = opcodes[ip++];
switch (opcode) {
case 0x12: stack.push(env[opcodes[ip++]]); break;
case 0x56: {
let right = stack.pop();
let left = stack.pop();
stack.push(left + right);
break;
}
// 几百个case...
}
}
你打断点、跟调用栈、看变量值,看到的永远是同一个dispatcher在转圈。以前对付普通混淆的"搜关键字、找加密入口、条件断点看返回值"这三板斧,在JSVMP这儿全部失灵——因为字符串常量不在代码里,在运行时的栈里;函数边界不在代码里,在自定义的call帧里;连控制流都不在代码里,而在字节码的操作码序列里。
现在主流的破解路线大概分三条。第一条是静态翻译:把整个VM的handler语义全部逆向清楚,然后写一个脱虚拟机脚本,把字节码批量翻译回可读的JavaScript。这条路最彻底,但工作量巨大,适合那种平台级的安全团队慢慢啃。第二条是动态插桩:不改字节码,只监控VM运行时的操作码序列、栈变化、内存读写,通过海量日志反推算法结构。第三条是RPC调用:不还原算法,浏览器里把VM跑起来,外部程序通过WebSocket等通道调它生成参数。RPC最省事,但依赖浏览器环境,移动端或者风控严格的场景容易被检测。
我这次两个目标都是典型的JSVMP,但难度侧重完全不一样。testab那个VM比较"纯",就是标准的分发器加处理函数;_signature那边不但有VM执行流,还裹了一层环境检测,VM外壳只是最终一环,前面还堆了各种浏览器指纹校验。所以文章里我会分别讲这两套思路,再聊怎么把AI揉进整个逆向流程里。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. AI在JSVMP逆向里的真实边界:能提效,但别指望全自动
先说个结论,省得大家抱有幻想:AI目前没法替你完整还原一个JSVMP的虚拟机执行流,但在日志分析、摘要、聚类、pattern识别这些环节,它能把效率提升一个量级。 我见过一些朋友拿大模型直接问"这段字节码是什么意思",得到的回答基本是废话。原因很简单:VM执行是动态过程,脱离了运行时状态,单看静态代码谁也猜不出语义。大模型擅长的是处理"结构化的、有上下文的、重复性高的文本",而插桩日志恰好就是这种形式。
我实际用AI做了四类事情,每一类都产生了实实在在的价值。
第一类是日志的语义摘要。一次跑批可能产生几十万条操作码日志,人眼看会疯掉。把采样后的日志丢给大模型,让它"总结这段日志中重复出现的操作码序列模式",它能在几秒钟内给出候选片段,我再针对这些片段去做定向插桩。
第二类是handler的分类与标注。VM里几百个handler,在没有符号信息时,先让AI根据handler的代码特征(操作码顺序、调用的内置函数、栈操作模式)去猜它大概在做什么——是字符串拼接、算术运算、还是对象属性存取。这个猜测不保证100%准确,但能给我一个初步的导航图,比完全乱摸快得多。
第三类是还原伪代码时的辅助注释。插桩到后期,我已经能从日志里拼出大致的算法轮廓,这时候把关键执行片段和上下文丢给AI,让它按"类C伪代码 + 操作数说明"的格式整理输出,相当于一个高级注释器。比如同样一段日志,AI能给出:
code复制// 将栈顶两个值按位异或,再与常量0x9E3779B9相加,存到寄存器R7
// 疑似TEA加密的delta常量
这种判断本身就是逆向经验的浓缩,哪怕它猜错了方向,也能提供线索。
第四类是AST反混淆脚本的生成。x程和头条都有数组移位、字符串拆分的混淆,写还原脚本是个体力活。让AI先读一段被混淆的数组和偏移量计算逻辑,再让它生成对应的还原规则,准确率相当高。这类任务本质上是"模式转换",正好是大模型最擅长的。
但有一个环节我试过很多次都不靠谱:让AI推演VM的跳转逻辑和动态分派。 JSVMP的handler里经常出现间接跳转、运行时修改指令指针、操作数依赖的跳转目标,这些东西本质上是一个状态机,每一步的转移都依赖前一步的运行时值。大模型没法连续执行长步骤的状态推演,经常推着推着就自相矛盾。所以这个环节我始终坚持人工分析,最多让AI做片段级的辅助推理。
所以合理的AI工作流应该是这样:人工负责定点和决策,AI负责量大管饱的文本处理。 先把插桩点定好,把日志格式设计好,剩下的日志清洗、聚类、初步语义猜测、伪代码注释,全部交给AI。反过来如果让AI主导,搞什么"自动分析执行流程",大概率最后会给你一堆看起来像模像样但根本跑不通的还原代码。
3. testab参数第一现场:入口定位、插桩布点与AI辅助语义还原
3.1 入口定位:不是搜参数名,而是跟HTTP请求栈
x程系Web端的testab参数,出现在几个核心业务接口的查询参数里。我一开始的思路是直接在Source里Ctrl+Shift+F搜"testab",结果捞出来一长串无关的匹配——有的是渲染逻辑里的字段名,有的是前端组件的prop。
后来换了个思路:不去搜参数名,而是先在Network面板里找到带testab的真实请求,复制为fetch调用,然后在代码里给fetch或者XMLHttpRequest原型挂一个hook,打印完整的调用栈。这样一跑,栈上会清楚显示参数是哪个函数拼接出来的,再从这个函数反向找它的上游。
这个方法对普通混淆有效,但testab所在的VM把真正的参数生成逻辑藏得很深。我hook到的是一个外壳函数,它调用了VM的invoke方法,传入一串字节码和初始环境对象。换句话说,我找到的不是算法,而是VM入口。这个时候就需要判断这个VM的结构特征,决定后续的进攻策略。
3.2 插桩布点:在最关键的三个位置埋日志
VM入口找到了,接下来就是在VM里埋点。我用的工具是Chrome DevTools的Local Overrides配合自写的hook脚本,在页面加载前注入。整体思路是在下面三个位置打日志:
code复制位置1:VM入口(记录参数和初始环境)
位置2:操作码分发主循环(每条指令执行前记录)
位置3:内部函数调用边界(call/ret操作码,记录参数和返回)
代码长这样:
javascript复制// 伪代码,示意插桩逻辑
const originalDispatcher = vm.dispatcher;
vm.dispatcher = function(opcode, ip, stack, env) {
// 只在执行特定操作码时记录关键栈信息
const shouldLog = [0x12, 0x56, 0x78, 0xA3].includes(opcode);
if (shouldLog) {
console.log(JSON.stringify({
type: 'vm_exec',
seq: logSeq++,
ip: ip,
opcode: opcode.toString(16),
stackTop: peekStack(stack, 5), // 栈顶5个元素
envKeys: Object.keys(env).slice(0, 5),
ts: Date.now()
}));
}
return originalDispatcher(opcode, ip, stack, env);
};
注意这里用了一个关键技巧:不记录每条指令,而是只在疑似关键操作码上记录。 我第一次是每条指令都打日志,结果500毫秒内生成了80万条日志,页面直接卡死,Chrome的内存占用飙到2GB。后来加了一个条件:只关注立即数入栈、算术运算、函数调用这三类操作码,日志量直接降了95%,而且信息密度更高。
3.3 从日志形态中找算法规律
插桩跑了两轮之后,我拿到了足够多的执行日志。这个时候AI开始进场。我把日志按时间窗口切成小段,每一段对应一次testab参数的生成过程,然后丢给大模型,让它找"当输入参数变化时,哪些操作码序列也发生了规律性变化"。
这一步非常关键。testab这类参数的VM实现里,核心算法往往不是用固定字节码跑完的,而是会加入花指令——大量无关的操作码混在里面,真正起作用的只有其中一小段。输入变化时,花指令序列不一定变,但核心算法那段一定会出现可观察的模式差异。
AI很快发现了一个规律:在每次参数生成的前半段,总会反复出现 push -> push -> xor -> add -> store 这样的五联操作码序列,而且最后一次store的地址偏移和输入字符串的字符编码是一一对应的。顺着这个线索,我回到插桩点去看对应handler的实现,证实了这是一个自定义的字符串编码循环,把明文映射成密文,再配合一个固定的常数表做二次变换。
3.4 最终还原:不是完全还原VM,而是还原最小映射
到这里,我已经不再需要理解整个VM的语义了。我的目标是:给定输入,能预测输出。通过插桩日志,我把testab的算法结构拆成了三层:
- 第一层:固定字符串拼接,把时间戳、设备ID、cookie的一部分按固定格式拼起来;
- 第二层:自定义编码循环,对拼接后的字符串做逐字符异或和查表替换;
- 第三层:一次混淆后的MD5变体计算,输出32位十六进制字符串,再把字符串按规则填充成testab的最终格式:
code复制固定前缀 + 时间戳密文 + 校验码
中间那层编码循环是整段逆向里最耗时的部分。好在插桩日志已经把每一次循环的输入、输出、查表索引都录下来了,我把它整理成一张真值表,让AI根据真值表反推编码公式。AI给出的候选公式里有几个是错的,但其中一个(c ^ 0x5A) + 0x3F加循环左移的假设,我用插桩数据验证后完全吻合。这一步验证非常关键,任何AI的假设,不上插桩数据验证都不能信。
4. _signature参数完全不同的战场:环境检测与执行流追踪
4.1 头条系签名参数的定位与VM识别
某头条的_signature参数,网上讨论热度一直很高,但它比testab难在它是多层嵌套的。请求里的_signature不是单一函数生成的,而是经过了一个"环境检测通过后,才会进入加密VM执行"的流程。我一开始按testab的老套路,hook网络黑请求找入口,也确实找到了一个生成_signature的上下文。但马上发现,直接调用这个函数返回的签名是假的——服务端拿到后会拒绝请求,报code 400。
原因就是环境检测。头条的加密JS在真正执行签名算法前,会做大量的浏览器指纹校验:navigator.userAgent、platform、webdriver标志、chrome对象是否存在、canvas指纹采样的结果、window.outerWidth这些属性的一致性,甚至还包括字体渲染特征。任何一项不匹配,签名算法要么不执行,要么在某个环节偷偷塞进错误标志。
4.2 环境检测这堵墙:比VM本身还烦
对这类带环境检测的目标,常规做法是补环境:在Node.js或者自建JS运行环境里,把浏览器独有的API一个个mock出来。但头条的检测不是简单的"读属性",它还检查属性的完整性、可配置性、以及toString的返回值。比如你用纯jsdom补环境,navigator.webdriver这个属性如果被标记为不可配置,检测还是能看穿。
我在这个环节折腾了将近两天,一直搞不定本地补环境。后来换了个更务实的思路:直接用浏览器当运行环境,用脚本控制浏览器自动化执行签名函数,然后通过本地网络把参数生成能力暴露成接口。这样环境检测天然通过,问题就只剩下如何从VM里拿到算法结构了。
4.3 头条系VM的差异化特征
进了VM以后,我发现头条的VM比x程那个要复杂一个档次。它的操作码不是固定长度,而是带可变长度立即数,而且handler里有大量运行时修改指令指针的操作。这意味着你不能静态地看字节码,必须动态追踪指令流,否则连"下一条指令执行什么"都猜不到。
动态追踪的做法依然是插桩,但插桩点的选择要有讲究。我在头条的VM dispatcher里不仅打印了opcode和stack,还把每个handler里ip寄存器的最终值也打印出来:
javascript复制// 记录每次执行完后的指令指针跳转情况
const originalHandler = vm.handlers.get(opcode);
vm.handlers.set(opcode, function(ctx) {
const ipBefore = ctx.ip;
const result = originalHandler(ctx);
const ipAfter = ctx.ip;
if (ipAfter - ipBefore !== 1) {
log({ type: 'control_flow', ipBefore, ipAfter, opcode });
}
return result;
});
这个"跳转追踪"非常管用。头条VM为了反插桩,故意在字节码里混了很多jump指令,让执行流不是顺序遍历,而是反复跳转。把这些跳转记录下来,就能画出真正的执行路径图,发现它其实只反复执行了一小段核心循环,剩下的全是干扰项。
4.4 执行流可视化的AI二次加工
把跳转日志导出来之后,我先用脚本过滤出"有效执行路径"——也就是去掉死代码分支和垃圾跳转后的指令序列,然后做成dot格式的调用关系图,再让AI生成这个执行流的自然语言描述。AI给出的总结非常到位:签名核心是一个三段的计算管线:初始化key扩展 -> 数据填充与混淆 -> 输出编码。虽然它在具体算法细节上帮不上忙,但给了这张"地图"之后,我再回头去看具体handler实现,效率比之前从零摸底快了好几倍。
最后_signature的算法结构被还原成这样:
code复制_signature = Base64URL(
HMAC-SHA256(
key = 动态密钥向量,
data = cookie值 + url路径 + 时间戳
)[0:16]
)
其中动态密钥向量是通过一个256字节的查表S盒生成的,这个S盒在每次页面加载时都会打乱一次,打乱的随机种子和某个cookie值相关。所以同样一个URL,换一个会话环境,_signature也会不同。这也解释了为什么纯扣代码很难跑通——它不是"固定算法+固定key",而是"算法依赖运行环境生成的随机状态"。
5. 插桩日志落盘与AI提示词模板:把经验变成可复用资产
5.1 日志格式设计:别小看这个环节,它决定AI分析质量
插桩和日志采集如果做得粗糙,后面AI分析根本无从下手。我这次踩过"日志字段全、但可读性差"的坑,后来重新设计了一个JSON Lines格式,每个日志条目包含以下字段:
| 字段 | 类型 | 说明 |
|---|---|---|
seq |
number | 全局递增序号,用于还原执行顺序 |
type |
string | 事件类型:vm_entry / vm_exec / control_flow / call / ret |
opcode |
string | 十六进制操作码,如 0xA3 |
ip |
number | 当前指令指针 |
stack |
array | 栈顶若干元素的快照,截断到最多8个 |
env_diff |
object | 环境变量变化增量,只记录被修改的key |
ts |
number | 毫秒时间戳 |
这个格式的关键点是:栈快照要截断,环境只记录增量。 最开始我把整个环境对象每个指令都打出来,日志文件动辄几个GB。改成增量之后,单次参数生成的日志只有几MB,既能完整反映执行状态,又不会大到无法处理。
5.2 条件插桩与抽样:日志量管理的实战细节
日志量管理是JSVMP插桩最容易被低估的问题。除了前面提到的"只在关键操作码打日志",还有两个技巧很实用。
第一个是采样插桩。如果目标是理解整体执行流程,不需要每一条指令都记录,可以每10条记录1条,或者用Math.random() < 0.1做随机抽样。抽样出来的日志虽然不连续,但做模式识别和聚类分析足够了。第二次循环再全量记录关键区域,进一步聚焦。
第二个是输入差异对比插桩。这个技巧在testab上特别有效——我先用两个不同的账号cookie各跑一次签名生成,然后对比两次执行日志的diff。由于VM入口和大部分handler都是固定的,两次日志的差异完全集中在和cookie值相关的少数代码段上,直接定位到核心算法。这个思路比大量插桩后再做关联分析快得多。
5.3 AI提示词模板:直接抄作业
AI辅助逆向的prompt不需要太复杂,关键是给足上下文、明确输出格式、限定分析范围。我整理了几个亲测好用的模板,实际用的时候按场景替换其中的描述即可。
模板一:日志语义摘要
code复制下面是一段JSVMP执行日志的抽样,字段含义:seq=执行序号,opcode=虚拟机操作码,
stack=当前栈快照。请帮我总结这段日志体现的执行模式,
重点关注:1. 重复出现的操作码序列;2. 栈深度的变化规律;
3. 可能对应的算法结构(如循环、查表、位运算、字符串拼接)。
输出格式:先给结论,再给每个结论对应的日志片段。
模板二:handler语义猜测
code复制以下是一个JSVMP handler的JavaScript代码片段。请根据它的逻辑,
猜测这个handler在虚拟机中承担的角色(例如:立即数压栈、算术运算、字符串操作、
算术右移、对象属性设置等),并用自然语言解释每一步操作的含义。
回答请用中文,控制在200字以内。
模板三:伪代码还原辅助
code复制根据下面的插桩数据(含输入、执行序列、输出值),请还原这段虚拟机的语义逻辑,
以类C伪代码输出,要求:
1. 明确标出每一步操作对应的操作码;
2. 如果存在查表操作,请给出查表规律;
3. 对不确定的部分,用[猜测]标注并说明理由。
这三个模板看起来简单,但每一条我都迭代过很多版。比如"限定输出格式",如果不写,AI可能会洋洋洒洒输出几百字,反而抓不住重点。再比如"对不确定的部分标注",这个要求能让AI主动暴露不自信的地方,避免我误把猜测当成事实。
6. 说点逆向后遗症:JSVMP打穿之后总结的经验教训
两个目标都搞定之后,我花了一周时间复盘,把过程中的判断失误和判断正确的点都过了一遍。有几条教训,我觉得比文章前面的技术细节更有价值。
第一,JSVMP逆向一定要先动态再静态,别反着来。 我一开始testab花了整整一天做静态分析,读VM代码、画handler分布表,结果一点用没有。后来转为"动态插桩发现问题、静态读代码补充说明"的思路,进度立刻起飞。虚拟机的逻辑本质上是状态机,静态阅读很容易陷入"每个片段都能看懂、串起来却不知道在干嘛"的困境。动态日志能强制让人看到执行的真实路径。
第二,日志处理到一定程度,就必须人肉介入做决策。 AI能非常高效地帮你从海量日志里找出"哪里值得深挖",但它不懂业务场景,不知道哪个参数是时间戳、哪个是cookie、哪个是设备ID。这些业务语义的判断还得靠逆向者结合前端整体代码去猜。我经常把AI分析结果当作一个"高级助手的工作底稿",真正的算法定论都是我自己手动验证过的。
第三,所有逆向结论都必须以"可复现的输入输出对"为验收标准。 说什么把VM还原了都不算数,只有"给同一输入、同一个环境状态,能算出完全一样的testab/_signature,并且服务端接受请求"才算成功。我就是在这个标准下毙掉过好几个AI给出的看似合理、但实际跑不通的还原假设。
再说个技巧,插桩埋点的时候给每个日志条目带上业务上下文标签,比如"来自testab生成流程"、"来自_signature的key扩展阶段"。这样在分析时能快速过滤日志,不会让无头绪的日志流主导整个分析方向。这个习惯我是在做头条那个项目时养成的,后来在另一个APP的签名逆向里也救了我一次。
最后想说的是,JSVMP逆向的终局不一定是把整个虚拟机翻译成可读代码。大多数时候,找到一个"输入到输出的最小映射"就够了——哪怕你不理解VM里90%的handler为什么存在,只要能把两端的行为对上,任务就完成了。这种"最小映射"思维,在面对大型混淆保护时,往往比追求大而全的还原方案实用得多。尤其是现在有AI辅助日志分析,定位最小映射的速度比纯人肉快了好几倍。如果你正在啃某个JSVMP参数,不妨按这个思路把插桩日志先跑起来,再用AI做语义摘要,大概率能少走不少弯路。
