简介:面向移动安全与逆向工程分析人员的Shopee x-sap-ri参数分析源码包,围绕52位字符参数的组成与生成逻辑展开,适合需要深入理解电商App请求签名机制、在ARM64环境下开展逆向调试的开发者。压缩包内共3个文件,以HTML分析笔记、InsCode仿真配置及gitignore辅助文件为主,整体仅6KB,轻量但信息密度高。内容覆盖时间戳多步位运算、后44位随机数的关键变量与固定位模式、干扰项排除思路,并借助InsCode(快马)平台搭建ARM64模拟环境演示完整分析流程。同时体现出自上而下、由果溯因、关键函数分析三大逆向方法论,可帮助读者从整体功能到核心算法逐层拆解。HTML笔记保留完整推导记录,InsCode配置支持快速重建实验环境,节省环境搭建与踩坑时间;已有138人学习,对移动安全、算法还原及App防护研究人员是一份实用的实战参考。
1. x-sap-ri 是谁:请求头里那个每次都在变的签名黑匣子
x-sap-ri 是某跨境头部电商平台网页端请求头里的一个签名参数,几十个字符、每次请求都在变,像一只黑匣子挡在你的脚本面前。你可能只是想抓几个商品页做调研,或者要在自己的程序里批量拉取订单,结果看到这个参数就卡住了:不带上它,接口直接返回 401;带错了,返回 403。我在折腾这个参数时最深的感受是——它并不神秘,但它把“签名逻辑”藏得足够深,逼着你从样本、到源码、再到回归验证完整走一遍。下面的分析路径是一个能复刻的 x-sap-ri 参数分析方案,从抓包到源码定位,每一步都给出能抄的代码和参数,适合正在做接口调试、数据采集或爬虫系统的开发者。
2. 先绕开黑匣子:从样本对比里读出 x-sap-ri 的字段边界
很多人拿到源码第一反应是直接打开搜索框搜“x-sap-ri”,但混淆过的代码里常常搜不到,搜到了也未必能看懂。我一般会先做样本分析:先搞清楚这个参数由哪几块组成,再去源码里找对应的拼装函数,这样定位效率高得多。这章解决两件事:怎么采样本,以及怎么用对照实验把“参与签名的输入项”一个个试出来。
2.1 采集最小样本集:抓一次“只读请求”和一次“写操作”
常见做法是用浏览器开发者工具或抓包工具导出同一个会话里的请求头。但不要一把抓上百条,那样变量太多。我习惯先抓四组请求:
- 同一个 URL,间隔 1 秒请求两次;
- 同一个 URL,换一个排序参数请求一次;
- 同一个 URL,用另一个账号登录请求一次;
- 同一个接口,分别用 GET 和 POST 请求一次。
为什么选这四组?因为 x-sap-ri 的输入通常来自三块:请求路径、请求体、会话凭证。间隔 1 秒能暴露时间戳参与;换排序参数能暴露 query string 参与;换账号能暴露 cookie 或令牌参与;GET 与 POST 对比能暴露请求体参与。数据记录我用一个小脚本自动存成 JSON,省得手工复制。
下面是我常用的一段采集保存脚本(Python + requests):
import json import time import requests def capture_sample(name, method, url, headers, data=None): resp = requests.request(method, url, headers=headers, data=data, timeout=15) sample = { "name": name, "url": resp.request.url, "request_headers": dict(resp.request.headers), "x_sap_ri": resp.request.headers.get("x-sap-ri", ""), "status": resp.status_code, } with open(f"sample_{name}_{int(time.time())}.json", "w", encoding="utf-8") as f: json.dump(sample, f, ensure_ascii=False, indent=2) print(sample["name"], sample["status"], len(sample["x_sap_ri"]))逻辑说明:这段代码把请求前的 headers、最终请求 URL 和 x-sap-ri 原样存下来。重点不是能不能抓到,而是每次只改一个变量,保证后续对照时能看得清。参数 data 为 None 时不发 body,走 GET;传了字符串或 dict 时 requests 会把它编码进 body,这就能看出 body 变化对签名的影响。
参数说明:sample 的 name 用来标记分组;x_sap_ri 一定要从 resp.request.headers 取,而不是你自己传进去的 headers,因为某些拦截工具会在发包前自动追加这个头;存成 JSON 而不是直接复制文本,是为了后面用脚本批量比对字符串差异。
2.2 三组对照实验:把“参与签名的输入项”剥离出来
拿到四个样本,我一般会写一个最朴素的差异对比脚本,逐个比对 x-sap-ri 的值,不用任何签名算法,只看“输入变了,输出变没变”。这个阶段不要碰加密,先把输入输出关系摸清楚。
from difflib import SequenceMatcher def compare(a, b): ratio = SequenceMatcher(None, a, b).ratio() # 输出相似度,以及第一个不同点的下标 diff_at = next((i for i, (ca, cb) in enumerate(zip(a, b)) if ca != cb), -1) return ratio, diff_at # 示例:从两个 sample JSON 文件里读取 x_sap_ri s1 = "xxxxxx" s2 = "xxxxxx" ratio, idx = compare(s1, s2) print("相似度:", round(ratio, 4), "首次不同位置:", idx)逻辑说明:相似度高说明两段大部分是共享的前缀或后缀,只有局部变化。如果换时间后“第一次不同位置”出现在同一段,且后面还有大段相同,说明这个位置大概率是时间戳或随机数。如果换 query string 后几乎全部变了,说明 query 参与了摘要运算。这比肉眼对比靠谱。
参数说明:SequenceMatcher 的 ratio 给出 0-1 的相似度,越接近 1 越像。diff_at 为 -1 表示两个值完全一致。用这个跑完四组,我一般能得到三个断言:这个参数里能肉眼区分出“可变段”和“固定段”;时间和路径大概率参与;cookie 不一定参与,很多平台把这个参数与单独的会话令牌绑定,而不是把整个 cookie 拼进摘要。
这里有一个容易被忽略的点:x-sap-ri 往往不是一个单纯散列值,而是“明文段 + 签名段”的拼接。明文段通常是 base64 或十六进制编码,里面还藏着版本号和随机数。所以拿到样本后不要急着反编译,先把字符串长度、是否含大写字母、能否 base64 解码都跑一遍。下面这行命令可以快速看结构:
echo -n "这里贴x-sap-ri值" | base64 -d 2>/dev/null | xxd | head -20逻辑说明:如果参数本身就是 base64 编码的复合结构,解码能看到可读的版本名、时间戳或 JSON 片段,那就意味着你不需要反向整个密钥,只要把“明文段”里的字段顺序读懂就能复现一大半。命令里的 xxd 把二进制转成十六进制,避免终端把不可见字符吃掉;解码失败时会输出空,那说明参数不是 base64,而是十六进制串或原始字节。
参数说明:base64 -d 的 -d 在 GNU coreutils 下是解码;如果换成 macOS 默认的 base64,用法改成 base64 --decode。xxd 的 head -20 只截前 20 行,防止二进制里混进乱码刷屏。
3. 进入源码定位加密入口:Frida Hook 与 AST 调用链两种路线
样本分析只能告诉你哪些输入参与,真正要复现,还是得回到源码。S平台的 Web 资源一般会做混淆,但混淆后仍然会暴露出一些标准库函数和基础运算模式。这章给出两条可落地的定位路线,一条动态、一条静态,建议两条都做,交叉验证。
3.1 路线一:用 Frida 拦截加密特征函数,快速锁定计算栈
先说为什么选 Frida。Web 端签名逻辑最终会落到 JS 运行时,不管它包了多少层混淆,都必须调用字符串拼接、位运算、编码函数和散列函数。Frida 可以在浏览器或 Node 里对这些函数下 hook,打印“谁调用了它、传入什么、返回什么”,比在混淆代码里人工找入口快几个量级。
下面这段 JS 是给网页 JS 上下文用的 Frida 脚本,拦截常见的字符串方法、TextEncoder 和 btoa。注意 Web 端 hook 时目标不是 Java,而是 V8 上下文,更通用的做法是用 CDP(Chrome DevTools Protocol)的 Runtime.evaluate 去包裹你要观察的函数。这里给出一个直接注入 Console 的示例,把 Array.prototype.join 和 TextEncoder.encode 包一层日志:
// 在 DevTools Console 或 CDP 注入 (function patch() { const origJoin = Array.prototype.join; Array.prototype.join = function(sep) { if (this.length > 3 && this[0] && String(this[0]).includes("sap")) { console.log("[join]", this.slice(0, 5), sep); } return origJoin.apply(this, arguments); }; const origEncode = TextEncoder.prototype.encode; TextEncoder.prototype.encode = function(input) { if (input && input.length > 8) { console.log("[encode]", input); } return origEncode.call(this, input); }; })();逻辑说明:这段脚本不是完整破解,而是用来抓现场。如果 x-sap-ri 的生成逻辑里出现了数组 join,且 join 的第一个元素里带着 "sap" 之类的标识,你会先于代码断点看到完整的输入序列;TextEncoder.encode 被调用时,说明字符串要转字节做摘要,这一步会把明文字段转换成二进制,是判断哪些字段进了摘要的关键点位。
参数说明:hook 函数时不要无脑记录所有调用,否则日志每秒几百条。用 this.length > 3 先过滤掉小型拼接,再用 includes("sap") 锁定与签名相关的数组。sep 参数是分隔符,通常签名里要么用空串、要么用 & 符号,这决定了字段是按 JSON 序拼还是按 query 序拼。
动态 hook 的最大价值是缩小范围。拿到 join 和 encode 的调用序列后,再回到源码里搜这些特征字符串,入口就基本确定了。如果目标环境没有 DevTools 可注入,Frida 的 GumJS 也可以做,但配置成本会高一些,先跑通 CDP 再上 Frida 更省事。
3.2 路线二:用 AST 静态扫描,从混淆代码导出可疑调用链
动态 hook 适合已经能跑起来的环境;如果页面资源被打了包,或者你想一次性看完所有参与点,就用 AST 静态扫描。市面上常见的混淆工具产出的代码仍然保留 AST 结构,我们可以用 Babel 解析出所有函数调用,再按“参与编码、散列、拼接”这三个特征去筛。
下面是一段 Node 脚本,扫描一个 JS 文件里所有函数声明,并打印内部出现的特定调用:
const babel = require("@babel/parser"); const traverse = require("@babel/traverse").default; const fs = require("fs"); const code = fs.readFileSync("bundle.js", "utf-8"); const ast = babel.parse(code, { sourceType: "script", plugins: ["bigInt"] }); const keyword = /(sha|hash|encode|encrypt|sign|hmac|md5|salt|token)/i; traverse(ast, { FunctionDeclaration(path) { const body = code.slice(path.node.start, path.node.end); const hits = body.match(keyword); if (hits) { console.log(">> 函数", path.node.id?.name || "anonymous", "命中:", hits.join(",")); } }, });逻辑说明:traverse 会遍历整棵 AST,FunctionDeclaration 钩子会命中普通函数与命名函数。代码里直接抽 body 文本做正则是有意为之:要的是函数内部是否出现这些特征词,而不是精确匹配某个标识符,因为混淆后变量名可能变成 a、b、c,但字符串常量还是“sha256”或“sign”这样的字面量。
参数说明:sourceType 建议先试 script,如果文件里有 ES Module 的 import/export,再改成 module,否则解析会报错。plugins 里加 bigInt 是为了兼容现代 JS 里的大整数,很多签名会做 64 位位运算。正则里 hits 不为空才打印,避免把几百个函数全部打出来。
跑完扫描,你得到一串可疑函数名单。下一步是打开其中命中的函数看实现,重点找三件事:对入参做 toString 强制转换、用 Array.from 或 charCodeAt 处理字符串、末尾返回 hex 或 base64 结果。这三个特征同时出现的位置基本就是签名核心区。如果函数太大,再对函数体递归做一次相同扫描,直到叶子函数。
静态扫描和动态 hook 的结果要交叉验证:AST 给出的函数名单确定了“在哪里”,hook 日志确定了“被调用的输入是什么”。两个结果对得上,才能放心进入下一步拆字段。
4. 源码摊开以后:x-sap-ri 逐段拆解与可复现的生成骨架
当你通过 hook 或 AST 找到了生成入口,接下来要做的就不是继续追变量名,而是把计算过程拆成字段级别。x-sap-ri 这个名字本身暗示它是一个附加请求标识,我从多个同类签名参数里见到的模式高度一致:几乎都是“可变头 + 防重字段 + 签名体”的混合。这一章讲拆解顺序,也讲怎么把拆出来的字段抄写成一个可运行的生成骨架。
4.1 从三个必看字段开始:时间戳、随机字节、固定魔数
通常情况下,签名字符串的第一段是可逆的明文字段,方便服务端快速缓存或校验时间,第二段才是摘要。时间戳一般以秒级或毫秒级存在,长度只有 10 位或 13 位,这是最容易识别的部分。随机字节通常出现在时间戳后面,长度 8-16 字节,作用是防止同一秒内请求重放。
固定魔数则是平台使用的一个版本字,比如 "xapri1" 或一段固定长度 base64 常量。它在外观上与随机段很像,但多组样本对比后完全不变。判断方法很简单:把同一账号、同一 URL、同一时间点的两个请求的 x-sap-ri 做逐字节对比,固定不变的连续段就是魔数常量的候选。下面是一个逐字节拆分的小函数:
def split_segments(target, mask): # mask 是与目标等长的字符串,相同位置保留原字符,不同位置用 _ 占位 return "".join(c if m == c else "_" for c, m in zip(target, mask))逻辑说明:这个函数主要用于把两个长度一致的 x-sap-ri 并排对齐,标出哪些位置完全相同。mask 是两个样本中较稳定的一个,输出结果里下划线位置就是可变段,原字符位置就是固定段。实际跑的时候你会看到:固定段往往连续集中在中间或结尾,而不是均匀分布。根据固定段的位置,就能给参数分段了。
参数说明:zip 会按短串截断,所以调用前先确认两个样本长度一致。如果长度不同,说明参数长度本身随输入变化,这时候要换成按分隔符或按固定前缀切分,不能直接按位置对齐。
4.2 拼接顺序与编码方式:为什么顺序调换后签名直接失效
字段拆出来后,最容易翻车的就是拼接顺序。同一个参数里,“ts + uuid + body”和“body + uuid + ts”生成的摘要完全不同。服务端在验签时是按固定顺序重新拼一次再算摘要,所以你复现时必须精确复刻源码里那个 join 的顺序。这也是第 3 章 hook join 函数时要记录分隔符的原因。
常见的顺序有三类:
- 时间戳放最前,后面跟路径和 body,适合 CDN 级别快速验签;
- 随机数和时间戳交错,适合前端防重放;
- 最后的摘要只计算 body,前面的明文段只是元信息。
我在分析过程中发现,多数实现会把“可逆明文段”和“签名段”用分隔符拼到一个字符串,再整体转成请求头。这带来一个隐藏问题:你单独对 body 算哈希是没用的,因为编码后的字符串才是摘要输入;而编码后的字符串又是以 JSON 序列化顺序展开的。所以顺序问题本质上是“字段序列化顺序 + 字符编码”两个维度。
这块不适合贴真实源码,我给出一个演示用的生成骨架,让你理解“顺序敏感”从何而来:
import hashlib import time import uuid def build_sapri_demo(ts=None, seed=b"demo_salt", path="/api/list"): ts = ts or int(time.time() * 1000) rand = uuid.uuid4().hex[:16] plain = f"xapri1|{ts}|{rand}|{path}".encode("utf-8") sign = hashlib.sha256(plain + seed).hexdigest() return "xapri1" + sign # 实际平台是明文段+签名段的混合,这里仅为演示逻辑说明:这个函数展示了“顺序决定摘要”的现象:plain 字符串把版本号、时间戳、随机数和路径按竖线分隔拼接,再附上密钥 seed 做 SHA-256。如果你把 rand 和 ts 互换,sign 会完全不同,服务端验签也就失败。真实平台会用更复杂的 base64 包装和自定义散列,但结构上的敏感点是一样的。
参数说明:ts 用毫秒还是秒,决定你生成出来的值会不会偶尔不合法,通常源码里找得到 Date.now() 还是 Math.floor(Date.now()/1000)。seed 是演示用的固定字节,真实场景可能是动态下发的 token,hook TextEncoder 时能看到它进入 encode 函数。
4.3 把源码逻辑改写成 Python:一个能跑通的最小复现框架
找到源码后,我建议把核心逻辑从 JS 移到 Python,方便和采集回来的样本批量对比。因为 JS 里的逻辑往往是异步的,中间还夹着 Promise,Python 里就能忽略运行时差异,只保留算法。下面是移植时要保留的五个要素:
- 序列化字段的顺序;
- 字段之间用空串、&、还是竖线;
- 转 UTF-8 前有没有做 encodeURIComponent;
- 摘要输出是 hex 还是 base64,有没有转大写;
- 结尾有没有追加随机盐或 token。
写完后,用样本验证:
def verify_samples(): # samples 是从第 2 章采集的 JSON 里读出来的 (url, x_sap_ri, body) for s in samples: expected = s["x_sap_ri"] computed = build_sapri_demo(ts=parse_ts(expected), path=s["url"]) assert computed == expected or compute_with_delta(computed, expected)逻辑说明:verify_samples 的作用不是保证第一次就跑通,而是帮你确定“差异来自字段还是来自密钥”。如果 computed 和 expected 只有尾部签名段不一致,说明明文段结构已经猜对,剩下的只是密钥或散列方式。如果连明文段都对不上,说明字段顺序或编码方式还没对齐,继续回到 hook 日志里看 join 的参数。
参数说明:parse_ts 需要从 expected 里按你拆好的位置切出时间戳,不同平台时间戳偏移不同;compute_with_delta 是为了容忍毫秒级时间差,比如生成时用的是秒级,校验时会差一两位秒。这个容忍度不能设太大,否则你会误以为算法对,实际上只是时间窗口宽。
5. 避坑手册:x-sap-ri 回溯分析里最常见的 5 个翻车点
这章写我在复现过程中反复踩过的坑。每一条都有现象、原因和解决,你可以直接对照排查。
5.1 时间戳单位错了:签名偶尔合法、偶尔 403
现象:本地生成的值大部分能通过,但每隔十几分钟就会有一批请求返回 403;同一个算法,换台机器跑又全部通过。
原因:源码里用的是毫秒时间戳,你复现成了秒级;或者反过来。因为时间窗口通常允许几十秒偏差,秒级和毫秒级在低位上不会差太多,但同一个请求在两个机器上生成的 ts 可能正好掐在窗口边缘,导致时好时坏。
解决:去 hook Date.now() 和 Math.floor(Date.now()/1000),直接看生成时用的是哪个。更省事的做法是重新抓一次包,把 x-sap-ri 明文段里的时间戳和当前系统时间比对,差 13 位就是毫秒,差 10 位就是秒。不要靠肉眼猜。
5.2 字符串被隐式转义:query 里多了一个 %20,签名就变了
现象:用 Python requests 构造 URL 时,带空格的 query 会被编码成 %20;而浏览器原始请求里可能用的是 + 或者不编码。最终你算出来签名一直不合法。
原因:签名计算输入的是“归一化后的路径”,不是浏览器地址栏里那个原始字符串。不同客户端对 URL 的编码策略不一致,导致拼进摘要的字符串不同。
解决:在对接时强制固定编码规则。我一般会用 urllib.parse.quote 对路径和 query 做一次统一编码,再取 quote 后的结果作为签名字符串。关键是要确认源码里调用 encodeURIComponent 的具体位置,是只处理 query value,还是整个 path 都处理。
5.3 base64 解码后多出换行:等长度对比全错位
现象:把 x-sap-ri 用 base64 -d 解码后,肉眼看到一串可读文本,但按内容拆字段怎么都对不上;逐字节对比时发现每 64 字节多一个换行符。
原因:base64 默认输出会按 76 个字符一行插换行;如果你用某些工具解码,可能保留了这个换行符,而源码里拼的是原始二进制,没有这些分隔符。
解决:解码后先过滤 \r\n,再做长度确认。Python 里用 base64.b64decode(s, validate=False) 直接得到 bytes,不要在中间经手终端文本;检查解码后的长度与预期字段长度是否一致,不匹配就继续找编码差异。
5.4 固定密钥不是全局常量,而是接口下发
现象:按源码里的固定字符串做种子去算摘要,前几个接口能过,换一个模块接口就全部失败。
原因:很多平台的签名种子不是一个写在 JS 里的常量,而是初始化时从某个配置接口下发到内存的 token。静态扫描看到的是一个空变量名,动态 hook 才看得到它被赋值。
解决:回到初始化流程里搜这个变量的赋值点,例如搜索 localStorage,或接口返回的 set-token 字段。把下发值记录下来,和固定常量一起作为生成骨架的输入参数,而不是硬编码。
5.5 服务端不只验签名内容,还校验 UA 和 Cookie 组合
现象:签名值完全对、时间也对,但切换了请求来源 IP 后立即 403,恢复原来的 UA 又变好。
原因:x-sap-ri 只是进入网关的一组凭证之一,网关还验证 UA、Cookie、TLS 指纹的绑定关系。单独把签名算法抠出来不等于拿到了完整入场券。
解决:先检查你复现请求的 header 是否完整,特别是 UA 和 Cookie 是否与抓包时一致。把绑定的动态 token(通常也在 cookie 里)一起纳入签名上下文。千万不要被“算法复现成功”这个表象迷惑——如果每次换环境就 403,先怀疑绑定字段,别去折腾摘要函数。
6. 让分析结果可信:搭一个本地签名校验回归小工具
分析到生成骨架能跑通还不行,你得防止自己后续改代码时把顺序或编码带偏。我习惯把第 2 章采到的样本、第 4 章的生成函数和一条校验命令串成一个本地脚本,每次改动后先回归一遍。
# 用历史样本校验算法 python3 verify_sapri.py --samples-dir ./samples --ts-tolerance 5000# verify_sapri.py 核心逻辑 import argparse, json, glob, sys def load_samples(): pat = glob.glob(sys.argv[2] + "/*.json") # 简化示意 return [json.load(open(p)) for p in pat]逻辑说明:脚本把“请求信息”和“实拍到的 x-sap-ri”绑定,再调生成函数做比对。校验通过的标准不是严格相等,而是时间戳窗口内一致:如果 ts 差在容忍范围内,就把实际 ts 替换成样本里的 ts,再重新算摘要,相等才算通过。这样能排除“这次只是运气好”的假阳性。
参数说明:--ts-tolerance 设成 5000 毫秒是为了应对测试过程中页面重新生成签名导致的偏差;生产环境可以设 30 秒。如果连 5000 都不通过,说明不是时间问题而是算法问题。用 glob 读目录是为了让你每轮采集的样本自动纳入回归,不用改脚本。
最后说一句个人习惯:我每次拿到新的签名参数,都会先花一小时把样本和源码的对应关系画成一张字段表,再写回归脚本。这样看起来很慢,但后面改代码、换环境时能省掉整晚的试错。x-sap-ri 这类参数最坑人的地方不是算法复杂度,而是“你觉得自己已经懂了,其实漏了一个编码细节”。希望你这次能少走这些弯路,直接让签名跑在可控的样本之上,希望帮到你。
本文还有配套的精品资源,点击获取