本文基于本地保存的一份小程序构建产物进行静态分析,样本对应的客户端版本为
7.0.38.2。内容仅用于学习客户端请求封装与签名设计;示例密钥均为虚构值,不包含包内真实 AK/SK。请只在获得授权的环境中测试,不要高频调用或采集与研究目标无关的数据。
术语约定
为避免把不同层次的“参数”混在一起,本文统一使用以下术语:
| 术语 | 本文含义 |
|---|---|
| API 端点(Endpoint) | HTTP 方法与资源路径的组合,例如 GET /bff/store/stores |
| 顶层业务参数(一级参数) | 业务函数传入 data 对象的第一层字段;GET 请求中会被编码为 URL 查询参数 |
| 公共请求头 | HTTP 封装统一加入的 Header,例如 ct、sid、xid 和 Trace ID |
| 签名请求头 | 按固定名称和顺序写入规范化请求的 Header 子集,并非全部公共请求头 |
| 派生签名参数 | 通过业务参数、请求头、时间和密钥计算出的 authorization、x-hmac-digest 等字段 |
| 规范化查询串(Canonical Query) | 对 GET 参数编码、排序并连接后的确定性字符串 |
| AK/SK | Access Key / Secret Key;AK 是标识,SK 是 HMAC 密钥,本文不披露样本中的真实值 |
一、先说结论
这份小程序的城市与门店功能最终访问 https://api.mcd.cn 下的三个只读 GET 接口:
| 功能 | 方法与路径 | 顶层业务参数(一级参数) | 额外业务请求头 |
|---|---|---|---|
| 城市分组 | GET /bff/store/cities/group |
无 | biz_from: 1007、biz_scenario: 103 |
| 经纬度反查城市 | GET /bff/store/cities |
latitude、longitude |
biz_from: 1005、biz_scenario: 103 |
| 城市门店列表 | GET /bff/store/stores |
pageNo、pageSize、cityCode、keyword、hotTagCode、beType、orderMode、simulationTest |
无 |
请求并不是简单拼上 Query String 就结束。客户端还会:
- 合并公共请求头;
- 清理和补齐一级参数;
- 将 GET 参数编码、排序,生成规范化查询串;
- 计算空请求体的 HMAC 摘要;
- 生成规范化请求串;
- 使用客户端内置的 AK/SK 计算 v4 签名;
- 写入
authorization、x-hmac-digest和request-timestamp; - 最后交给小程序网络层发送。
这里的 hmac-auth-v1 是 Authorization 的协议标识,sv=v4 才是本文还原的签名版本,两者不要混为一谈。
二、分析对象与定位方法
当前目录中与分析直接相关的文件如下:
| 文件 | 作用 |
|---|---|
appservice.app.js |
约 1.64 MB 的小程序业务 Bundle,也是静态分析的原始依据 |
mcd_api.py |
不执行原始 Bundle,直接复刻凭据解码、参数规范化和 v4 签名 |
test_mcd_api.py |
固定向量测试,验证 Python 结果与原始 JavaScript 签名器一致 |
verify_original_signer.js |
隔离加载原始凭据模块与签名模块,用于交叉验证 |
README_mcd_api.md |
客户端命令说明 |
由于 Bundle 已压缩,先搜索稳定的接口路径和签名请求头,比尝试从页面组件一路跟踪更高效。几个关键模块是:
| Webpack 模块 | 关键内容 |
|---|---|
26 |
城市分组、经纬度反查城市、门店列表的业务函数 |
35 |
biz_scenario 常量,城市相关接口使用字符串 "103" |
13 |
通用 HTTP 封装,负责合并公共头、业务头和请求参数 |
2199 |
初始化公共请求头、请求拦截器、v4/v5 签名分流 |
685 |
混淆后的 hmac-auth-v1 v4 签名器 |
1112 |
混淆后的 v4 AK/SK 提供函数 |
模块 26 中三个调用可以还原为下面的可读形式:
get("/bff/store/cities/group", {}, {
biz_from: "1007",
biz_scenario: "103"
});
get("/bff/store/cities", {
latitude,
longitude
}, {
biz_from: "1005",
biz_scenario: "103"
});
get("/bff/store/stores", {
pageNo,
pageSize,
cityCode,
keyword,
hotTagCode,
beType,
orderMode,
simulationTest
});
以上代码是依据压缩代码改写的等价表达,不是从 Bundle 直接复制的源码。
三、从业务调用到网络请求
整个调用链可以概括为:
请求处理链路
- 页面业务函数构造调用意图
- 模块 26 生成顶层业务参数
- 模块 13 合并 HTTP 请求配置
- 模块 2199 补充公共请求头并清理参数
- 命中 v5 路径时尝试生成 v5 签名,失败则回退 v4
- 模块 685 生成 v4 HMAC 签名
- 写入 request-timestamp 后交给小程序网络层发送
这张图也解释了一个范围问题:当前构建中已经存在 v5 分支,但并非所有路径都走 v5。mcd_api.py 还原的是这三个城市与门店 GET 请求可以复现的 v4 流程,不能据此推断整个小程序的全部接口都只使用 v4。
四、三个接口的顶层业务参数(“一级参数”)
“一级参数”不是标准 HTTP 术语。本文用它指业务函数传给 HTTP 层的 data 对象中的顶层字段。对于 GET 请求,这些字段最终参与 URL 查询串和请求签名的生成。
1. 获取城市分组
GET /bff/store/cities/group
这个接口没有 Query 参数,规范化查询串是空字符串。业务标识放在请求头中:
biz_from: 1007
biz_scenario: 103
2. 根据经纬度反查城市
GET /bff/store/cities?latitude=31.230525&longitude=121.473667
| 参数 | 含义 | 默认值 | 客户端检查 |
|---|---|---|---|
latitude |
纬度 | "0" |
parseInt(latitude) <= 0 时拒绝 |
longitude |
经度 | "0" |
parseInt(longitude) <= 0 时拒绝 |
业务请求头为:
biz_from: 1005
biz_scenario: 103
需要注意,原实现用 parseInt 做前置检查,却把原始字符串传给接口。因此:
- 小数经纬度不会被截断后发送;
- 但判断阶段只看整数部分;
null、缺失、0或负数会被视为非法。
这个判断显然贴合中国境内经纬度的使用场景,并不是一个通用的全球坐标校验器。
3. 获取城市门店列表
GET /bff/store/stores
| 参数 | 含义 | 当前脚本默认值 |
|---|---|---|
pageNo |
页码 | "1" |
pageSize |
每页数量 | "20" |
cityCode |
城市行政代码 | 必填 |
keyword |
门店关键词 | "" |
hotTagCode |
热门筛选标签 | "" |
beType |
业务类型 | "" |
orderMode |
点餐模式 | "" |
simulationTest |
客户端白名单/模拟测试标记 | "" |
原始业务函数会把这些字段全部放进 data。空字符串仍然是参数的一部分,不能在签名后擅自删掉,否则实际 URL 与签名时使用的规范化查询串会不一致。
五、公共请求头与“参与签名的头”
模块 2199 生成的公共头包括:
ct
sid
xid
language
v
content-type
tid
d
x-b3-traceid
x-b3-spanid
meddyid
routingid
其中几个值有额外计算:
ct:微信小程序为31,支付宝小程序为32;x-b3-traceid:32 位大写十六进制随机值;x-b3-spanid:取 Trace ID 的前 16 位;routingid:提取meddyid中的数字,取最后两位,再按十进制整数输出。例如abc0017xyz → 17、abc00 → 0。
v4 默认只将以下七个小写请求头放入规范化请求串,顺序固定:
ct
language
sid
sv
token
v
x-mcd-gw-v
签名器会强制写入 sv: v4,并在缺失时补 x-mcd-gw-v: 1。即使 sid 或 token 没有实际值,对应的空行也必须保留。
没有进入上述列表的头,不代表可以随意伪造或删除。它们只是不参与这一步 HMAC 计算,服务端仍可能在路由、会话、风控或链路追踪阶段单独使用。
支付宝分支传给 v4 签名器的头列表更短:
ct;sid;sv;v;x-mcd-gw-v
因此跨平台复现时不能直接复用微信小程序的默认签名头列表。
六、v4 参数与签名的完整计算
第 1 步:清理 GET 一级参数
请求拦截器会遍历 GET 的 data 对象,把值为 undefined 或 null 的字段改成空字符串。字符串参数在此前还会执行 trim()。
这一步会影响最终签名。例如:
{ keyword: null }
会先变为:
{ keyword: "" }
而不是被删除,也不是编码成 keyword=null。
第 2 步:生成规范化查询串
每个一级参数被转换为:
encodeURIComponent(key)=encodeURIComponent(value)
然后按完整的 key=value 字符串做字典序排序,最后使用 & 连接:
from urllib.parse import quote
JS_COMPONENT_SAFE = "-_.!~*'()"
def encode_component(value):
return quote(str(value), safe=JS_COMPONENT_SAFE)
def canonical_query(params):
pairs = [
f"{encode_component(k)}={encode_component(v)}"
for k, v in params.items()
]
return "&".join(sorted(pairs))
例如:
输入:{"keyword": "a b", "cityCode": "310100"}
输出:cityCode=310100&keyword=a%20b
必须使用 JavaScript encodeURIComponent 的兼容规则。空格是 %20,不是表单编码常见的 +。
门店列表的完整空值示例会得到:
beType=&cityCode=310100&hotTagCode=&keyword=&orderMode=&pageNo=1&pageSize=20&simulationTest=
第 3 步:计算请求体摘要
签名器对 GET 请求使用空字符串作为 Body:
Body = ""
摘要不是普通 SHA-256,而是以 SK 为密钥的 HMAC-SHA256,再进行 Base64:
x-hmac-digest = Base64(HMAC-SHA256(SK, Body))
因此所有使用同一个 SK 的 GET 请求,其 x-hmac-digest 都相同;真正区分路径与 Query 的是下一步请求签名。
对于非 GET 请求,Body 通常是紧凑形式的 JSON.stringify(data)。JSON 字段顺序、空格、Unicode 表达和数据类型都可能改变摘要,所以不能用格式化后的 JSON 参与计算。
第 4 步:计算签名时间
签名时间为:
signTimeMs = Date.now() + clockDiffMs
utcDate = new Date(signTimeMs).toUTCString()
例如固定毫秒时间戳 1785038400123 对应:
Sun, 26 Jul 2026 04:00:00 GMT
clockDiffMs 用来修正客户端与服务端的时钟偏差。原客户端在认证失败时,会读取响应 Date 头并更新这个偏差。
第 5 步:生成规范化请求头
微信小程序 v4 的请求头块为:
ct:31
language:zh
sid:
sv:v4
token:
v:7.0.38.2
x-mcd-gw-v:1
头名称顺序固定,缺失值仍输出冒号和空值。不能对它再次排序,也不能因为值为空就删除该行。
第 6 步:拼接规范化请求串
格式为:
UPPER_METHOD + "\n" +
PATHNAME + "\n" +
CANONICAL_QUERY + "\n" +
AK + "\n" +
UTC_DATE + "\n" +
CANONICAL_HEADERS + "\n"
以门店列表和虚构 AK 为例:
GET
/bff/store/stores
beType=&cityCode=310100&hotTagCode=&keyword=&orderMode=&pageNo=1&pageSize=20&simulationTest=
DEMO_ACCESS_KEY
Sun, 26 Jul 2026 04:00:00 GMT
ct:31
language:zh
sid:
sv:v4
token:
v:7.0.38.2
x-mcd-gw-v:1
最后一行后面仍有一个换行符。域名、协议和 Fragment 不进入规范化请求串,只有解析后的 Pathname 参与。
第 7 步:计算请求签名和 Authorization
requestSignature =
Base64(HMAC-SHA256(SK, canonicalRequest))
Authorization 的结构为:
hmac-auth-v1
# AK
# requestSignature
# hmac-sha256
# utcDate
# signedHeaderNames
实际使用 # 连接成一行:
hmac-auth-v1#AK#SIGNATURE#hmac-sha256#UTC_DATE#ct;language;sid;sv;token;v;x-mcd-gw-v
使用本文的虚构密钥、固定时间和上面的门店参数,可以得到一个可公开的测试值:
authorization:
hmac-auth-v1#DEMO_ACCESS_KEY#Sn7ZthuBYFvAWIogVgjuGkyD6VrNLvIJeVeRxfmKHw4=#hmac-sha256#Sun, 26 Jul 2026 04:00:00 GMT#ct;language;sid;sv;token;v;x-mcd-gw-v
x-hmac-digest:
BRvylsOxfJQZF1Ndas7mfMU1inGXh4STJ/0p2SZhVAU=
示例 SK 为 DEMO_SECRET_KEY_DO_NOT_USE,没有使用样本中的任何真实凭据。
第 8 步:写入请求时间戳
原始拦截器在完成 v4 或 v5 签名后执行:
header["request-timestamp"] = Date.now();
所以 request-timestamp 不在 v4 规范化请求串和默认签名头列表中,并且理论上可能比生成 utcDate 时晚几毫秒。Python 复刻为了可重复测试,使用同一个 timestamp_ms 同时生成两者,这不会改变 v4 请求签名。
七、AK/SK 是如何从 Bundle 中恢复的
模块 1112 没有以直观常量形式导出 AK/SK,而是:
- 定义一个字符串数组;
- 循环移动数组首元素到末尾;
- 对若干位置执行
parseInt和算术运算; - 当校验值等于
671342时停止旋转; - 从固定索引取字符串片段并拼接成 AK 与 SK。
mcd_api.py 的 decode_v4_credentials() 只读取模块文本并复现上述数组旋转,不执行整份未知 JavaScript。这样做的优点是:
- 分析边界清晰,只处理目标模块;
- 不会触发小程序初始化或网络逻辑;
- 更容易加入长度、模块边界和校验值检查;
- 方便在测试和博客示例中避免输出真实凭据。
客户端内置的对称密钥本质上无法对终端用户保密。混淆可以提高分析成本,却不能把随客户端分发的 SK 变成真正的服务端秘密。生产系统仍应依靠登录态、授权范围、限流、风控和服务端业务校验,而不能只依赖静态 HMAC 密钥。
八、一个最小的 Python v4 实现
下面代码省略了凭据提取,只展示参数和签名计算。实际使用时应从受控配置读取 AK/SK,并避免写日志:
import base64
import hashlib
import hmac
import json
from datetime import datetime, timezone
from email.utils import format_datetime
from urllib.parse import quote, urlsplit
SIGNED_HEADERS = (
"ct", "language", "sid", "sv", "token", "v", "x-mcd-gw-v"
)
def hmac_b64(secret: str, message: str) -> str:
raw = hmac.new(
secret.encode(),
message.encode(),
hashlib.sha256,
).digest()
return base64.b64encode(raw).decode()
def js_component(value) -> str:
return quote(str(value), safe="-_.!~*'()")
def canonical_query(params: dict) -> str:
pairs = [
f"{js_component(key)}={js_component(value)}"
for key, value in params.items()
]
return "&".join(sorted(pairs))
def sign_v4_get(url, params, headers, ak, sk, timestamp_ms):
headers = {str(k): str(v) for k, v in headers.items()}
headers["sv"] = "v4"
headers.setdefault("x-mcd-gw-v", "1")
utc_date = format_datetime(
datetime.fromtimestamp(timestamp_ms / 1000, tz=timezone.utc),
usegmt=True,
)
header_block = "\n".join(
f"{name}:{headers.get(name, '')}" for name in SIGNED_HEADERS
)
canonical = (
"GET\n"
f"{urlsplit(url).path or '/'}\n"
f"{canonical_query(params)}\n"
f"{ak}\n"
f"{utc_date}\n"
f"{header_block}\n"
)
signature = hmac_b64(sk, canonical)
headers["authorization"] = (
f"hmac-auth-v1#{ak}#{signature}"
f"#hmac-sha256#{utc_date}#{';'.join(SIGNED_HEADERS)}"
)
headers["x-hmac-digest"] = hmac_b64(sk, "")
headers["request-timestamp"] = str(timestamp_ms)
return headers
完整项目实现还专门兼容了 JavaScript 对 null、布尔值、数组和对象的字符串转换规则。上面的最小代码适合本文三个接口的字符串/数字标量参数,不应直接当成所有数据类型的通用替代品。
九、如何使用当前目录中的复刻脚本
脚本默认只打印请求,不访问网络:
py -3.11 .\mcd_api.py cities
py -3.11 .\mcd_api.py city `
--latitude 31.230525 `
--longitude 121.473667
py -3.11 .\mcd_api.py stores `
--city-code 310100 `
--page-no 1 `
--page-size 20
只有显式加入 --send 才会真正发送 GET 请求。公开演示时还应对输出中的 authorization 做脱敏,因为它包含 AK、签名值和签名时间。
游客或登录态相关值可通过参数或环境变量传入:
$env:MCD_XID = "游客 xid"
$env:MCD_SID = "登录 sid"
$env:MCD_MEDDY_ID = "meddyId"
$env:MCD_TID = "tid"
城市和门店接口在当前样本中可以构造空会话请求,但这只描述当前构建和当前接口行为,不代表服务端长期承诺,也不意味着其他接口可以匿名访问。
十、验证:不要只验证“能请求”,还要验证“算法相同”
只看服务端返回 200 并不足以证明签名还原正确,因为接口可能存在缓存、灰度或兼容逻辑。当前项目采用固定向量进行离线交叉验证:
py -3.11 -m unittest -v .\test_mcd_api.py
测试覆盖:
- 模块
1112的凭据解码结果具有合理长度; - 固定时间、固定参数下,Python 生成的 Authorization 和 Body Digest 与原始模块
685的输出一致; - Query 编码排序、Trace ID 格式和
routingid计算符合预期。
为了避免把真实 Authorization 写入测试日志,单元测试把两项结果拼接后再计算 SHA-256,只比对固定摘要:
cc8cf3ef9b6036246f193f41801ab625c1137fe2faac7af25c4e3b38bcbae3cb
verify_original_signer.js 则通过隔离的 require Stub 加载原始模块。它不会发送网络请求,但会打印包含真实 AK 的 Authorization,执行和分享输出时应先脱敏。
十一、最容易踩的坑
1. Query 签名正确,实际 URL 却被请求库改写
用于签名的 Query 和最终发送的 Query 必须逐字节一致。尤其注意参数排序、空值保留、空格编码和非 ASCII 字符。
2. 把 urlencode 的 + 当成 %20
JavaScript encodeURIComponent("a b") 的结果是 a%20b。某些表单编码函数会得到 a+b,两者签名不同。
3. 忽略空的签名请求头
匿名请求的 sid 和 token 可以为空,但规范化请求头里仍然必须存在:
sid:
token:
4. 误以为 request-timestamp 参与 v4 HMAC
它在签名后写入,仅用于请求时间与监控链路;真正参与签名的时间是 Authorization 中的 UTC 时间。
5. 对 JSON 做格式化
非 GET 请求使用紧凑 JSON.stringify(data) 结果计算 Body Digest。增加缩进、改变键顺序或把数字变成字符串,都会得到不同摘要。
6. 只看到 v4,忽略 v5 分流
当前请求拦截器会对配置命中的路径尝试 v5,失败后才回退 v4。本文三个接口的复刻结果不能直接套用到支付、下单、账户等高风险接口。
7. 把客户端静态密钥当成安全边界
任何发到客户端的密钥都可以被恢复。HMAC 可以保证请求格式一致和一定程度的完整性,但不能单独证明“请求来自可信用户”。
十二、总结
这次还原真正有价值的部分,不是找到三个 URL,而是把业务一级参数、公共请求头、规范化规则和签名派生参数串成了一条可验证的链路:
业务参数
→ 空值清理与字符串 trim
→ encodeURIComponent
→ 排序后的 Canonical Query
→ 固定顺序的 Canonical Headers
→ Canonical Request
→ HMAC-SHA256 + Base64
→ Authorization / x-hmac-digest
对于类似小程序,推荐遵循“接口常量定位 → 公共请求层 → 请求拦截器 → 签名模块 → 固定向量验证”的顺序。这样既能减少在页面代码中的无效搜索,也能把“看起来一样”的复刻提升为可重复、可测试的工程实现。