JS逆向实战:请求头签名与动态字体反爬叠加解析
发布时间:2026/10/26 12:25:47 锦皓数字建站

抓一个企业信息类站点的公开数据最让人头疼的往往不是登录环节而是请求头里那串看不出规律的十六进制字符串以及页面上一堆方框或者牛头不对马嘴的汉字。前者是 JavaScript 逆向里最典型的 header 加密后者是字体反爬的标准套路。这两套机制单独出现都不算难一旦叠在一起新手很容易在第一步就卡死签名对不上直接被拒签名对上了又拿到一堆乱码数字两轮折腾下来人都麻了。这篇内容按我自己的排查顺序把这两块拆开讲清楚。签名参数是怎么在混淆代码里被生成的、断点该下在哪里、加密函数怎么从一团乱麻里抠出来本地跑字体反爬的映射关系到底藏在文件哪个位置、用字形轮廓怎么做特征比对、遇到每次刷新都换一套的动态字体怎么保持长期稳定。内容适合有一定前端基础、正在学 JavaScript 逆向、或者被这两类反爬卡过项目的读者。文中所有代码和字段名都做了脱敏处理域名、参数名一律用示例代替思路可以直接迁移。需要提前说清楚一点这类技术的正当用途是学习前端保护机制、做自己有权访问的数据采集、以及评估自家站点的防护强度。对外的数据抓取请务必遵守目标站点的服务条款与 robots 协议控制请求频率别把别人的服务器当成自己的压测环境。技术是中性的用在哪、怎么用取决于写代码的人。1. 请求头里那串签名才是真正的门1.1 一次抓包看到的三个异常字段打开开发者工具的 Network 面板随便点一个返回数据的接口把请求头展开通常能看到几个和普通浏览器请求不一样的字段。它们不会有标准的Accept、User-Agent那种熟悉感而是带着业务前缀比如X-Auth、X-Sign、X-Timestamp、X-Nonce这类命名。cookie 里往往也埋着一个类似的令牌跟着一起发出去。判断哪个字段真正参与服务端校验最直接的办法是本地重放。把请求复制成 cURL先原样发一次确认能通然后逐个删掉可疑字段再发看返回结果。有的字段删了没影响说明只是埋点或者灰度标识有的字段删了立刻返回鉴权失败或者空数据那它就是关键参数。这个方法笨但极其有效比对着代码猜半天强得多。我第一次做这类分析时光靠删字段就排除了三个干扰项省了大量读混淆代码的时间。字段名典型取值形态猜测作用验证方式X-Auth32 位小写十六进制可能是令牌或签名删掉重放看是否 401X-Timestamp13 位毫秒时间戳防重放窗口校验改成很久以前的值重放X-Nonce8 到 16 位随机串防重复请求两次请求用同一个值对比X-Sign大写十六进制或 Base64参数摘要签名改任一业务参数后观察是否变化表格里最后一行值得多说两句。签名类字段有个很明显的特征你把业务参数改一个字符它的值就会整体变化而且是雪崩式的变化不是局部变。这基本可以断定它是摘要算法常见的是 MD5、SHA1、SHA256偶尔也有 HMAC 变种。反过来如果你改了参数它纹丝不动那它更可能是和时间戳绑定的令牌而不是对参数的摘要。1.2 签名参数通常挂在哪一层签名放在请求头、URL 查询串还是请求体里背后其实有工程上的考量不是随便选的。放请求头是最常见的做法原因有三一是不会污染 URL避免被 CDN 日志、浏览器历史、Referer 无意中记录二是跨域场景下配合自定义头能触发预检服务端多一层控制三是和业务参数解耦前端拦截器可以统一注入业务代码完全无感。放 URL 查询串的情况多见于一些老接口或者需要被缓存层识别的场景这种情况下签名往往是对整个查询串排序后做的摘要。放请求体的通常是表单提交类接口签名会作为 body 的一个隐藏字段一起提交。还有一类比较隐蔽的签名藏在 cookie 里通过响应头的Set-Cookie下发每次请求自动带上这种反而最省事因为浏览器帮你处理了。真正麻烦的不是位置而是签名内容到底包了什么。常见的组合是业务参数按字典序拼接 → 加时间戳 → 加随机串 → 加一个硬编码在 JS 里的盐 → 做摘要。这里任何一环的顺序错了算出来的值都对不上。所以逆向的第一步不是急着读代码而是先把输入确定下来也就是搞清楚签名函数的入参结构。这个下文会详细讲。2. 顺着调用栈往回走把签名函数从混淆里挖出来2.1 断点不是随便下的从网络面板倒推很多人一上来就去全局搜索字段名搜到一堆\x58\x2d\x53\x69\x67\x6e这样的东西就懵了。其实更高效的路径是从结果倒推过程。在 Network 面板找到那个请求右键选择Replay XHR请求会重新发一次在 Sources 面板里对XMLHttpRequest.prototype.send或者fetch下断点勾上Pause on any XHR/fetch让请求在发出前停下来。停下来的那一刻调用栈就是你最好的地图。栈顶通常是框架内部的拦截器往下翻几层会看到一个业务函数它的作用域里大概率能看到headers或者config对象里面就有那个签名值。这时候别急着单步先在栈帧里点开各个作用域看看有没有sign、token、timestamp这些变量名。如果变量名被混淆成了_0x1a2b就看它的值值和请求头里那个签名对得上的那个变量就是你要找的。我个人的习惯是找到值之后右键那个变量选Store as global variable控制台里就能用temp1直接访问然后写几行代码调一调看看把它换成别的值请求还能不能通。这一步能确认它到底是签名还是别的什么避免走了半天冤枉路。2.2 字符串数组与控制流平坦化的处理手法混淆工具产出的代码有几个经典特征认出来之后还原思路就比较清晰了。第一个是字符串数组文件开头有一大坨var _0x4f2c [\x73\x69\x67\x6e, getTime, ...]后面所有字符串都通过_0x4f2c[0x1]这种方式引用。第二个是解码函数数组里的字符串常常还是加密的需要一个立即执行函数在启动时解密一遍。第三个是控制流平坦化一段简单的 if-else 被改写成while(true){ switch(_0xabc--) { case 0: ... } }的形式看得人脑壳疼。处理字符串数组最有效的办法不是手动替换而是让浏览器自己跑出来。在解码函数执行完之后的那一行下断点控制台里直接打印那个数组变量整个明文数组就出来了。如果在源码里定位不到解码完成的位置可以在文件末尾加一行console.log(_0x4f2c)或者用调试器的Logpoint功能不用改源码也能看到。控制流平坦化相对麻烦但也不用全部还原。大多数情况下你只需要跑通一个函数而不是读懂整个文件。把switch里的case顺序用脚本重新排一下按照_0xabc的初始值反推出执行顺序再把代码块按顺序重排可读性就能恢复七八成。如果懒得动手直接让虚拟机执行原始混淆代码也是可行的只是调试体验差一些。下面是我常用的字符串数组还原脚本思路是把数组和解码函数一起在 Node 里跑一遍把结果 dump 出来const fs require(fs); const vm require(vm); const source fs.readFileSync(./obfuscated.js, utf-8); // 用一个最简单的桩环境防止解码函数里引用 window 报错 const sandbox { window: {}, document: { createElement: () ({}) }, navigator: { userAgent: Mozilla/5.0 }, console: console, atob: (s) Buffer.from(s, base64).toString(binary), }; vm.createContext(sandbox); // 只取文件开头的数组声明和解码 IIFE避免执行后续业务逻辑 const head source.slice(0, source.indexOf(function _0x)); try { vm.runInContext(head, sandbox, { timeout: 3000 }); } catch (e) { console.warn(部分解码失败可分段执行, e.message); } // 通常解码后的数组会被挂回原变量名直接打印 const names Object.keys(sandbox).filter(k /^_0x/.test(k)); names.forEach(n console.log(n, sandbox[n]));这段代码的价值不在于多高明而在于它体现了一个原则能交给引擎执行的就别用手算。混淆的本质是机器能读懂、人读不懂那就让机器去读。2.3 抠出来的代码怎么在本地跑起来定位到加密函数之后下一步是把它从整个文件里剥离出来让它在本地 Node 环境里独立运行。这个过程俗称抠代码或者补环境听起来玄乎其实就是缺什么补什么。第一轮直接把函数体连同它依赖的变量、工具函数一起复制出来扔到一个新文件里跑一下看报什么错。报window is not defined就补一个global.window {}报document is not defined就补一个最简对象报navigator、location、localStorage同理。关键是别一次补全按报错驱动补一个跑一次这样才知道哪些是真正用到的哪些是冗余的。// env.js —— 最小可用环境桩 global.window global; global.navigator { userAgent: Mozilla/5.0 (Windows NT 10.0; Win64; x64), platform: Win32, language: zh-CN, }; global.location { href: https://example.com/list, protocol: https: }; global.document { cookie: , referrer: https://example.com/, createElement: () ({ getContext: () null, style: {}, }), getElementsByTagName: () [], }; global.localStorage { _s: {}, getItem(k) { return this._s[k] || null; }, setItem(k, v) { this._s[k] String(v); }, }; // 加载并调用抠出来的签名函数 require(./sign.js); const result global.__sign({ page: 1, keyword: demo, ts: Date.now() }); console.log(sign , result);这里有几个坑要提前说。一是Date和Math.random可能被页面重写过如果原代码里有Date.prototype.getTime的改写本地行为会和浏览器不一致需要把这个改写也一起扣出来。二是有些签名函数依赖document.cookie里的某个值本地是空的算出来的结果自然对不上这时候要把真实 cookie 塞进去。三是时区问题如果签名里用到了new Date().toString()本地环境和服务器时区不一致会直接导致签名失败最好统一成时间戳运算。我踩过最深的一个坑是某个函数内部用了window.performance.now()本地补了window但没补performance报错信息被 try-catch 吃掉了返回undefined最后是逐行打断点才发现的。所以补环境的时候尽量把 try-catch 先注释掉让错误暴露出来别让静默失败浪费时间。3. 字体反爬不是障眼法是另一套编码3.1 页面上的乱码其实是私有区码点第一次遇到字体反爬的人最常见的反应是这页面怎么全是方块。其实仔细看方块里是有东西的只是字形被替换过。打开 Elements 面板选中一个数字看到的文本内容是#xe123;这样的私有区码点范围通常在 UE000 到 UF8FF 之间。浏览器用页面引入的自定义字体去渲染这个码点字体文件里把 UE123 这个位置画成了8的样子所以你看到的是8但复制出来是\ue123。整个链路是这样的font-face声明了一个.woff或.ttf文件font-family指向它页面元素的文本用私有区码点浏览器加载字体后按 cmap 表把码点映射到字形索引再从glyf表里取出轮廓数据渲染出来。反爬的核心就在 cmap 这张表上——它等于一张码点 → 字形的字典只要把这张字典读出来再搞清楚每个字形长什么样映射关系就破了。用 Python 的fontTools解析是最省事的路径几行代码就能把 cmap 打印出来from fontTools.ttLib import TTFont font TTFont(target.woff) cmap font.getBestCmap() # {码点: 字形名} glyf font[glyf] for code, name in sorted(cmap.items()): glyph glyf[name] if glyph.numberOfContours 0: continue # 空字形或复合字形跳过 coords glyph.coordinates print(hex(code), name, 点数, len(coords), x范围, (min(p[0] for p in coords), max(p[0] for p in coords)))getBestCmap()返回的是 Unicode cmap 子表一般就是你要的那张。字形名通常是uniE123这种命名但不绝对有的字体商会用glyph001之类的无意义名字所以别依赖名字依赖码点和轮廓数据。复合字形composite glyph的numberOfContours是负数需要先glyf[name].getCoordinates(glyf)展开再处理直接读coordinates会报错这一点在文档里写得不太明显。3.2 用字形轮廓做特征比对拿到 cmap 只是第一步你还需要知道每个字形到底对应哪个真实字符。如果反爬做得比较敷衍字形名会直接叫uniE123.1或者干脆就是eight一眼就能看出来。但稍微认真一点的名字都是随机生成的这时候就得从轮廓特征反推。轮廓特征可以从几个维度提取控制点数量、轮廓的包围盒宽高、各点坐标的哈希、轮廓数量。真实场景下同一个字符在不同字体文件里的轮廓可能被轻微扰动比如点坐标整体偏移 1 到 2 个单位或者增加几个冗余点所以不能做精确哈希得做相似度匹配。我一般用点数 宽高比 坐标归一化后的欧氏距离这三项加权效果比较稳。def glyph_feature(font, name): glyph font[glyf][name] coords, _, _ glyph.getCoordinates(font[glyf]) xs [p[0] for p in coords] ys [p[1] for p in coords] w max(xs) - min(xs) or 1 h max(ys) - min(ys) or 1 # 归一化到 0-1消除字号差异 norm [((x - min(xs)) / w, (y - min(ys)) / h) for x, y in coords] return { count: len(coords), ratio: round(w / h, 3), points: norm, } def similarity(a, b): if abs(a[count] - b[count]) a[count] * 0.2: return 0.0 if abs(a[ratio] - b[ratio]) 0.1: return 0.0 n min(len(a[points]), len(b[points])) dist sum(((a[points][i][0] - b[points][i][0]) ** 2 (a[points][i][1] - b[points][i][1]) ** 2) ** 0.5 for i in range(n)) / n return max(0.0, 1 - dist * 3)这套方法的前提是你手里有一份标准字形库。做法很简单找一个页面上能看到真实字符的对照位置比如详情页里同时用普通字体展示的数字把字体文件按码点解析后人工或者半自动地把字形和真实字符绑定一次存成 JSON。之后每次遇到新字体拿新字形去和标准库做相似度匹配取最高分即可。0 到 9 这十个数字加上小数点、冒号、百分号一共也就十几个字符人工标一次十分钟的事一劳永逸。3.3 动态字体每次刷新都是一张新表静态字体反爬是入门级真正让人抓狂的是动态字体每次刷新页面服务端重新生成一个字体文件码点和字形的对应关系整体打乱。今天 UE001 是8明天可能就是3。这时候只解析一次 cmap 是没用的必须每次请求都重新拉字体、重新解析、重新匹配。判断是不是动态字体最粗暴的方法是连续刷新两次对比两次拿到的字体文件 MD5。如果不一样再看 cmap 里的码点集合是否变化码点集合变了说明是全新字体码点集合没变但字形位置变了说明是重排。两种情况处理思路一样都是每次都以当前字体文件为准重新走一遍特征匹配。工程上要做的是把解析 → 匹配 → 缓存这条链路做成幂等的。缓存键用字体文件的 MD5 而不是 URL因为 URL 里常常带随机参数同一个文件可能会有多个 URL。匹配结果存成{字体MD5: {码点: 真实字符}}的字典同一个字体再次出现时直接查缓存省掉重复的轮廓计算。这套机制搭好之后单次页面解析的额外耗时能压到几十毫秒基本不影响整体抓取效率。还有个经验有些字体文件会把部分字形做成空壳轮廓点数极少甚至为零用来干扰基于点数的识别。遇到这种情况特征匹配会自动失败这时候退一步用渲染截图做像素比对也能兜底虽然慢但准确率更高。别在一种方法上死磕多准备一条后路。4. 两套机制叠在一起时的稳定性问题4.1 时间戳、随机数与签名有效期签名里带时间戳的接口服务端一定会做时间窗口校验常见的是允许 ±300 秒或 ±600 秒的偏差。这意味着你算出来的签名不是永久有效的隔一段时间就得重算。本地调试时如果反复用同一个签名发请求过一会儿突然开始报错十有八九不是代码坏了是时间戳过期了。正确的处理方式是把签名计算做成每次请求前调用而不是启动时算一次缓存起来。函数签名大致是sign(params)内部自己取当前时间戳外部只管传业务参数。随机串同理nonce必须每次重新生成如果复用会被服务端的防重放机制拦下来。还有一种更隐蔽的情况服务端不仅校验时间窗口还校验时间戳的单调递增也就是后一次请求的时间戳不能小于前一次。如果本地时间因为 NTP 同步往回跳了一下就会出现莫名其妙的失败。稳妥的做法是维护一个本地递增计数器取max(当前时间, 上次时间 1)作为实际使用的时间戳能规避绝大多数抖动。4.2 字体映射缓存的失效判断字体缓存失效的判断不能只看时间得看内容。我见过有人设置缓存 10 分钟过期结果服务端是每 5 分钟换一次字体中间 5 分钟一直在用错误的映射解析数据全错还找不到原因。更可靠的做法是每次请求响应里检查字体 URL 或者响应头里的文件哈希变了就重新拉。有些站点更狠字体文件路径完全不变但内容每次请求都不同靠 CDN 缓存控制头来区分。这种情况就得在下载后立刻算一次 SHA1和上次的比对。多一次几百 KB 文件的哈希计算代价可以接受换来的是映射绝对不会错位。失效情形判断依据处理策略字体 URL 变化响应 HTML 里的font-face地址直接重新下载解析URL 不变但内容变下载后计算文件 SHA1比对哈希不同则重建映射码点集合变化cmap 的 key 集合视为全新字体全量匹配字形轮廓轻微扰动特征相似度低于阈值用标准库做模糊匹配兜底表格里最后一行是最容易被忽略的。有的站点不做整体重排只是在原有基础上给每个字形加一点随机偏移肉眼完全看不出区别但精确哈希会全部失效。这时候如果你的匹配逻辑是哈希相等才算命中就会全军覆没。所以特征匹配一定要留模糊空间阈值设得松一点宁可多匹配几次也别直接判失败。5. 工程化与边界能跑和能长期跑是两回事5.1 请求节奏与失败重试的设计脚本能跑通第一版的时候人是最容易上头的恨不得立刻把并发拉到 50 去刷全量数据。这么干的结果通常是被限流、被封 IP、或者触发更严格的风控然后前面的工作全白费。正确的节奏是把并发压到个位数请求之间加随机间隔比如 800 到 2000 毫秒之间浮动。这个数字不是拍脑袋是根据大多数站点默认的限流策略估算的低于这个值触发概率会明显上升。重试策略也要讲究。遇到 403、429 这类状态码不要立刻重试先退避等 5 秒、15 秒、60 秒指数增长最多重试三次。遇到签名错误类的业务返回码别重试因为重试也是错的应该重新走一遍签名计算流程再发。遇到字体解析失败也别重试请求重试的是字体下载和匹配环节。把不同错误类型分开处理比无脑while(retry 3)靠谱得多。判断是不是被限流了除了看状态码还可以看响应时间。正常情况下 200 毫秒的接口突然变成 3 秒哪怕返回的仍是 200也说明服务端在给你降速这时候主动停下来歇几分钟比硬刚更明智。5.2 把逆向结果封装成可替换的模块逆向代码最大的问题是脆弱。对方前端一改版你抠出来的函数可能整个失效。所以从一开始就要把它当成一个可替换的适配器来设计而不是散落在主流程里的几十行脚本。具体做法是定义一个稳定的接口比如signRequest(params) - headers内部实现随便怎么变外部调用方不感知。字体解析同理定义decodeText(text, fontBuffer) - plainText换了解析算法也不影响调用方。这样改版的时候你只需要重写适配器内部主流程一行不用动。配置项也要抽出来。盐值、字段名、签名算法、字体匹配阈值全部放到配置文件里别硬编码在函数中间。我见过太多人把盐值写在代码第 300 行改版之后找了半天才想起来在哪儿。集中管理之后改版排查的时间能省一大半。最后一条经验是关于日志的。逆向类脚本一定要打详细日志但要分级。请求级别的日志记录 URL、耗时、状态码签名级别的日志记录入参和出参的摘要别打完整值太长字体级别的日志记录文件哈希和匹配成功率。出问题的时候看一眼日志就知道是哪一环崩的比重新断点调试快十倍。日志文件记得轮转别跑一天把磁盘写满。真正把这类项目做久了我才明白逆向能力只占三成剩下七成是工程稳健性和对边界的管理。代码写得再巧妙如果三天两头因为对方改版就全线崩那这个项目就没法交付。反过来拆得足够清晰、切得足够细、日志足够全就算核心算法失效了也能在半小时内定位并替换掉。这才是能长期跑下去的状态。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。