腾讯滑块验证码collect参数解析:JSVMP保护下的前端逆向实战
腾讯滑块验证码的collect参数这几年做前端逆向、风控评估或者接口安全测试的朋友大概率都碰过。它看起来只是验证码接口请求里的一串参数实际上是前端把鼠标轨迹、点击行为、浏览器环境、时间序列之类的信息打包加密之后上报给服务端服务端再结合其他维度判断当前操作到底是真人还是脚本。我做这个项目的原因很简单在一次授权范围内的安全评估中我需要搞清楚collect参数到底是怎么生成的以及腾讯前端的JSVMP保护能挡住多少种常见的分析方式。整个项目做下来我最大的感受是逆向本身不算最耗时真正复杂的是和JSVMP对抗——它把核心逻辑全部转译成自定义字节码再由一个自研解释器在浏览器里逐条执行。传统的前端逆向三板斧搜字符串、断点单步、还原AST放到它的面前基本全部失效。这篇文章我不会写成那种“复制几段代码就能绕过验证码”的教程因为那既不符合合规要求也不负责任。我更想分享的是一套可复用的分析路径如何定位collect参数、如何理解JSVMP的指令结构、如何通过插桩拿到执行轨迹并还原关键算法以及在遇到环境检测、指令trace、RPC调用这些需求时可以怎么落地。适合正在做前端逆向、渗透测试、风控研发的同学也适合刚接触JSVMP、想找一条完整分析思路的新手。1. 腾讯滑块验证码的运行机制与collect参数的价值1.1 我在实际项目里遇到的collect参数长什么样我是在某个系统的登录接口安全评估里遇到这个参数的。当口令输入错误、触发二次验证时浏览器会向验证码服务发起一组请求其中一个最显眼的字段就是collect。它的值是一串经过编码的数据肉眼看上去像Base64叠了一层URL编码发请求的脚本来自一个名为tc_script的JS文件。这个文件本身做了重度混淆关键计算都被封装在JSVMP的字节码块里。这里先把验证码的完整交互链路拆一下方便不熟悉前端的读者理解页面加载后前端的验证码脚本初始化向服务端申请初始参数包括appid、场景ID、随机token等。用户拖动滑块或点击按钮前端收集整个过程的鼠标轨迹、按下与松开的时间差、移动加速度、视窗尺寸等行为数据。这些原始数据经过编码与加密拼到collect参数里随验证请求一起发送到验证服务。服务端结合交互行为、浏览器指纹、IP等多维信息打分决定是否下发验证票据。collect参数在里面的角色很关键它是服务端判断“行为像不像人”的主要依据。如果只在接口上拼一个固定字符串服务端几乎立刻能识别出来。真人轨迹在时间轴上是连续且有波动的速度和加速度也不会恒定不变但固定字符串不存在这些特征。1.2 为什么腾讯要用JSVMP保护collect的生成逻辑如果collect的生成逻辑只是普通JavaScript函数静态分析工具可以很轻松地把逻辑还原出来甚至直接用于自动化脚本。为了抬高逆向门槛腾讯前端脚本引入了一层虚拟化保护也就是JSVMP。它的核心思路是把原本的JS函数、变量名、控制流全部转换为自定义的字节码浏览器端用一个解释器逐条读取并执行。从保护方的角度看这种做法的好处非常明显。常规反混淆工具面对的不再是AST树而是一堆数字指令调试器单步看到的只是解释器自身的执行过程跟具体业务逻辑差得很远。我一直觉得JSVMP和JIT是相反的方向——JIT是把字节码编译成本地代码来提速JSVMP则是把源码翻译成字节码再解释执行用性能换安全。从逆向方的角度看这意味着三条最常用的路基本都被堵住了字符串搜索几乎找不到可读的关键线索字符串都被编码成了数字或密文。AST还原字节码不是标准JS语法无法直接解析成AST。单步调试指令动辄几千条靠人工去定位关键分支工作量极大。但JSVMP也不是无懈可击。它本质上还是一个解释器模型无论怎么混淆解释器要处理指令就得暴露指令读取、分派、执行的路径。这些路径就是对抗的切入点。2. 逆向前的准备工具、环境与总体思路2.1 我搭建的工具链做这个项目用的工具非常朴素核心是Chrome DevTools、一个抓包代理加上Node.js做验证环境另外准备了一套RPC脚本用来复用浏览器环境。具体清单如下Chrome DevTools日常定位、动态断点、查看作用域和调用栈。抓包代理我习惯用自己搭的本地代理重点看滑块验证码对应的接口请求和响应内容。F12脚本面板放一些临时的Hook脚本辅助分析。Node.js用于补环境测试把JS从浏览器里搬到Node环境跑通。自定义插桩脚本这一步在JSVMP分析中是最关键的后面详细展开。这套工具组合不需要太多商业工具关键是分析思路要清晰。工具只是辅助多数时间其实花在理解和修正数据的正确性上。2.2 从网络请求倒推入口step 1在页面上操作滑块触发一次有效的验证请求在抓包工具里找到验证接口的完整请求参数。这里需要重点看几个字段collect、相关随机数、时间戳、应用ID等。step 2在DevTools里右键点击请求选择“Search in Sources”或者直接全局搜索collect这个关键字定位到生成collect参数的函数位置。这里要注意在实际项目里全局搜索往往会比较痛苦。因为文件名和变量名都是混淆过的甚至collect这个字符串也可能被拆开处理。我当时搜索到的只是它被赋值的地方再往上的调用栈全都指向JSVMP解释器。step 3在collect被赋值的位置下断点然后看当前函数的调用栈反复往上回溯最终找到解释器入口。这个过程需要点耐心因为中间会夹杂大量和collect无关的赋值操作。2.3 认识JSVMP的指令结构很多人第一次看到JSVMP相关代码都会发懵因为整个执行模型和普通JS完全不一样。我简单说一下它的几个组成指令数组一串数字每个数字对应一个操作码opcode。操作数区紧跟在操作码后面的数据可能是立即数、索引或偏移量。解释器主循环通常是一个while循环不断按pc程序计数器从指令数组中取值执行对应的分支。上下文区存放临时变量和结果的数组执行过程中的数据都堆在这里。如果用最简洁的伪代码描述它大概是这样的while (pc instructions.length) { opcode instructions[pc]; switch (opcode) { case 1: // 加法指令 reg[dest] reg[src1] reg[src2]; break; case 2: // 函数调用指令 break; // ... 其他操作码 } }放在JS环境里这些操作基本都是通过数组和对象完成的。所有关键字符串、函数调用在字节码里都会被转换为数字ID如果不做trace几乎看不到可读信息。3. 定位分析挖掘collect参数的生成链路3.1 通过Hook和堆栈回溯找到虚拟机入口我在collect被赋值的位置下断点后看到调用栈非常大而且大部分帧都是同一个函数也就是解释器主循环在循环调用。于是我做了两步操作。第一步在解释器入口下条件断点条件是某个寄存器的值等于我关心的字符串ID。这个字符串ID通常需要事先通过抓包或者脚本在运行时确认。第二步观察解释器里pc指针的跳转找到当前指令属于哪个函数块。这一步能帮我快速定位到底哪几段字节码是跟collect生成相关的。整个过程听起来简单实际操作时会反复试错。我踩过的一个坑是JSVMP的指令流里会插入一些无效跳转用来干扰静态跟踪。只有盯着真实运行的数据流才能确定哪些指令是真正执行到的。3.2 指令插桩跑出完整执行轨迹手动断点在指令上千条的场景下是不可行的。我写了一段简易的插桩脚本在解释器主循环的每次迭代里输出当前pc、操作码、操作数以及上下文区的一些关键寄存器值。这样能拿到一次滑块操作对应的完整字节码执行轨迹。插桩脚本的核心思路很朴素把switch之前的所有输入全部存档。我在我的环境里大致是这样做的const original vmDispatcher; vmDispatcher function (...args) { const pc args[0]; const opcode args[1]; log.push({ pc: pc, opcode: opcode, reg: snapshotRegs() }); return original.apply(this, args); };实际实现当然要复杂一些但核心思路差不多。这一步的产出非常有价值对比多次滑块操作的trace记录找出相似的一串指令那一段往往就是公共的加密逻辑。然后单独把这串指令从虚拟机上下文里抽出来作为重点分析对象。3.3 从指令流中识别编码与算法特征有了trace之后我开始在指令流里找算法特征。Base64编码通常会出现按位与、移位、查表操作哈希算法通常会有循环位移和异或某些对称加密算法会有大量的S盒查表操作。我在trace数据里看到了很多位移和异或指令并且在上下文数组中发现了一张64字符的索引表。这个特征非常明显——走的应该是Base64或类似变体的编码逻辑。顺着这条线我把编码逻辑还原成了Python脚本做验证结果与浏览器端生成的字符串完全对上。不过这里也要提醒一句JSVMP的指令可能会把一次普通的加法和一次编码操作混在一起需要结合上下文寄存器值去判断不要见到位运算就以为是加密。4. 还原collect参数的生成逻辑4.1 参数结构与语义拆分我实际跑通之后把collect的明文结构拆成几段每段对应一个独立的语义固定版本标识标记参数格式的版本号。时间戳与随机数保证每次请求不重复。鼠标轨迹序列记录了若干采样点的坐标和时间差。行为特征移动总时长、是否匀速、停顿次数、点击偏移等。浏览器环境因子UA特征、屏幕尺寸、语言、时区、Canvas指纹哈希等。这一段在逆向过程中非常关键因为每一段都对应一种判断维度。如果做防御评估想要验证服务端对collect各字段的校验强度首先就得理解每段数据是怎么被使用的。否则只是比着一个样本伪造很容易在某一个维度上露馅。4.2 核心算法逐段还原针对不同段做还原时我采用了不同的思路编码逻辑用之前识别出的Base64变体把三字节组映射成四字符。轨迹采样鼠标事件回调里间隔取样记录坐标与系统时间。指纹计算涉及Canvas、UA等输入统一拼接后做哈希。我把每一段都写成独立函数最后组合在一起。整个还原过程最大的收获不是拿到一串可用的字符串而是理解了前端风控的取数逻辑——它把行为和环境拆分得非常细任何一个维度缺失都会暴露出来。4.3 用Node.js补环境复跑还原出来的JS代码要在Node里跑通通常要处理浏览器专有对象。我用的方式很简单先把来源里的判断逻辑梳理出来看它到底调用了哪些全局对象然后逐一mock。补环境的难点不在于对象数量多而在于有些对象在Node里不存在时会抛异常抛异常后JSVMP会走一个特殊分支返回错误表面上看不出真正的原因。我当时用了一个小技巧给全局对象挂上Proxy拦截所有get调用。这样哪个属性访问失败就会记录日志然后一个个补。慢慢把document、window、screen这些对象补齐最后在Node里跑通了collect的生成输出结果和浏览器保持一致。const handler { get(target, prop) { if (!(prop in target)) { console.log(missing global: ${prop.toString()}); return undefined; } return target[prop]; } }; global.window new Proxy({}, handler); global.document new Proxy({}, handler);不过这个方法也有副作用。某些属性在浏览器里本身是只读的在Node里被mock成可读写后可能会导致脚本走不同的分支。所以补环境时需要反复用浏览器的真实结果来校准。5. JSVMP对抗的进阶玩法5.1 日志插桩与指令级trace前面提到的插桩脚本其实是我项目里投入产出比最高的东西。没有它后续的分析基本无从谈起。插桩的时候有几个注意事项控制日志量只记录pc、操作码、关键上下文即可其他寄存器信息不必全打否则日志文件几十分钟就能上G。设置过滤条件可以按操作码过滤只记录函数调用、字符串操作相关的指令。保存原始数据符号化记录要保留否则后续排查困难。有的场景下还可以做“选择性插桩”只在某个特定opcode出现时输出详细信息其他时候只记录pc。这样既不会漏掉关键信息也不会让日志量大到难以处理。5.2 RPC复用浏览器环境如果某个算法的还原成本太高还有一个比较稳妥的方式是RPC让浏览器里已经加载的验证码脚本自己去跑在外部远程调用它生成collect。这个方案的好处是不需要完全理解内部逻辑坏处是稳定性受页面生命周期约束页面一刷新就失效而且依赖真实浏览器环境。我在评估某个敏感算法的时候就是用RPC方式验证的。先把目标函数挂到window上再从外部通过WebSocket或HTTP调它。这个做法适合对整体流程做应急验证不适合做大规模自动化。5.3 指令翻译与等价还原回到JSVMP本身最完整的对抗方式是把字节码翻译回可读的JavaScript也就是做等价还原。这比RPC更难但通用性更强。我做过一个尝试解析指令数组把switch的每个case都映射回一个高级动作再把执行流的跳转整理成控制流图最后生成一份可读的伪代码。翻译过程中最大的障碍是数据流依赖和跳转合并。JSVMP的字节码往往把连续操作拆得很碎并插入大量死代码与跳转干扰还原。这个方向没有捷径只能靠trace数据一遍遍校准。6. 实战中踩过的坑与排查思路6.1 反调试与性能分析限制我一开始在DevTools里下断点时页面会被检测并进入死循环或直接重置验证状态。后来排查发现脚本里对DevTools的用户代理做了检测同时对调用栈深度和性能分析工具的使用做了限制。实用一点的解决办法是在DevTools里禁用JavaScript源码映射并用无痕模式打开目标页面。修改脚本中相关检测逻辑让我能正常下断点而不触发特殊分支。在插桩环境里直接把检测分支短路掉。如果只是做数据观察不使用单步调试也能拿到很多信息。例如用插桩脚本记录关键函数的输入输出分析起来反而更快。6.2 动态数据导致结果不一致另一个很常见的坑是明明还原好的脚本浏览器跑一次一个值Node跑一次另一个值。后来排查发现是获取随机数的方式不同。浏览器里的Math.random带上了特定上下文Node里默认不是这个种子导致后续加密结果完全不一样。解决办法是把随机数种子也纳入补环境的范围用固定种子做对比验证。这样才能判断到底是算法不对还是随机源的问题。6.3 排查思路速查表我整理了一个表格记录常见现象、可能原因和排查方向方便以后遇到类似问题的时候快速定位现象可能原因排查方向collect为null或空串环境检测未通过提前return查看JSVMP入口是否走了错误分支轨迹数据缺失事件监听被移除检查是否有DOM事件劫持哈希结果不一致时间戳或随机数种子不同对比浏览器与Node的执行参数运行时卡死虚拟机内死循环定位pc是否一直在无意义跳转反调试弹窗DevTools检测移除检测代码或使用无痕模式这张表看起来简单但在实际分析时非常重要。因为JSVMP的报错往往不会直接告诉你哪里出了问题更多时候是通过运行结果的不一致来反推。7. 技术边界与应用建议完整分析完这个项目之后我对逆向JSVMP这件事的看法比较冷静。技术层面确实有得做但需要极强的耐心和大量trace数据支撑绝不是看几眼源码就能解决的事情。更重要的还是用法这类逆向能力更适合用在对自家业务风控的评估、对第三方服务的安全评估在授权范围内以及前端代码加固方案的设计上。如果反过来思考怎么让JSVMP发挥最大保护效果我的建议是不要在纯前端逻辑里放永久有效的决定性密钥因为前端任何保护最终都有可能被还原。服务端必须保留独立的强校验能力前端collect只是辅助因子。把虚拟机的字节码做成动态下发而不是写死在前端脚本里。举个例子即使有人完整还原了collect的生成逻辑如果服务端在每次请求时都动态调整一个校验因子那么固定算法很快就会失效。真正安全的风控是多个维度叠加而不是把全部赌注押在某个前端参数上。最后再分享一个我在整个项目里很深的体会面对JSVMP这类虚拟化保护最不推荐的做法就是一上来硬啃源码。先抓包、再定位、再插桩、最后还原每一步的核心都是数据驱动——让代码自己把行为说出来而不是靠眼睛去读。这套方法论不只适用于腾讯滑块对绝大多数JSVMP保护的场景都是通用的。做逆向永远是在跟保护方互相拉扯但只要坚持合规、有授权、以防御和评估为出发点这个过程对个人能力的提升会非常明显。