首页 / 资讯中心 / 文章详情

abogus-jsvmp签名逆向:从VM入口到算法还原

abogus-jsvmp签名逆向:从VM入口到算法还原 ★ FEATURED ARTICLE
第一次遇到某短视频平台的 abogus-jsvmp 签名参数时说实话我是有点兴奋的。折腾过接口逆向的人都知道普通 AES、MD5 加盐这种加密基本属于开胃菜真正让人头疼的是那种把整段业务逻辑丢进自定义虚拟机里的保护方案。这次的目标正是这样一个典型的 JSVMP 体系接口请求里带一个叫 abogus 的签名值无论你怎么翻代码看到的都是几个巨大的混淆函数和一堆看不懂的字节码。这篇文章把整个分析链路完整记录了一遍包括怎么从混淆代码里定位 VM 入口、怎么提取指令和常量池、怎么还原控制流、最后怎么在 Node 环境里补环境并完成算法复现。整个过程只服务于技术学习、授权测试和接口调试场景不建议对未授权目标做任何尝试。1. 项目背景JSVMP 是怎么逼到面前的1.1 一条网络请求引发的逆向追问事情起因很简单某个自动化数据采集项目需要在这类短视频平台上面跑一批定向内容校验任务。业务上很常规就是在登录态下请求某个接口把返回的数据落库做二次分析。刚开始一切都顺利Cookie 带上、UA 伪装好、请求头尽量模仿浏览器接口正常返回。但在某个版本更新之后服务端突然开始频繁拒绝请求错误码提示里的关键词基本指向“签名校验不通过”。抓包一看请求参数和请求头里多了一个之前没见过的字段abogus。每次刷新页面这个值都不一样长度固定字母数字混排。当时第一反应是找生成入口直接在开发者工具里全局搜索 abogus结果只搜到几段被压成一行、变量名全是单字母的 JS。单靠格式化去读人会直接疯掉。更麻烦的是关键逻辑并不是明文 JS 函数而是跑在一个 JSVMP 解释器里也就是把原生 JavaScript 编译成一套自定义字节码然后由运行时解释执行。明文代码里只有一个壳真正的算法全部埋在字节码数据里。后面花了几天时间把整个 JSVMP 的执行流程、指令调度方式和环境校验点全部理清了也成功在本地把 abogus 的生成过程还原了出来。这篇文章就是把这条完整路径拆开来讲每个环节的入手思路、关键断点和容易踩的坑都会提到。1.2 JSVMP 到底保护了什么先给不太熟悉的朋友解释一下 JSVMP。VMP 这个词最早来自软件保护领域意思是把原始指令转成虚拟机指令再用一个解释器去执行让静态分析很难直接看懂。JSVMP 就是把这套思路搬到 JavaScript 上常见做法是准备一段字节码数组、一个指令分派循环、一组 opcode 处理函数原始业务逻辑被编译成字节码后运行时由这些处理函数逐条解释执行。放到这个例子里abogus 的生成逻辑并不会以普通函数的形式出现在代码中。你看到的是一个类似下面这样的壳函数function generateSign() { return vm_execute([/* 字节码数组 */], 0); }字节码里存了字符串常量、函数调用编号、加减异或等操作码。你无法通过格式化直接看到“先拼字符串、再做哈希、再取前几位”这样的逻辑只能看到解释器在按一个长长的数组顺序执行。这也是 JSVMP 最核心的保护逻辑把“数据”和“算法”拆开算法在解释器里数据在字节码里普通静态扫描根本无从下手。除了隐藏算法JSVMP 还会在解释循环里插入大量的环境检测点比如检测是否在 Node 环境、是否缺少浏览器对象、调用栈是否异常。这些检测点和算法混合在一起导致你就算把字节码抠出来放到纯 Node 里跑也会报错。所以完整的还原路线不光是还原字节码逻辑还要把环境补全才能让结果稳定复现。2. 工具准备与基础识别2.1 本地调试链路搭建开始逆向前先把调试环境准备好。我这里用的是了一套非常通用的本地链路一个支持 HTTPS 抓包的代理工具加 Chrome DevTools再加上一个本地 Node 运行时。代理工具的作用很简单就是把网页加载的 JS 文件和接口请求都记录下来尤其是新版代码和旧版代码的 diff能快速判断 abogus 是在哪个版本引入的。Chrome DevTools 用来下断点、看调用栈、动态观察变量变化。Node 环境则用在最后阶段做补环境验证。如果要从自动化浏览器里直接取代码可以用 Puppeteer 或 Playwright 控制一个无头浏览器把目标 JS 拉到本地文件然后用本地静态服务器替换。这样每次调试不用重复刷新页面效率高很多。搭建本地调试链路时有几个细节值得注意代理工具需要配置 HTTPS 证书信任否则浏览器会拦截流量。这一步只要在本地调试设备上安装证书即可。替换 JS 文件时尽量保持文件路径和原有加载顺序一致避免因为加载顺序变化导致后续逻辑报错。如果页面有 Service Worker 缓存替换可能要绕开缓存最简单的方式是把本地代理的响应头里加一段禁止缓存的字段。2.2 一眼认出 JSVMP 的几个特征很多人拿到混淆代码不知道从哪里下手其实 JSVMP 有几个非常明显的指纹。第一个特征是看到一个超大数组里面全是数字偶而夹杂英文字母和编码字符串。这个数组通常不是直接给普通逻辑用的而是作为字节码池存在。第二个特征是一个巨大的 switch 或 if-else 链分支数量可能上百个每个分支里又调用同一个执行函数。这就是指令分派循环通常长这样function vm_execute(bytecode, pc) { while (pc bytecode.length) { switch (bytecode[pc]) { case 1: // 处理某个操作 break; case 2: // 处理另一个操作 break; // ... } } }第三个特征是字符串常量以密文形式存在运行时通过一个解码函数动态还原。解码函数本身又可能被单独混淆。还有一个常见特征是函数参数里出现类似vm_state、stack、scope的变量说明这是一个模拟栈机执行过程中大量使用数组模拟调用栈。看到这些特征基本可以确定是 JSVMP而不是普通的混淆压缩。识别出 JSVMP 之后不要急着去还原整段字节码。正确的做法是先找到入口把“调用关系”和“执行上下文”摸清楚再决定怎么下断点。3. 从混淆代码到可读逻辑的还原步骤3.1 定位 VM 入口和指令分派器还原工作的第一步是定位 VM 入口。所谓入口就是业务逻辑第一次调用 vm_execute 的那个位置。以我这次的目标为例我需要找到 abogus 最终被调用时调用栈里最靠近业务函数的那一层。如果直接在 DevTools 里搜索abogus字符串搜到的往往是赋值语句比如某个对象上的属性叫 abogus。真正的生成逻辑并不在这个赋值语句附近。我的做法是给最终字段赋值的位置下断点。比如页面源码里有一行sign generate_vm_sign(params);我就在这一行下断点然后单步进入函数。如果函数直接返回字节码执行结果那就继续往下跟很容易看到 vm_execute 被调用。跟进 vm_execute 之后第一眼看到的就是指令分派循环。在这里可以观察每次循环的 pc 值和操作码这就是字节码的执行轨迹。为了高效记录执行轨迹可以在分派循环入口处加一条条件断点把当前指令和关键寄存器变量打印到控制台。比如在下面这行下断点while (pc bytecode.length) { // 在这里加日志输出 bytecode[pc] }每执行一条指令控制台就会打出一条记录。连续记录几千条之后就能看到整个生成过程的执行全貌。这一步并不需要立刻理解每条指令的含义先把轨迹拿到手后面再做语义映射。3.2 提取字节码与常量池拿到执行轨迹之后需要把完整字节码和常量池提取出来。字节码数组在 vm_execute 被调用时就已经存在内存里可以直接在控制台执行类似这样的表达式JSON.stringify(bytecode)有时候字节码数组是动态生成的比如页面先做若干轮 xor 解码再把解码结果当作字节码。这种情况下光在 vm_execute 入口取一次是不够的需要在数组每次变化的位置都做一次快照。我常用的办法是在数组赋值语句后加断点观察新数组和旧数组的关系。常量池一般藏在另一个数组里用一串索引来引用。常见的结构是var constants [, str1, 123, true, null];操作码里的某个参数就代表 constants 的下标运行时从 constants 中取出真实值。所以提取字节码的同时也要把 constants 完整保存下来。否则你只能看到一堆load_const 2却不知道 2 对应的值是什么。有些版本的 JSVMP 还会对常量池做分页处理比如所有字符串打散成多个子数组再通过三四个 index 组合。遇到这种情况可以直接劫持常量读取函数在返回真实字符串时断点记录一次。这样拿到的常量是最完整的也不会漏。3.3 还原控制流平坦化字节码还原完成之后还不能直接对应到原始 JS 逻辑因为这中间还有一层控制流平坦化。JSVMP 在编译时会把原本的 if、for、while 结构拍平改成由调度状态驱动的跳转。普通代码里的条件判断在字节码里会变成“比较指令 条件跳转指令”而跳转目标往往是通过数组下标动态计算的。要把这种平坦化结构还原成可读逻辑常用的方法是把执行轨迹中的所有“跳转边”记录下来构建一张基本块图再根据基本块的跳转关系恢复循环和分支。手工做这项工作非常痛苦。我是用 Node 写了一个 Babel 脚本对下载下来的混淆 JS 做 AST 级分析把 switch 结构识别出来还原成基本块之间的有向关系。一个简化的还原思路是这样的const parser require(babel/parser); const traverse require(babel/traverse).default; const t require(babel/types); // 找到包含指令分派循环的 switch // 1. 收集每个 case 中的表达式 // 2. 找出 switch 的判别变量 // 3. 分析每个 case 结束时如何修改判别变量 // 4. 重组基本块生成新的控制流实际执行时这条链路并不会全自动跑通。switch 的每个 case 里可能包含多条指令每条指令都有自己的跳转逻辑再加上异常处理、试运行等干扰分支自动还原结果经常出现几个基本块孤立的情况。我的做法是先用脚本还原 80%剩下 20%靠人工分析执行轨迹补齐。字节码层面的还原反而比 AST 层面的还原更接近原始逻辑因为执行轨迹是把每一行真实跑过的指令都列出来了不包含死代码。4. 函数导出与补环境实战4.1 从 VM 外壳中剥离生成函数当字节码的执行逻辑被摸清之后接下来要做的就是把核心生成函数从整个 VM 外壳中剥离出来做成一个可独立调用的模块。这一步的思路并不是真的把解释器删掉而是尽量缩小需要模拟的范围。我的做法是构造一个“最小执行壳”保留 vm_execute、常量池和必要的处理函数把跟 DOM、事件、异步调度无关的代码全部跳过。可以先通过之前记录的调用栈找到 abogus 生成时真正用到的处理函数编号然后把其他分支函数注释掉。这样可以省掉大量环境相关代码让后续补环境变得简单。最小壳构造完成后直接尝试在 Node 里调用const vm require(vm); const code fs.readFileSync(./min_shell.js, utf-8); const sandbox {}; vm.createContext(sandbox); vm.runInContext(code, sandbox);第一次调用大概率会报错比如出现navigator is not defined、window is not defined、document is not defined。这些都是预期内的环境缺失接下来进入补环境阶段。4.2 用 Node 沙箱补齐浏览器指纹JSVMP 保护中环境检测通常不是单独一段代码而是散布在很多指令流程中。可能一条加解密指令执行前会先检查某个环境变量是否满足条件。补环境的核心原则是尽量复用普通值不要为了凑某个随机数而引入大量随机逻辑。常见的检测点包括navigator.userAgent、navigator.platform、navigator.languagewindow、document、location 对象canvas 指纹相关的 createElement 与 getContextDate.now、Math.random 等时间随机源Function 构造函数、toString 行为Error 堆栈格式在我的案例里最关键的是 navigator 和 document。因为字节码里大量使用一个get_env指令专门获取浏览器环境值。我在补环境时先构造了一个基础沙箱把最常用的对象填进去const sandbox { navigator: { userAgent: Mozilla/5.0, platform: Win32, language: zh-CN, cookieEnabled: true, }, window: {}, document: { createElement: function () { return { getContext: function () { return {}; }, width: 0, height: 0, }; }, }, location: { href: https://example.com, protocol: https:, }, Date, Math, };补环境不是一个静态操作需要根据报错信息不断迭代。每报一个 undefined就补一个对应的属性。但要注意补得越多系统开销越大也越容易在代码中留下过深的痕迹。我建议先用一个最小 sandbox 跑通流程再逐步补充检测点。4.3 输出一致性校验补环境最终要用输出一致性来检验。做法是准备一组固定输入参数分别跑原网页环境和本地 Node 环境对比最终生成的 abogus 值是否一致。如果一致说明环境补到位了。如果不一致就需要进一步定位差异发生在哪一步。我习惯的做法是在 VM 解释器里加一条日志指令每次执行关键操作码时记录输入输出。然后在浏览器端同样加一个代理日志两者对比。这样可以看到某一步的中间值是在哪里开始分叉的。很多时候分叉点出现在 Math.random 或时间戳上。此时需要在字节码执行前固定随机数种子或时间值再进行对比。还有一种情况是字符串编码问题。浏览器端的字符编码和 Node 端不完全一致尤其涉及 Unicode 转义和 UTF-8 编码时一个字符的字节差异会直接导致最终签名完全不同。遇到这种问题我建议在两端分别打印每一个字符串常量的 UTF-8 字节序列逐字节比对基本能快速定位。5. 踩坑记录与排查思路5.1 指令集版本对不上导致还原失败JSVMP 最烦人的一点是几乎每个版本的操作码定义都会变。同一个执行函数老版本的 0x03 可能表示字符串拼接新版本就改成了数组读取。如果参考老版本的指令表去还原新版本的字节码结果会南辕北辙。解决思路是不要依赖网上流传的老指令表而是从当前代码里单独提取指令语义。每个操作码对应的处理函数就是最权威的指令表。把每个处理函数读懂记录它操作了哪些变量、改变了哪些寄存器就可以重建一份当前版本的指令映射表。虽然工作量不小但准确率是最高的。5.2 反调试和死代码干扰JSVMP 体系里经常塞入大量陷阱代码。有些代码块永远不会真正走到但它们在静态分析时非常醒目容易把注意力带偏。我一开始就在一个看似复杂的 AES 实现上浪费了不少时间后来通过执行轨迹发现那个分支根本不会被调用纯粹是为了干扰静态分析。反调试则更直接常见的是无限 debugger 和 debugger 关键词触发。无限 debugger 一般不要直接去改代码跳过而是可以右键选择“Never pause here”或者在本地替换文件时删掉含有 debugger 的代码片段。对于真正的生产分析我通常会用本地替换的方式把 debugger 语句去掉然后重新加载页面这样调试起来顺畅很多。但需要注意如果整个 JSVMP 有完整性校验改动文件内容可能导致校验失败这就需要先处理校验逻辑。5.3 小样本通过、大样本失败还有一种非常隐蔽的坑用少量测试数据验证时结果全部正确一上量就开始偶发失败。这种情况多半是环境检测跟随机性有关系。比如代码会根据 Date.now 或 performance.now 生成一个状态然后参与后续加密。少量测试时不同样本之间的间隔足够大状态冲突概率低。但高频调用时生成时间戳或随机量出现重复导致服务端校验不通过。解决方式是在本地复现时主动控制时间源和随机源把每次调用之间的状态隔离干净。更简单的是观察字节码里是否引用某个全局缓存变量如果每次调用后缓存没有更新第二、三次请求就会复用旧状态。定位到缓存变量后在每次调用前强制重置即可。5.4 合规边界与授权提醒做了这么多逆向分析最想说的一点是边界问题。逆向工程本身是一种技术能力但用在什么目标上、用来做什么是有清晰界线的。如果你的项目没有从目标方获得授权或者用于绕过风控、爬取非公开数据那风险极高。我自己在做这类分析时第一件事就是确认测试范围。通常我会使用模拟项目 X也就是一个完全由自己搭建的模拟环境里面复刻了目标平台的接口形态、JSVMP 混淆过程和签名校验逻辑。所有分析都只在这个模拟环境里进行不与真实线上系统产生交互。这样既能完整掌握逆向技术又能保证行为合规。如果你只是对 JSVMP 技术本身感兴趣也建议自己写一个简单的指令分派器生成一些自定义字节码再模拟执行和还原。这样练出来的内功比直接去分析某个平台能安全得多而且同样扎实。6. 个人经验小结这次 abogus-jsvmp 逆向最大的收获并不是成功把还原后的算法跑通而是完整理解了 JSVMP 的对抗思路。它把编程问题变成了一个“解释器 字节码 环境模拟”的复合问题要求分析者同时掌握 AST、浏览器原理、运行时调试和代码虚拟化知识。任何一个环节不熟都可能卡住很久。如果让我给刚接触这类技术的人一个建议我会说不要一上来就追求“还原整段算法”而是先把执行轨迹拉出来找出最核心的加密数据流。大多数 JSVMP 的保护重点其实就是几个关键参数核心参数还原了整个链路基本就通了。还要学会用动态日志来反推静态逻辑这比盯着混淆代码发呆效率高得多。后续如果再遇到新的 JSVMP 版本我的思路已经固定下来先找入口、再提字节码、后补环境、最后做一致性校验。这套流程虽然听起来不复杂但每一步都有非常多的细节尤其是环境检测这一块几乎每个版本都会变出新花样。保留好每一版的指令映射表和补环境配置会让我们在下一次实战中少走很多弯路。
阅读完成 · 觉得有帮助?
咨询建站