资讯动态

蒲公英x-sign签名逆向:从抓包到Python复现的全过程

发布时间:2026/10/29 17:41:59 来源:尧图企业网站定制
简介资源包面向接口调试、爬虫与逆向分析人员聚焦蒲公英x-sign签名参数提供Python和Node.js两套生成与校验示例帮助理解请求签名机制、API鉴权流程及辅助字段的作用。压缩包共八个文件包含五个JavaScript脚本与三个Python脚本整体仅76KB按客户端、服务端与工具模块拆分JavaScript脚本用于浏览器端或Node环境模拟Python脚本适合本地批量调试和脚本化验证。目前已有850人学习下载适合具备基础HTTP与加密算法认知的开发者。包内覆盖参数排序、查询字符串拼接、HMAC散列、请求头携带签名等核心步骤直接对照两种语言实现同一签名的方式并附有文档说明与加密辅助模块便于梳理签名链路相关脚本均可直接运行或迁移作为真实项目与接口联调中的参考实现尤其适合在缺少官方文档时用作逆向工程起点。包内文件目录组织清晰能快速定位所需脚本。1. 蒲公英 x-sign 是什么一个请求头参数为什么值得花一晚上折腾用脚本批量上传安装包到蒲公英分发平台或者把应用列表拉下来做自动巡检大部分人第一步就卡在同一个地方接口返回“签名错误”而服务器要求的正是请求头里那个叫 x-sign 的参数。x-sign 是蒲公英客户端在每次 HTTP 请求上附加的签名参数属于接口鉴权的一部分用来确认请求来自合法客户端、且参数没有被篡改。凡是需要把蒲公英的接口自动化的人——做 CI/CD 自动分发、做版本巡检、或者维护内测轮转的应用运营同学——都绕不开弄清它的生成方式。这篇笔记就按我实际捋过的路径来写先讲清它由什么组成再讲怎么定位具体算法最后给一套能复现的最小脚本和踩坑记录。2. 拆开 x-sign 的生成黑匣子输入项、签名算法与排序规则2.1 签名由哪些输入决定时间戳、请求路径、参数体与盐值x-sign 不是客户端随便拼的一串随机数它是一段摘要签名服务端收到请求后会按同样规则重新计算并比对。要拆这个黑匣子第一步不是急着读 APK而是先搞清楚签名大概由哪几类输入决定。常见的签名输入包括请求方法、请求路径、查询参数、请求体、时间戳和密钥盐值。前几项是公开的时间戳和盐值是最容易出问题的两个变量。时间戳通常是毫秒级 Unix 时间戳也就是int(time.time() * 1000)这种量级的数字。服务端拿它校验请求是否新鲜防止有人把抓到的请求包原样重放。盐值则是埋在客户端里的秘密字符串可能是一个固定字符串也可能是从某个接口动态获取后缓存在内存里的值。蒲公英这类签名里盐值往往以硬编码形式出现在客户端 so 库或 Java/Kotlin 代码里少数情况由服务器下发、客户端启动时拉取。请求路径指的是去掉域名之后的完整路径比如/app/list有些实现还会把 query string 里的参数按键名排序后拼到路径后面。GET 请求的签名输入通常是 URL 参数POST 请求则会把 body 原始字符串直接纳入拼接。如果只看 query 不看 body就会出现 GET 能通、POST 一直签名错误的怪现象。这一节的结论是签名输入项的集合先列全再管算法否则后面每一步都会跟着错。2.2 先从长度和格式判断算法MD5、HMAC-SHA1 还是组合签名拿到一个合法的 x-sign 值之后第一件事是看它的格式。不同的摘要算法会给出不同长度的结果32 位小写十六进制基本就是 MD540 位十六进制大概率是 SHA1 或 HMAC-SHA164 位十六进制则像是 SHA256。还有一些实现会把原始输入和时间戳用特定符号拼起来比如1700000000000.abcd1234...这种格式说明时间戳被显式放到签名里服务端可以只解析前段就能判断过期不必重新计算全文摘要。还有一种常见设计是“组合签名”也就是签名值由两段组成一段是客户端版本号一段是摘要结果。比如v2.abcdef...。这种情况下服务端升级算法时还能兼容旧版本客户端只要段号不同就切换到对应的校验规则。对逆向的人来说这是个福利因为版本号直接告诉了你算法有几种分支省得一个盐值翻来覆去寻找。判断算法的另一个角度是看客户端的加密调用痕迹。反编译之后搜MessageDigest、Mac、Cipher这些类名能直接看到用的是哈希、消息认证码还是对称加密。蒲公英这类接口签名用哈希摘要居多其中 MD5 加盐又最常见。HMAC 类算法会多一个SecretKeySpec的构造过程从代码结构上很容易区分。2.3 顺序敏感与编码敏感为什么同样的参数两次签名结果不同很多初接触签名的同学会以为签名就是把参数随便拼起来做个哈希但实际上蒲公英这类签名对顺序和编码极其敏感。最常见的拼接规则是对所有参数按键的 ASCII 码升序排序然后用keyvalue的形式用连接。这个规则看起来简单但坑在于排序时按什么粒度排、空值要不要过滤、嵌套对象怎么展平。服务端和客户端只要有一处排序规则不一致生成的结果就不可能相同。编码问题同样隐蔽。URL 参数在传递过程中会被编码但编码方式可能有两种空格转成%20还是转成中文是按 UTF-8 编码后逐字节转十六进制还是先按 Unicode 码点直接输出。签名计算通常发生在编码之后也就是说客户端先把 query 字符串完整地转成规范形式再把这段规范字符串送进摘要函数。如果脚本用 Python 的urllib.parse.urlencode默认空格会转成而服务端如果按 RFC 3986 标准把空格转成%20两边算出来的摘要就对不上。这类问题定位起来很费眼神我一般会把签名前的原始拼接字符串直接打印出来和抓包工具里看到的实际请求串逐字节对比。不要只对比最终的 x-sign 值那是一条死路对比拼接前的明文才是快速定位方向。理解了这些坑接下来就可以真正动手去逆向和复现签名逻辑了。3. 从抓包到 Frida Hook定位蒲公英客户端签名逻辑的完整排查链路3.1 用 Charles 抓到带 x-sign 的原始请求要分析 x-sign第一步永远是抓到一条真实的、携带 x-sign 的请求。用 Charles 或 Fiddler 配置好 SSL 代理后手机装上对应的 CA 证书打开蒲公英 App操作几个页面就能在请求列表里看到大量接口。过滤出包含x-sign头的请求把请求行、请求头、请求体和响应体完整保存下来。抓包时最容易翻车的是蒲公英客户端做了证书校验也就是 SSL Pinning。表现为 Charles 里能看到域名但所有请求都显示红叉或者 App 直接提示网络错误。这种情况就需要借助 Frida 绕过证书校验或者把客户端的系统证书目录替换掉。绕过证书校验属于常规逆向调试手段只用于自己手里的测试设备不要在别人设备上操作。抓包得到的原始请求里除了关注 x-sign 本身还要记录两个信息第一是请求的时间戳最好精确到秒第二是请求体和 query string 里所有参数的原始字节形态。后面验证签名算法时需要拿着同样的参数和时间戳重算如果原始数据丢了就只能重新抓包。我一般会把请求头完整复制下来包括User-Agent、x-app-version这类看似无关的头因为签名计算偶尔会把 UA 或版本号也掺进去。3.2 用 Frida Hook OkHttp 请求构造现场抓包只能看到结果看不到签名是在哪一行代码里拼出来的。蒲公英的 Android 客户端几乎都走 OkHttp 网络栈所以用 Frida Hook OkHttp 的请求构造链是最高效的方式。思路很简单拦截okhttp3.Request的构建过程把请求头、URL、请求体全部打印出来同时也盯住签名生成后的写入时机。下面这段 Frida 脚本是一个可以落地的起点适合在已经用 Frida 连上测试设备的场景里跑Java.perform(function () { var Request Java.use(okhttp3.Request); Request.build.implementation function () { var req this.build(); var url req.url().toString(); var headers req.headers().toString(); if (headers.indexOf(x-sign) 0 || headers.indexOf(X-SIGN) 0) { console.log([URL] url); console.log([HEADERS]\n headers); } return req; }; var Buffer Java.use(okio.Buffer); var RequestBody Java.use(okhttp3.RequestBody); RequestBody.writeTo.implementation function (sink) { this.writeTo(sink); var buf Buffer.$new(); try { this.writeTo(buf); var body buf.readUtf8(); if (body.length 0) { console.log([BODY] body); } } catch (e) { // 某些 RequestBody 只能被消费一次读不到就直接忽略 } }; });这段脚本的逻辑是每次构建请求时先检查请求头里有没有 x-sign有就直接打印 URL 和完整头信息同时包装了writeTo方法尝试在原逻辑执行后把请求体内容读出来。注意第二个 hook 里writeTo不能调两次否则会破坏原始请求体流所以我在注释里标了这是个易错点。参数说明方面脚本里Request.build是 OkHttp 的构造入口不同 OkHttp 版本方法签名基本稳定okio.Buffer用来做临时缓冲。如果你的蒲公英版本用的不是 OkHttp 而是 Retrofit 加 OkHttp这套继续有效因为 Retrofit 底层也是 OkHttp。如果客户端把签名放进自定义拦截器里还可以再补一个okhttp3.Interceptor.chain的 hook直接看拦截器对请求的改写过程。3.3 用 jadx 反编译搜盐值与拼接规则Hook 拿到了运行时数据但盐值和拼接规则还得看静态代码。把蒲公英的 APK 拖进 jadx等待反编译完成然后用字符串检索定位。搜索关键词优先试这几个x-sign、X-SIGN、x_sign、signature、sign。运气好的话直接就能找到设置请求头的代码位置顺着往上翻就是签名的计算函数。jadx -d output app.apk grep -r x-sign output --include*.java | head -n 20grep 结果会列出所有出现 x-sign 字符串的 Java 文件路径。定位到文件后在 jadx 里打开看它把签名写进 header 的那一行再顺着方法调用往上追。签名函数的名字五花八门可能叫getSign、makeSign、buildSignature也可能藏在某个工具类里。关键是把传入的参数看清楚通常能看到时间戳、加密盐值和拼接逻辑。盐值经常不是直接写在同一个文件里而是散落在资源文件或者另一个常量类里。如果 jadx 里搜不到明文盐值那八成是写进了 native so 库此时需要去看lib/arm64-v8a下的.so文件用objdump或IDA打开分析。不过蒲公英这类分发平台的 App 很少把盐值放 so 库Java 层能找到的概率很大。静态分析到这里就已经拿到全部要素了下一步就是写脚本复现。4. 用 Python 复现蒲公英 x-sign最小签名类与一次完整调用4.1 先搭一个参数归一化与排序模块复现 x-sign 的第一步不是直接写加密函数而是先写参数归一化模块。因为签名计算对输入字符串的格式极其挑剔归一化模块负责把参数转成服务端认可的标准形式。具体来说要做三件事过滤空值、按键排序、拼接查询字符串。排序规则默认按 ASCII 码升序排列。这里要特别留意Python 的字典排序结果和 Java 的TreeMap排序结果在绝大多数情况下一致但遇到大小写字母混用的场景会有例外所以我在代码里显式指定了排序键。from collections import OrderedDict from urllib.parse import quote def normalize_params(params: dict) - str: 把参数字典转换成签名用的标准字符串。 规则 1. 过滤掉值为 None 或空字符串的项 2. 键按 ASCII 码升序排序 3. 键值都转成字符串后拼接为 keyvaluekeyvalue sorted_items [] for key, value in params.items(): if value is None or value : continue sorted_items.append((str(key), str(value))) sorted_items.sort(keylambda item: item[0]) encoded_pairs [] for key, value in sorted_items: encoded_key quote(key, safe) encoded_value quote(value, safe) encoded_pairs.append(f{encoded_key}{encoded_value}) return .join(encoded_pairs)这段代码的逻辑核心是quote(value, safe)它把空格转成%20而不是并且对中文字符按 UTF-8 编码后的字节做百分号转义。这个细节很关键因为 Java 端常用的URLEncoder.encode默认把空格转成而服务端如果按 RFC 3986 解析签名时就必须用%20。如果对接时发现签名总差一位先检查这里。normalize_params的返回结果就是签名算法里最核心的拼接原材料。参数说明safe表示连斜杠、冒号这类字符也转义如果服务端对某些字符不做转义可以把对应字符加进safe比如safe/。排序用的是sort(keylambda item: item[0])确保按键名排序而不是按keyvalue后的整串排序。4.2 实现签名生成函数并跑通一次接口归一化模块就绪后签名生成函数就顺理成章了。签名生成的通用公式是把请求方法、请求路径、归一化参数、时间戳、盐值按约定顺序拼接中间用换行符或特定分隔符隔开然后做摘要运算。下面代码里我把拼接顺序设计成可配置的因为不同版本的服务端对拼接顺序的偏好不一样。import hashlib import hmac import time import requests from urllib.parse import quote def build_x_sign( method: str, path: str, params: dict, secret: str, timestamp: int None, algorithm: str md5, ) - str: 生成蒲公英接口需要的 x-sign 请求头。 拼接规则可按实际逆向结果调整顺序 method \n path \n normalized_params \n timestamp \n secret ts str(timestamp) if timestamp is not None else str(int(time.time() * 1000)) normalized normalize_params(params) raw_string \n.join([ method.upper(), path, normalized, ts, secret, ]) if algorithm md5: digest hashlib.md5(raw_string.encode(utf-8)).hexdigest() elif algorithm hmac-sha1: digest hmac.new( secret.encode(utf-8), raw_string.encode(utf-8), hashlib.sha1, ).hexdigest() else: raise ValueError(funsupported algorithm: {algorithm}) return f{ts}.{digest} # 实际调用示例 if __name__ __main__: signer_secret 从逆向阶段获得的盐值 sign build_x_sign( methodGET, path/api/app/list, params{page: 1, page_size: 20}, secretsigner_secret, ) print(x-sign:, sign)代码里的参数需要注意timestamp必须是毫秒级整数如果传入的是一个 10 位的秒级时间戳服务端会直接判过期。raw_string用换行符做分隔属于常见做法也有些服务端习惯直接用空字符串拼接所以要优先按逆向阶段看到的实际拼接方式改。返回值f{ts}.{digest}这种时间戳带摘要的格式在蒲公英的签名里出现过如果实际逆向结果不带时间戳前缀把 return 改成只返回digest即可。这个方法保留了algorithm参数方便在 md5 和 hmac-sha1 之间切换不必每次改函数主体。跑通一次调用的场景通常是这样的先用固定时间戳生成签名再用 requests 发请求观察返回码。如果返回 200说明签名逻辑完全正确如果返回 401则要进入调试环节把生成的签名和抓包里的签名放在一起比对。4.3 把算法、盐值和时间戳单位做成配置避免写死签名逻辑跑通只是开始真正让脚本可维护的是把变量从代码里抽出来。蒲公英客户端一旦升级盐值、算法甚至拼接顺序都可能变化如果这些值散落在业务代码里每次调整都要翻代码、改逻辑效率极低。我一般会把签名相关的所有参数集中到一个 JSON 配置文件里脚本启动时加载。{ version: 1, algorithm: md5, timestamp_unit: ms, secret: 逆向阶段获取到的盐值, timestamp_sep: ., join_sep: \n, sort_rule: ascii, url_encode: quote_percent20 }字段含义依次是签名版本号、摘要算法、时间戳单位、盐值、签名中时间戳与摘要的分隔符、拼接分隔符、排序规则与 URL 编码方式。配置化的价值在于蒲公英升级后如果只是盐值变化只改secret一个字段如果是算法升级只改algorithm。这样脚本本身保持稳定改配置就能跟上客户端节奏。参数优化在这里体现得很实在不要为了追求“一行代码跑通”把盐值直接写死在 Python 文件里否则以后每次升级都要改代码、测回归、重新部署。把可变项抽成配置是这套方案能长期运行的基础。配置载入后签名生成函数只需读配置对象不再接收散落的参数整体结构清晰许多。5. 蒲公英 x-sign 调试避坑签名失效的 5 个常见原因与排查顺序5.1 秒级时间戳当成毫秒级传接口永远返回过期现象是接口返回签名过期或者时间戳不合法但日志里时间戳看起来又没毛病。原因是 Python 里time.time()返回的是秒级浮点数如果直接用int(time.time())会得到 10 位数字而服务端要求的是 13 位毫秒级时间戳。解决方式很简单int(time.time() * 1000)。排查顺序上建议先把生成的 x-sign 用抓包工具里记录的原始请求对照一遍看签名值里嵌入的时间戳数字位数是否一致。这个坑很基础但翻车频率最高十次签名失败有三次是栽在这里。5.2 GET 通了 POST 就失败签名没把 body 纳入计算现象是同一个接口的 GET 请求签名验证能通过换成 POST 后永远返回参数校验失败。原因很可能是生成签名时只看 query 参数忽略了 POST body 也参与签名计算。蒲公英这类 API 对 POST 请求通常要求把 raw body 字符串直接拼进待签名字符串。解决方法是把 body 文本作为另一个输入项传给签名函数并且保证拼接顺序与抓包结果一致。我自己的习惯是在签名函数参数里加上body: str body 非空时插在 query 和 timestamp 之间。5.3 中文参数编码不一致服务端按 UTF-8 字节算摘要现象是参数是中文时签名一定失败纯英文就能通过。原因是签名用的编码方式不对常见的错是直接把中文字符 Unicode 码点拼进去而服务端用的是 UTF-8 编码后的字节流转成十六进制再做摘要。解决方式是统一走 RFC 3986 百分号编码并且编码前先把字符串转成 UTF-8 字节。对应到 Python就是quote(value, safe)默认行为。如果服务端客户端用的是application/x-www-form-urlencoded标准空格会转成反过来把%20改回即可。这一条建议在调试时打印出归一化后的字符串仔细看中文和空格到底变成了什么形态。5.4 换设备换 IP 就失效签名里绑了设备维度信息现象是同一套签名脚本在一台测试机上跑得好好的换到另一台机器或者另一个网络环境就全部 401。原因很可能是在拼接字符串里混入了设备 ID、客户端版本号甚至用户登录态这些值包装在请求头里但生成签名时也用到了。解决方式是把请求头里的所有字段都拉出来看尤其是以x-开头的自定义头它们往往就是签名输入项的一部分。我一般会在脚本里记录一份完整请求头快照哪个头参与签名、哪个头是纯粹的标记全部标注清楚。5.5 App 升级后旧脚本全部翻车密钥盐值和算法版本换代现象是蒲公英客户端自动更新后旧脚本生成的 x-sign 突然全部失效但代码没动过。原因是客户端升级时更换了内置盐值甚至整体切换了摘要算法。解决方式是把签名参数放进配置并按蒲公英版本号分开管理升级后先抓一条新版本的请求包对比新老 x-sign 的格式和长度长度变了说明算法变了长度没变只有内容不同大概率只是盐值换代。配置化在这里就是后悔药改一个字段比翻代码重编译轻快得多。6. 把 x-sign 构建做成可持续维护的验证方案6.1 用浏览器开发者工具做端到端签名自检当脚本生成了 x-sign但接口返回仍然不对我通常会用浏览器开发者工具做一个快速自检。把 Python 生成的签名值和请求参数手动填进浏览器的 fetch 请求里在 Console 里直接发请求。浏览器和服务端之间的交互是干净的没有代理干扰。如果浏览器里通了说明签名算法没有问题问题出在脚本的网络层或请求头遗漏如果浏览器里也失败那就要回到签名输入项去核对。这个方法比反复改脚本重跑快得多尤其是排除了代理和 TLS 层面的干扰。6.2 把签名版本、盐值和算法纳入配置管理配置化的方案在第 4 章已经给了 JSON 示例这里要强调的是维护纪律。每次客户端版本升级优先抓包看看签名格式有没有变化然后把验证过的配置写入文件并标注它适配的蒲公英版本号。这样当线上脚本出问题时可以通过版本号快速定位该用哪套配置。配置注释里写清楚时间戳单位、拼接顺序和编码规则能省掉很多重复逆向的时间。6.3 接口升级时先判断“换盐”还是“换算法”遇到签名失效不要直接开始翻 App 二进制先做一次差分。拿到新版本生成的合法签名后用旧算法和旧盐值重算一份和新签名对比。如果输出格式相同、长度相同只是摘要内容不同换盐值的概率最大如果长度、分隔符、前缀都变了说明算法整体升级。这个判断 10 分钟就能完成能避免走错方向。我自己的实践是把旧的配置文件和抓包记录留档至少三个版本方便做横向对比。维护这种签名配置最容易翻车的就是“客户端升级了本地没有版本对应表”。现在我在每个配置文件里都加了适配版本字段升级时回归一次就行。希望这些经验能帮你少在 x-sign 上熬夜。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑