资讯动态

蒲公英x-sign签名机制逆向解析:从Frida定位到Python复现

发布时间:2026/10/7 3:43:57 来源:尧图企业网站定制
简介这是一份面向爬虫开发、接口调试及逆向分析初学者的蒲公英x-sign签名算法参考代码包。内容围绕小红书相关API请求中的x-sign、xhs-x-s等签名参数展开提供Python与Node.js两种语言实现覆盖参数排序、拼接、HMAC-SHA256哈希生成等关键步骤并附带加密模块与基础工具脚本便于对照学习签名机制与请求头构造方式。资源共8个文件包括5个JavaScript文件和3个Python文件以x-sign.js、client-x-s.py、server-x-s.js等为核心压缩包大小约76KB结构简洁、适合快速查阅。发布以来已有850人学习下载适合具备一定HTTP协议基础、正在研究接口签名逻辑或希望借鉴代码组织方式的开发者。通过阅读源码可理解签名串生成流程、请求参数预处理方法以及Python与Node.js在实现同一算法时的差异。1. 蒲公英 x-sign 参数不是玄学破解前先搞懂它是什么用过蒲公英 App 的人可能都遇到过这种情况你用抓包工具盯住它的接口明明请求路径、post body 都和客户端一致但服务器偏偏回一个“sign 校验失败”。问题就出在每个请求头里那个不起眼的x-sign参数上。它不是一个平台分配的固定 token而是根据请求参数、时间戳、设备信息实时算出来的一串签名值只要你有任何改动验签就过不去。这篇文章讲的就是把这套参数机制拆开、复现、并在自己的脚本里稳定跑通的全过程。适合谁看一是想自动化调蒲公英接口的工程师二是做接口安全测试的朋友三是对 App 端签名算法感兴趣、想补逆向思路的前端开发者。我不打算把它讲成玄学而是按“抓包确认 → 找生成函数 → 复现算法 → 规避暗坑”的顺序把路上要踩的雷提前摆出来。下面先走最关键的一步定位 x-sign 到底在哪生成的。2. 定位 x-sign 的生成现场从抓包到 JS 逆向的最小流程2.1 先验证签名参数的存在与校验逻辑拿到蒲公英 App 的安装包之后第一件事不是急着解包看代码而是把线上真实请求的流量先拉出来。常见做法是用 Charles 或 mitmproxy 做中间人代理把手机请求转发到电脑上过滤出蒲公英相关的 API 域名。你会看到每个请求头里都带着x-sign而且同一个接口连续请求两次x-sign并不一样。这一步能说明两件事第一x-sign是动态签名不是写死的常量第二它的输入条件里大概率包含时间戳或随机数。为了进一步验证你可以手动改一下请求里的任意参数比如把page从 1 改成 2再拿原来的x-sign去请求服务器会返回类似invalid sign的错误。这说明 x-sign 和请求体、请求路径是绑定的任何越界操作都会被识别。一个容易被忽略的细节是很多请求除了x-sign还会有x-time或timestamp这类参数。我见过不少项目把时间戳单独放在 header 里x-sign 对它也做了运算所以抓包时记得把所有请求头原样保留下来尤其是自定义头字段。不要只盯着 x-sign 本身签名算法往往是一组参数联动的结果。2.2 用 Frida 主动调用定位生成函数确认 x-sign 是动态签名后下一步就是进 App 内部找生成逻辑。蒲公英客户端没有做很强的混淆但直接逆向 Android 包拿到的是 smali 代码看起来太吃力我一般会优先用 Frida 做动态注入。核心思路是在 App 运行时 hook 常见的哈希函数看看x-sign在计算时走了哪些入口。第一步把手机连上电脑用frida-ps -U确认蒲公英进程是否在跑。然后写一个 hook 脚本拦截java.security.MessageDigest的update和digest方法把传入的字节数组打出来。因为很多签名算法底层都是 MD5 或 SHA 系列只要 hook 住这些函数基本能看到明文拼装的痕迹。// frida_trace_sign.js Java.perform(function () { var MessageDigest Java.use(java.security.MessageDigest); MessageDigest.update.overload([B).implementation function (input) { console.log(update - bytesToHex(input)); this.update(input); }; MessageDigest.digest.overload().implementation function () { var result this.digest(); console.log(digest - bytesToHex(result)); return result; }; function bytesToHex(bytes) { var result ; for (var i 0; i bytes.length; i) { result (0 (bytes[i] 0xff).toString(16)).slice(-2); } return result; } });这段脚本的作用是把所有哈希函数的输入和输出都打印出来。当你手动触发一次带x-sign的请求后console 里会出现一长串字符串那多半就是参与签名的原始内容。注意update([B)只是其中一种重载有些客户端会传带偏移量的重载如果没输出就把所有update重载都 hook 一遍。拿到原始拼装字符串之后剩下的就是分析它由哪些字段组成。常见套路是请求路径 请求参数按字典序拼接 时间戳 固定盐值但这只是猜法实际以 hook 输出为准。你会发现这一行字符串正好能和抓包时的请求体对上比如page1limit20这种子串就藏在里面。这样基本就锁定生成现场了。2.3 把算法从 JS 里搬到 Python 的取舍定位到签名函数后还有一个选择要做用 Python 重写算法还是直接把 JS 端逻辑用execjs调起来。这里容易出现分歧。如果你的目标是稳定长期维护我强烈建议重写成 Python如果只是想快速验证思路临时用execjs跑 JS 没问题。为什么这样说因为蒲公英这类 App 会频繁更新版本每次升级都可能改签名算法。如果你用的是整套 JS 拷贝一个版本迭代过后就可能会失效但如果你把它抽成纯 Python 的签名模块更新时只需要改参数拼接顺序或哈希算法名维护成本低得多。另外execjs 依赖本地 Node 环境部署在 CI 或云函数上时多一层运行时就是多一种故障可能。从工程角度还需要把签名算法独立成一个类或函数输入统一为“请求方法、路径、业务参数、时间戳”输出为 x-sign。这样不管以后是跑单请求还是并发都能复用同一份逻辑。后面章节我会把参数拼接和加密函数展开讲这里先记住一个原则不要把签名逻辑和请求逻辑写在一个函数里否则排查问题时会非常痛苦。3. 复现 x-sign 生成算法参数拼接、加密函数与动态盐3.1 抽离核心加密函数的方法把签名算法重写成 Python 之前得先找到算法里最核心的加密函数。从 Frida hook 结果看蒲公英的 x-sign 最终是对一个字符串做 MD5但这个 MD5 的输入字符串非常讲究。它不是简单地把参数拼起来而是经过两层加工第一层把业务参数按照固定规则转换成数组第二层再对数组求哈希。实际操作时我会先把加密部分单独抽出来看。下面是一段简化后的 Python 实现演示了如何根据原始字符串生成 x-sign。真实场景下原始字符串的组装规则要按你 Frida 抓到的输出调整。import hashlib import time def generate_x_sign(raw_string: str) - str: raw_string 是参与签名的原始字符串 一般由路径、参数、盐值等按固定顺序拼接而成。 md5 hashlib.md5() md5.update(raw_string.encode(utf-8)) return md5.hexdigest().upper()这段代码是一个非常基础的 MD5 封装之所以单独抽出来是为了后面测试的时候能快速替换成 SHA1 或 HMAC 版本。你不需要一开始就写复杂的生产逻辑先把输入输出跑通确认它和 App 端表现一致。很多新手一上来就追求装备齐全结果连原始字符串都没搞对拿到什么都是错。参数说明里要特别留意大小写。x-sign的值是全大写的十六进制字符串还是小写决定了你的脚本能否通过校验。我见过不止一次因为大小写不一致被服务器拒绝的情况。另外MD5 默认返回 32 位小写 hex如果你抓到的是大写就在返回前加.upper()如果看到的是 16 位短签名那就别用完整 32 位说明算法里做了中间截断。3.2 关键的参数拼接顺序常见的签名参数拼接方式有两种一种是所有参数按 key 的字典序排列后keyvalue用连接另一种是保持请求体原有顺序取部分字段参与签名。蒲公英实际采用的是类似字典序的拼接但排序范围不只是业务参数还包括了系统参数和时间戳。我曾经踩过一个坑用 Python 的urlencode直接生成参数字符串和 App 端抓到的原始拼接串总是差几个字段。后来对比发现App 端对所有参与签名的参数先做了一次sorted()然后才连接。Python 里 sorted 默认按 unicode 码点排序和 Java 的TreeMap排序结果一致所以这块直接用没问题但要注意参数值里如果有或空格URL 编码和原始范围的定义可能和 App 不一致所以参数值不要顺手做 quote保持原始字符串即可。下面是一段可以照着改的拼接示例它模拟了从请求字典生成 x-sign 的过程def build_sign_string(method: str, path: str, params: dict, timestamp: str) - str: # 将参数排序并拼接为 kv 形式忽略空值 sorted_params sorted(params.items(), keylambda item: item[0]) param_str .join(f{k}{v} for k, v in sorted_params if v is not None) # 拼接结构以抓包/Frida结果为准这里只是一个通用模板 raw f{method}{path}{param_str}{timestamp}SALT_VALUE return raw注意这里我特地把SALT_VALUE用占位符表示。盐值不可能通过抓包直接看到你只能从 Frida 打印出的输入字符串里剥离出业务参数后剩下的那部分就是盐。不要为了省事把盐值写死在业务代码里更不要拿一个字段当盐却在代码注释里写“固定盐”。盐值一旦泄露服务器端很容易检测到异常流量最好还是动态更新。另一个关键是顺序。method是GET还是POST在签名中占了很大权重如果你在脚本里把方法写错哪怕后续参数全对验签也过不了。有些接口支持 GET 和 POST 两种方式签名里的 method 也必须跟着变所以建议把 method 也纳入签名函数的入参。3.3 让 x-sign 请求通过的必调 HTTP 头签名算法复现成功不等于请求就能通。把 x-sign 加到请求头只是第一步还有很多环境因素会影响服务器验签。比如蒲公英客户端在请求里带了固定的User-Agent它其实也参与签名如果你用 Python requests 默认的 UA服务器端看到的上下文和签名不一致照样拒绝。所以我在封装请求时会维护一个和 App 端一致的 header 模板。以下是一个简化版本import requests import time def api_request(url: str, params: dict): timestamp str(int(time.time())) sign_str build_sign_string(POST, /api/example, params, timestamp) x_sign generate_x_sign(sign_str) headers { User-Agent: Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36, Content-Type: application/x-www-form-urlencoded, x-time: timestamp, x-sign: x_sign, } resp requests.post(url, dataparams, headersheaders, timeout10) return resp.json()这里的x-time和x-sign是配套出现的服务器会把x-time原样取出来参与验签同时检查时间戳是否在允许的偏移范围内。有的实现还会加入防重放机制同一时间内相同签名只能使用一次。所以不能只生成一次 x-sign 然后反复使用时间窗口一过哪怕加密算法是对的也会被判无效。参数说明部分Content-Type也值得留意。蒲公英很多接口是表单提交但有的更新后改成了 JSON 格式这时候参与签名的字符串就不是kvkv形式而是JSON.stringify后的原始串。如果用表单方式提交 JSON签名必然对不上。建议在做请求时先抓包确认当前的Content-Type再决定用哪种拼接方式。4. 把 x-sign 做成稳定的签名服务从单请求到短时间并发4.1 缓存签名与失效策略当你只是在调试接口时每次请求现算签名没问题一旦要跑定时任务或批量采集就得考虑签名生成模块的性能和可靠性。生成 x-sign 的算法本身不重就是几次字符串拼接加一次哈希瓶颈往往在 HTTP 请求的往返时间上。所以你不需要对签名本身加内存缓存而是要缓存登录态的 token 和会话状态。蒲公英的接口很多需要登录后使用登录 token 一般有效期较长可以放在 Redis 里并设置过期时间。签名生成函数建议设计成无状态的纯函数这样并发环境下不会有共享变量污染。如果你发现同一个时间内大量请求都返回签名错误就要考虑是不是某台机器的时钟偏差过大导致x-time和服务器时间差了几十秒。class SignContext: def __init__(self, salt: str): self.salt salt def sign(self, method: str, path: str, params: dict, timestamp: str) - str: raw f{method}{path}{self._sort_params(params)}{timestamp}{self.salt} return hashlib.md5(raw.encode(utf-8)).hexdigest().upper()这段代码把盐值放到实例属性里比全局变量更直观。如果你有多套环境比如测试环境和生产环境使用不同盐值就可以用两个SignContext实例隔离避免误用。注意生产环境不要把这个类设计成单例因为多线程下线程安全虽然没问题可一旦盐值需要动态更新单例模式会引入额外的状态同步成本。4.2 并发请求时的签名防重放当一个服务同时要发出一批请求时最容易踩的是重放检测。我之前做批量任务时用同一个 timestamp 生成一批请求的签名结果服务器只认可第一条后面的全部失败。原因很快查到蒲公英的防重放逻辑不是简单验签而是会记录最近一段时间内用过的签名指纹同一签名重复使用就视为重放攻击。解决办法很简单每条请求生成独立的签名时间戳精确到秒甚至毫秒。如果你在同一秒内发出几十个请求时间戳相同的概率就会变高。这时候可以把时间戳的精度从秒改成毫秒并用随机因子参与签名让每条请求的输入字符串都不相同。很多签名算法里本身就有随机数参与就是从源头避免重放你的复现逻辑里也要考虑这一点。实现上我用uuid.uuid4().hex作为请求号把它放进签名参数里。这样就算时间戳一样请求号不同签名也不同。类似思路可以看下面这段示例def make_request_with_nonce(url: str, params: dict): nonce uuid.uuid4().hex[:16] params {**params, nonce: nonce} timestamp str(int(time.time() * 1000)) sign context.sign(POST, /api/example, params, timestamp) # 发起请求参数说明nonce这个名字在很多接入文档里指的是“一次性随机数”将它放入请求体不仅解决重放冲突还能提高签名熵值。要注意加了非业务参数之后签名函数里参与排序的字典也必须包含它否则签名和请求体就脱节了。这个细节在联调时特别常见我见过只把 nonce 放进 header、没放进签名参数的翻车现场。4.3 日志与监控签名失败的可观测性签名接口不像普通业务接口它报错信息通常很模糊。服务器端可能只返回一个sign error不会告诉你错在哪。你只能通过客户端日志推断。我自己的习惯是把每次生成签名的输入字符串和输出值打出来但要注意脱敏不能把盐值和用户 token 明文打全否则日志泄露等于签名规则对外公开。推荐做法是记录签名输入字符串的哈希指纹而不是原文。比如把 raw_string 再做一次短哈希后写入日志这样排查时可以横向对比两次请求的输入是否一致。同时在本地开发时保留一个 debug 开关只有当环境变量DEBUG_SIGN1时才打印原文。一旦线上出现签名失败先按时间窗口和请求量分组看是不是特定时间段集中报错。如果是优先怀疑服务器时间校准或盐值轮换导致。如果只是一个请求报错重点查参数顺序和数据类型比如int转字符串后Python 的1和 Java 的1看起来一样但如果你参数里有TrueJava 拼出来是truePython 却是True这种大小写差异也会让签名对不上。5. x-sign 逆向与调试的 5 个避坑记录这一章我把这几年在蒲公英 x-sign 上遇到过的典型问题整理成记录。每一条都是真实调试过程中反反复复确认过的按“现象 → 原因 → 解决”的顺序写方便你排查时对照。坑 1明明算法和抓包结果一致却提示 sign 过期现象Frida 打印的原始字符串和自己的拼接结果一模一样签名也相同但服务器返回 “sign expired”。原因App 端的时间戳是按服务器时间生成的你的手机或电脑本地时间偏了十秒以上导致签名验签时超出允许的偏移范围。解决不要直接取本地时间先调一次蒲公英的时间接口把服务器时间差值保存下来生成 x-sign 时把偏移量加上。同时保证本机时间和 NTP 同步。坑 2同一脚本跑一周后突然全部请求失败现象之前完好的签名突然失效没有任何代码改动。原因蒲公英客户端更新了版本签名算法里的盐值发生了轮换或拼接规则从字典序改成了特殊规则。客户端旧版本仍能使用说明服务端暂时兼容了新旧两套验签但兼容窗口结束后旧签名就失效。解决捕获到新版本安装包重新用 Frida hook 一遍签名函数对比新旧版本的差异。重点看盐值和拼接常数更新到自己的代码里。建议每周跑一次回归抓包而不是等报错了再处理。坑 3用 execjs 调用 JS 加密后速度极慢现象单独算一个签名很快但批量请求时每秒只能发出十来个请求明显卡顿。原因execjs 每次调用都要启动 Node 子进程进程启动开销远大于加密本身。解决把签名算法重构成纯 Python 原生的 hashlib 实现或者用 Node 跑一个常驻服务通过 HTTP 调用获取签名。我最终选了前者因为它不增加额外依赖部署也简单。坑 4params 里的空值被拼接处理后验签失败现象请求体里包含key这样的空值签名算法把它当字符串拼进去了但服务器端却忽略了空值。原因蒲公英 App 端在生成请求体时会过滤空参数而你的脚本没有做同样处理导致签名输入多了一个字段。解决在build_sign_string里过滤掉值为None或空字符串的键。记住要同时影响请求体本身不能只在签名函数里过滤否则请求体和签名还是不一致。坑 5postman 调试可以通过Python 却过不了现象同一份参数在 Postman 里复制客户端的 x-sign 能通Python 代码换成自己的签名就报错。原因Postman 在无代码模式下保留了原请求头里的所有自定义字段而你的 Python 请求少带了x-time或少了某个自定义头。解决把抓包请求里的所有 header 原样复制到 Python 脚本里。不要只保留常用的几个标准头部自定义头部一个都不能丢。如果少了x-time服务器端拿当前时间假设你的 timestamp值的窗口一错位就全错了。6. 让 x-sign 方案少踩坑的最后一招验证与回归签名类接口的开发最怕的不是写不出来而是“今天能写出来明天就失灵”。我给自己定了一条规矩每次写完签名模块必须构造一组固定的输入把期望的 x-sign 值固化到测试用例里。这样任何人改了代码只要跑一遍单测就能发现签名规则是否被破坏。具体做法是抓包保存三组真实请求数据包括请求路径、参数、时间戳和服务器接受的 x-sign把它们作为测试基线。在测试环境里用相同输入执行签名函数比对输出是否一致。这三组数据覆盖典型场景一个空参数请求、一个带中文值的请求、一个带复杂嵌套 JSON 的请求。中文值很值得测试因为 Python 在发送请求时如果没指定编码很容易把 UTF-8 字符串变成 unicode 转义和客户端的原始字节不一致导致签名错位。另一个做法是做“签名回归抓包”定期用最新版客户端发起一次真实请求对比自己代码生成的签名是否仍然被服务器接受。这不是每次变更都需要的但建议在 App 发版日后的第二天做一次因为很多算法调整都藏在版本更新里。我习惯在本地保存一个小脚本专门干这件事——拉取最新包、抓包、跑签名对比全程十分钟左右。如果你接手的是别人写的签名模块开局第一件事不是读代码而是先按上面的方式建立基线数据。很多时候代码本身没错只是盐值过期了人却被困在几十行加密逻辑里来回查。先用基线数据刷新自己的判断再决定要不要改代码会省下不少时间。说回 x-sign 本身它就是客户端和服务器之间的一套默契双方都按同一个规则计算签名谁也不知道对方手里的盐是什么。作为逆向方我们能做的是用足够多的样本反推规则并且时刻准备着应对规则变化。记住不要贪图一时的稳定就绕过校验那样的脚本跑不长久。把基础工程做好签名失败时能迅速定位才是更可持续的路。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑