蒲公英x-sign签名全解析:生成逻辑、代码实现与避坑指南
简介蒲公英x-sign参数是小红书接口签名机制中的关键字段常用于验证请求来源与完整性防止恶意篡改。这份资源面向需要逆向分析小红书API或对其前端请求签名逻辑进行深入研究的开发者压缩包体积仅76KB共8个文件其中5个为JavaScript脚本、3个为Python脚本分别覆盖服务端响应模拟、客户端请求构造、参数加密与签名生成等核心环节并附有简要文档说明目录紧凑便于快速定位。Python端可参考基于hmac/sha256生成x-sign的完整实现Node.js端则提供了crypto模块的对应写法两类语言对照非常直观能帮助理解签名生成的完整步骤包括参数排序、URL编码、拼接密钥以及最终哈希计算。已有850人学习下载适合具备一定爬虫基础或前端逆向经验的中高级开发者。通过资源内的脚本与文档读者可快速掌握x-sign类参数的构造思路并可结合xhs-x-s等辅助字段进一步探索小红书风控体系下的完整验证机制为后续接口调试或数据采集提供实用参考。1. 蒲公英 x-sign 参数签名弄不明白接口永远调不通做 App 内测分发的人十有八九都跟蒲公英PGYER打过交道。上传包、查应用信息、拿下载链接一套操作下来你会发现它接口里的 x-sign 参数像个黑匣子——明明参数都带齐了可一请求就返回签名错误。我当年第一次腾挪蒲公英接口时光在这个 x-sign 上就翻了一整天的车后来把抓包数据逐条拆开对比才看明白它其实是「时间戳 参数序列 固定盐」做的一次 MD5 摘要。这篇笔记就是把这套签名参数从黑匣子里拽出来讲清楚它的生成顺序、拼接规则和校验逻辑顺便附上可直接改的生成代码和踩坑记录。适合正在逆向调试蒲公英接口、或者想用脚本批量处理应用信息的开发者和测试工程狮。2. 先看懂 x-sign 的生成逻辑从抓包到分字段拆解2.1 抓包定位找全签名相关的三个关键字段调试蒲公英接口第一步不是写代码而是把真实请求长什么样抓出来。我常用的方式是手机连代理Charles 或 mitmproxy 都行在蒲公英应用内随便点开一个应用详情页观察发出去的请求头。你会发现每个请求头里都带着 x-sign、x-timestamp 和 x-nonce 这三个字段三者的作用完全不同x-timestamp毫秒级 Unix 时间戳服务端拿它来判断请求是否过期一般超过几分钟就会拒绝。x-nonce一次性随机字符串每次请求都不同防重放攻击用的长度通常 16 到 32 位。x-sign核心签名把所有请求参数加盐之后做摘要的结果也是服务端校验的主角。抓包时注意别只看请求头还要看请求体。蒲公英部分接口用的是表单格式参数可能在 body 里而 x-sign 计算时是要把 body 里的参数也一起算进去的。我见过不少人在这个细节上吃亏——只签了 URL 上的 query 参数body 里的字段没管结果怎么调都是 403。确认完字段之后下一步就是找生成签名时用到的原始字符串。这里有个经验优先看 App 本身的请求发送逻辑而不是去猜算法。你可以把 x-timestamp 和 x-nonce 取固定值然后反复修改某个参数的值观察 x-sign 的变化规律。如果改一个字符整个签名完全变样说明是全量参与计算的如果只有某一段在变那就说明签名是按字段筛选参与计算的存在拼凑或拼接逻辑。2.2 签名摘要的常见形态MD5 为主偶尔见到 HMAC把多组请求的 x-sign 和原始参数放在一起比对很快就能猜出摘要算法。蒲公英这边的做法偏旧式参数按字典序排序拼成 keyvalue 的连续字符串后面加上固定盐值再做一次 MD5最后转成 32 位小写十六进制字符串。这个结论不是我拍脑袋是拿十几组抓包数据反推验证过的——同一时间戳和 nonce 下参数顺序变了 x-sign 就不对而换成 MD5 后完全能复现出原签名。HMAC 的说法我也见过但实际统计下来蒲公英多数接口路径用的是 MD5。HMAC-SHA256 多出现在新版 API 或部分私有接口里。怎么区分看签名长度32 位十六进制基本可以认为是 MD564 位十六进制更可能是 SHA256 系列带随机前缀的还要考虑加盐方式。反推时先试 MD5 裸摘要不对再试加盐、再试 HMAC这个排查顺序能省很多时间。参数排序是第二个关键点。URL 上的 query 参数和 body 里的表单参数要分别处理有的接口只签 query有的则把 query 和 body 合并成一个集合再统一排序。实战中以抓包结构为准我建议先按「合并排序再做字符串拼接」这个逻辑实现如果签名校验失败再改成「仅 query 参与、仅 body 参与」的排列组合去试。通常两三轮就能锁定正确的参与范围。2.3 固定盐从哪里来反编译与抓包结合着找盐值salt是签名算法里最隐蔽的一段。有的接口直接把盐写死在代码里有的则是从某个配置接口动态下发。蒲公英的接口更倾向于前者——固定盐藏在 App 的 so 文件或 Java 层代码里。逆向时可以用 jadx 打开 APK全局搜「x-sign」字符串附近往往就是签名函数的入口。找到函数后再看它调用了哪些字符串常量那里很可能就是盐值。如果不想逆向还有个笨但有效的方法自己构造一个最简单的请求只传一个参数从抓包里拿到 x-sign然后用常见盐值字典空字符串、appid、包名、固定单词等逐个试 MD5匹配上了就反推出盐来了。这个方法我在好几个接口上都试通过蒲公英这个也不算太难。3. 手写一套 x-sign 生成代码MD5 拼接与时间戳校验3.1 核心生成函数参数排序、拼接、加盐、摘要一条龙下面这段代码是我按照蒲公英接口的常见生成逻辑整理的Python 实现可直接跑通流程。细节上不同版本接口可能略有差异但骨架是通用的。import hashlib import time import random import string from urllib.parse import urlencode, quote SALT your_salt_here # 从抓包/反编译得到的盐值换成你实际拿到的 def generate_x_sign(params: dict, timestamp: str, nonce: str, salt: str SALT) - str: 生成蒲公英风格的 x-sign 签名。 params 是字典类型包含所有参与签名的参数不含 x-sign 本身。 返回 32 位小写 MD5 字符串。 # 1. 先把参数按 key 的字典序排序保证签名顺序稳定 sorted_keys sorted(params.keys()) raw_parts [] for key in sorted_keys: value params[key] # 空值不参与签名这是很多实现里容易漏掉的规则 if value is None or value : continue # 注意参数值需要做 URL decode 后再拼接不要拿编码后的字符串去签 raw_parts.append(f{key}{value}) # 2. 用 连接所有参数键值对形成原始字符串 raw_string .join(raw_parts) # 3. 加上时间戳和随机串再拼盐值 sign_string f{raw_string}timestamp{timestamp}nonce{nonce}{salt} # 4. 做 MD5 摘要 md5 hashlib.md5() md5.update(sign_string.encode(utf-8)) return md5.hexdigest()这段代码有几个地方值得解释。第一参数值为什么要先 URL decode因为抓包时看到的是编码后的字符串比如%E8%8B%B9%E6%9E%9C但实际参与签名的必须是解码后的原始文本否则签名永远对不上。第二空值不参与签名这条规则很多人会忽略导致同样参数在不同请求里签名结果不稳定。第三盐值是直接拼在 nonce 后面中间不加连接符这是从抓包反推出来的常见位置也可能存在加 或加冒号的变体你拿到真实接口时可以灵活调整。参数顺序是这套签名能否复现的生命线。sorted_keys 保证了字典序排序但这个排序是 Python 层面的字典序要注意它跟服务端语言Java、Go里的排序规则可能不完全一致——比如大写字母、小写字母、下划线谁在前谁在后。如果发现签名对不上需要关注一下排序时是否区分大小写。3.2 时间戳与 nonce 的配合一次性签名的有效期蒲公英服务端校验签名时会用当前时间和 x-timestamp 做对比超过一定时间窗口就判定过期。这意味着你生成签名后不能缓存太久最好是在发送请求前几百毫秒内现场生成。我在脚本里一般这样处理def build_common_headers(params: dict) - dict: # 时间戳用毫秒级注意 Python 里默认是秒 timestamp_ms str(int(time.time() * 1000)) # nonce 随机生成 32 位字符串 nonce .join(random.choices(string.ascii_letters string.digits, k32)) # 时间戳和 nonce 也要参与签名注意传入字典 sign_params dict(params) sign_params[timestamp] timestamp_ms sign_params[nonce] nonce x_sign generate_x_sign(sign_params, timestamp_ms, nonce) headers { x-sign: x_sign, x-timestamp: timestamp_ms, x-nonce: nonce, User-Agent: Mozilla/5.0 (iPhone; CPU iPhone OS 16_0 like Mac OS X), Content-Type: application/x-www-form-urlencoded, } return headers注意这里有个容易翻车的细节generate_x_sign 函数内部已经手动拼接了 timestamp 和 nonce但传入的 params 字典里也包含了这两个字段。这是有意的——有些接口要求 timestamp 和 nonce 也要在参数序列里参与排序。如果服务端校验时用到的原始字符串没有这两个字段签名会不一致。具体以抓包结构为准如果抓包里 URL query 上有 timestamp 和 nonce那签名时就要包含如果没有就去掉。3.3 多接口的盐值复用与热加载蒲公英不同接口路径用的盐值可能是同一个也可能各不相同。实务里我一般把盐值配置放到独立文件里方便切换# config.py SALT_MAP { /apiv1/app/list: salt_for_list, /apiv1/app/info: salt_for_info, /apiv1/app/upload: salt_for_upload, }然后封装一个带缓存的获取函数避免每次请求都读配置文件。实际调试时如果某个接口签名一直失败优先怀疑是这个接口的盐值和已有盐值不同而不是算法问题。用二分法测试最快先拿一个简单参数试能把签名对上就说明算法和盐都对对不上再逐一排查。4. 把它跑进真实请求从签名到 HTTP 调用的完整链路4.1 构造完整请求query、body 与 header 怎么配合光有签名生成还不行真正发起请求时参数的放置位置直接决定了签名会不会被服务端认可。蒲公英接口的参数分布有规律应用列表查询这类 GET 接口参数放 URL query上传、更新信息这类 POST 接口参数放 body 表单。而 x-sign 计算时要同时考虑两处参数。import requests def fetch_app_list(page: int 1) - dict: base_url https://www.pgyer.com/apiv1/app/list params { page: page, per_page: 10, } # 生成签名前先确定参数最终会放在哪里 query_string urlencode(params) # 注意urlencode 会把中文和其他特殊字符转义但签名要用原始值 # 所以需要构建一个未编码的参数字典传给签名函数 headers build_common_headers(params) final_url f{base_url}?{query_string} resp requests.get(final_url, headersheaders, timeout10) return resp.json()这段代码里的坑在 urlencode。requests 库自己也会做一次编码如果你先 urlencode 一次再传给 requests参数值就会被双重编码最终请求的样子和抓包时不一致。我的习惯是传原始字典给 requests 的 params 参数让它自己处理编码签名时则用未编码的原始字符串。这样能保证服务端看到的参数和签名时用的一致。headers 里另外要留意 User-Agent。蒲公英对异常 UA 有风控用默认的 python-requests 很容易触发验证。我一般会伪装成真实手机浏览器的 UA比如 iPhone 或主流 Android 机型。如果你是在做自动化批量拉数据建议每次请求轮换 UA 池里的值降低被 ban 的概率。4.2 文件上传场景签名参数覆盖文件元信息蒲公英的核心场景是上传安装包这类接口的签名更麻烦因为文件本身是二进制它的元信息文件名、包名、版本号、大小要参与签名。这时候参数构造不能只靠字典要结合 multipart/form-data 的格式约束。def upload_app(file_path: str, app_name: str, version: str) - dict: url https://www.pgyer.com/apiv1/app/upload # 文件大小和打开时间也是参与签名的参数 file_size os.path.getsize(file_path) params { fileSize: str(file_size), appName: app_name, version: version, } headers build_common_headers(params) # 注意headers 里不能手动设置 Content-Typerequests 会自己带 boundary # 如果你手动设置了 Content-Typemultipart 的 boundary 会被覆盖掉 with open(file_path, rb) as f: files {file: (os.path.basename(file_path), f, application/octet-stream)} resp requests.post(url, dataparams, filesfiles, headersheaders, timeout120) return resp.json()这种场景下文件读取时间会拖慢请求导致时间戳从生成到服务端校验的间隔变大。所以一定要在签名生成后立刻发请求中间不要做文件读取这类耗时操作。我通常先打开文件流、生成签名、再一次性拼接上传三步紧挨着做完。4.3 会话与 Cookie签名之外的隐形拦路虎蒲公英部分接口除了验签名还会校验登录态。未登录时即使签名正确也可能返回需要登录的错误码。这类接口要先调用登录接口拿 Cookie 或 Token再带着 Cookie 请求业务数据。登录接口本身也需要签名这就形成了一个先有鸡还是先有蛋的问题你还没登录怎么拿得到签名所需的参数解决办法是在登录接口的请求里把账号密码作为参数参与签名。签名只依赖你构造的参数本身不依赖登录态所以可以先算签名、再登录、后拿数据。Session 管理上要注意用 requests.Session 而不是每次新建一个会话这样 Cookie 能自动保持。多账号同时操作时每个账号建一个独立 Session 实例避免 Cookie 串号。我踩过一次特别深的坑——两个账号共用了一个 Session结果 A 账号的操作全部记录在了 B 账号名下排查了半天才发现是 Session 串了。5. 蒲公英 x-sign 实战避坑签名失败、时间戳翻转、参数排序等常见问题5.1 签名校验失败几乎都是参数参与范围不对称现象同样的算法同一个盐值在测试脚本里签名能生成但请求打过去就是 401 或签名错误。屡试不爽。原因服务端计算签名时使用的参数集合跟你客户端传入的集合不一致。常见的情形包括body 参数没参与计算、timestamp 和 nonce 重复参与导致双重拼接、空值参数被客户端滤掉但服务端保留或反过来。这类问题不拆开对比很难发现。解决把发送请求前的最终参数集合完整打印出来再对比抓包数据里的实际请求参数。重点检查三处——时间戳是否进了参数字典、nonce 是否进了参数字典、body 字段是否也拼进了原始字符串。我一般写一个 debug 开关把签名用的原始字符串完整输出一次和服务端反推的对一遍几分钟就能定位。5.2 时间戳老是过期毫秒与秒的灾难现场现象签名生成没问题但服务端一直提示请求过期或时间戳无效明明刚生成的。原因蒲公英接口的 x-timestamp 用的是毫秒级时间戳而很多语言的标准时间戳函数默认返回的是秒级。如果你直接把秒级时间戳拿来签名服务端一换算发现是几十年前的时间自然判定过期。另一个坑是时区问题——如果代码运行环境的时区设置异常时间差会放大到足够触发过期校验。解决统一用毫秒级时间戳并打印出来核对位数。正确的毫秒时间戳是 13 位数字10 位的是秒。写一个自检函数把当前时间、生成的签名、非对称的时间差全打印出来看是否在正常窗口内。服务器时区尽量设置为 UTC避免本地时区带来偏差。5.3 参数排序翻车字典序的暗坑现象所有参数都参与了签名算法也是 MD5可签名就是不对。折腾半天怀疑盐值错误。原因Python 的 sort 和 Java/Go 的排序在遇到特殊字符时结果不一样。比如下划线 _ 和大写字母 A不同语言里的排序位置可能不同。一旦排序顺序和蒲公英服务端不一致签名必然失败。这类问题最常见于包含包名、链接这类带下划线参数的实际场景。解决抓包读参数顺序。抓包工具里能看到 URL query 参数的排列顺序——服务端构造签名时就是不排序或按特定顺序排的。如果你的抓包里参数是按顺序出现的直接按那个顺序拼字符串别依赖代码的自动排序。必要时用 with_items 手动指定参与签名的参数顺序。5.4 签名反推失败盐值不对还是算法不对现象MD5、SHA1、HMAC 都试过了签名仍然对不上怀疑盐值没找对。原因盐值可能不是静态字符串而是由多个部分拼接而成比如设备 ID 固定值 时间戳某几位。这种情况下直接搜完整盐值没有意义要拆开找每一段的生成规则。蒲公英新版本的接口里确实出现过这类动态盐的场景。解决先固定时间戳和 nonce再用逐位排除法试。把已知的参数、时间戳、nonce 固定下来只变化盐值候选看哪个值能让 MD5 结果和目标签名一致。如果所有静态盐都试过不对就去反编译找动态盐的拼接逻辑。还有一个更隐蔽的细节盐值可能不在字符串尾部而在两个参数的中间位置这种只能靠逆向源码获得线索。6. 验证签名是不是对了一秒钟搭个本地校验脚本拿到签名生成代码后不要急着直接打生产接口。先搭一个本地校验脚本把已知的 x-sign 当成 target用你生成的签名去对比一致了就说明算法和盐都对再上真实请求。这个办法能帮你把「签名错」和「接口其他问题」彻底分开。def verify_sign(target_sign: str, params: dict, timestamp: str, nonce: str) - bool: generated generate_x_sign(params, timestamp, nonce) if generated target_sign.lower(): print([] 签名验证通过算法和盐值正确) return True else: print(f[x] 不一致目标签名 {target_sign}) print(f[x] 本地生成 {generated}) # 打印原始字符串便于排查 sorted_keys sorted(params.keys()) raw_parts [f{k}{params[k]} for k in sorted_keys if params[k]] raw_string .join(raw_parts) print(f[x] 拼接串 {raw_string}timestamp{timestamp}nonce{nonce}{SALT}) return False验证时用的测试数据要拿真实抓包里的一组完整请求——包括 URL 原始 query、body 表单、header 里的 x-timestamp、x-nonce、x-sign。把这些原封不动填进 verify_sign 函数只要输出「验证通过」就说明你的算法和盐值没毛病接下来可以放心写业务逻辑。这里有一个我吃过亏的细节抓包拿到的 x-sign 可能是大写生成的是小写比对前必须先统一转小写。别小看这个它浪费过我一整个下午。从那以后我每次写签名相关代码都会强制走一遍「抓包 → 反推 → 本地校验 → 真实请求」四个步骤校验不过绝不往下走这一套习惯帮我挡掉了至少七八成签名类的坑。希望这份拆解能帮你少走几步弯路直接把蒲公英 x-sign 这个黑匣子变成手上顺手的工具。本文还有配套的精品资源点击获取