有手就行系列——大众点评 mtgsig 构造(微信小程序 wx-jsguard 纯算)
本篇范围说明(下篇):
上篇 把点评 iOS 的
application/binary响应从 DES 解到明文 JSON。这篇补请求侧:HTTP 头里的mtgsig怎么来的、怎么构造。路线选择上我走了微信小程序:签名在
wx-jsguard的 JS 里,能纯算;iOS 那条是原生白盒 VMP,离线复现成本完全不是一个量级。本文所有字段名、算法步骤都对真实抓包做过 self-test 逐字段 match,敏感 token / 设备指纹已打码。
0、背景
上篇抓包时,你一定见过这种 header(字段已打码):

GET /wxmapi/wxsearch/search?... HTTP/1.1
Host: m.dianping.com
mtgsig: {"a1":"1.2","a2":17xxxxxxxxxx,"a3":"...","a4":"...","a5":"...","a6":"...","a7":"wx……","x0":3,"d1":"..."}
没有它,接口直接 403 / 风控;有了过期的、或 URL 对不上的,也是 403。上篇把「看得见响应」打通了,这篇把「自己能签请求」补上。
先把结论放前面,避免你在 iOS 白盒里空转半个月:
| 端 | 签名实现 | 离线纯算难度 | 本文是否展开 |
|---|---|---|---|
点评 iOS dpscope |
SAKRequestSignatureProcessor → 原生 VMP 白盒 |
极高(字节码 + 设备指纹) | 只对比,不展开 |
| 点评微信小程序 | @mtfe/wx-jsguard 纯 JS |
中(算法可逆,设备字段要种子) | 本文主线 |
小程序这条线的关键包路径类似:
induction/wxmp/@mtfe/wx-jsguard/jsguard.js

解 wxapkg → 反混淆 → 搜 calcmtgsig / header.mtgsig,就能落到真正的组装函数。后面所有算法都是从这里抠出来的。
声明:仅供安全研究与技术学习交流。密钥、openid、dpid、指纹串等一律打码或改写;请勿对线上业务造成干扰。
1、整体签名链路
鸟瞰图先放这儿,后面按块拆:

抓包一条合法请求
│
▼
抽出设备绑定字段:a1 / a3 / a6 / a7 / x0
抽出 a5 尾巴:vm_out(约 200+ 字节,复用)
反推 env_flag(a5[7] 与时间戳 CRC 异或)
│
▼
对任意新请求 (method, url, body, ts),现签五步:
1. 规范化 query → 排序 → 拼 z 字符串
2. 用 ts + a6 + env_flag + vm_out 重算 a5
3. MurmurHash2(z, ts) 与 MurmurHash2(a5, ts) → 拼出 a4
4. MD5 串 + 循环左移异或 → d1
5. JSON 九字段 → header.mtgsig
│
▼
Python 发请求;服务端按「自洽性」校验 a4/d1
(不独立重算那段 vm_out)
一句话:可离线重算的是「和 URL / 时间绑定的部分」;不能离线重算的是「设备侧 vm 输出」——抓一次当种子,后面反复用。
2、mtgsig 长什么样
小程序版是一个 9 字段 JSON(紧凑序列化,无空格):
{
"a1": "1.2",
"a2": 17xxxxxxxxxx,
"a3": "08……………………",
"a4": "f8ce3984……(48 hex)",
"a5": "K8bcQ+YX……(自定义 Base64,约 300+ 字符)",
"a6": "w1.6……",
"a7": "wx734c1ad7b3562129",
"x0": 3,
"d1": "a0b13ad5……(32 hex)"
}
字段分工可以记成三层(黄=每请求重算,蓝=设备种子,灰=常量):

| 字段 | 角色 | 来源 | 每次请求是否变 |
|---|---|---|---|
| a1 | 协议/算法版本 | 常量,常见 "1.2" |
否 |
| a2 | 毫秒时间戳 | Date.now() |
是 |
| a3 | 设备指纹串 | jsguard finger | 否(同设备) |
| a4 | 请求绑定签名 | 算法算 | 是(URL/时间一变就变) |
| a5 | 环境 token | 前 16B 可算 + 尾巴复用 | 前半随时刷新 |
| a6 | 风控/环境串(siua 一类) | finger / 上报缓存 | 基本固定 |
| a7 | 小程序 appId | 如 wx734c… |
否 |
| x0 | 旋转位数 | 常量 3 |
否 |
| d1 | 二次校验 | 算法算 | 是 |
服务端真正校验的是 a4 / d1 是否和 (method, path, query, a5, a2, …) 自洽。
a3/a6 更像设备身份,短时间复用没问题;长期风控需要注意频率。
3、入口:从 wxapkg 到 calcmtgsig
实操路径(有手就行):
- 微信开发者工具 / 手机沙盒拿到点评小程序
.wxapkg - 解密 + 解包(公共脚本一堆,不赘述)
- 在包里搜
mtgsig、jsguard、calcmtgsig - 定位到
@mtfe/wx-jsguard模块
反混淆后,核心组装大概是这种形态(变量名已简化):

// 伪代码:jsguard 内 calcmtgsig
r = {}
r.a1 = VERSION // "1.2"
r.a2 = s // 毫秒时间戳
r.a3 = finger.d() // 设备指纹
r.a4 = S // murmur 拼出来的 hex
r.a5 = w // 自定义 base64(token)
r.a6 = m // 环境串
r.a7 = APPID // wx...
r.x0 = 3
// d1 = md5Array(a1+a2+a3+a4+f+v+a7) 再和 (o<<x0) 等异或
r.d1 = md5ToHex(B)
header.mtgsig = JSON.stringify(r)
你不需要把整个 jsguard VM 跑起来——签名公式已经暴露在这段 JS 里。
后面几节就是把里面的 ge(Murmur)、Gn(CRC)、Tn(自定义 Base64)翻译成 Python。
4、四个积木函数
4.1 MurmurHash2 变体(jsguard 的 ge)
标准 MurmurHash2 骨架,最后多 XOR 一次魔法数 0x5BD1E995:
MURMUR_MAGIC = 0x5BD1E995
def murmur2_jsguard(data: bytes, seed: int) -> int:
n = len(data)
h = (seed ^ n) & 0xFFFFFFFF
i = 0
while n >= 4:
k = data[i] | (data[i+1] << 8) | (data[i+2] << 16) | (data[i+3] << 24)
k = (k * MURMUR_MAGIC) & 0xFFFFFFFF
k ^= k >> 24
k = (k * MURMUR_MAGIC) & 0xFFFFFFFF
h = (h * MURMUR_MAGIC) & 0xFFFFFFFF
h ^= k
h &= 0xFFFFFFFF
i += 4
n -= 4
# 尾部 1~3 字节按小端拼进 h(与常见 Murmur2 一致)
if n >= 3: h ^= data[i+2] << 16
if n >= 2: h ^= data[i+1] << 8
if n >= 1:
h ^= data[i]
h = (h * MURMUR_MAGIC) & 0xFFFFFFFF
h ^= h >> 13
h = (h * MURMUR_MAGIC) & 0xFFFFFFFF
h ^= h >> 15
return (h ^ MURMUR_MAGIC) & 0xFFFFFFFF # ← 多这一下
两个调用点:
x = murmur2_jsguard(z, a2)// z 是规范化后的请求串u = murmur2_jsguard(a5.encode("utf-8"), a2)
seed 用的是完整毫秒时间戳 a2,不是 a2 & 0xFFFFFFFF(后面 o 才截断)。
4.2 自定义 CRC32(jsguard 的 Gn)
查表多项式还是常见的 0xEDB88320,但最终 XOR 不是 0xFFFFFFFF,而是 0x12477CDF:
def custom_crc32(data: bytes) -> int:
r = 0xFFFFFFFF
for b in data:
r = CRC_TABLE[(r ^ b) & 0xFF] ^ (r >> 8)
return (0x12477CDF ^ r) & 0xFFFFFFFF
这个只在 a5 内部用:时间戳 4 字节的 CRC、以及 y[0..11] 的整段 CRC。
4.3 自定义 Base64(a5 字母表)
a5 不是标准 Base64 字符集,而是 jsguard 内置的 64 字符置换表(下文用 Tn 指代)。实现方式很省事:
CUSTOM = "ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5"
STD = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
def decode_a5(s: str) -> bytes:
return base64.b64decode(s.translate(str.maketrans(CUSTOM, STD)))
def encode_a5(b: bytes) -> str:
return base64.b64encode(b).decode().translate(str.maketrans(STD, CUSTOM))
算法层没有花活,就换了张表。用标准 Base64 直接 decode 会失败或解出垃圾。
4.4 端序工具
jsguard 里混用了大端和小端,别搞反:
| 符号 | 含义 |
|---|---|
ie(u32) |
uint32 → 4 字节大端 |
le_hex_u32(u32) |
uint32 → 小端 4 字节 的 8 位 hex |
oe(bytes) |
bytes → 小写 hex |
5、请求规范化:z 字符串
签名输入不是裸 URL,而是:
z = utf8( METHOD + " " + path + " " + sorted_query )
// 非 GET 且有 body 时:再 append body[:16200]
query 处理是最容易踩坑的一步,必须和 JS 一致:
- 按
&切开每个k=v +当空格,再decodeURIComponent- 用 jsguard 的
fe(基本等价encodeURIComponent,safe 集-._~)重新编码 key/value - 按
(key, value)字典序排序 - 拼回
k=v&k2=v2...
def build_z(method: str, url: str, body: str = "") -> bytes:
p = urllib.parse.urlparse(url)
pairs = []
for part in (p.query or "").split("&"):
if not part:
continue
k, _, v = part.partition("=")
k = urllib.parse.unquote(k.replace("+", " "))
v = urllib.parse.unquote(v.replace("+", " "))
pairs.append((fe(k), fe(v)))
pairs.sort(key=lambda kv: (kv[0], kv[1]))
w = "&".join(f"{k}={v}" for k, v in pairs)
z = f"{method.upper()} {p.path} {w}".encode()
if method.upper() != "GET" and body:
z += body.encode()[:16200]
return z
⚠️ 和抖音 a_bogus 那篇一样:编码细节错了,后面哈希全错。
常见翻车:用了parse_qsl默认行为、没排序、空格编成%20和+混用、path 带了 host。
6、a5:可算的 16 字节 + 复用的 vm_out
6.1 二进制布局
把 a5 用自定义表 decode 后,长度大约 240~260 字节,前 16 字节结构固定:

| offset | 长度 | 含义 |
|---|---|---|
| 0 | 7B | MD5(a6 ‖ ie(ts)) 的 hex 前 14 字符,两两转字节 |
| 7 | 1B | env_flag XOR CRC32(ie(ts)) 的低 8 位 |
| 8 | 4B | ts 低 32 位,大端:ie(a2 & 0xFFFFFFFF) |
| 12 | 4B | CRC32(y[0..11]) 大端 |
| 16 | N | vm_out:jsguard 闭包状态,离线算不出来 |
布局示意(只读结构,不依赖等宽对齐):
y[0 .. 6] MD5 前缀 7B
y[7] env_flag XOR crc(ts)
y[8 .. 11] timestamp BE 4B
y[12 .. 15] CRC32(y[0..11]) BE 4B
y[16 ..] vm_out (复用抓包尾巴)
实测一组样本:总长 249 字节 → vm_out = 233 字节。不同版本 N 会漂,以抓包为准。
6.2 为什么不能从零生成整段 a5
vm_out 来自 jsguard 内部 VM / 生物特征拼装结果,输入包含大量运行时闭包(源码里能看到对私有对象 qn 一类状态的 JSON.stringify)。
你在 Node 里硬补环境,成本接近再写一个小程序运行时。
但服务端校验策略(小程序 wxmapi 实测)是:
- 会验:
a4、d1是否由当前 URL + a5 字符串 + 时间戳按公式推出来 - 不会(至少当前):独立重跑那段 vm 逻辑核对
vm_out每一位
所以工程解法变成:
从任意一条合法抓包:
1) decode a5 → 取 y[16:] 作为 vm_out
2) 用 y[7] ^ CRC(l) 反推 env_flag
3) 保存 a1/a3/a6/a7/x0
之后每次新请求:
只重算 y[0..15],尾巴拼原来的 vm_out → 新 a5
6.3 重算前 16 字节
def build_a5(a6: str, ts_ms: int, env_flag: int, vm_out: bytes) -> str:
o = ts_ms & 0xFFFFFFFF
l = struct.pack(">I", o) # ie(o)
md5_hex = hashlib.md5(a6.encode() + l).hexdigest()
y = bytearray(int(md5_hex[i:i+2], 16) for i in range(0, 14, 2)) # 7 bytes
y.append((env_flag ^ (custom_crc32(l) & 0xFF)) & 0xFF)
y.extend(l) # +4 → 12 bytes
y.extend(struct.pack(">I", custom_crc32(bytes(y)))) # +4 → 16
y.extend(vm_out)
return encode_a5(bytes(y))
种子提取(抓包一次):
a5_bytes = decode_a5(captured["a5"])
vm_out = bytes(a5_bytes[16:])
o = captured["a2"] & 0xFFFFFFFF
l = struct.pack(">I", o)
env_flag = a5_bytes[7] ^ (custom_crc32(l) & 0xFF)
7、a4:请求绑定签名
a4 是 48 个 hex 字符 = 24 字节。和 d1 合在一起的鸟瞰公式:

a4 = hex( ie(x) ‖ ie(u) ‖ A )
── 4B ── ── 4B ── ─ 16B ─
x = murmur2_jsguard(z, a2)
u = murmur2_jsguard(utf8(a5), a2)
o = a2 & 0xFFFFFFFF
A = bytes.fromhex(
le_hex(x) + le_hex(u) + le_hex(x^o) + le_hex(x^u^o)
) # 注意:这里是「小端 hex 再解回 bytes」,不是 ie 大端
对应 Python:
x = murmur2_jsguard(z, s)
u = murmur2_jsguard(a5.encode(), s)
o = s & 0xFFFFFFFF
A = bytes.fromhex(
le_hex_u32(x) + le_hex_u32(u) + le_hex_u32(x ^ o) + le_hex_u32(x ^ u ^ o)
)
a4 = (ie(x) + ie(u) + A).hex()
几个容易写错的点:
- a5 要先完整字符串算出来,再
murmur(a5_utf8)——中间不能用「旧 a5」 A那段是 小端 hex 拼串再fromhex,和前面ie(x)大端并存,别统一成一种端序- z 的 method 必须大写
GET/POST
URL 一变 → z 变 → x 变 → a4 必变。
这也是为啥「把 start=0 的 mtgsig 原样贴到 start=10」会秒 403。
8、d1:二次校验
d1 把身份字段和 a4 再揉一次:
f = u & 0xFFFFFFFF
v = hashlib.md5(a6.encode() + ie(o)).hexdigest() # 32 hex
M = f"{a1}{a2}{a3}{a4}{f}{v}{a7}" # 字符串拼接,f 是十进制
B = list(struct.unpack("<IIII", hashlib.md5(M.encode()).digest())) # 4 个小端 u32
j = ((o << x0) | (o << (32 - x0))) & 0xFFFFFFFF # 循环左移 x0 位
B[0] ^= j
B[1] ^= f
B[2] ^= f ^ j
B[3] ^= B[0] # 注意依赖更新后的 B[0]
d1 = "".join(le_hex_u32(x) for x in B) # 32 hex
x0 固定为 3 时,j 就是把时间戳低 32 位循环左移 3 位。
M 里的 f 是 无符号十进制字符串,不是 hex——这点源码里是 f = _ >>> 0 直接拼进字符串。
9、串起来:从种子到 header
@dataclass
class Seed:
a1: str
a3: str
a6: str
a7: str
x0: int
env_flag: int
vm_out: bytes
# 可选:抓包时的 a2,风控时间窗紧时优先复用
def forge(seed: Seed, method: str, url: str, body: str = "", ts_ms: int | None = None) -> dict:
s = ts_ms if ts_ms is not None else int(time.time() * 1000)
z = build_z(method, url, body)
a5 = build_a5(seed.a6, s, seed.env_flag, seed.vm_out)
x = murmur2_jsguard(z, s)
u = murmur2_jsguard(a5.encode(), s)
o = s & 0xFFFFFFFF
A = bytes.fromhex(
le_hex_u32(x) + le_hex_u32(u) + le_hex_u32(x ^ o) + le_hex_u32(x ^ u ^ o)
)
a4 = (ie(x) + ie(u) + A).hex()
f = u & 0xFFFFFFFF
v = hashlib.md5(seed.a6.encode() + ie(o)).hexdigest()
M = f"{seed.a1}{s}{seed.a3}{a4}{f}{v}{seed.a7}"
B = list(struct.unpack("<IIII", hashlib.md5(M.encode()).digest()))
j = ((o << seed.x0) | (o << (32 - seed.x0))) & 0xFFFFFFFF
B[0] = (B[0] ^ j) & 0xFFFFFFFF
B[1] = (B[1] ^ f) & 0xFFFFFFFF
B[2] = (B[2] ^ f ^ j) & 0xFFFFFFFF
B[3] = (B[3] ^ B[0]) & 0xFFFFFFFF
d1 = "".join(le_hex_u32(w) for w in B)
return {
"a1": seed.a1, "a2": s, "a3": seed.a3, "a4": a4,
"a5": a5, "a6": seed.a6, "a7": seed.a7,
"x0": seed.x0, "d1": d1,
}
# 使用
headers["mtgsig"] = json.dumps(forge(seed, "GET", url), separators=(",", ":"))
自测怎么过
对同一条抓包做闭环:种子从这条的 mtgsig 解出,forge(method, url) 用原 a2,九个字段应 逐字段相等。

a1 match ✓
a2 match ✓
a3 match ✓
a4 match ✓
a5 match ✓
a6 match ✓
a7 match ✓
x0 match ✓
d1 match ✓
overall: PASS
这一步不过,别急着打线上——100% 是端序 / query 排序 / a5 字母表写错了。
10、和上篇 iOS 线怎么拼

请求侧
├─ 小程序 wxmapi → 本文 mtgsig(jsguard 纯算)
└─ iOS mapi → frida-rpc 签 SAK
│
▼
HTTPS
│
响应侧(上篇)
└─ DES-ECB + gzip / 新版本 AES + zstd + NVObject
│
▼
明文 JSON
两套签名不能互换:
- 小程序:
m.dianping.com/wxmapi/...,header 九字段,jsguard 纯算 - iOS:
mapi.dianping.com/mapi/...,常见带a0:"3.0"的结构,a5 白盒 VMP
上篇解响应、本篇签请求——合在一起才是「看得见 + 发得出」。
若目标是 iOS 业务接口,签名侧更稳的工程路径仍是:真机 frida 调 SAKRequestSignatureProcessor,而不是硬啃 VMP。
11、踩坑清单
| 现象 | 原因 | 处理 |
|---|---|---|
| a4 对不上抓包 | query 未排序 / 编码不一致 | 严格按 §5 做 fe + sort |
| a5 decode 失败 | 用了标准 Base64 表 | 换 Tn 字母表 |
| self-test a5 差一截 | 复用了整段旧 a5 却换了时间 | 只复用 y[16:],重算前 16B |
| 线上 403,self-test 却 PASS | 种子过期、频控、缺其它 header | 补齐 openid/dpid/Referer 等;控制 QPS |
| 换 URL 仍 200、数据不对 | 签过了但业务参数错 | 和签名无关,查 regionId/start 等 |
| 想一套算法打 iOS | 实现不是同一套 | 回 iOS frida-rpc |
另外:
- body 截断 16200 字节——大 POST 别整包哈希
- 时间窗:部分环境对
a2有滑动窗口;调试时先固定抓包时间戳,再试Date.now() - 不要把 a3/a6 当可随机字段——服务端把它们当设备身份,乱填等于新设备冷启动
12、局限(说清楚边界)
这篇解决的是:小程序请求头 mtgsig 的构造。
还没在本文承诺的:
- iOS 白盒 a5 纯算——另一座山
- 翻页风控——签过了也不等于
start>0一定放行,业务层还有 session / 行为特征 - 长期稳定的设备农场——
a3/a6/vm_out仍是单设备种子,滥用一样会进小黑屋
安全研究场景下,一条合法抓包当种子 + 纯算 a4/d1/a5 前缀,足够把协议层读懂;工程化爬取还要自己处理频控、会话和合规边界。
13、常见问题
Q1: 字母表 / CRC 常数会不会随版本变?
A: 会。换包后优先 diff wx-jsguard,搜 0x5BD1E995、0x12477CDF、64 字符常量串,三处在就能快速确认算法是否迁移。
Q2: a1 什么时候不是 1.2?
A: 协议升级时。以抓包为准,不要写死后永远不看。
Q3: 只用 HAR 里的 mtgsig 原样重放?
A: 同 URL 短时间可能行,换 query 必挂。本文的意义就是 URL 变了还能现签。
Q4: 小程序明文接口还要不要管响应解密?
A: wxmapi 多数直接 JSON;iOS mapi 才是上篇那套 DES/AES。别两套混着解。
Q5: 和美团 App / 其它小程序通用吗?
A: 美团系大量复用 jsguard / SAK 思路,但 appId、finger、字母表、字段集合都可能不同。当思路模板,不当 drop-in 脚本。
14、小结
- 点评请求签名头叫
mtgsig,小程序版是 9 字段 JSON。 - 算法在
wx-jsguard,不在你想象的「必须 frida 才能签」。 - 积木:
MurmurHash2^MAGIC、自定义CRC32^0x12477CDF、自定义 Base64、MD5、循环左移异或。 - a5 = 16 字节可算头 + vm_out 复用尾巴——这是整篇最关键的工程洞察。
- a4/d1 强绑定 URL;种子字段
a3/a6/a7绑定设备。 - 自测标准:对抓包样本 9 字段 bit-level match,再谈打真实接口。
上篇把密文响应变成可读 JSON,这篇把请求头变成可生成的签名。到这里,「看得见 + 签得出」这条小程序协议链路就算闭合了。
免责声明:本文内容仅供安全研究与学习交流。请遵守《网络安全法》《数据安全法》,不要对任何线上业务造成干扰。文章中所有 token、指纹、openid、完整可运行利用链均未提供或已打码;若需复现请自行动手从合法抓包样本推导。
White haloowhite.com