抓一个企业信息类站点的公开数据,最让人头疼的往往不是登录环节,而是请求头里那串看不出规律的十六进制字符串,以及页面上一堆方框或者牛头不对马嘴的汉字。前者是 JavaScript 逆向里最典型的 header 加密,后者是字体反爬的标准套路。这两套机制单独出现都不算难,一旦叠在一起,新手很容易在第一步就卡死:签名对不上直接被拒,签名对上了又拿到一堆乱码数字,两轮折腾下来,人都麻了。
这篇内容按我自己的排查顺序把这两块拆开讲清楚。签名参数是怎么在混淆代码里被生成的、断点该下在哪里、加密函数怎么从一团乱麻里抠出来本地跑;字体反爬的映射关系到底藏在文件哪个位置、用字形轮廓怎么做特征比对、遇到每次刷新都换一套的动态字体怎么保持长期稳定。内容适合有一定前端基础、正在学 JavaScript 逆向、或者被这两类反爬卡过项目的读者。文中所有代码和字段名都做了脱敏处理,域名、参数名一律用示例代替,思路可以直接迁移。
需要提前说清楚一点:这类技术的正当用途是学习前端保护机制、做自己有权访问的数据采集、以及评估自家站点的防护强度。对外的数据抓取请务必遵守目标站点的服务条款与 robots 协议,控制请求频率,别把别人的服务器当成自己的压测环境。技术是中性的,用在哪、怎么用,取决于写代码的人。
1. 请求头里那串签名,才是真正的门
1.1 一次抓包看到的三个异常字段
打开开发者工具的 Network 面板,随便点一个返回数据的接口,把请求头展开,通常能看到几个和普通浏览器请求不一样的字段。它们不会有标准的Accept、User-Agent那种熟悉感,而是带着业务前缀,比如X-Auth、X-Sign、X-Timestamp、X-Nonce这类命名。cookie 里往往也埋着一个类似的令牌,跟着一起发出去。
判断哪个字段真正参与服务端校验,最直接的办法是本地重放。把请求复制成 cURL,先原样发一次确认能通,然后逐个删掉可疑字段再发,看返回结果。有的字段删了没影响,说明只是埋点或者灰度标识;有的字段删了立刻返回鉴权失败或者空数据,那它就是关键参数。这个方法笨但极其有效,比对着代码猜半天强得多。我第一次做这类分析时,光靠删字段就排除了三个干扰项,省了大量读混淆代码的时间。
| 字段名 | 典型取值形态 | 猜测作用 | 验证方式 |
|---|---|---|---|
X-Auth | 32 位小写十六进制 | 可能是令牌或签名 | 删掉重放看是否 401 |
X-Timestamp | 13 位毫秒时间戳 | 防重放窗口校验 | 改成很久以前的值重放 |
X-Nonce | 8 到 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 面板选中一个数字,看到的文本内容是这样的私有区码点,范围通常在 U+E000 到 U+F8FF 之间。浏览器用页面引入的自定义字体去渲染这个码点,字体文件里把 U+E123 这个位置画成了"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 动态字体:每次刷新都是一张新表
静态字体反爬是入门级,真正让人抓狂的是动态字体:每次刷新页面,服务端重新生成一个字体文件,码点和字形的对应关系整体打乱。今天 U+E001 是"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、耗时、状态码;签名级别的日志记录入参和出参的摘要(别打完整值,太长);字体级别的日志记录文件哈希和匹配成功率。出问题的时候,看一眼日志就知道是哪一环崩的,比重新断点调试快十倍。日志文件记得轮转,别跑一天把磁盘写满。
真正把这类项目做久了我才明白,逆向能力只占三成,剩下七成是工程稳健性和对边界的管理。代码写得再巧妙,如果三天两头因为对方改版就全线崩,那这个项目就没法交付。反过来,拆得足够清晰、切得足够细、日志足够全,就算核心算法失效了,也能在半小时内定位并替换掉。这才是能长期跑下去的状态。