☰
接口签名逆向:抓包定位、JS还原与Python复刻稳定运行
2026/10/26 3:04:30 网站建设 项目流程

简介:面向社交媒体数据采集学习者与爬虫进阶开发者的实战型代码包,聚焦小红书平台的数据抓取与算法还原。内容围绕 Python 请求封装、x-s 与 x-common 参数还原、JavaScript 逆向分析及数据解析展开,提供毫秒级采集思路和开箱即用的接口调用方式,适合已有一定爬虫基础、希望理解前端加密与请求模拟的开发者参考。压缩包共 29 个文件,以 11 个 py 源码为主,辅以 yml 配置、md/rst 文档、txt 依赖说明及 Makefile、bat 等构建脚本,整体约 28KB,目录涵盖核心模块、工具函数、测试用例、配置与文档说明,便于按结构梳理实现脉络。已有 4451 人学习,可用于研究采集流程、参数生成逻辑、异常处理与项目组织方式,并作为社交媒体数据挖掘的练手项目。

1. 抓包里那串签名不是加密,而是服务端和浏览器之间核对暗号

打开开发者工具,随手翻一个内容列表接口,请求头里总有一串看不懂的十六进制字符串,旁边还跟着一个十三位数字。多数人第一反应是「上了 AES」,于是转头找密钥、找 IV、找 padding,折腾两天一无所获。真相往往朴素得多:那串东西是 MD5 或 HMAC 的输出,十三位数字是毫秒时间戳。所谓 Python 算法还原与 JavaScript 逆向,核心工作量并不在破译密码,而在把「拼接字符串的顺序」和「运行环境的细节」这两件事一比一复现出来。这份资源把这条链路从抓包定位、混淆代码还原一直拆到 Python 侧可运行的复刻实现,值得搞懂的地方在于:签名同时承担了防重放和防篡改两个职责,理解了这两个目标,你就知道该复现哪些字段,而不是把整段混淆代码原样搬走。

2. 签名为什么必须由 JS 生成:拦截点、拼接顺序与校验逻辑

签名的本质是服务端和客户端共享一套「输入 → 输出」的确定性计算。服务端收到请求后,用同样的输入重算一遍,比对不上就拒。这个设计决定了逆向的三个必须回答的问题:输入是什么、拼接顺序是什么、输出被放在哪里。下面按请求从发出到被校验的顺序拆。

2.1 拦截点在哪一层:请求头是在哪个函数里被写进去的

业务代码很少直接在fetch调用处写签名,通常是封装了一层 request,在拦截器里统一处理。定位这一层最直接的办法是在真正发包的 API 上下断点,然后看调用栈往上一层层找,栈里第一个出现「参数被加工」的帧就是目标。

比起盲目全局搜索,Hook 原型方法成本更低,因为它不需要提前知道函数名。下面这段注入要在页面业务脚本执行之前完成,否则原型上的方法已经被业务代码持有引用,重写只对新调用生效:

// 在页面加载前注入,拦截所有请求头的写入动作 const rawSet = XMLHttpRequest.prototype.setRequestHeader; XMLHttpRequest.prototype.setRequestHeader = function (key, value) { // 只打印疑似参与签名的头,避免控制台被 Accept、Referer 刷屏 if (/^(sign|ts|nonce|trace|token)/i.test(key)) { console.log('[header]', key, value, new Error().stack); } return rawSet.call(this, key, value); }; // fetch 走的是另一条路径,需要单独包一层 const rawFetch = window.fetch; window.fetch = function (input, init) { const h = init && init.headers; if (h && h.sign) console.log('[fetch]', h.sign, init.body); return rawFetch.apply(this, arguments); };

new Error().stack是关键——它把「谁写的这个头」直接打出来,省去手动断点猜栈的时间。这段代码只读不改,不会影响页面行为,但要注意Rx.defineProperty把方法设为不可重写的场景,那种情况下需要退回断点法。找到写入点后,往上读两三层就能看到签名函数的入口,函数体大概率是被混淆的。

2.2 参与签名的到底是什么:时间戳、随机串和请求体摘要

一个典型的签名输入由四部分组成,缺一不可:

字段作用常见形态
路径与查询串绑定具体接口与参数path?k1=v1&k2=v2
请求体摘要防止 body 被改原始 JSON 串或键值对串
时间戳防重放窗口13 位毫秒 / 10 位秒
固定盐或设备串提高复现门槛硬编码字符串、UA 派生值

拼接顺序是这套机制里最容易踩坑的部分。服务端和前端约定了一个固定的拼接模板,改一个字符顺序结果就完全不同,而混淆代码里这段逻辑往往被拆成十几个小函数。常见的实现思路是:先把参数按键名排序,再按k=v用&连接,前面拼上 path,后面拼上 ts 和盐,最后整体做一次哈希。你不需要相信这个描述,但你需要验证它——验证方法在 3.3 节。

还有一个经常被忽略的输入:随机串。它不参与校验的语义,只参与计算,作用是让同一秒内的多次请求产生不同的签名。复刻时必须保留它,因为有些实现会把随机串和 time 一起拼进原串。另外,有些接口会把null、空字符串、数组这类特殊值单独做一次处理,直接照搬字典序列化会翻车,这个坑在 4.2 节展开。

2.3 服务端校验失败时,不同状态码对应不同的失败原因

逆向过程中最难的不是写代码,是判断「我现在错在哪」。服务端通常不会告诉你详细原因,但不同响应形态对应不同问题,这是一条可以反推的经验线:

返回「参数缺失」类错误,通常是头没带上或者键名拼错,回去核对接入点的 header key 大小写。返回「签名错误」类错误,说明字段齐了但计算结果不对,问题出在拼接顺序或者哈希算法的选择上。返回「请求过期」类错误,说明时间戳和服务器时钟偏差超过窗口,检查本机时间以及用的是秒还是毫秒。返回「请求频繁」或者直接是空数据,那已经不是签名问题,是设备指纹和行为特征被判定为异常了——这两类问题在算法层面是修不好的,见第 5 章。

还有一种隐蔽的情况:签名完全正确,但服务端返回的是另一个版本的接口。多端共存时,同一路径在不同设备类型下校验逻辑可能不同,抓包时要确认请求头的 UA 和实际校验分支一致。这个问题没有任何报错提示,只能靠对比不同 UA 下的请求特征来发现。

3. 从混淆代码里挖出签名函数:定位、AST 还原与对拍验证

混淆不是加密,它只是让「读」变贵。这个判断很重要,因为一旦接受这一点,逆向就从「猜密钥」这个无底洞,变成了一个可穷举、可验证的工程量问题。下面三步是常见的工作流。

3.1 三种定位手法:栈回溯、关键字搜索、运行时 Hook

栈回溯适合已经确定发包位置的情况,成本最低,但要求你抓得到那一刻的调用栈。关键字搜索适合入口不明显、但签名函数里有明显特征(比如md5、encrypt、sign这些字符串还留着)的情况,全局搜一遍然后逐个下断点。第三种是运行时 Hook,不关心函数叫什么,只关心它做了什么:

// 观察哪些地方在读时间戳和随机数,这两类调用高度集中在签名函数附近 const rawNow = Date.now; Date.now = function () { const v = rawNow.call(Date); console.log('[now]', v, new Error().stack.split('\n')[2]); return v; };

这类 Hook 的价值在于「缩小范围」。签名函数一定会读时间,大概率会读随机数或者读navigator,把这几类调用全打出来,交叉比对调用栈,重合度最高的那一处基本就是入口。注意不要同时 Hook 太多种,日志量会爆炸,一次挂一两个就够了。

还有一类更省事的做法:先把整个 JS 文件下载下来,用格式化工具展开,搜索XMLHttpRequest或者fetch的出现位置,反向找它们附近对 headers 对象的赋值。混淆通常会打乱函数位置,但对内置对象的引用不会变,这个特征比函数名可靠。

3.2 AST 还原:字符串数组、控制流平坦化与属性混淆

拿到入口函数后,读不动是常态。最常见的三种混淆手段各有对应的还原思路:

字符串数组(string array)是把所有字面量抽到一个大数组里,代码里只留下_0x1a2b(0x3f)这种调用。还原的关键是找到数组变量和访问函数,然后在本地执行一次初始化逻辑,把数组内容取出来,做一次全局替换。用 Babel 遍历比较稳妥:

// 还原 _0xabc(0x12) -> "sign" 这类调用 const t = require('@babel/types'); module.exports = function ({ types: t }) { return { visitor: { CallExpression(path) { const { callee, arguments: args } = path.node; // 只处理「函数名是标识符 + 唯一参数是数字字面量」的形态 if (!t.isIdentifier(callee) || args.length !== 1) return; if (!t.isNumericLiteral(args[0])) return; // 从预先执行拿到字典表里查值,查不到就跳过,避免误伤业务函数 const val = dictionary[args[0].value]; if (typeof val === 'string') path.replaceWith(t.stringLiteral(val)); }, }, }; };

dictionary需要你先在一个隔离环境里把数组的初始化代码跑一遍拿到,不要试图静态推导。控制流平坦化则是把函数体拆成若干基本块,塞进一个while(true){ switch(x) {...} }的分发器,还原思路是提取每个 case 的块,按执行顺序重新串起来——这一步手工做成本很高,通常只在卡住的时候才做,因为大多数签名函数的核心逻辑只有二三十行,跳过外壳直接读块内容也够用。

属性混淆是把obj.sign写成obj['\x73\x69\x67\x6e'],这种用一次JSON.parse或者十六进制转换就能批量处理掉,最简单的一类。

3.3 还原结果怎么验证:用同一组输入做对拍

还原到一定程度后,必须停下来验证,否则很容易在错误的方向上越走越远。验证方式是对拍:给 JS 的原始函数和你的 Python 复刻喂完全相同的入参,比对输出。为了做到这一点,需要把 JS 函数从页面里「抠」出来单独跑,通常要把依赖的全局对象补上(见 3.2 之外的环境补全话题)。

对拍脚本本身很简单,但有几个硬性要求:入参里的时间戳必须是显式传入的常量,不能各自取Date.now();随机串也必须固定;字符串编码要保持一致。这三个条件不满足,对拍永远不会通过,而且你会误以为是算法错了。

# 对拍:固定 ts 和 nonce,只比对哈希结果 import subprocess, json, hashlib CASE = {"path": "/api/feed", "params": {"a": "1", "b": "中文"}, "ts": "1700000000000"} # JS 侧通过 node 调用被抠出来的函数,输出纯 JSON js_out = subprocess.run( ["node", "sign_bundle.js", json.dumps(CASE)], capture_output=True, text=True, check=True, ).stdout.strip() py_out = build_sign(CASE["path"], CASE["params"], CASE["ts"]) # 见 4.1 实现 assert js_out == py_out, f"不一致: {js_out} vs {py_out}"

capture_output拿到的必须是干净的 stdout,check=True让 node 报错时直接抛异常而不是静默返回空串——这条经验是踩坑换来的,早期用一个返回空的脚本跑对拍,看着「通过」了半天。对拍通过之后也不代表万事大吉,还要覆盖特殊值:空字符串、null、数组、超长字符串、含 emoji 的文本,这四个用例能筛掉八成的编码问题。

4. Python 侧复刻:为什么「跑通一次」和「稳定跑一年」是两件事

跑通一次只要运气好,稳定运行需要把所有隐式假设显式化。这一章讲清楚三条路线的取舍,以及两个最容易翻车的实现细节。

4.1 两条复刻路线:纯 Python 重写与 JS 运行时桥接

纯重写的优点是快、无依赖、易部署,缺点是 JS 侧逻辑一改你就得跟着改。桥接的优点是永远跟得上,缺点是引入完整运行时,性能和部署复杂度都上去了。

纯重写适用于签名逻辑已经还原干净、且只依赖标准哈希的情况:

import hashlib, time, urllib.parse def build_sign(path: str, params: dict, ts: str | None = None, salt: str = "硬编码盐值") -> dict: """返回可直接塞进 headers 的签名字段。""" ts = ts or str(int(time.time() * 1000)) # 毫秒时间戳,精度必须一致 # 键排序 + k=v 连接,顺序必须和 JS 完全一致,不能依赖 dict 插入顺序碰运气 raw = "&".join(f"{k}={params[k]}" for k in sorted(params)) # 注意:这里不做 URL 编码,因为 JS 侧是先拼原串再哈希 base = f"{path}?{raw}{ts}{salt}" return { "sign": hashlib.md5(base.encode("utf-8")).hexdigest(), # 32 位小写 "ts": ts, }

三个参数值得单独说明:ts允许外部注入,是对拍和回归测试的前提;salt提成参数而不是写死在函数体里,是因为部分接口的盐会按端类型切换,写死了改起来要动代码;sorted(params)这一行是整个函数最危险的地方,它假设了 JS 侧也是排序的——如果你的对拍发现不通过,第一个要怀疑的就是这里,改成按插入顺序试试,或者改成按某个显式字段顺序。

桥接路线则是把抠出来的 JS 打包成一个只暴露一个函数的模块,Python 侧用子进程或者嵌入式引擎调用。子进程的写法就是 3.3 节那样,嵌入式引擎的好处是省掉进程启动开销,但要注意它对 ES6+ 语法的支持程度,遇到不支持的语法就退回子进程方案。我一般会在桥接方案外再包一层缓存:同样的入参在 1 秒内复用结果,因为签名里的时间戳精度是毫秒,实际上同一秒内多次请求只有随机串不同,如果随机串也能复用,缓存命中率会很高。

4.2 URL 编码与序列化顺序:最容易翻车的地方

这一节的每一条都是实际撞过的墙。

第一是空格。JS 的encodeURIComponent把空格编成%20,Python 的urllib.parse.quote_plus编成+,quote编成%20。用错函数,签名计算结果直接错,而且错误只在参数含空格时出现,测试用例里没空格就永远发现不了。

第二是保留字符集。encodeURIComponent不编码A-Z a-z 0-9 - _ . ! ~ * ' ( ),Python 的quote默认 safe 是/,不编码_.-~和字母数字,但会把!'()*编码掉。含这些字符的参数一旦出现,两边结果就不一样。稳妥做法是显式传 safe:

from urllib.parse import quote # 对齐 encodeURIComponent 的不编码集合 SAFE = "-_.!~*'()" def js_encode(s: str) -> str: return quote(s, safe=SAFE)

第三是数组与嵌套对象。JS 侧可能是a[]=1&a[]=2,也可能是a=1,2,还可能是 JSON 串整体参与哈希。这三种在 Python 里序列化出来完全不同,只能靠对拍确定,没有通用规律可循。

第四是数字精度。JS 的 Number 是双精度浮点,超过 2^53 的整数会丢精度,Python 的 int 是任意精度。如果签名串里出现了长整型 ID,JS 侧可能已经把它转成了科学计数法或者丢了末位,Python 侧照原样拼就会对不上。解决办法是在 JS 侧就把它String()化,或者在 Python 侧模拟同样的截断——后者非常恶心,优先选前者。

第五是字典顺序。JS 对象的键顺序有个特殊规则:整数索引键会按数值升序排在前面,字符串键按插入顺序排。Python 的 dict 是纯插入顺序。参数里混有数字键名时,两边的遍历顺序天然不一致,只能显式排序对齐。

4.3 时间戳与时钟:签名有效期背后的窗口计算

签名里的时间戳不是装饰。服务端会拿它和自身时间比对,超过窗口就拒。窗口大小通常不会公开,逆向时可以通过二分法试探:把 ts 往前拨 1 分钟、5 分钟、30 分钟,看哪个点开始报过期。这个数字决定了你的客户端对时钟偏差的容忍度。

工程上要注意三件事。一是本机时钟漂移,长时间运行的机器不校时,几天下来偏几十秒很正常,签名会突然大面积失败而代码毫无改动。二是时区,time.time()返回的是 UTC 秒数,不受时区影响,但如果代码里用了datetime.now()再去取时间戳,就会差 8 小时。三是精度,服务端如果按秒校验而你传了毫秒,多半直接判非法,反之亦然,这个在对拍阶段就能确认。

一个实用的做法是把「取时间戳」这件事收敛到一个函数里,并且在这个函数里做一次本地时间与服务端时间的差值校准:

import time _OFFSET = 0.0 # 本地时钟相对服务端的偏移,秒 def server_ts_ms() -> str: # 单调时钟 + 校准偏移,避免系统时间被 NTP 回拨时产生倒退 return str(int((time.time() + _OFFSET) * 1000))

_OFFSET的初始值可以靠一次请求的响应头里的Date字段估算出来,简单相减即可。这段代码看起来小题大做,但当采集任务连续跑几十个小时,时钟校正是唯一能解释「昨天好好的今天全挂」的变量。

5. 排查手册:签名突然失效时的 5 个现场

签名类问题的排查有一条铁律:先证明「同样的入参能不能算出同样的结果」,再证明「算出来的结果服务端认不认」。前者是算法问题,后者是环境问题,混在一起查只会浪费时间。

5.1 接口层的现象与原因

现象一:本地跑通,换台机器或者上服务器就全挂。原因通常是环境差异,最常见的是 Python 版本不同导致字典行为或者编码默认值变化,其次是系统 locale 影响字符串处理。解决办法是把运行环境固定下来,依赖版本锁定,并且在启动时打印一次 Python 版本和 locale 到日志里,出问题时第一眼能看到。

现象二:签名正确但返回空数据或者风控提示。原因不在算法,在请求特征。缺 Cookie、UA 与签名假设的端类型不一致、请求间隔过于规律,都会触发。解决办法是把一次完整的浏览器请求的所有头原样带上,包括那些看起来没用的Accept-Language、Referer,然后再逐步删减,找到最小的必要集合。

现象三:前几十个请求正常,之后开始失败。原因是频控或者设备指纹被标记,和签名无关。解决办法是降速、增加请求间隔的随机性,并检查是否有某个固定字段(比如设备 ID)在高频下被关联。这一步没有任何技术手段能绕过,只能调整行为节奏。

5.2 算法层的现象与原因

现象四:对拍通过,线上偶发失败,失败率大概百分之几。原因是某些特殊参数触发了编码分支,比如含中文、含 emoji、含+号。解决办法是把线上真实出现过的参数全量落盘,挑出失败样本单独跑对拍,通常能定位到一个具体的编码差异点。这个坑很隐蔽,因为绝大多数参数都是纯 ASCII。

现象五:代码没改,某天开始全部失败。原因大概率是 JS 侧逻辑更新了,或者盐值轮换了。解决办法是建立一套自动化的「签名健康检查」:定时用固定入参调一次接口,失败就告警,然后重新走一遍第 3 章的定位流程。这套检查的价值在于把「发现故障」的时间从几小时压缩到几分钟。

还有一种现象值得单独提:对拍脚本自己骗自己。比如 JS 侧输出带了换行,Python 侧strip()掉了,看着一致其实不一致;或者对拍时用的入参里恰好没有触发分支的字段。规避办法是对拍用例必须包含空值、特殊字符、超长串、数字键名这四类,并且比对前两边都做一次显式规范化。

6. 设计取舍:还原算法和模拟浏览器,谁更抗变化

把签名算法还原出来,本质上是用「一次性的逆向成本」换「长期的运行效率」。这个交换划不划算,取决于三个变量:接口变更频率、请求量级、以及你对失败的可接受度。

维度算法还原浏览器自动化
单次请求开销毫秒级,纯计算百毫秒级,含渲染与脚本执行
并发能力高,无状态低,受实例数限制
抗前端变更弱,JS 一改就要重跟强,前端改了它自己会跑
环境依赖低高,需要完整运行时
调试成本前期高,后期低前期低,后期排查难

这张表里最容易被低估的是「抗前端变更」这一行。算法还原的脆弱性不是线性的——它可能半年不动,也可能一周改两次。所以真正影响决策的不是「能不能还原」,而是「你愿不愿意维护一条随时可能断的链路」。如果请求量不大、时效性要求不高,浏览器方案的综合成本反而更低;如果要跑大规模采集,算法还原几乎是唯一选择,因为它的边际成本接近于零。

我更倾向于一种混合结构:主链路走算法还原,同时保留一个低频的浏览器探针,定时跑一次真实请求并把结果和算法结果比对。探针不承担业务流量,只承担「报警」职责——它比任何日志都更早发现前端变更。这个思路我一般叫它「双轨校验」,实现成本大概两天,但能省下后面无数次「为什么突然全挂了」的深夜排查。

还有一点值得想清楚:这套能力的边界在哪。签名还原解决的是「请求合法性」,解决不了「行为合理性」。服务端如果还有频率、路径序列、设备指纹层面的判断,算法再准也没用。所以做这件事之前先想明白瓶颈在哪一层,别把两周时间花在还原一个本来就不是瓶颈的签名上。

这类项目的血泪经验通常集中在一个地方:你以为你在逆向算法,其实你在逆向假设。每一次对拍失败,背后都是一个你没意识到的隐式约定——空格的编码方式、数字的精度、键的排序规则、时间的取整方式。这些约定没有任何文档,只能靠一次次比对逼出来。从那以后我每次接这类活,第一步都是先把对拍框架搭好,把固定入参和断言写死,再去读混淆代码。没有对拍的逆向就是在黑匣子里摸象,改一行跑一次,永远不知道自己是前进还是后退。这份资源把整条链路——从抓包定位、Hook 采集、AST 还原到 Python 复刻和双轨校验——整理成了可以逐段对照的代码与笔记,按自己的目标顺着走一遍,比零散地搜几十篇帖子要扎实得多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询