技术分享:麦当劳小程序还原——请求链路、顶层参数与 HMAC v4 签名(不包括d值)
本文最后更新于23 天前,其中的信息可能已经过时,如有错误请5Yqg5b6u5L+ha2Fud2s2NjY=

本文基于本地保存的一份小程序构建产物进行静态分析,样本对应的客户端版本为 7.0.38.2。内容仅用于学习客户端请求封装与签名设计;示例密钥均为虚构值,不包含包内真实 AK/SK。请只在获得授权的环境中测试,不要高频调用或采集与研究目标无关的数据。

术语约定

为避免把不同层次的“参数”混在一起,本文统一使用以下术语:

术语 本文含义
API 端点(Endpoint) HTTP 方法与资源路径的组合,例如 GET /bff/store/stores
顶层业务参数(一级参数) 业务函数传入 data 对象的第一层字段;GET 请求中会被编码为 URL 查询参数
公共请求头 HTTP 封装统一加入的 Header,例如 ctsidxid 和 Trace ID
签名请求头 按固定名称和顺序写入规范化请求的 Header 子集,并非全部公共请求头
派生签名参数 通过业务参数、请求头、时间和密钥计算出的 authorizationx-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: 1007biz_scenario: 103
经纬度反查城市 GET /bff/store/cities latitudelongitude biz_from: 1005biz_scenario: 103
城市门店列表 GET /bff/store/stores pageNopageSizecityCodekeywordhotTagCodebeTypeorderModesimulationTest

请求并不是简单拼上 Query String 就结束。客户端还会:

  1. 合并公共请求头;
  2. 清理和补齐一级参数;
  3. 将 GET 参数编码、排序,生成规范化查询串;
  4. 计算空请求体的 HMAC 摘要;
  5. 生成规范化请求串;
  6. 使用客户端内置的 AK/SK 计算 v4 签名;
  7. 写入 authorizationx-hmac-digestrequest-timestamp
  8. 最后交给小程序网络层发送。

这里的 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 直接复制的源码。

三、从业务调用到网络请求

整个调用链可以概括为:

请求处理链路

  1. 页面业务函数构造调用意图
  2. 模块 26 生成顶层业务参数
  3. 模块 13 合并 HTTP 请求配置
  4. 模块 2199 补充公共请求头并清理参数
  5. 命中 v5 路径时尝试生成 v5 签名,失败则回退 v4
  6. 模块 685 生成 v4 HMAC 签名
  7. 写入 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 → 17abc00 → 0

v4 默认只将以下七个小写请求头放入规范化请求串,顺序固定:

ct
language
sid
sv
token
v
x-mcd-gw-v

签名器会强制写入 sv: v4,并在缺失时补 x-mcd-gw-v: 1。即使 sidtoken 没有实际值,对应的空行也必须保留。

没有进入上述列表的头,不代表可以随意伪造或删除。它们只是不参与这一步 HMAC 计算,服务端仍可能在路由、会话、风控或链路追踪阶段单独使用。

支付宝分支传给 v4 签名器的头列表更短:

ct;sid;sv;v;x-mcd-gw-v

因此跨平台复现时不能直接复用微信小程序的默认签名头列表。

六、v4 参数与签名的完整计算

第 1 步:清理 GET 一级参数

请求拦截器会遍历 GET 的 data 对象,把值为 undefinednull 的字段改成空字符串。字符串参数在此前还会执行 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,而是:

  1. 定义一个字符串数组;
  2. 循环移动数组首元素到末尾;
  3. 对若干位置执行 parseInt 和算术运算;
  4. 当校验值等于 671342 时停止旋转;
  5. 从固定索引取字符串片段并拼接成 AK 与 SK。

mcd_api.pydecode_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

测试覆盖:

  1. 模块 1112 的凭据解码结果具有合理长度;
  2. 固定时间、固定参数下,Python 生成的 Authorization 和 Body Digest 与原始模块 685 的输出一致;
  3. 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. 忽略空的签名请求头

匿名请求的 sidtoken 可以为空,但规范化请求头里仍然必须存在:

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

对于类似小程序,推荐遵循“接口常量定位 → 公共请求层 → 请求拦截器 → 签名模块 → 固定向量验证”的顺序。这样既能减少在页面代码中的无效搜索,也能把“看起来一样”的复刻提升为可重复、可测试的工程实现。

文末附加内容
上一篇
下一篇