“腾讯滑块验证码collect参数逆向与JSVMP对抗实战”这个项目我做了大概两周,期间踩了不少坑,也把JSVMP的几层保护从黑盒翻到了白盒。最近有同行问到collect参数到底怎么定位、怎么还原,我干脆把整条路线重新梳理一遍。这篇内容适合有一定JS逆向基础、想系统性理解JSVMP保护机制的人,也适合刚拿到腾讯滑块一脸懵、不知道从哪下手的初学者。我不打算只贴结论,重点讲清楚每一步的选择逻辑,以及我在实战中碰到的真实问题和解法。
1. 项目基础:先弄清楚collect参数是什么
1.1 腾讯滑块验证码的工作机制
腾讯滑块验证码本质上是前端先做一轮环境采集和风险判定,再把结果提交到后端风控,由风控决定是否弹出验证码、滑块能否通过。用户在页面上看到的滑块只是冰山一角,隐藏在后面的是一整套浏览器指纹、环境检测、行为轨迹、时序分析等逻辑。
整套流程可以拆成四段:
- 页面加载时,前端脚本启动,自动采集运行环境的各类信息。
- 采集完成后,脚本生成一个加密的字符串,随验证请求提交给服务端。
- 服务端解出这段字符串中的环境指标和风险标签,再结合当前用户的行为特征打分。
- 分数不够或环境可疑时,服务端下发滑块验证动作,用户拖动后把轨迹再次提交。
其中第二步提到的“加密字符串”,在腾讯验证码体系里就是collect参数。它几乎携带了整台设备的指纹信息,服务端校验条款也是围绕它展开。所以逆向的重点,就是想明白collect是怎么生成的、里面有哪些字段、用什么算法组织,最后能不能在脱离真实浏览器的环境下稳定复现。
1.2 collect参数在请求中的位置与形态
先看一次正常的验证码请求,网络面板里搜索collect这个词,结果通常长这样:
collect: {"cd":-178,"ee":{}, "fa":{...}, "ga":"...", "ia":false, "ja":{...}, "ka":{...}, "la":"..." }表面上是一个JSON结构,嵌套了几层对象,里面的字段名都是缩写,没有注释,没有类型声明。字段的命名规律看起来像是压缩混淆之后的产物,直接猜含义会非常浪费时间。
我实际处理过的一个请求中,collect里不同字段加起来超过二十个,其中既有字符串、数字、布尔值,也有嵌套对象和数组。更麻烦的是,同一字段在不同请求里可能会变化——有的字段只在特定条件下出现,有的字段值会随着运行环境不同而不同。比如某些字段只在WebView环境下才有,某些字段在移动端和桌面端返回的格式都不一样。
所以第一步不是看到什么抓什么,而是先抓一批不同环境下的请求做横向对比,把稳定字段和动态字段区分开。只有把基线梳理清楚,后面做还原和模拟才知道哪些地方要动态计算。
1.3 为什么说collect是风控的关键入口
如果服务端只靠一张滑块图片做背景切割校验,那很简单,模拟个拖拽轨迹就能过。但腾讯这套体系明显不是,collect几乎可以理解为一封“环境自白书”。
风控服务端拿到collect以后,会做几件事:
- 校验参数结构是否完整,字段类型和取值范围是否符合前端脚本的预期。
- 校验指纹信息是否自洽,比如canvas指纹、WebGL渲染器、字体列表、时区、语言、硬件并发数之间有没有矛盾。
- 校验行为特征是否合理,比如鼠标轨迹、触摸事件时间间隔、页面停留时间。
- 校验加密内容能否被正确解析,解析结果中是否存在明显的模拟器特征或异常标签。
所以只关注滑块轨迹远远不够,collect生成这一关过不了,后面一切免谈。而我之所以选这个项目做实战,就是因为它的保护强度比一般JS加密高一个层级——JSVMP,正好可以顺带把这类保护方案的通用对抗思路摸透。
2. 深度拆解JSVMP:为什么collect保护这么硬
2.1 JSVMP的运作原理
JSVMP全称是JavaScript Virtual Machine Protection,核心思路是把JavaScript代码先编译成一套自定义的字节码,然后在运行时由一个解释器逐条执行这份字节码。开发者看到的代码只剩下面目全非的解释器逻辑,真正的业务逻辑全部藏在了字节码里。
从外部看,它的执行过程大概长这样:
- 原始JS代码经过自定义编译器,转成指令序列。
- 指令序列按一定规则编码,存放于数组、字符串或对象属性中。
- 解释器从指令流中逐条读取操作码,查表分发到对应处理逻辑。
- 每条指令执行时改变虚拟机的栈、变量表、上下文等状态。
- 最终输出在外部看就是一次普通函数调用,返回一个结果。
换句话说,平时我们追函数逻辑的堆栈、断点、调用关系,在这套机制里大部分都失效了。你看到的是循环里套循环、switch里套switch,真实的运算逻辑被切碎后藏在一大批无意义的分支和跳转里。
2.2 怎么判断一个脚本是否用了JSVMP
网上关于JSVMP识别的资料不少,但很多都停留在“有很多case的就是JSVMP”这种粗糙判断。实际分辨可以从这几个维度入手:
- 代码中大量存在switch-case分支,且case数量非常多,几十上百个起跳。
- 主流程中存在多层while(true)循环,循环内部有变量自增或跳转标记,从阅读上很难找到退出条件。
- 出现几万行甚至十几万行的“扁平化”代码,而你很清楚原始功能不可能有那么复杂。
- 变量名和函数名几乎全部被替换成无意义的短名称,比如a、b、c,或者下划线加数字组合。
- 函数调用频繁通过数组下标或属性名间接完成,直接看不到函数声明与调用点之间的对应关系。
腾讯那个滑块脚本我拿到手后,第一眼就看出了两个特征:一个是文件体积比想象的大,另一个是核心逻辑区域里有大量操作码分发表。常规混淆最多做到变量名替换,但JSVMP是连控制流都改成虚拟机执行了,两者完全不在一个等级。
2.3 为什么AST还原在JSVMP面前容易“翻车”
网上大量文章都说“用AST还原控制流平坦化”,这招对普通混淆有效,但对JSVMP帮助有限。原因在于:控制流平坦化只是把代码结构拍平了,但代码逻辑本身还在,还保留着函数调用、表达式运算、变量赋值这些可读元素。而JSVMP把这些元素全拆成了字节码,你在AST里看到的只是一堆解释器的状态更新操作,业务逻辑根本没有以可读的形式存在。
所以那段时间我踩过很多次坑:拿AST插件去还原JSVMP的代码,结果运行完以后确实漂亮了一点,但这个“漂亮”只是把解释器内部的分支理顺了,collect参数的核心算法依然没有暴露出来。真正有用的做法是绕过解释器本身,从两个方向下手:
- 动态方向:不去还原代码,而是直接Hook解释器执行前后的关键状态,等真正的字符串拼装、加密运算发生在内存里时,把它拦截下来。
- 静态方向:想办法找出原始操作码表和解释器的指令处理函数映射,把字节码翻译回可读的中间表示,再结合AST做结构重建。
我最终采用的方式是把两者结合,先用动态分析确认collect生成的入口和输出时机,再用静态分析还原其中真正关键的运算逻辑。这里我建议如果你也是第一次接触JSVMP,别一开始就扎进AST,先试着从动态黑盒的角度理解整个执行链路,否则容易在无关代码上浪费大把时间。
3. 实操记录:collect参数的逆向与对抗全流程
3.1 前置准备与工具选型
工欲善其事,必先利其器。这次我用到的工具如下:
- Chrome DevTools:用于抓包、断点调试、动态修改JS代码。
- Babel:用于对JS代码做AST解析、遍历、修改和生成,重点是处理还原后的代码再编译。
- jsdom:在Node.js里模拟浏览器环境,便于脱离浏览器运行完整JS脚本。
- Puppeteer:做动态执行和补环境验证,特别是在某些接口需要真实浏览器行为时才用。
- Fiddler或Charles:抓取移动端或其它进程发起的请求,这主要用于对比不同环境下的collect差异。
工具不需要多,关键是思路。整个过程我依次经历了四个阶段:网络抓包定位、脚本代码定位、JSVMP动态分析、补环境和算法复现。每个阶段的侧重点不同,下面分别展开。
3.2 第一阶段:从网络请求中锁定collect位置
打开目标页面,触发验证码,然后在DevTools的Network面板里搜索所有包含collect的请求参数。这一步你会看到两种可能:一类是GET请求的query参数里带collect,另一类是POST请求的请求体里带collect。我遇到的场景是POST请求,collect藏在一个更大的JSON对象里。
先别急着看生成逻辑,先把请求参数完整的复制下来,多做几组:
- 正常浏览器打开,不做任何额外操作,直接触发验证码。
- 浏览器里清掉一些关键指纹信息后再触发。
- 修改系统时区或语言后再触发。
- 无头浏览器环境下触发一遍。
这样对比下来,很快就能把collect里哪些字段是环境自适应的、哪些字段是每次都会变的、哪些字段是固定写死的区分开。这也决定了后面写模拟代码时,哪些参数需要动态计算,哪些可以用常量代替。
3.3 第二阶段:定位collect参数的生成入口
请求参数出现在网络上,它的来源一定在某个JS文件里。在DevTools里全局搜索collect这个关键词,搜索范围覆盖所有JS文件,通常能搜到多处匹配。有些匹配是赋值语句,有些是字符串拼接,有些是对象键名,真正有用的匹配是那些把collect作为属性名写入请求对象的代码。
我建议按以下顺序缩小范围:
- 搜索collect,先找赋值点,不是正常变量赋值,而是“某个对象.某个属性 = 某块内容”的形式。
- 找到赋值点后,在那一行下断点,刷新页面触发验证码。
- 命中断点后,向上查看函数调用栈,找到包含collect生成的最近一个函数。
- 从那个函数开始,单步调试,观察collect对象里每个字段的来源。
需要注意的是,腾讯这套脚本里collect名字本身可能被混淆过,不一定每个版本都叫collect。更为稳妥的办法是:从请求构造的位置往回找,也就是在发送请求的xhr或fetch位置下断点,看请求参数对象是在哪个作用域里被组装出来的。找到这一段代码后,再回溯整个组装过程中引用的变量,就能精确定位到collect对象本体。
3.4 第三阶段:JSVMP动态分析与关键状态捕获
这是整个项目里最费时间的一步。腾讯滑块脚本里,collect的核心处理过程被包在JSVMP的“壳”中。从外部看,函数的真实逻辑没办法直接阅读,但有一个规律是可用的——它最终一定会把结果赋值给某个变量,并作为对象属性返回。
我的做法是:
- 在collect生成函数的返回值处设断点,等函数return时,查看返回值对象。
- 在该函数内部查找所有写对象属性的地方,给写入操作打上日志断点,观察每个属性的值及写入时机。
- 找到可疑的加密操作,比如调用了一些高密度计算函数,通过修改输入值看输出变化,判断它们各自承担什么职责。
日志断点在DevTools里可以直接通过右键断点选择“Add log point”实现,不用修改源码,也不用重新加载整个脚本。我当时的做法是把整个对象写入过程全部打了日志,然后拉取日志文件,对每个字段值做对比分析。这个阶段收获巨大,既搞清了字段之间的依赖关系,也找到了几个关键算法的内存入口。
为了把某个字段的计算过程看得更清楚,我还用过一种取巧的方式:直接在DevTools的Console里重新执行一小段脚本,先把之前的JSVMP解释器实例拿到手,再手工调用解释器内部的某些处理函数。只要能拿到虚拟机实例的引用,绕开外部封装直插内部是完全可行的,前提是你对它的对象结构已经有一定了解。
3.5 第四阶段:AST还原与算法重建
动态分析找到了关键路径后,静态分析来做算法重建。JSVMP的字节码解析器再怎么复杂,它执行的最小单元一定是“读一条指令、查操作码、执行对应处理函数”的循环。只要能把操作码表和每个操作码对应的处理逻辑理出来,就能把字节码翻译成可读的中间指令。
实际操作时,我用Babel把JSVMP脚本解析成AST,然后按以下步骤处理:
- 找到解释器的主循环函数,这个函数特征非常明显:一个while/switch结构,内部核心逻辑是按照某个指针从数组中读取数字,再从分发表里查函数地址。
- 提取分发表:把case分支和对应处理函数一一对应,整理成表格。
- 对每个处理函数单独还原:函数内如果只是简单的入栈、出栈、变量读取写入,就做好标记,不必展开细读。
- 定位到真正复杂的处理函数,比如字符串拼接、数组操作、位运算、加密算法相关的部分,结合动态Hook结果做针对性还原。
有一点要特别提醒:AST还原在JSVMP里只能帮你看到解释器本身的结构,不代表能看到业务逻辑。真正决定collect参数生成的还是那段字节码。所以我的实操路线是把动态Hook到的状态变化和静态还原出来的指令执行顺序对照起来,逐步推断出每个操作码的语义,进而重建核心算法。
以collect里的某个加密字段为例,我在动态分析时发现它经历了一次类Base64编码加一次自定义的字典替换。顺着这个线索回到静态代码里搜索编码相关的操作函数,很快就找到了对应那几条指令。把这几条指令从虚拟机的逻辑中单独抽出来,再用Python重写一遍,就完成对这一步算法的复现。
3.6 补环境:让脚本在Node.js里稳定跑起来
算法复现以后,还要解决“完整运行整个脚本”的问题。腾讯滑块脚本在运行时依赖大量浏览器API,比如document、navigator、screen、canvas、WebGL等。直接在Node.js里执行肯定是会报错的,必须补环境。
补环境的本质不是让所有API都和浏览器一模一样,而是让脚本在这个环境下“认为是它熟悉的浏览器”,从而顺利走到生成collect那一步。我补环境时的经验如下:
- 先用一个简易的浏览器环境对象框架(例如jsdom)搭底。
- 把脚本运行过程中报错缺失的属性一个个补上。
- 每次补完跑一遍,记录新的报错,继续补,直到没有报错。
- 重点关注navigator属性、screen对象、window下的各种事件相关属性、CanvasRenderingContext2D上的方法。
- 某些方法内部会读取底层渲染结果,比如canvas.toDataURL,如果不返回合理值,后续算法会算出异常指纹。
补环境这块很容易陷入“补了报错、报错再补”的循环里。建议给脚本运行的入口包一个try-catch,把完整的调用堆栈打印出来,这样能快速定位到底哪个对象没有被定义,或者哪个属性的类型不对。我实际补了两三轮就稳定通过了,真正费时间的反而是后续对生成结果一致性的校验。
3.7 验证与结果对比
脚本能在Node.js里跑通并输出collect参数后,还要做一组对照实验。我当时的做法是:
- 用Puppeteer打开真实浏览器环境加载同一套JS代码,生成一份collect。
- 同一份JS代码在Node.js补环境后运行,生成另一份collect。
- 对比两份collect的结构和关键字段值,找出差异。
实测下来,大部分字段可以做到一致,少数环境相关性强的字段(比如canvas指纹、字体列表)会有差异。针对这些差异,要么直接在补环境阶段就把相关API的返回值固定成和真实浏览器一致,要么理解它的算法后自行构造。这里的选择要看你的业务场景,如果只是做数据分析和研究,字段值差异不影响整体理解,如果是要完整模拟请求,那必须逐字段对齐。
4. 实战中的高频问题与排查技巧
4.1 collect参数收集阶段最容易踩的坑
收集参数时大家最容易犯的错误是“只跑了一遍就开始逆向”。同一份collect,在不同页面、不同时间、不同浏览器版本下可能都不完全一样。如果只抓一条请求就急着定位,很容易被偶然出现的字段干扰判断。
我建议至少构造三种环境各抓五条请求:
- 本机普通浏览器环境。
- 移动端模拟器环境。
- 无头浏览器环境。
然后把这十几条请求里的collect参数汇总,做一个字段矩阵。某个字段如果只在移动端出现,不需要在PC端模拟里关心;某个字段如果每次值都不同,就要注意它是不是随机数;某个字段如果只在某一条里特别长,要警惕是不是加了额外条件导致的数据分支。
这个过程虽然繁琐,但能省掉后面大量试错时间。我从头到尾做了一遍,大概多花了半小时,但后面定位逻辑时几乎一次命中。
4.2 JSVMP断点调试失灵怎么办
JSVMP在动态调试时有一个最恶心的特性:你刚在某一行下好断点,刷新一次后发现断点位置跑偏了,或者原来那个函数根本不执行了。这是因为JSVMP的指令分发循环会让同一段代码反复被不同的业务逻辑使用,你在某一个case上下断点,它可能在处理别的指令时也路过这里。
我常用的对策有三个:
- 只在业务逻辑的边界下断点,不深入解释器内部。比如collect对象组装完成、即将返回时,在return语句处下断点。
- 使用条件断点。给断点加上条件,只有某个特定值出现时才暂停。比如某个寄存器值等于某个特殊标记时再断。
- 改用日志断点。不打暂停,而是打印关键变量的值,这样即使代码被反复执行,也不会因为断点中断影响执行流程。
还有一个办法是直接改写脚本,把JSVMP解释器某个函数的开头插入一句复制参数值的代码,然后通过console.log输出。这种方法需要对构建流程有基本了解,但非常有效。
4.3 补完环境后生成结果对不上怎么排查
补环境完成后,最常见的现象是“能跑,但结果不正确”。这种问题排查起来特别头疼,因为错误可能出在任何一环。
我的排查顺序是:
- 先确认是否成功进入collect生成逻辑。在入口和出口分别打印日志,看看执行流程是否走完。
- 对比字段差异。把真实浏览器生成的collect与Node.js生成的collect逐字段做diff,锁定差异集中在哪个子对象里。
- 定位差异的来源是环境API返回值不对,还是算法还原不对。如果差异字段恰好对应canvas指纹、Audio指纹、字体检测结果,那问题大概率在环境API返回值。
- 针对环境API返回值,去真实浏览器里把对应结果打出来,再在Node.js里手动设置成相同值,继续跑。
这套排查流程说起来简单,实际操作中可能会翻来覆去好几轮。但每次把差异范围缩小一层,离最终解决就不远了。不要试图一口气把二十多个字段全部对齐,先挑影响最大的三五个核心字段,确定无误后再处理边缘字段。
5. 新手避坑与小技巧分享
5.1 先从黑盒入手,别一头扎进AST
我在前面反复强调过:JSVMP的AST还原容易让人陷进无止境的代码阅读里。新手最容易犯的错误是拿到脚本就开始全文分析,试图把每一行都看懂。这种思路在普通JS里可行,在JSVMP里行不通。
正确姿势是:
- 用黑盒视角先找出输入输出。
- 用动态手段定位关键路径。
- 用差异分析缩小待还原的代码范围。
- 最后才针对关键路径做静态还原。
换句话说,先把“从哪来、到哪去”搞清楚,再深入“中间怎么算”。这样能把静态分析的工作量降低好几个量级。
5.2 每个版本都在变,要保留“快照”习惯
腾讯前端的验证码脚本更新很频繁,可能每隔几周就换一版。今天写的还原代码,下次版本更新后可能连入口函数名称都对不上。我建议每次分析时把脚本文件、请求参数、运行日志都存档,并标记好版本号。
另外,不要过度依赖写死的函数名或变量名,最好是根据结构特征定位,而不是根据名字定位。这样即使对方下次换个混淆名,你的定位思路依然有效。
5.3 一个容易被忽略的隐蔽环境检测点
腾讯这套脚本里有些环境检测藏在很隐蔽的位置,比如通过判断某个数组的长度来决定是否进入异常分支,或者通过某个对象是否有某个方法来判断是不是真实浏览器。补环境时如果不是特别细心,很容易漏掉。
一个实用的做法是:在运行脚本之前,先用一个干净的浏览器内核把脚本需要的所有API枚举一遍,把类型、返回值、属性名都记录下来,再在Node.js里做一个自动映射。虽然手工核对仍然不可避免,但至少能避免“明明属性存在却没定义”这类低级问题。
最后再说两句
做腾讯滑块collect参数逆向和JSVMP对抗这段时间,我最大的体会是:这类验证码风控系统的核心难点不在于单点算法有多复杂,而在于整个链路环环相扣,任何一环缺失都会导致整体失败。JSVMP只是其中比较显眼的一个技术点,真正决定成败的反而是你对执行链路、环境差异、数据流关系的理解深度。
如果你也在摸这套体系,建议按“黑盒定位 -> 动态Hook -> 静态还原 -> 补环境验证”的顺序推进,一步一个脚印,别指望看几篇文章就能一晚上搞定。遇到卡住的地方,不妨把脚本放一放,换个角度重新观察请求和运行日志,很多答案其实早就藏在数据里了。