ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

jsvmp逆向实战:某音a_b参数纯算还原全记录

jsvmp逆向实战:某音a_b参数纯算还原全记录

1. 项目整体思路与目标拆解

1.1 a_b参数是什么,为什么要啃它

先说点实在的。a_b参数是某音Web端请求里一个高频出现的query参数,几乎所有核心接口都带着它,服务端拿它做风控判断。这个参数不像普通签名那样把入参拼一拼再做个md5就完事,它是通过一套完整的JS虚拟机保护生成的,也就是标题里写的jsvmp。

所谓jsvmp,简单说就是把正常的JS逻辑先用自定义编译器转成一段字节码,然后在浏览器里用解释器一条一条执行。你打开控制台看源码,只能看到一个巨大的解释器框架和一段密文,业务逻辑全被藏进了字节码里。还原这段字节码,把算法逻辑用纯JS或Python重写出来,就是“纯算”。不依赖浏览器、不依赖真实环境,逻辑可控、移植方便,这是纯算最大的价值。

我在还原V 1.0.1.19-fix.01这个版本时,最直观的感受是:这个版本比早期版本多了几层操作数混淆,而且fix.01本身就是个补丁版,说明官方在原有逻辑上修过东西,逆向思路也得跟着微调。这篇文章就是把整个拆解过程记录下来,适合正在做Web端风控参数研究、或者对JS虚拟机保护感兴趣的开发者阅读。

1.2 纯算还原 vs 补环境 vs RPC的选型对比

拿到a_b参数需求之后,第一件事不要急着看代码,先把方案定了。目前主流做法有三类:纯算还原、补环境、RPC调用,各自的优缺点非常明显。

方案优点缺点适用场景
纯算还原逻辑彻底可控,运行稳定,可部署到任意语言的服务端工程量巨大,字节码分析费时费力,版本更新后需重新跟高并发、长期稳定运行
补环境上手快,代码量小,前端页面改改就能跑依赖浏览器环境对象,环境检测一更新就崩,维护成本高短期验证、小规模采集
RPC调用完全不碰算法逻辑,直接用真实客户端执行需要维护真实客户端实例,并发受限,单点风险大低频调用、应急方案

我最终选了纯算,核心原因是:a_b参数的调用量非常大,补环境在并发上来之后会出现各种环境指纹不一致的问题,RPC则被客户端实例数卡死。只有把算法真正抠出来,才能在服务端用任何语言复现,性能和可控性都最好。

但这不代表纯算是“最优解”,它是最费人的解。如果你只是临时调几个接口,补环境反而更划算。方案没有好坏,只有合不合适。

1.3 版本号V 1.0.1.19-fix.01透露了什么信息

这个版本号很关键。很多人拿到一个版本就开始看代码,忽略了版本本身的含义。V 1.0.1.19是一个常规迭代号,fix.01表示这是一个修复版本。修复了什么?从实际抓包和代码对比来看,这个版本修的是部分环境采集逻辑的边界判断,同时对时间戳精度做了收敛处理。

对我们逆向的影响是什么?第一,不能拿网上旧版的分析结论直接套,很多指令序列和常量表都变了。第二,fix版本往往会修复一些“过于明显的特征”,如果旧脚本里有写死的操作数偏移,在新版本上会直接跑飞。第三,这也是一个提醒:做纯算还原时,尽量把版本信息固化到项目配置里,后续每次更新都做字节码diff,而不是从头盲猜。

2. jsvmp核心原理与调试环境搭建

2.1 虚拟机保护的基本盘:opcode分发器

jsvmp的本质就是一个自定义的栈虚拟机。它不是新概念,和Java虚拟机、Python解释器是一个套路,只不过这个虚拟机专门为混淆JS逻辑而存在。

整个解释器可以拆成三个部分:字节码区、操作数栈、opcode分发器。字节码区存放的是编译后的二进制指令序列,操作数栈用来临时存储计算结果,opcode分发器则是核心循环——它不断读取当前指令的opcode,然后跳转到对应的处理逻辑执行。

用一个不严谨但好懂的类比:你把一道菜的做法写成了只有自己看得懂的符号(字节码),然后请了一个只按符号做菜的厨师(解释器)。外人就算拿到符号和厨师,也不知道菜的具体步骤。还原算法,就是把符号翻译回能看懂的菜谱。

从还原角度看,我们不需要完整实现一个解释器,只需要让这个解释器“说出来”它每一步做了什么。这就是插桩要干的事。

2.2 插桩与日志:让虚拟机动起来

要让虚拟机动起来,最直接的办法是在opcode分发器里加点“窃听器”。找到解释器的主循环之后,我通常会在执行每条指令时打印这四样东西:当前opcode、操作数、操作数栈关键值、程序计数器pc。

插桩代码大致长这样:

// 伪代码,仅示意插桩思路 while (pc < bytecode.length) { const opcode = bytecode[pc]; const beforeStack = JSON.stringify(stack.slice(-5)); // 原始执行逻辑 dispatch(opcode, operands, stack, context); // 插桩记录 log.push({ pc, opcode, operands, beforeStack, afterStack: JSON.stringify(stack.slice(-5)), result: stack.length ? stack[stack.length - 1] : undefined }); pc = nextPC; }

这一步不用追求完美,先跑通,拿到足够多的执行日志再慢慢分析。实际操作中日志量非常庞大,一个a_b参数生成过程可能执行上万条指令,直接看日志能把人看吐。所以日志格式一定要加JSON序列化,方便后面用脚本分析,别用console.log随便打,不然分析阶段会非常痛苦。

我在这个版本里还遇到了一个有意思的现象:部分opcode存在“假执行”,即指令执行了,但结果立即被后续指令覆盖。这种是纯干扰项,在日志里表现为连续两条指令写入同一个槽位。分析时可以先过滤掉,不必浪费时间逐条理解。

2.3 反调试与花指令的排除思路

jsvmp保护一般会叠加反调试和花指令,V 1.0.1.19-fix.01也不例外。先说反调试,比较常见的有三类:

第一类是无限debugger,很多人一打开控制台就断住。处理思路很简单,找到debugger语句的位置,用浏览器开发者工具的“Never pause here”功能屏蔽,或者直接改写代码把debugger删掉。

第二类是时间校验,虚拟机会检测某段代码的执行耗时,如果执行时间异常短或异常长,就判定为调试环境。这个比较麻烦,需要我们在还原时对时间类操作单独打标,后面在纯算代码里统一mock。

第三类是函数toString校验,解释器的某个环节会检查关键函数是否被改写。在调试时一旦hook了原生方法,就可能触发校验,导致虚拟机走错误分支。

再说花指令。花指令在jsvmp里通常表现为:执行一条指令后产生了一个明确但不参与后续计算的值,或者多个opcode序列只在某种条件下生效。排除花指令没有一个自动化的通用方法,我在实操中主要用“执行日志对比法”——分别在正常执行和修改某个输入后执行,对比日志差异。两边日志中行为完全一致的指令段,基本就是花指令或死分支。

3. 纯算还原的核心环节实操

3.1 定位字节码加载入口

还原的第一步是找到字节码是从哪加载进来的。某音这个jsvmp不是把字节码明文写在JS里的,而是经过了一层字符串解密,然后通过webpack模块机制注入到解释器里。

定位方法我建议按下面的顺序排查:

  1. 在解释器主循环入口处下断点,观察bytecode变量是在哪个函数里被赋值的。
  2. 往上回溯调用栈,找到调用解释器的模块ID。
  3. 在webpack的__webpack_require__里对数组做hook,打印每次模块加载情况,找到字节码加载的那一次。
  4. 对字节码数组本身做内存搜索,确认它的解密位置和解密算法。

这个版本里,字节码加载用的是一次性解码,解码函数执行完会把原始密文清掉,内存里只保留解码后的数组。所以想在运行时抓“完整明文”会比较费劲,更好的做法是hook解码函数,在解密后的结果上做快照。

我记得当时写了一个大概二十行的hook脚本,专门拦截解密函数返回值,把字节码数组dump到全局变量里,然后通过JSON.stringify复制出来。这一步成功后,后面所有分析才有了数据基础。

3.2 解析常量池与外部函数表

拿到字节码之后,第一件事不是逐条分析指令,而是先把常量池和外部函数表拆出来。jsvmp为了减小体积,会把字符串、数字等常量单独存在一个常量表里,指令中通过索引引用。外部函数表则是虚拟机运行时需要调用的原生JS函数列表,比如Date.now、Math.round、window.devicePixelRatio等等。

外部函数表的解析非常关键,因为纯算还原的核心难点之一就是把“哪些逻辑在字节码里、哪些逻辑借调了宿主环境”分清楚。比如一个时间戳获取,在字节码里可能只显示一个CALL指令,参数指向外部函数表第N项,对应的是Date.now。如果不建表,后面看到的就只是一堆索引,完全没法理解业务逻辑。

我在解析时会把外部函数表整理成一张对照表:

索引宿主函数用途
0x0ADate.now时间基准
0x0FMath.round数值取整
0x17window.screen.width屏幕特征采集
0x22Array.prototype.push数组操作

这张表是逐步完善的,一开始只能识别出一部分,后面边分析边往里补。建议用JSON格式保存,方便后续脚本读取。

3.3 栈帧语义分析与关键算法提取

常量池和函数表就位后,就可以开始逐条翻译字节码了。这是我整个流程里最耗时的阶段。为了降低难度,我一般先把单条字节码的opcode整理成伪代码,再根据栈的变化规律把伪代码合并成逻辑块。

比如某一段字节码的执行日志如下:

0x0012 PUSH_CONST 0x01 // 取常量表第1项 0x0013 PUSH_CONST 0x05 // 取常量表第5项 0x0014 ADD // 两数相加 0x0015 PUSH_ENV 0x0A // 调用Date.now 0x0016 XOR // 异或运算

翻译成人话就是:(常量1 + 常量5) ^ Date.now()。一条一条看觉得不复杂,但当指令上万条时,人脑是跟不上的。所以我的习惯是写一个半自动翻译脚本:先按opcode表把每一条指令转成寄存器式中间语言,再写规则把常见指令模式合并成高级表达式。

在V 1.0.1.19-fix.01里,我发现一个比较坑的地方:部分操作数不是直接存放在指令流里,而是经过了一次额外的LOAD_INDIRECT间接寻址。这个设计导致按静态方式看字节码是看不懂的,必须结合运行时的栈快照才能确定操作数真实值。这也是为什么插桩日志是必需品,而不是可选项。

整个关键算法提取完成后,最终会得到类似这样的核心逻辑:

function generateAB() { const timestamp = new Date().getTime(); const base = parseInt(timestamp / 1000, 10); let seed = base * 0x3C + 0x1F; // 下面这些值来自外部函数表和常量表 const feature = collectEnvFeatures(); // 对应外部函数表各项调用 const cab = encodeFeature(feature); // 对应字节码核心计算块 const tail = calculateTail(seed, cab); // 对应另一段字节码 return seed.toString(16) + cab + tail; }

这只是一个示意结构,真实逻辑比这个复杂得多,但它能说明还原的目标:把黑盒变成一个可以用普通JS重写、并且能脱离浏览器运行的函数。

3.4 用AST工具辅助验证还原结果

纯算写完以后,最怕的是“局部正确、整体错误”,比如所有子函数看起来都对,但组合起来结果就是不对。我的做法是引入AST层面的对比验证,让机器帮我把还原结果和原始执行过程做对齐。

具体思路是:把插桩日志中记录的每一次运算结果,与纯算代码中对应位置的计算结果做差对比。差异集中在某个数据槽位时,就能快速定位是哪一步翻译错了。AST工具在这时主要是辅助可视化,比如用AST Explorer把还原出的JS代码渲染成语法树,检查是否有异常的嵌套结构。

我还会把纯算代码打包进一个带日志断点的版本,在Node里跑一遍,把中间变量输出成JSON,再和浏览器里插桩输出的JSON逐字段比对。这种结构化对拍比肉眼盯控制台高效得多。

写一个简单的对拍脚本:

// 对拍逻辑伪代码 const browserLogs = require('./browser_logs.json'); const pureLogs = runPure(generateAB, testInputs); for (let i = 0; i < browserLogs.length; i++) { if (browserLogs[i].expr !== pureLogs[i].expr) { console.log('Mismatch at index', i); break; } }

对拍全部通过之后,再把浏览器环境彻底去掉,用Node独立跑一遍,确认生成结果与真实浏览器抓包结果一致。这一步过了,纯算还原才算是真正落地。

4. 常见问题与排查技巧实录

4.1 典型问题速查表

整个还原过程中一定会遇到各种问题,我把V 1.0.1.19-fix.01这个版本里最典型的问题整理成了速查表,方便对号入座。

现象可能原因排查思路
插桩日志中途丢失虚拟机检测到调试状态切换了分支关闭时间校验,重新从主入口插桩
还原后结果字符串长度不对漏掉了某个append操作对比日志中的字符串拼接节点
计算结果与浏览器不一致外部函数表映射错误核对索引,重点检查Math/Date相关函数
服务端调用频繁报错环境特征采集结果差异过大检查采集顺序,确认没有漏采集项
版本升级后旧脚本全废字节码常量表整体变更固定版本快照,做字节码diff
偶现参数校验失败精度处理不一致统一使用相同的时间戳基准和取整方式

以上每一个问题我都实际踩过,尤其是环境特征采集顺序这个坑。a_b参数里的环境采集项是有固定顺序的,这个顺序不是按代码调用顺序生成的,而是经过字节码重排后的顺序。纯算还原的时候如果不按重排后的顺序生成,最终特征串就是错的。

4.2 三个印象深刻的坑

第一个坑是时间戳基准不一致。我最初还原时直接用的Date.now(),但原逻辑里其实先取了一次时间戳保存到局部变量,后续所有计算用的都是这个基准值。我在还原时又在不同步骤里重新取了多次当前时间,导致计算结果一会儿对一会儿不对。后来把时间基准改成统一注入,问题就消失了。这也是一个很容易被忽略的细节:字节码里所有时间相关指令,必须先确定它们引用的是同一个基准变量,还是每次都重新取。

第二个坑是操作数栈的清理时机。jsvmp的操作数栈在某些指令执行后不会立刻弹出已经消费的操作数,而是保留到下一个同步点才统一清理。我在写还原脚本时,以为所有指令都是标准栈语义,导致部分指令的索引错位。解决办法是严格按照插桩日志里beforeStack和afterStack的快照来调整栈偏移,而不是靠猜。

第三个坑是fix.01版本修改了某个标志位的判断逻辑。早期版本的a_b生成过程里有一个布尔标志控制是否拼接某段固定字符,fix.01把这个标志的判断条件反转了,同时加了一个掩码运算。如果只看旧资料,这一段怎么都译不通。后来通过对比新旧版本的字节码diff,才定位到是这个标志位的问题。这个经历让我养成了一个习惯:分析任何版本之前,一定先做一次字节码整体指纹比对,确认和已知版本的关系。

写在最后的一些体会

纯算还原是一件非常磨人的事,它不像补环境那样改改window对象就能跑,也不像RPC那样挂着客户端就能调。它需要你真正把虚拟机的每一条指令都读懂,把每一张表都建清楚,把每一个外部函数都对齐。这个过程很枯燥,但一旦跑通,你对jsvmp的理解会透彻很多,后续再遇到其他版本的字节码,上手的速度会快不少。

我个人在实际操作中的体会是:插桩日志和分析脚本的工程质量,决定了纯算还原的上限。不要写一次性脚本,用完就扔,要把日志抓取、指令翻译、对拍验证这些环节都做成可复用的工具。这样每次遇到新版本,只需要调整常量表和外部函数表,主体框架可以继续复用。

按照这个思路做下来,哪怕后续某音继续迭代版本,你手里也已经有了一套可以快速跟进的分析流水线。这个项目最值得沉淀的,不是某个版本的a_b生成函数,而是那套帮你把黑盒变成白盒的过程和方法。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进