拓冰建站拓冰建站
首页 / 资讯中心 / 正文

F5 Shape JSVMP逆向与高并发Header生成架构实战

1. 从一次风控拦截说起F5 Shape与JSVMP到底在防什么做过海外业务数据采集的朋友大概率都遇到过这样的场景脚本跑得好好的突然某天开始请求全部返回403或者干脆给你一个空白页面连错误信息都不给。抓包一看请求头里少了几个关键字段或者某个字段的值明显不对。这时候你打开浏览器开发者工具发现页面加载时执行了一大坨混淆得亲妈都认不出来的JavaScript变量名全是_0x1a2b这种函数调用层层嵌套用AST还原工具跑一遍出来的代码还是看不懂。这就是F5 Shape在起作用。它本质上是一套客户端指纹采集与行为验证系统核心逻辑全部封装在混淆后的JS代码里而JSVMPJavaScript Virtual Machine Protection是它最核心的保护手段——把原本的JS逻辑编译成自定义字节码再通过一个解释器在浏览器里执行。你看到的那些_0x开头的变量只是最外层的壳真正的逻辑藏在字节码数组和解释器循环里。TikTok和不少航司的官网、APP内嵌H5页面都用了这套方案。航司场景尤其典型查票价、查余票、下单前都要过一遍Shape验证验证不过直接给你跳回首页。TikTok的Web端和部分API接口同样如此msToken、X-Bogus、_signature这些参数背后都有Shape的影子。这篇文章不打算讲怎么“绕过”或者“破解”——那是另一个层面的问题。我想聊的是当你需要在自己的系统里模拟合法客户端行为时Shape到底检测哪些点这些检测点对应的Header是怎么生成的以及在高并发场景下怎么设计一套稳定、可扩展的Header生成方案。关键词里的“无限并发”不是指真的无限而是指通过合理的架构设计让Header生成不再成为并发瓶颈。提示本文讨论的所有技术细节均基于公开的Web标准与通用的前端逆向分析方法不涉及任何特定平台的私有协议破解。实际业务中请严格遵守目标网站的服务条款与相关法律法规。2. Shape JSVMP的检测点拆解它到底在看什么2.1 浏览器环境指纹不是只看User-Agent那么简单很多人以为改个User-Agent就能骗过Shape那是五年前的做法。现在的Shape会采集几十个维度的环境信息我把它分成四类第一类是基础环境信息包括navigator.userAgent、navigator.platform、navigator.language、screen.width/height、devicePixelRatio、timezone等。这些看起来简单但Shape会做交叉验证。比如你的User-Agent说是Windows但navigator.platform返回MacIntel直接判定为异常。第二类是Canvas与WebGL指纹。Shape会在Canvas上绘制一段文字或图形然后读取像素数据做哈希。不同GPU、不同驱动、不同操作系统渲染出来的结果有细微差异这个哈希值就成了设备的“指纹”。WebGL那边还会读取UNMASKED_VENDOR_WEBGL和UNMASKED_RENDERER_WEBGL拿到显卡型号和驱动版本。第三类是音频指纹。通过AudioContext生成一段音频信号读取AnalyserNode的频域数据。这个在不同设备上也有差异而且比Canvas更难伪造因为它依赖真实的音频硬件。第四类是行为特征。鼠标移动轨迹、点击间隔、滚动速度、键盘输入节奏这些都会被采集。Shape的JSVMP里有一整套行为分析模型不是简单看有没有鼠标事件而是看轨迹的加速度曲线是否符合人类操作习惯。这四类信息采集完之后会经过一系列变换最终生成一个加密字符串塞进请求头的某个字段里。这个字段名在不同场景下不一样TikTok可能是X-Bogus或msToken相关航司可能是X-Shape-Data之类的。2.2 JSVMP字节码的执行特征时间差与调用栈JSVMP除了采集环境信息还会检测运行环境本身是否“正常”。我实测下来它主要看两个东西一是执行时间差。Shape的代码里会埋一些performance.now()的调用记录关键函数的执行耗时。如果在Node.js里用jsdom模拟浏览器环境某些API的耗时特征和真实浏览器差异很大。比如canvas.getContext(2d)在真实Chrome里可能只要0.1ms在jsdom里可能要5ms以上。Shape会把这些时间差作为异常信号。二是调用栈深度与函数来源。通过Error().stack或者Function.prototype.toString的返回值Shape能判断当前执行的函数是原生的还是被Hook过的。如果你用Puppeteer的evaluateOnNewDocument注入了太多脚本某些原生函数的toString结果会变成function () { [native code] }以外的形式这就露馅了。注意很多人喜欢用Object.defineProperty去Hooknavigator的属性但Shape会检测属性的getter是否是原生代码。一旦发现navigator.userAgent的getter被替换过直接标记为风险。2.3 网络层校验Header顺序与TLS指纹除了JS层面的检测Shape在服务端还会做网络层校验。这部分经常被忽略但恰恰是高并发场景下最容易出问题的地方。Header顺序HTTP/1.1的Header是有顺序的浏览器发出的请求Header顺序遵循一定规律。比如Host永远在第一个User-Agent通常在Accept之前Cookie在Referer之后。如果你用Python的requests库默认的Header顺序和浏览器完全不同Shape服务端一对比就能发现。TLS指纹也就是JA3指纹。不同浏览器、不同版本的TLS握手参数支持的加密套件、扩展字段、椭圆曲线等组合起来形成一个指纹。Python的requests用的是OpenSSL默认配置和Chrome的TLS指纹差异很大。高并发场景下如果你用同一套TLS配置发大量请求Shape很容易把这些请求关联到一起。HTTP/2伪头顺序如果目标站点支持HTTP/2Shape还会检查:method、:path、:authority、:scheme这些伪头的顺序。Chrome的顺序是固定的用httpx或aiohttp发HTTP/2请求时伪头顺序可能不同。这三个网络层检测点在单次请求时可能不明显但在高并发场景下只要有一个维度不一致Shape就能通过聚类分析把异常请求揪出来。3. Header生成的核心逻辑从参数拼接到加密签名3.1 一个完整的Shape Header包含哪些字段以TikTok Web端为例一个通过Shape验证的请求Header里通常包含以下关键字段字段名作用生成方式X-Bogus请求签名JSVMP内部算法生成依赖URL参数、时间戳、设备指纹msToken会话令牌首次访问时由服务端下发后续请求携带_signature签名参数部分接口使用算法与X-Bogus类似但参数不同User-Agent浏览器标识需与设备指纹一致Cookie会话信息包含ttwid、msToken等Referer来源页面必须是目标站点的合法页面航司场景下字段名可能不同但结构类似一个设备指纹字段、一个时间戳字段、一个签名字段外加标准的浏览器Header。这些字段的生成不是孤立的。X-Bogus的计算会用到msToken的值msToken又和Cookie里的ttwid有关联。所以你不能单独生成某一个字段必须整套一起生成。3.2 JSVMP入口函数的定位与参数还原要生成合法的Header第一步是找到JSVMP的入口函数。Shape的代码通常会在页面加载时执行入口函数一般挂在window对象的某个属性下或者通过document.createElement(script)动态加载。我常用的定位方法是在Chrome DevTools的Sources面板里对X-Bogus这个字符串做全局搜索。因为最终生成的签名会赋值给请求头所以代码里一定会有X-Bogus这个字面量。找到之后往上追溯调用栈就能定位到生成函数。但JSVMP的调用栈通常很深而且函数名都是混淆过的。这时候需要配合断点调试在X-Bogus赋值的地方下断点然后看调用栈里的每一层找到最外层的入口函数。入口函数的特征通常是接收一个对象参数返回一个字符串。参数还原是个体力活。JSVMP会把原始参数打散成多个数组通过索引访问。你需要跟踪这些数组的赋值过程把原始值还原出来。常见参数包括url请求的完整URLmethodHTTP方法timestamp当前时间戳deviceId设备标识pageId页面标识这些参数在JSVMP内部会经过多轮变换最终参与签名计算。3.3 签名算法的还原思路从字节码到可执行代码还原JSVMP的签名算法目前主流有两种思路第一种是AST还原。用babel或esprima把混淆代码解析成AST然后写插件把_0x开头的变量名替换成有意义的名字把字符串数组还原成字面量把控制流平坦化打散。这种方法适合混淆程度不高的版本Shape早期版本可以用这招。但现在的JSVMP版本核心逻辑已经编译成字节码了AST还原只能拿到解释器框架拿不到真正的算法。第二种是动态调试插桩。在解释器循环的关键位置插桩打印出每条字节码的执行结果然后根据执行轨迹反推算法。这种方法工作量大但通用性强。我一般会在解释器的switch语句里加日志记录操作码和操作数跑一遍完整流程后把日志导出成文本再人工分析。还有一种取巧的办法直接把JSVMP的解释器代码整个抠出来在Node.js里跑。因为解释器本身是纯JS不依赖浏览器API环境采集部分除外所以可以在Node里执行。然后把环境采集的结果伪造好传入解释器就能得到签名。这种方法适合需要高频生成Header的场景因为省去了浏览器启动的开销。提示抠解释器代码时要注意Shape会检测Function.prototype.toString的返回值。如果你直接复制代码到Node里某些函数的toString结果会暴露。解决办法是用Proxy包装一下让toString返回原生代码的格式。4. 无限并发Header生成的架构设计4.1 为什么单机浏览器方案撑不住高并发最朴素的Header生成方案是用Puppeteer或Playwright启动一个浏览器打开目标页面等Shape初始化完成然后调用页面里的生成函数。这个方案在低频场景下没问题但并发一上来就崩了。一个Chrome实例大概占300-500MB内存启动时间2-3秒。如果你需要每秒生成100个Header意味着要维持几十个浏览器实例内存直接爆炸。而且Shape的初始化是异步的页面加载完成后还要等几秒才能拿到可用的生成函数。更麻烦的是Shape会检测页面是否被自动化工具控制Puppeteer默认的navigator.webdriver是true虽然可以改但还有其他特征。我实测过单台8核16G的机器用Puppeteer方案最多支撑每秒20-30个Header生成再高就排队了。而且稳定性很差跑几个小时就会有几个浏览器实例卡死。4.2 解释器池环境快照把生成成本降到最低要支撑高并发核心思路是把Shape的JSVMP解释器从浏览器里剥离出来在Node.js进程里常驻然后为每个请求注入不同的环境快照。具体做法分三步第一步环境快照采集。用真实浏览器访问目标页面在Shape初始化完成后把window对象上所有与Shape相关的属性、navigator的所有字段、Canvas指纹、WebGL指纹、Audio指纹全部导出成JSON。这个JSON就是一份“环境快照”。你可以采集多份快照每份对应一个不同的设备指纹。第二步解释器池初始化。在Node.js里创建一个vm沙箱把Shape的解释器代码加载进去。然后为每个沙箱注入一份环境快照让解释器以为自己运行在对应的浏览器环境里。沙箱初始化完成后就得到一个可以随时调用的Header生成函数。第三步请求分发。收到Header生成请求时从池子里取一个空闲的解释器传入URL、时间戳等参数调用生成函数拿到签名后把解释器归还池子。整个过程在内存里完成耗时通常在1-5ms。这个方案的关键在于环境快照的逼真度。如果快照里的Canvas指纹和真实浏览器差异太大Shape服务端可能会拒绝。我的经验是采集快照时用真实的Chrome浏览器不要用无头模式因为无头模式的Canvas渲染结果和正常模式有差异。4.3 并发控制与故障隔离别让一个坏请求拖垮整个池子解释器池虽然快但有个问题Shape的代码里可能有死循环或者异常分支一旦某个解释器卡住整个池子都可能被拖垮。所以必须做并发控制和故障隔离。我的做法是每个解释器设置超时。调用生成函数时用Promise.race加一个50ms的超时。超时后直接销毁该解释器从池子里移除并补充一个新的。池子大小动态调整。维护一个最小空闲数和最大总数。空闲数低于阈值时自动扩容高于阈值时自动缩容。扩容时新建解释器缩容时销毁多余解释器。健康检查。每隔一段时间用固定的测试参数调用每个解释器验证返回的签名格式是否正确。连续失败三次的解释器直接淘汰。请求队列。当所有解释器都在忙时请求进入队列等待。队列设置最大长度超过后直接拒绝避免雪崩。这套机制跑下来单台8核16G的机器可以稳定支撑每秒500-1000个Header生成具体取决于解释器的复杂度和超时设置。4.4 与Redis的配合缓存与限流虽然解释器池本身很快但在极端并发下还是需要Redis来做一层缓冲。缓存某些参数比如msToken的有效期较长可以缓存在Redis里不用每次重新生成。设备指纹也可以缓存同一设备在一段时间内复用同一份快照。限流用Redis的INCR和EXPIRE做滑动窗口限流控制每秒向目标站点发送的请求数。这个限流不是限制Header生成而是限制实际业务请求避免触发服务端的频率风控。分布式协调如果有多台机器同时生成Header可以用Redis的SETNX做分布式锁确保同一时间只有一个机器在更新环境快照池。注意Redis缓存的数据要注意过期时间。msToken这类令牌通常有有效期过期后必须重新获取。我一般设置缓存时间为令牌有效期的80%留出缓冲。5. 实战中踩过的坑与排查链路5.1 Header顺序不对导致403一个隐蔽的坑有一次我明明签名算对了但请求还是返回403。抓包对比浏览器和脚本的请求发现Header顺序不一样。浏览器发出的请求Host在第一个然后是Connection、Content-Length、User-Agent、Accept、Accept-Encoding、Accept-Language、Cookie、Referer。而我的脚本里User-Agent跑到了Accept后面。排查过程是这样的先用mitmproxy抓浏览器请求导出Header列表记录顺序。然后在脚本里用OrderedDict按同样顺序构造Header。改完之后403消失了。这个坑的教训是不要用requests的默认Header一定要手动指定顺序。Python 3.7的dict是有序的但requests内部可能会重新排序。稳妥的做法是用http.client直接构造请求或者用httpx的headers参数传入有序字典。5.2 环境快照过期Canvas指纹的时效性环境快照不是永久有效的。我遇到过这样的情况同一份快照用了两周突然开始大量失败。排查后发现Chrome更新了版本Canvas渲染结果变了。Shape服务端可能维护了一个指纹库新版本Chrome的指纹和老版本不同用老快照生成的签名被判定为异常。解决办法是定期更新环境快照。我一般每周重新采集一次或者在Chrome发布新版本后立即更新。采集时要注意不同操作系统的Canvas指纹不同如果你的业务覆盖多个地区最好每个地区采集一份。5.3 解释器内存泄漏一个不容易发现的问题Node.js的vm沙箱虽然隔离性好但如果不正确销毁会造成内存泄漏。我一开始没注意跑了一天之后发现内存涨到了8G。用--inspect分析堆快照发现大量ContextifyContext对象没有被回收。原因是每次调用vm.createContext创建沙箱后如果没有显式地把沙箱引用置为nullV8的GC不会回收。解决办法是在销毁解释器时先把沙箱里的所有属性置为null然后调用vm.createContext返回的对象的null化最后手动触发GC用global.gc()需要启动时加--expose-gc。5.4 并发下的签名碰撞时间戳精度问题高并发场景下如果多个请求在同一毫秒内生成签名时间戳相同签名可能也相同。Shape服务端如果收到大量相同签名的请求会判定为异常。解决办法是在时间戳里加入随机因子。比如用Date.now()加上一个0-999的随机数或者用performance.now()的高精度部分。但要注意时间戳的格式必须和Shape期望的一致不能随意改。我一般是在毫秒时间戳后面拼接一个随机字符串然后整体参与签名计算。6. 航司场景的特殊性与适配思路航司的Shape验证和TikTok有些不同。TikTok主要防的是批量爬取和自动化操作航司除了这些还要防黄牛抢票和恶意占座。所以航司的Shape检测点更多验证更严格。我实测下来航司场景有几个特殊点一是会话绑定更紧。航司的msToken通常和IP、设备指纹、用户账号三者绑定。如果你换了IP但没换设备指纹或者换了设备指纹但没换IP都可能触发验证。高并发场景下这意味着你需要维护IP池和设备指纹池的对应关系。二是行为验证更频繁。航司在查询余票、提交订单等关键步骤都会触发Shape验证而且验证的难度会动态调整。如果系统检测到异常会升级验证等级比如要求完成滑块验证或短信验证。三是签名参数更多。航司的签名除了URL和时间戳还可能包含航班号、出发地、目的地、日期等业务参数。这些参数必须和实际请求一致否则签名无效。适配航司场景时我的建议是降低并发增加随机延迟模拟真实用户的查询节奏。不要用固定的时间间隔发请求而是用正态分布的随机间隔。设备指纹要多样化不要所有请求都用同一份快照。7. 一些个人经验与后续可扩展的方向这套方案我前后迭代了三个版本。第一版用Puppeteer简单但撑不住并发第二版抠了解释器代码在Node里跑性能上来了但稳定性差第三版加了池化和健康检查才算真正可用。如果后续要继续优化我觉得有几个方向值得尝试一是用WASM加速解释器。Shape的JSVMP解释器是纯JS执行效率有限。如果把解释器编译成WASM生成速度还能再提升一个数量级。不过WASM的环境模拟更复杂需要把浏览器的API用WASM重新实现一遍。二是用机器学习做环境快照生成。与其手动采集快照不如训练一个模型根据目标设备的特征自动生成逼真的环境快照。这个方向目前还在探索阶段但潜力很大。三是分布式解释器池。把解释器池部署到多台机器上用gRPC做通信实现水平扩展。这样理论上可以支撑任意规模的并发只要机器够多。最后分享一个小技巧调试Shape的时候善用Chrome的Overrides功能。把Shape的JS文件下载到本地修改后通过Overrides加载这样可以在不破坏原始代码的情况下加日志、下断点。比直接在DevTools里改代码方便得多而且刷新页面后修改还在。这套东西说到底是个工程问题不是算法问题。算法再精妙工程上撑不住并发也是白搭。反过来只要架构设计合理即使签名算法还原得不够完美也能通过环境模拟和请求调度把成功率做到可接受的水平。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门