YAOTU INSIGHTS

前端签名参数逆向实战:定位Acs-Token与ab_sr生成链路

前端签名参数逆向实战:定位Acs-Token与ab_sr生成链路
1. 先搞清楚这两个参数到底在防什么1.1 抓包看到的第一印象接到“目标系统”这个接口安全评估任务的时候我第一个动作和大多数做前端安全的人一样打开抓包工具先跑一遍核心业务链路看请求长什么样。结果没跑几步就看到每个请求头里都带着两个陌生的东西一个是Acs-Token一个是ab_sr。值看起来像是一长串十六进制加字母的混合体长度不固定字符集也明显不是普通的 Base64。我习惯先别急着搜 JS 里的参数名而是先观察这两个值的行为规律。这一步看起来不起眼但往往能决定后面定位的效率。我手动改了一下请求体里的某个业务字段服务端直接返回“参数校验失败”再把这个请求原封不动复制一份隔几分钟重新发送服务端又返回了“请求已失效”之类的内容。这两组实验基本说明了一件事Acs-Token不是写死的静态令牌它既和请求内容相关又和时间戳或者防重放机制有关典型的动态签名参数。1.2 Acs-Token 和 ab_sr 为什么总成对出现很多刚接触这类参数的朋友会问为什么一个系统要搞两个签名参数一个不行吗实际上这两者在大多数前端风控体系里承担的职责是不一样的。Acs-Token更像是一张“请求完整性封条”它把业务参数、时间戳、随机数、设备信息等拼在一起用某个摘要或加密算法算出一个值。服务端拿到请求后用约定好的算法重新算一遍比对不一致就拒绝。这就是为什么我修改请求体之后回包会直接报参数校验失败。而ab_sr更偏“行为环境标记”。它通常聚合了浏览器指纹、运行环境特征、用户操作轨迹或者页面生命周期里的一些状态。服务端通过它判断请求是不是来自一个看起来正常的浏览器环境而不是某个自动化脚本或伪造客户端。如果给这两者做个类比Acs-Token像是寄快递时箱子上的防拆封条保证内容没有被换过ab_sr则像是快递物流记录里的轨迹信息判断整个过程是不是反常。封条完好只能说明东西没被调包但一个脚本发出的请求在外面看起来“封条”也可能完好这时候就需要ab_sr这类风控信息辅助识别。1.3 大多数前端签名参数的通用设计套路理解参数背后的设计套路等于提前拿到了整个调试地图。我拆过不少类似的签名体系发现它们的骨架高度相似。前端在发起请求前会先准备一组数据请求方法、路径、业务参数、时间戳、随机数、客户端指纹可能还有服务端下发的某个种子值。这些数据按约定好的规则拼接有的还会做编码或压缩然后传给一个签名函数。签名函数内部可能使用摘要算法也可能是对称加密或非对称加密最终把结果放在请求头或者请求体里提交给服务端。服务端验签并不神秘它不需要解出所有字段的原始含义只要能用相同的算法基于请求中的内容算出同样的签名再核对时间窗口和随机数是否新鲜就可以决定是否放行。这类设计要防的无非是两件事第一请求参数被篡改第二请求被恶意重放。为了加强第二点通常还会加入风控字段ab_sr里的那些指纹和状态信息就是干这个的。搞懂这一层设计后逆向定位的方向就很清楚了我们不需要从头到尾阅读整份混淆 JS只需要找到三个关键点——签名函数在哪里、它是基于哪些输入计算的、计算过程有没有用到动态环境值。2. 从请求入口反推签名逻辑的几条思路2.1 先全局搜索参数名还是先 Hook如果刚接触这类问题最常见的做法是在浏览器开发者工具里对Acs-Token做全局搜索因为在不少系统中请求头名称是直接以字符串形式出现在网络封装层里的。我个人的经验是可以搜但不能只依赖这一条路。搜索命中的位置往往不止一处有的是在请求封装模块里设置 header有的是在某个环境检测代码里读取同名变量还有的是在混淆代码里被动态拼接出来的片段。直接在这些命中点里盲猜容易绕远。更稳妥的做法是先通过抓包观察到的特征做一个小判断如果参数名能搜到优先从“设置 header”的那个点入手如果搜不到直接上 Hook。搜索的价值在于快速确认“这个字符串的拼装点在哪里”Hook 的价值在于从运行时行为上锁定“这个值到底是在哪一步被写到请求头里的”。两条线同时推进通常半小时内就能定位到核心模块。2.2 调用栈回溯从发送点往回找我见过不少朋友上来就在 Sources 面板里到处翻代码想把整个前端资源全部读一遍这种思路在面对小型站点时还行碰到大型系统基本是浪费时间。正确姿势是反着来不要从加密函数往后找而是从请求发出的那一刻往前找。因为签名函数无论写得多么隐蔽它最终产生的值一定会被某个代码路径带到XMLHttpRequest.setRequestHeader或者fetch的参数里。我们在发送点打断点等请求触发后调用栈自然会把这条路径完整地展示出来。实际操作用的是 Chrome DevTools 的 XHR/fetch 断点在 Sources 面板右侧的 XHR/fetch Breakpoints 区域添加一个 URL 关键字然后点击页面里的某个按钮触发请求。命中断点后打开 Call Stack就能看到一串很长的调用函数列表。前几个是框架内部的封装比如请求库、拦截器继续往下翻会出现业务代码里比较有语义的函数名。签名函数就藏在这些业务函数附近通常就是我们肉眼能看到的第一个“比较可疑”的模块。2.3 监听对象属性谁读谁负责还有一种场景比较棘手参数名在 JS 资源里搜不到Hook 请求头也看不到明显规律只知道最终写给服务端的 header 里有一个对象比如config.headers它的某个属性被赋成了签名值。这时候可以换个思路用Object.defineProperty监听这个属性当代码读取它的时候打印调用栈谁能读到它谁就是使用者谁给这个属性赋了值谁就是生成者。我通常会在页面初始化的时候执行一段探针脚本把这个属性改造成访问器属性get和set里都加日志。这样接下来不管代码是读还是写控制台都会输出当时的调用位置。这种方式尤其适合定位那种被模块间反复传递的参数对象。用的时候注意别在正式环境留探针调试完成记得刷新页面清理掉。// 探针脚本示例监听某个请求配置对象的 header 访问情况 (function () { const target window.someRequestConfig; // 替换成你实际观察到的对象 Object.defineProperty(target, headers, { get() { console.trace([getter] headers 被读取); return target.__realHeaders; }, set(v) { console.trace([setter] headers 被赋值, v); target.__realHeaders v; }, configurable: true, }); })();2.4 工具准备与工作目录安排在开始动代码之前把工具和工作目录整理好能省很多事。我常用的组合是浏览器开发者工具负责动态调试抓包工具负责观察请求结构变化Node.js 作为最后把签名函数迁移过去的运行环境。建议单独建一个目录把从浏览器里保存下来的相关 JS 文件放到本地使用格式化功能先美化一遍再统一搜索关键字符串。格式化这一步很关键混淆后的代码往往是一整行或者极紧凑的写法不格式化根本没法做静态阅读。有一点要提醒不要一上来就依赖各种自动化逆向工具。那些工具在处理简单场景时效率高但遇到大型混淆系统容易黑盒化你只知道结果却说不清中间过程。手工断点加调用栈回溯虽然慢一点但能把整个签名逻辑的链路彻底摸透后面不管是要写防护建议还是做加固方案心里都会更有底。3. 实战定位 Acs-Token 与 ab_sr 的生成函数3.1 在 setRequestHeader 打点定位的核心第一步是在请求头写入的位置设下埋伏。以XMLHttpRequest为例我推荐直接在原型链上修改setRequestHeader给它包一层日志逻辑。这样无论代码从哪个角落发起请求只要它尝试写Acs-Token或ab_sr控制台就会立刻打印出参并输出调用栈。用console.trace能直接看到当前调用链比自己一层层翻调用栈更快。// 在页面加载后尽快执行这段脚本 (function () { const originalSetHeader XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader function (key, value) { if (key Acs-Token || key ab_sr) { console.log([capture], key, value); console.trace([trace] 写入请求头的位置); } return originalSetHeader.call(this, key, value); }; })();如果目标系统用的是fetch而不是 XHR就去改写window.fetch把传入的headers参数完整打出来。这里的思路是完全一样的凡是签名值最终要经过的通道都值得插一根探针。需要留意的是有些请求是在页面加载早期发出的探针脚本必须放在所有业务脚本之前执行可以用浏览器扩展或者 DevTools 的 Overrides 功能实现手动在控制台粘贴往往来不及。3.2 跟着调用栈走进加密模块探针一旦生效点击页面上的某个查询按钮控制台就会输出类似[capture] Acs-Token: 6f2a...紧接着的console.trace会给出调用链。我当时的调用栈简化后大致是setRequestHeader getHeader (网络拦截层) request (请求封装库) interceptors.request (请求拦截器) handleClick (业务代码) _sign (可疑模块) fingerprint (环境指纹采集)从这段调用链能看出签名不是由请求封装库自动生成的而是业务代码在点击事件处理函数里先调用了_sign然后把结果塞进 header。所以下一步就是回到 Sources 面板搜索_sign这个函数名在它的定义处打断点。等断点命中之后观察它的入参和返回值入参里有一个业务参数对象、一个时间戳、一个随机字符串返回值就是最后写进请求头的那串Acs-Token。到这里整个签名函数的边界已经非常清楚了。接着再往fingerprint这个函数里钻能看到ab_sr的数据来源。这类函数往往读取了canvas、navigator、screen等浏览器环境信息然后拼接成一个固定格式的字符串。ab_sr和Acs-Token虽然都在这条调用链上但一个更偏算法校验一个更偏环境采集职责分得比较开。3.3 处理混淆 JS 时的定位技巧如果遇到参数名被混淆全局搜Acs-Token搜不到怎么办这里有几种实操中很管用的处理方式。第一搜索字符串的十六进制或 Unicode 变体。Acs-Token变成\x41\x63\x73\x2d\x54\x6f\x6b\x65\x6e之后特征完全不一样在搜索框里输入Acs或Token这种片段反而可能命中。第二把搜索范围缩小到格式化后的代码里再根据探针日志中记录的调用函数名去反向定位。第三如果整个脚本做了控制流平坦化大量代码块被打散我不建议逐行读懂而是只看数据流找尾部返回字符串的函数看它读哪些变量。还有一个很小的经验直接在控制台执行某个可疑函数的toString()能绕开一部分变量名混淆。因为混淆通常只改源码文本函数内部的逻辑结构还是会以字符串形式暴露出来。特别是那些体积不大但被包装得很深的签名函数toString()之后往往能一眼看出它拼接了哪些字段、调用了哪个算法。4. 把签名函数搬到 Node.js 里跑通4.1 抽离 Webpack 模块定位到加密函数之后很多人会直接在浏览器里继续调试但更实用的做法是把它搬到一个可以自由控制的 Node.js 环境里。目标系统采用的是模块化打包我在浏览器执行window[webpackChunkxxx]看到了它的模块加载器。Webpack 模块的运行机制其实不复杂每个模块是一个函数模块间通过__webpack_require__相互引用。我们不需要把整个 webpack 运行时全搬回去只需要把签名函数所在模块以及它依赖的几个兄弟模块抽出来。实际操作中我会在浏览器控制台里先执行模块加载器暴露的入口找到对应模块的索引号然后用一个自定义的加载器壳子把模块函数包起来在一个全局对象上挂载__webpack_require__实现模块间互调。判断模块依赖最直接的方式是看这个函数内部使用了哪些__webpack_require__(编号)把编号一个个复制出来。随后在本地建一个modules.js按编号存储模块函数再用一个简单的加载器把它们串起来。// 本地简易 webpack 模块加载器教学示例 const modules { 100: function (module, exports, require) { // 这里是签名模块的真实逻辑按浏览器中断点处的源码搬入 }, 200: function (module, exports, require) { // 这是签名模块依赖的工具函数 }, }; const cache {}; function localRequire(id) { if (cache[id]) return cache[id].exports; const module { exports: {} }; cache[id] module; modules[id](module, module.exports, localRequire); return module.exports; }搬代码的时候有一个原则不要贪多。很多模块虽然被签名函数间接引用但真正影响签名输出的只有几个。把依赖圈定得越小后面补环境就越轻松。4.2 补齐缺失的浏览器环境签名函数从浏览器搬到 Node.js 最常见的报错就是“某个全局变量不存在”。这类函数为了生成指纹往往直接读取window、document、navigator、screen等对象。我这边遇到的是函数内部读navigator.userAgent拼进指纹串还读document.cookie里的某个标识。解决办法是在 Node.js 启动阶段把这些全局对象 mock 成和浏览器环境一致的值。最简单的做法是给这些全局对象赋值一个最小可用的假对象只填充签名函数依赖的字段。比如global.window global; global.navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ..., language: zh-CN, platform: Win32, }; global.document { cookie: user_idtest_abc; session_id123456, }; global.screen { width: 1920, height: 1080, colorDepth: 24, };如果签名函数里还有更复杂的 DOM 操作不要用手写 mock 硬扛直接用 jsdom 模拟一个完整文档对象更省力。但对于canvas指纹这类复杂逻辑我建议直接手动 mock 成一个固定返回值因为 Canvas 在 Node 里的渲染结果很难做到和浏览器完全一致它对我们的验证目标并不重要。4.3 用 Proxy 兜底缺什么打什么补环境最怕的是“闭着眼睛补”你不知道代码会访问什么只能靠报错一点点试。这里有个非常高效的技巧——用 Proxy 包一层全局对象任何属性访问只要没命中就打印一条日志。这样跑一次脚本所有缺失的字段就全部暴露出来了不用反复试错。const missingCache new Set(); const fakeWindow new Proxy(globalThis, { get(target, prop, receiver) { if (!(prop in target) !missingCache.has(prop)) { missingCache.add(prop); console.warn([missing], prop.toString()); } return Reflect.get(target, prop, receiver); }, }); global.window fakeWindow; global.navigator fakeWindow.navigator; global.document fakeWindow.document;跑第一轮的时候控制台会刷出一堆[missing] canvas、[missing] chrome、[missing] devicePixelRatio。此时不要慌逐个过滤这些字段在签名函数里到底起什么作用如果只是被读取并拼进字符串mock 成固定值就行如果参与了算法计算就要保证和浏览器里的取值一致。这种“缺什么打什么”的迭代方式比我一开始手写几十行 mock 靠谱得多。5. 高频问题与调试思路速查5.1 “xxx is not defined” 的环境补齐问题在 Node.js 里执行搬过来的签名函数最常遇到的就是xxx is not defined。第一反应不要急着补这个变量而是先想清楚它属于哪一类。如果它是一个浏览器 API比如window、document、navigator那就按前面说的方式 mock 对应字段如果它是一个自定义函数名那说明模块依赖没有抽完整需要回到浏览器确认这个函数是从哪个模块导出的再把对应模块补进本地加载器。这里容易踩一个坑某些环境检测代码会故意访问一个不存在的变量如果补出来的值不对反而走错分支。遇到这种情况我建议在浏览器里先确定这个报错变量的真实取值再拿到本地 mock。比如navigator.webdriver在正常浏览器里是false或undefined但自动化环境可能是true。签名函数如果把这个值拼进了ab_sr两边不一致就会导致签名结果不同。5.2 无限 debugger 与反调试调试过程中最烦人的不是加密难而是反调试。我遇到过的无限 debugger 实现通常是往代码里插入debugger;语句再用定时器反复触发。在 DevTools 里打开文件会被断住特别是一段混淆代码里埋了好几个debugger光手动点掉就耗尽耐心。处理办法是在 Sources 面板里定位到debugger语句所在的那一行右键选择“Never pause here”把断点禁用掉。如果反调试逻辑是动态生成的右键禁用可能不够我有时会直接把脚本内容下载到本地批量把debugger替换成空语句再用 Overrides 功能重写到页面里。这样不影响线上逻辑只影响本地调试视图。还有一个细节有些反调试会判断窗口大小或是否打开了 DevTools不要在页面加载前就开着控制台先把逻辑跑通再开调试器。5.3 全局搜索搜不到参数名如果你把整个前端资源都翻遍了还是搜不到Acs-Token别急着怀疑方向先检查是不是字符串被做了变形。我以前遇到过一种写法字符串被拆成Acs -Token再拼回去直接搜完整名称当然搜不到。这种场景下搜单个片段能提高命中率。另一种可能是参数名本身不是常量而是由某个函数从一个配置表中动态取出来的比如getHeaderName(token)。搞清楚这一点需要结合探针打印的调用栈看看写 header 的那个函数是否接收了一个逻辑名称。只要探针日志完整顺着调用栈往上找一般都能定位到转换逻辑。5.4 本地签名结果和浏览器不一致把签名函数搬到 Node.js 后最令人头疼的就是同样一套代码两边算出来的值不一样。出现这种情况先不要怀疑算法搬错优先怀疑环境差异。签名输入里只要有时间戳、随机数或者浏览器环境值任何一点细微差别都会让结果完全不同。我的排查思路是先做“变量固定”在浏览器调试时把签名函数接收的时间戳和随机数手动替换成固定值观察输出再在 Node.js 里用同样的固定值跑一遍逐一比对每个中间变量。如果中间值对不上就查看它依赖的环境字段是什么去浏览器里读取同名字段的值再回来补齐 mock。还有一个容易被忽略的点有些签名函数内部会读取performance.now()或Date.now()这样的动态时间这类字段无法通过简单 mock 完全固定需要在对比时尽可能在同一个时间窗口内执行。现象可能原因排查方向报错某个浏览器全局变量不存在环境缺字段用 Proxy 日志定位缺失字段再 mock 浏览器取值本地运行被反复 debugger 卡住插入的反调试逻辑搜 debugger 并禁用或用本地文件替换参数名全局搜索搜不到字符串被拆分或动态拼接搜片段、查看十六进制变体、利用探针调用栈Node 与浏览器签名不一致环境字段差异或动态时间固定时间戳和随机数逐层打印中间值对比模块间提示 require 方法未定义模块依赖抽取不完整回到浏览器确认模块依赖关系补全模块函数调试这类前端签名参数我个人最大的体会是不要一上来就盯着加密算法本身先把请求发送链路摸清楚。Acs-Token和ab_sr这类参数看着唬人本质上只是一个校验体系的出口真正值得花时间的其实是“它从哪来、输入是什么、依赖哪些环境”。把这些搞明白无论后面是要做自己系统的安全加固还是评估现有防护是否可靠都能对症下药。另外老生常谈一句所有调试行为都必须限定在自己拥有权限或获得明确授权的系统范围内这套分析思路是用来保护系统、找出防护弱点的不是用来绕过别人防线偷数据用的。