首页
社区
课程
招聘
[原创] 头条评论 `_signature` 逆向实战:从 `_$jsvmprt` VM 字节码到纯 Node 零 Cookie 爬取用户评论
发表于: 2026-9-29 18:02 1014

[原创] 头条评论 `_signature` 逆向实战:从 `_$jsvmprt` VM 字节码到纯 Node 零 Cookie 爬取用户评论

2026-9-29 18:02
1014

摘要:本文按时间顺序,完整记录一条"还原 _signature 并爬取评论"的逆向全过程。目标接口的签名藏在 acrawler SDK 里,而 acrawler 的算法本体是一台自研 JS 虚拟机 _$jsvmprt 解释的字节码——不是混淆,是编译产物,肉眼不可读。两条常规路线(手写还原算法 / 分析签名值)全部走不通后,最终靠补环境 + 原脚本重放,把 71KB 的 SDK 原样塞进 Node 的 vm 沙箱里跑起来,直接调用它的 sign() 产出有效签名。中途一个消融测试把整个任务砍掉了大半:实测评论接口只认 _signature、零 cookie、零 ttwid。本文把走通的、走不通的、以及补环境时踩的每一个坑,一五一十都写出来。全文分两半:上半场(§0~§10)讲怎么靠「补环境 + 消融测试」把评论爬下来;下半场(§11)回头把 VM 字节码拆开,还原出签名算法骨架

起因很朴素:想爬某平台一篇文章的评论。评论接口长这样:

_signature 是客户端生成的签名,服务端校验它。需求落到工具上就一句话:用纯 Node 把这个签名算出来,脱离浏览器。

当时手上有:

一眼看去这是个标准的 JS 逆向任务:找到签名函数,还原它。结果第一脚就踢到了铁板——而且后面证明,前面拦着的是两块硬骨头:_$jsvmprt VM 字节码保护、以及一整套看似必需、实则对评论接口完全多余的 cookie 反爬链。

还没开始逆签名,先撞上一个更基础的问题:直接 curl 文章 URL,返回 200,但内容根本不是文章,而是一段反爬挑战脚本,内联了约 71KB 的 acrawler SDK:

这段挑战脚本本身就是一条重要线索,它告诉我们反爬链的起点:

关键认识 ①:_signature 前面还有一道 __ac_signature 反爬门(aid=99999999)。当时第一反应是"完了,要爬评论得先把这一整条链都过一遍"——这个判断后面被消融测试推翻了,先按下不表。

这里埋一个贯穿全过程的教训,我提前说出来:"反爬链很长"不等于"目标接口依赖整条链"。 画全链路地图是对的,但别被地图吓住。后面 §7 的消融测试证明,评论接口其实只依赖其中一环。

过门后的真实页面(用真实浏览器打开)才暴露 acrawler 的本体。几个事实:

sign 有两种入参形态,走不同代码路径:

到这里,目标已经很清晰:还原 sign({url}) 的实现。

去读 acrawler.js 的 sign 实现,傻眼了。SDK 里签名算法的本体长这样:

这就是整场逆向最关键的判断:

关键认识 ②:_$jsvmprt + 一坨 hex = VM 字节码保护。这不是混淆(混淆是把可读代码搅乱,理论上还能还原),这是编译产物——算法已经被编译成另一门字节码,由内嵌的解释器执行。"手写干净 JS 还原哈希"是死胡同,你面对的不是代码,是一台虚拟机。

怎么确认它是"虚拟机"而不是"一段被压缩的普通代码"?看 _$jsvmprt 函数体的形状。美化后约 460 行,核心是三个互相调用的函数(我后面用 §11 的命名先剧透一下):

一段普通代码不会有"取字节 → 查表 → 分派 → 操作栈"这套循环。看到这个形状,就能一眼定性:这是解释器,hex 是它的字节码。 这个判断直接决定了后面的路线——不是去"读算法",而是要么"带着这台 VM 一起跑"(§6 补环境),要么"把这台 VM 拆开读"(§11 静态还原)。

同时,VM 启动时把 33 个全局对象注入环境,作为环境探测面:

这就是「脱离浏览器」的真正难点:签名算法依赖一个它以为存在的浏览器环境,你得把这个环境补给它。

现象:想从 acrawler.js 里把签名算法读出来、翻译成干净 JS。

证据:外层只有 _$jsvmprt("484e4f...", [...]) 一行,没有任何明文哈希函数;sign 的赋值在字节码里,外层搜不到。

结论:算法被编译进字节码,肉眼不可读,逐行翻译无门。排除手写还原。

现象:对单个 _signature 值做结构拆分,想反推出算法。

证据:对比同一浏览器多次 sign({url}) 的结果,结构是这样的:

结论:签名值与时间/参数绑定,时间戳段每次都变,分析一个过期值没有意义。方向错了——要先定位「生成代码」,不是「分析值」。

这两条死路的价值不在"此路不通",而在它们共同指向了唯一可行的方向:算法不可读、值不可反推,那就让原脚本自己跑起来。这就引出了补环境。

思路一句话:伪造一个"足够像浏览器"的全局,让 acrawler 原样在 Node vm 里跑起来,直接调 sign()。算法还在字节码里,但没关系——我们不读它,我们执行它。

核心代码(acrawler_env.js):

这三行看着轻描淡写,真正的功夫全在 buildSandbox()——那台"假浏览器"造得像不像,直接决定 acrawler 肯不肯吐签名。

vm.createContext 给的沙箱是空的——连 window、document 都没有。而 §3 已经看到,VM 启动时要探测 33 个全局对象。所以 buildSandbox 要做的,就是照着那张探测面,一样一样把浏览器"糊"出来。分四层:

第一层,语言内建。 这些浏览器和 Node 都有,直接把宿主的塞进去就行:

第二层,宿主类。 Node 里可能有也可能没有的(WebSocket/Request/Headers/DOMException)——有就用真的,没有给个空壳,不让 acrawler 里的 typeof 检测抛错:

第三层,浏览器指纹面。 navigator/location/screen/localStorage 这些,acrawler 的 dfp 指纹采集要逐个读。给一套自洽的假值即可——关键是自洽(UA 说自己是 Chrome,vendor 就得是 Google Inc.,webdriver 必须 false):

第四层,DOM / canvas 桩。 这是最容易崩的一层。init 会往 <head> 塞 <script>,sign 会 createElement("canvas") 采集画布指纹。桩要"能被调用不报错",但不需要真渲染(原因见 §6.3):

canvas 元素的 toDataURL 干脆返回一张固定的 1×1 PNG base64——反正服务器不校验它长什么样。

最后一步,也是第一个把我卡住的坑(见 §6.2 坑 1)——沙箱里没有 global,得让 window 自引用:

坑 1:global is not defined。

第一次跑,直接 ReferenceError: global is not defined。原因:vm.createContext 的沙箱里没有 global。修复很简单——自引用:

坑 2:环境判定。

沙箱里如果定义了 exports/module/define,VM 会判定这是 Node 环境;定义了 InstallTrigger/safari 会被判定为 Firefox/Safari。这两个都不能有——留 undefined,让 VM 判定为 Chromium 系。

坑 3:init 报 appendChild 错。

init 内部会 document.getElementsByTagName("head")[0].appendChild(...)。一开始我的 getElementsByTagName 返回 [],...[0] 是 undefined,直接报 Cannot read properties of undefined (reading 'appendChild')。修复:必须返回含元素的数组。

坑 4:DOM/canvas 桩。

init({dfp:true}) 会做 canvas/webgl/字体指纹采集。sign 里会 createElement("canvas") + getContext("2d") + toDataURL。关键是——canvas 桩给空实现即可(fillText/measureText/getImageData 返回假值),无需真实渲染。为什么?因为(§6.2 会讲)服务器根本不校验指纹真实性。

坑 5:sign 入参形态。

sign("a8bK9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Q4x3X3g2Q4x3X3g2Q4x3X3f1`.") 这种传字符串的调用,直接抛 [object Object]。因为 sign 只认两种形态:sign({url}) 或 sign("", nonce)。传一个非空裸字符串是不合法的,这个错很容易犯。

init({dfp:true}) 本意是采集设备指纹,但我们只提供了"能跑通"的 DOM 桩,没复现真实 canvas 像素。结果签名照样有效(§7 实测 HTTP 200)。

原因:服务器只校验 acrawler 内嵌密钥算出的 MAC + 格式,不校验设备指纹是否"真实"。47 字符(无指纹段)的 Node 签名同样能过。这是补环境方案能成立的根本前提。

原以为「脱离浏览器」要过完整反爬链(__ac_nonce → __ac_signature → ttwid → 文章页)。但直觉靠不住,直接做消融测试——把 cookie 一件件剥掉,看评论接口还认不认。

三组配置对比(ablation.js):

三者结果完全一致。

关键认识 ③:整条反爬链(__ac_signature / ttwid)是给「加载文章页」用的,评论接口根本不管 cookie,只看 _signature 对不对。

这是整个实验最省工作量的一个判断。它把原本要啃的 ttwid(一个 104KB 的 webpack 打包、调 ttwid.bytedance.com 拿服务端下发 JWT 的机制)整个砍掉了——评论接口不需要它。

虽然消融测试证明 __ac_signature 对爬评论非必需,但为了完整验证"补环境方案真的还原了 acrawler",还是顺手过了那道反爬门(full_chain.js):

即 aid=99999999, dfp=0 这条 sign("", nonce) 路径也还原成功。这证明补环境方案不是"碰巧只对 _signature 生效",而是真的把 acrawler 的 sign 完整跑起来了。

真实端到端验证输出(verify.js,签名已脱敏,结构保留):

关键点:每页(offset)都要重新生成签名——签名覆盖 URL,含 offset,且内嵌秒级时间戳,必须"取到 → 立刻用",不可缓存复用。

坑 6:主评论正文在 comment.text,不是 comment.content。

一开始按直觉取 comment.content,结果全是空。实际字段是 comment.text。

坑 7:回复的作者字段和主评论不一样。

主评论作者是 comment.user_name;但回复的作者在 .user.name / .user.screen_name,没有 user_name 这个字段。回复正文在 .content。主评论和回复的 schema 是两套,混用就拿到空值。

主评论的 comment.new_reply_list 只含顶部几条回复,不是全部。一条热门评论的 reply_count 可能上百,完整回复历史要另走回复分页接口:

响应内层结构:

用同一个 _signature(aid=24),每页重新生成。实测一条最热评论 146 条回复完整拉满(8 页):

坑 8(自己犯的):写回复分页时,maxCount 参数一度失效——一页固定返回 20 条,break 判断写在 push 之后,传 maxCount=1 实际拿到 20 条。教训:限流判断要放在入列之前,不是之后。

补环境跑通、评论也爬到了,任务本该结束。但有个问题一直悬着:签名算法到底是什么? 补环境是「骗过它」,不是「看懂它」——算法在字节码里,我们只是让原脚本自己跑了起来,里面算的到底是 MD5 还是别的、用了什么哈希,其实一直没搞清。

于是回头做了一轮静态还原:不执行字节码,纯靠读 _$jsvmprt 解释器源码(F/G/K 三个函数),把那段 hex 逆向成可读的伪汇编。这条线和前面的补环境是两条独立的路:补环境是「带 VM 一起跑」,静态还原是「把 VM 拆开读」。

那串 hex 不是从头到尾都是指令。_$jsvmprt(b, e, f) 拿到参数 b(就是那串 hex)后,做的第一件事不是执行,而是解析头部。跟着解释器源码里对 b 的下标访问走一遍,头部布局就出来了(下标以「hex 解码成字节后」的字节偏移计):

魔数这一步很关键:解释器一上来就拿前 16 字节和硬编码的 "HNOJ@?RC" 比,对不上就抛 error magic number。这既是完整性校验,也是给逆向者的第一个明确锚点——看到 HNOJ@?RC 基本就能确认这是同一套 _$jsvmprt VM,跨样本通用。

字符串池的解密是整个还原的突破口。池里每条字符串都被 XOR 加密,解密就一行:

r ^ byte 逐字节一异或,682 条字符串全出来了:

函数名和字段名一出来,后面识别算法就有了锚点——encryptUint32Array 一看就是分组加密,getSignature 是主流程,body_hash=/_signature= 是输出字段名。这一步的意义是把「一坨 hex」变成「一堆有名字的函数」:反汇编时每条 push_str N 都能内联出真实字符串,天书立刻有了可读的地标。

拿到可读字符串只是有了「地标」,还不能反汇编。要把代码区那 21922 字节切成 11262 条指令,必须先回答两个问题:每个字节对应哪个操作码?每条指令的操作数占几个字节? 这两个答案都藏在 G 函数(解释器主循环)里。

主循环的骨架是这样的:从代码区当前位置取一个字节 j,先把它变换成内部操作码 op,再进一个巨大的分支表按 op 分派。变换那一行是整个还原的钥匙:

为什么是 13 和 241?这不是随手写的常数,是精心挑的一组互质对:

拿到 op 后,G 再把它拆成几个子字段来决定语义:

最容易踩的坑在操作数长度上:一条指令后面跟几个字节的操作数,不是由解码后的 op 决定,而是由原始字节 j 查表决定的。F 函数(预处理器)里有 6 张长度表,按 j 落在哪个区间给出操作数宽度(0/1/2/4 字节等)。我一开始想当然用 op 去查长度,结果指令边界整个错位,dump 出来后面全是乱码——回头对着 F 的分段才发现长度锚定的是 j 不是 op。解码用 op,切长度用 j,两者不能混。

这也解释了 G 里为什么有两条对称的分发路径:一条 I=0 直接从 hex 流里现读现解码,一条 I=1 从 F 预处理好的操作数数组 W[] 里取。两条路语义完全一致,只是取操作数的来源不同——这本身也是一个交叉验证点:两条路径对同一条指令解出的操作数必须一致,对不上就说明长度表读错了。

摸清「op=13j%241 解码 + j 查表定长 + F/W 双路校验」这套规则后,写个反汇编器 disasm.js 把 11262 条指令 dump 出来,再把 §11.1 解出的字符串常量按索引内联进去,字节码就从天书变成可读伪汇编:

最后这三行还顺带印证了字符串锚点的价值:看到 push_str "nonce must be..." 紧跟 new 1 + throw,不用读上下文就知道这里是 sign 的入参校验分支——把裸字符串抛成一个 Error 对象。(完整的 241 项 opcode 语义表见内部报告,正文不铺开。)

反汇编出来遇到一个矛盾:局部变量读 c["$"+z](带 $ 前缀),函数参数又读 c[z](数字键),两套怎么统一?答案在 K 函数构造作用域对象的三行:

拆开就是:作用域对象同时是「自己 + 父作用域链 + 参数表」三样东西。搞清 $N 指向谁,反汇编里的 load_local $2 才不是天书——$2 就是主模块作用域 = 那张工具函数表。不打通这个模型,算法还原无从谈起。

顺着 encryptUint32Array、getSignature 这些函数名追下去,签名的算法骨架浮出来了(只讲家族特征,内嵌密钥与可复现常量已脱敏):

还原公式与 VM 实跑对拍一致(如 sdbm(0,"abc") = 807794786、b64_30(123456789) = "HW80V"),交叉验证通过。


冰与火的战歌:Windows内核攻防实战高级班!从零到实战,融合AI与Windows内核攻防全技术栈,打造具备自动化能力的内核开发高手。

收藏
点赞 ・15
打赏
分享
最新回复 (8)
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2 楼
感谢分享
2026-9-29 18:26
0
雪    币: 120
活跃值: (150)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3 楼
感谢大佬分享
2026-9-29 18:50
0
雪    币: 7420
活跃值: (7680)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
4 楼
66
2026-9-29 19:48
0
雪    币: 110
活跃值: (9616)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5 楼
123
2026-10-1 11:40
0
雪    币: 3269
活跃值: (6400)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6 楼
666
2026-10-2 20:45
0
雪    币: 14
活跃值: (815)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
7 楼
666
2026-10-4 09:45
0
雪    币: 0
活跃值: (261)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
8 楼
学习下666
2026-10-7 14:27
0
雪    币: 238
活跃值: (621)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
9 楼
6666
2026-10-8 14:45
0
游客
登录 | 注册 方可回帖
返回