接口签名逆向实战:从JS分析到Python自动签名
先声明一件事这篇东西不会教你破解任何一家真实平台的线上接口所有演示都建立在我本地搭的模拟网关之上。但它解决的问题是真实的——当你面对一个带 sign 签名的接口签名算法被藏在压缩过的 JS 里时怎么一步步把它还原出来然后用 Python 复刻成一个能自动签名的请求器。前几天有人问我某个电商平台的商品价格接口能不能直接抓。页面源码里看不到价格回到接口层发现所有参数都齐了唯独有个 32 位的 sign 参数校验死活过不去。这个 sign 就是各大平台通用的反爬手段你不光要带上正常的业务参数还要证明你“知道”签名规则。这篇文章就把完整的逆向思路拆开讲一遍目标是让你看完之后遇到同类签名接口能自己独立跟完一遍流程。1. 放着现成的页面爬虫不用为什么要啃接口签名先说需求背景。我接到的任务其实很单纯每天定时抓一批商品的最新价格做比价分析。最朴素的方案是写个页面解析器把商品详情页的 HTML 抓下来再用 CSS 选择器提取价格文本。理论上能跑通但实际用起来非常痛苦。页面渲染本身就是动态的价格往往由 JS 异步加载后写入 DOM。你以为抓到的 HTML 里有价格实际上那只是个 loading 占位符。就算某些商品快照里能拿到初始价格遇到秒杀、促销价、阶梯价的时候页面里往往只显示变更后的价格没有原始数据无法追溯价格变化的链路。更要命的是整个 HTML 体积动辄几百 KB要在里面翻一个数字响应速度和流量成本都很难受。对比之下直接打接口是另一个路子。接口返回的是结构化 JSON字段清晰一个价格请求 2KB 就能搞定。页面上的价格要 500ms 渲染接口请求 150ms 就回来了。关键是接口里的字段维度比页面展示丰富得多很多平台会返回原价、到手价、活动价、优惠券门槛等一整套数据这些信息页面根本不会完整展示。而 sign 就是挡在接口前面的一道闸。它本质上是客户端对请求参数做的一次 HMAC 或摘要计算服务端收到请求后会用同样的规则重新计算一遍对不上就直接拒绝。服务端通过这种机制确保每个请求都是由“官方客户端”发出的而不是自定义脚本。所以这个任务的核心从“怎么解析页面”变成了“怎么还原客户端签名规则”。这也是这类需求最常见的技术分水岭页面解析是体力活签名还原是智力活后者才是接口抓取的精髓。1.1 签名接口的典型结构先把目标请求拆开看一个需要签名的请求一般长这样GET /api/goods/price?goods_id1001ts1712900000sign4A32F0C2B1D...goods_id是业务参数ts是客户端时间戳sign就是我们要破解的核心。服务端拿到请求后会取出除了sign之外的所有参数按照约定好的规则排序、拼接、加盐然后计算摘要值再和sign比对。平台之间唯一不同的就是这段算法的细节但整体骨架大同小异。这正是逆向能进行的根本原因算法必须在客户端存在只要它在客户端就一定能通过 JS 分析把它挖出来。1.2 我为什么建议把“逆向”理解为“还原”而不是“破解”很多新人一听逆向就觉得是黑客行为。实际做下来你会发现大多数平台的签名算法并没有太多密码学层面的难度本质上是确定性的字符串变换。你的工作是还原一套已知算法而不是破解加密。MD5、SHA 这类摘要算法是公开的平台不可能自创一个摘要算法它们能做的是把输入规则搞得复杂、加盐、多层嵌套让还原难度高一点。理解了这一点整个任务的心态就对了你是在做“输入输出对比分析”不是在攻击某个系统。2. 第一步抓包确认请求参数锁定 sign 到底哪来的逆向的第一步不是看算法而是先搞清楚网络层发生了什么。拿我本地搭的模拟网关来演示。网关是一个 Flask 服务暴露了一个带签名校验的价格接口完整代码见下面方便你跟着复现。2.1 搭一个带签名校验的模拟网关为了后面每一步都有据可查我在127.0.0.1:9000起了一个模拟服务算法设计成最常见的模式参数按字典序排序、拼接成字符串、尾部加盐、MD5 后转大写。# mock_gateway.py import time import hashlib from flask import Flask, request app Flask(__name__) SECRET_KEY demo_secret_2024 ITEMS { 1001: {goods_id: 1001, title: 模拟商品A, price: 199.9}, 1002: {goods_id: 1002, title: 模拟商品B, price: 89.5}, } def make_sign(params: dict) - str: # 参数按 key 字典序排序拼成 keyvalue... sorted_keys sorted(params.keys()) raw .join(f{k}{params[k]} for k in sorted_keys) raw fkey{SECRET_KEY} return hashlib.md5(raw.encode(utf-8)).hexdigest().upper() app.route(/api/goods/price) def price(): goods_id request.args.get(goods_id) ts request.args.get(ts) sign request.args.get(sign) if not all([goods_id, ts, sign]): return {code: 400, msg: missing params}, 400 if abs(int(time.time()) - int(ts)) 300: return {code: 401, msg: ts expired}, 401 params {goods_id: goods_id, ts: ts} if sign ! make_sign(params): return {code: 403, msg: bad sign}, 403 return {code: 0, data: ITEMS[goods_id]} if __name__ __main__: app.run(port9000)启动之后直接访问价格接口不带 signGET http://127.0.0.1:9000/api/goods/price?goods_id1001ts1712900000返回403 bad sign。这个 403 就是我们要从“过不去”变成“过得去”的起点。2.2 抓包时要关注哪几个关键信息如果你面对的是真实目标第一步永远是打开抓包工具清空缓存后重新触发一次价格请求。实际操作时没必要把整个页面生命周期都录下来重点只盯四件事URL 路径和完整 query 参数触发这个请求的前置请求比如是否先拿 token、cookie请求头里是否有自定义字段部分平台的参数会塞在 header 里响应中的错误码和错误信息很多平台会在响应里提示“sign error”抓包工具的选择上电脑端 Charles 和 Fiddler 都行但我个人更推荐用浏览器自带开发者工具配合移动端模拟去定位请求来源。原因很简单开发者工具的 Network 面板可以直接右键某个请求复制为 cURL还能直接用 Initiator 列看到这个请求由哪个 JS 文件、哪一行代码触发。这个调用栈信息对后续定位加密函数极其关键。2.3 从参数特征初步判断签名算法拿到完整请求后先观察 sign 本身的形态长度为 32 的十六进制字符串大概率是 MD5长度为 40大概率是 SHA1长度为 64大概率是 SHA256带和乱码可能是 Base64 或 HMAC 变种这只是初步猜测真正要确认得去 JS 代码里找证据。但这一步能帮你筛选搜索方向不至于在混淆到妈都不认识的代码里乱转。3. 第二步用断点和调用栈逼出加密函数的位置网络层看到的是一个黑盒JS 代码才是白盒的入口。这一步的核心任务只有一个在压缩后的代码里找到生成 sign 的那个函数。3.1 全局搜索从“关键词”到“函数名”打开开发者工具的 Sources 面板CtrlShiftF 全局搜索。搜索词优先选接口参数名比如goods_id或者选sign字符串、sign这种拼接片段。找到包含这些关键词的 JS 文件之后再用格式化工具美化代码开发者工具自带 Pretty Print可读性会好很多。需要注意一个反直觉的现象很多平台不会直接把sign写在 URL 拼接的逻辑里而是封装在一个独立的加密函数中外层传入一个对象函数内部完成排序、拼接、加盐、计算、返回。你搜sign:不一定能搜到赋值语句反而有可能搜到的是一个空字符初始化。遇到这种情况换一个关键词再搜比如md5、sha、salt、secret、appKey、accessKey这些都是加密实现里很常见的命名。如果整个 JS 进行了变量名混淆所有函数名都变成了 a、b、c 这种那就不能靠搜函数名了二是靠“行为特征”去找。具体做法是搜索charAt、sort、join、slice这些字符串操作关键字然后在结果里逐个检查是否有对参数对象取值后排序拼接的代码。3.2 XHR 断点让程序停在你想要的位置搜索只能把范围缩小到某个文件但真正定位到准确的函数最可靠的方式是设 XHR 断点。在 Sources 面板右侧找到 XHR/fetch Breakpoints添加一个 URL 片段只要包含/api/goods/price的请求发出JavaScript 执行就会立刻暂停在send调用那一行。程序暂停后右侧的 Call Stack 面板会显示一层层的调用关系。从最底层的send往上翻找到构建参数的那个中间层函数往往就是加密逻辑的家。你可以点击调用栈里的每一层观察右侧 Scope 面板里变量的变化很快就能发现某个局部变量里存着明文拼接前的原始参数字符串。这一步我建议多做几次第一次断在 URL 拼接前第二次断在数据组装前通过多个快照的变量对比把参数从业务数据到明文串再到 sign 的完整变换过程拼出来。3.3 Hook 技巧在 MD5 调用处直接截住还有一种更省力的技巧叫 Hook。目标平台页面里一般都会加载一些公共的 MD5 库你要做的就是在调用摘要函数之前把函数包一层记录输入和输出。以 CryptoJS 为例可以在 Console 里执行(function() { const originMD5 CryptoJS.MD5; CryptoJS.MD5 function(...args) { const result originMD5.apply(this, args); console.log([MD5 INPUT], args); console.log([MD5 OUTPUT], result.toString()); return result; }; })();一旦页面里任何代码调用 MD5控制台就会自动打印出入参和出参。你只需要发起一次价格请求就能看到 sign 对应的明文到底是什么。这个方法在实战里极其高效因为大多数平台的“盐”也是通过 MD5 或其它摘要函数一起计算的你能直接看到完整的拼接结果。如果平台用的是 Web Crypto API 的crypto.subtle.digest同样可以 Hooksubtle对象上的方法原理一致。核心思路是与其猜不如让程序自己告诉你。4. 第三步还原拼接规则在 Python 里 1:1 复刻签名当你掌握了 JS 里 sign 的生成逻辑之后剩下的工作就是把它翻译成 Python。翻译并不难难的是搞清楚一个关键细节拼接规则的顺序和边界。4.1 最经典的排序拼接模型我本地模拟网关使用的算法是1. 取出请求参数对象中除 sign 外的所有字段 2. 按键名升序排序 3. 以 key1value1key2value2 形式拼接成字符串 4. 在末尾追加 keySECRET_KEY 5. 整体做 MD5结果转大写假设我们要请求goods_id1001时间戳为1712900000那么拼接出的明文是goods_id1001ts1712900000keydemo_secret_2024这个明文做 MD5 后转成大写就是最终的sign。其实很多人不理解为什么平台要设计这种“先排序再拼接”的规则原因很朴素请求参数是在 URL 上传递的用户传参时顺序可以五花八门服务端如果按收到的顺序拼接那么同样的参数在不同顺序下会得到不同的签名校验就没法做了。所以设计者强行规定“先排序再拼接”保证服务端计算和客户端计算使用的是完全一致的字符串。4.2 Python 实现代码对照着抄在 Python 里实现这段逻辑只需要十几行# sign_client.py import hashlib import time import requests def make_sign(params: dict, secret_key: str) - str: sorted_keys sorted(params.keys()) raw .join(f{k}{params[k]} for k in sorted_keys) raw fkey{secret_key} return hashlib.md5(raw.encode(utf-8)).hexdigest().upper() def fetch_price(goods_id: str, secret_key: str) - dict: params { goods_id: goods_id, ts: str(int(time.time())), } params[sign] make_sign(params, secret_key) resp requests.get( http://127.0.0.1:9000/api/goods/price, paramsparams, timeout5, ) return resp.json() if __name__ __main__: result fetch_price(1001, demo_secret_2024) print(result)运行之后只要ts在 300 秒有效期内服务端就会正常返回价格 JSON。这个实现逻辑上等价于你在 JS 里看到的签名函数只是语言不同。4.3 处理非字典序排序的变种实战里并非所有平台都按字典序排序还有两种常见变体按照参数定义的先后顺序排序某些参数的顺序写死在代码里不随字典序变化部分参数参与签名部分参数不参与签名比如sign自己不能参与debug、callback这类辅助参数也可能被剔除遇到非字典序排序时用 Python 复刻的难度会大一点因为你不能再简单地sorted(params.keys())而是要把参与签名的 key 按特定顺序列出来然后逐一取值拼接。我建议先把 JS 里那段签名函数完整读一遍看它到底遍历了哪些字段、用什么顺序再在 Python 里用一个有序列表去对应不要想当然。4.4 别忘了边界字符空格、空值、中文编码这块是最容易阴沟翻船的地方。很多平台的拼接逻辑里包含特殊字符处理非 ASCII 字符要先做 URL 编码还是直接用原始 UTF-8 字符串参与摘要空值参数是拼接a还是直接跳过大小写敏感吗摘要结果是转大写还是转小写我的做法是先在浏览器 Console 里手动跑一遍原始算法输出一个基准值然后用 Python 实现同样输入对比两者输出是否一致。不一致就逐步加日志缩小差异在哪一步。这种对比驱动的调试方式比盯着代码猜要快得多。5. 真实战场里最容易被坑的五个细节签名算法还原出来之后很多人会以为万事大吉其实是刚走出新手村。下面这五个坑我都在实际操作里踩过排名分先后。5.1 时间戳过期窗口见过不少平台的接口有效期是 60 秒甚至更短客户端生成请求时会实时取Date.now()。如果你把 ts 写死调用第一分钟能通后面全部 401。正确做法是在每次真正发请求前取当前时间戳。另外注意时间戳单位有的平台用秒有的用毫秒13 位数字错了同样签名不匹配。5.2 参数类型不一致导致摘要对不上这是最隐蔽的坑。比如某个参数在 URL 里是字符串1001但签名函数里传入的是数字1001。JS 里1001和1001做字符串拼接时结果都是1001看似没问题但有的平台会先把值转成 JSON 再参与拼接数字会被序列化成1001字符串会被序列化成1001带不带引号MD5 结果完全不同。所以还原签名时一定要留意每个值在 JS 执行环境里的实际类型。5.3 签名里藏了随机数或一次性 token有些平台为了防重放会在签名拼接串里加一个nonce随机数每次请求都变。还有的平台会先用一个前置接口换取 tokentoken 必须参与签名计算。这意味着你如果要长期自动化就不能只还原一个 MD5 逻辑还得管理 token 的获取和刷新。遇到这种情况我的建议是把 token 的时效、刷新机制一起做成一个独立的模块避免主任务被 token 过期卡死。5.4 服务端对 UA、Referer、Cookie 有额外校验签名校验通过不代表请求一定能过。很多平台还会对请求头做风控UA 不是最新版本、Referer 不合法、Cookie 里缺少特定追踪字段都会触发异常。我在某个项目里就遇到过sign 全对但服务端要求必须带一个trace_id的 Cookie这个字段是页面启动时种下的没有它接口直接返回 200 但数据为空。处理方式是在请求头里补齐浏览器环境相关的标识或者先模拟访问首页拿 Cookie再带着 Cookie 请求数据接口。5.5 频率控制和验证码的二次挑战这些都是真实环境中躲不开的软风控。当你的请求频率逼近正常用户的阈值平台会逐步升级限制先是返回随机丢失字段再是要求输入验证码最后直接封 IP。签名算法只能让你“进得了门”控制请求频率和模拟真人行为才是稳定长期运行的前提。6. 把签名工具工程化之前先想清楚合规边界到这里你已经掌握了接口签名逆向和分析的完整方法论。但我想明确一点上面所有演示都是基于我自己的模拟网关它是我自己在电脑上搭建的签名规则、盐值都是我设计的。如果你打算对任何真实平台的线上接口做类似操作请务必先确认这一行为是否符合对方的技术条款和当地法律法规。6.1 逆向行为本身的技术边界从技术上讲只要功能代码在用户设备上运行就一定可以被还原这是前端开发的本质约束。但从规则上讲大多数平台的服务条款都明确禁止未经许可的自动化访问、逆向工程、绕过技术保护措施。这不仅仅是道德问题还可能触发违约责任甚至更严重的法律风险。所以我的建议非常明确仅对自有系统、授权测试目标、或明确允许第三方调用的开放接口进行签名分析和自动化不要把自己的业务逻辑建立在破解他人接口的基础上很容易因为对方更新算法而一夜归零涉及敏感数据的抓取和存储更要慎重评估数据来源的合法性6.2 如果你确实有合法抓取需求优先走官方通道很多平台都有开放平台提供正规的 API 接入通道。官方 API 虽然可能限量、收费、字段不如页面全但它稳定、合法、不需要逆向维护。把官方通道的能力评估清楚再决定要不要走“签名还原”这条技术路线才是工程决策的正确顺序。在我个人经验里凡是需要长期、稳定、批量获取数据的场景除非对方明确没有开放接口否则我都倾向于先申请官方 API。逆向是被迫的选择而不是炫耀技术的手段。6.3 工程化时注意的自动化健康度如果你确实在做合规的 API 接入工程化水平也会影响存活时间。我的团队在维护一套自动化采集框架时除了签名生成器外还会额外做这些事请求频率限流每个商品间隔随机 1~3 秒避免整齐划一的请求节奏响应内容校验每次返回先判断 code 字段和数据完整性异常时立刻熔断数据缓存已经成功拉到的价格写入本地缓存短时间重复请求直接读缓存减少上游压力失败重试和告警连续失败 5 次自动切备用通道并推送报警到群这套机制让我在想签名还原只是解决了“能不能请求”的问题但真正决定业务能否长期稳定跑下去的是后续这一整套工程化能力。7. 最后再分享一个我自己的小习惯每次逆向一个签名算法我都会把完整的分析过程记录成一个文档包括请求包的上下文、JS 定位路径、拼接规则、Python 实现、验证结果。这个习惯救过我很多次。因为签名算法不会只验证一次就结束平台升级后你会发现原有的还原逻辑失效这时候拿出旧文档对照变更差异能快速定位到是排序变了、盐变了还是整个摘要算法换了。另一个小技巧是尽量把签名逻辑做成独立的请求中间件不要把它散落在业务代码的各个角落。Python 里可以封装成一个sign_client类业务方只需要传业务参数签名生成、时间戳刷新、请求头补齐全都在内部处理。这样一来算法更新时你只需要改中间件一个文件而不是满项目找调用点。这次分享就到这里。如果你手头正卡在某个接口的签名上不妨按我上面的链路走一遍先抓包看参数再用关键词和断点定位接着用 Hook 直接看入参最后 Python 复刻并对拍验证。整个过程的核心不是某个具体的 hash 算法而是一种从黑盒到白盒的推演方式把不可见的东西一步步变成可复现的逻辑。