本文记录一次针对微信小程序请求头
d的静态分析、动态验证与 Python 复现过程。内容仅用于自有系统调试、兼容性研究和经授权的安全测试,请勿用于绕过第三方平台的风控或访问控制。
一、问题背景
在目标小程序的网络请求中,可以观察到如下请求头:
d: Bk7xNZ8vJAP0pmqSkVg+aBxfw4LCS3mz...
最初容易把它和业务代码中的普通变量 d 混淆。例如滑块验证码逻辑中也存在:
w = {
d: sliderX / imageWidth,
m: mouseTrack,
c: endTime - startTime
};
但这个 d 是验证码动作对象里的“归一化滑动距离”,与 HTTP 请求头 d 没有关系。真正的请求头值来自设备指纹 SDK 的 getDeviceId()。
二、分析结论
请求头 d 的核心逻辑可以概括为:
const deviceId = await SMSdk.getDeviceId();
if (deviceId && !request.header.d) {
request.header.d = deviceId.substring(0, 3000);
}
getDeviceId() 有两个返回分支:
const result = SMID
? "B" + SMID
: smEncryptedData
? "D" + smEncryptedData
: "";
| 前缀 | 数据来源 | 含义 |
|---|---|---|
B |
SDK 缓存或服务端返回的 SMID |
服务端设备标识,也常被称为 boxId |
D |
SDK 本地采集并加密的设备数据 | 无可用 SMID 时的回退数据包 |
需要特别注意:SDK 本地保存的 smidV2 是生成指纹时使用的本地种子,不等于服务端返回的 SMID。
三、代码追踪过程
3.1 从请求拦截器定位 header.d
在压缩后的 appservice.app.js 中,请求发送前会调用:
O = await getDeviceId();
if (O && !request.header.d) {
request.header.d = O.substring(0, 3000);
}
因此,HTTP 请求头没有额外的签名计算;它直接使用 getDeviceId() 的返回值,并限制为最多 3000 个字符。
3.2 追踪 getDeviceId 的 SDK 来源
主包没有直接包含 SDK 正文,而是通过异步模块加载:
const module = await require.async("./shared/utils/fp/index");
const SMSdk = module.SMSdk;
随后使用业务配置初始化:
SMSdk.initConf({
organization,
publicKey,
useShortBox: true,
authConf: {
location: false,
bluetooth: false
}
});
在后续取得的 appservice.app2.js 中,可以找到完整模块:
define("shared/utils/fp/index.js", function (...) {
// 完整的设备采集、gzip、AES、RSA 和 getDeviceId 实现
});
3.3 定位无 SMID 分支
SDK 的核心返回逻辑如下:
getDeviceId: function () {
return new Promise(function (resolve) {
const readResult = function () {
const result = SMID
? "B" + SMID
: smEncryptedData
? "D" + smEncryptedData
: "";
if (result) {
resolve(result);
return result;
}
};
if (!readResult()) {
collectDeviceInfo().then(function (items) {
if (!readResult()) {
const requestBody = buildOuterRequest(encryptFingerprint(items));
const encoded = base64(JSON.stringify(requestBody));
resolve("D" + encoded);
}
});
}
});
}
四、D 值生成流程
4.1 启动 UUID 与 AES 密钥
SDK 初始化时生成 UUID v4:
uid = UUIDv4()
然后计算:
aesKeyText = MD5(uid).hexdigest()[0:16]
aesKey = UTF8(aesKeyText)
这里使用的是 MD5 十六进制字符串的前 16 个字符,而不是 MD5 的前 16 个原始字节。
4.2 ep 的生成
UUID 使用业务配置中的 RSA 公钥加密:
ep = Base64(
RSA_PKCS1_v1_5_Encrypt(publicKey, UTF8(uid))
)
ep 用于让服务端恢复本次启动 UUID。只有公钥时可以生成 ep,但无法从已有 ep 反向解出 UUID;解密需要对应的 RSA 私钥。
4.3 指纹 data 的生成
设备指纹对象的处理顺序为:
fingerprint JSON
↓ UTF-8
gzip
↓
Base64
↓
AES-CBC
↓
十六进制字符串 data
AES 参数为:
算法:AES-CBC
密钥:UTF8(MD5(uid).hexdigest()[0:16])
IV:UTF8("0102030405060708")
填充:ZeroPadding
SDK 使用的 ZeroPadding 有一个细节:只有数据长度不是 16 的倍数时才补 0x00;已经对齐时不会再增加一整个分组。
4.4 外层数据结构
加密后的字段被组装成:
{
"appId": "default",
"organization": "YOUR_ORGANIZATION",
"ep": "RSA_ENCRYPTED_UID_BASE64",
"data": "AES_CIPHERTEXT_HEX",
"os": "weapp",
"encode": 6,
"compress": 2
}
最终请求头为:
D = "D" + Base64(UTF8(JSON.stringify(outer)))
因此,一个典型的无 SMID 请求头会以 DeyJ... 开头。其中:
- 第一个字符
D是类型前缀; eyJ...是 JSON 文本经过 Base64 后常见的开头。
五、useShortBox 的影响
目标代码使用:
useShortBox: true
SDK 在计算完整指纹和 tn 后,会从最终上传对象中删除部分体积较大的字段,包括:
time、pixelRatio、statusBarHeight、safeArea、connectedWifi、
wifiList、screenWidth、screenHeight、windowWidth、windowHeight、
language、system、benchmarkLevel、screenBright 等
这只会缩短最终上传内容,并不表示这些字段从未参与采集。tn 在裁剪前计算,因此仍可能反映完整对象。
六、Python 复现源码
以下代码展示生成 D 的核心密码学过程。完整可运行版本见 tools/smsdk_d_generate.py。
import base64
import gzip
import hashlib
import json
import uuid
from Crypto.Cipher import AES, PKCS1_v1_5
from Crypto.PublicKey import RSA
AES_IV = b"0102030405060708"
def compact_json(value):
return json.dumps(
value,
ensure_ascii=False,
separators=(",", ":"),
)
def zero_pad(data: bytes, block_size: int = 16) -> bytes:
remainder = len(data) % block_size
if remainder == 0:
return data
return data + b"\0" * (block_size - remainder)
def generate_d(
fingerprint: dict,
organization: str,
public_key_text: str,
app_id: str = "default",
uid: str | None = None,
):
uid = uid or str(uuid.uuid4())
# CryptoJS 中的 key 是 MD5 十六进制文本的前 16 个字符
aes_key_text = hashlib.md5(uid.encode()).hexdigest()[:16]
aes_key = aes_key_text.encode("ascii")
# JSON -> gzip -> Base64
plain_json = compact_json(fingerprint).encode("utf-8")
compressed = gzip.compress(plain_json, mtime=0)
compressed_b64 = base64.b64encode(compressed)
# AES-CBC + ZeroPadding -> hex
cipher = AES.new(aes_key, AES.MODE_CBC, AES_IV)
encrypted = cipher.encrypt(zero_pad(compressed_b64))
data_hex = encrypted.hex()
# RSA PKCS#1 v1.5(uid) -> Base64
public_key = RSA.import_key(public_key_text)
ep = base64.b64encode(
PKCS1_v1_5.new(public_key).encrypt(uid.encode("utf-8"))
).decode("ascii")
outer = {
"appId": app_id,
"organization": organization,
"ep": ep,
"data": data_hex,
"os": "weapp",
"encode": 6,
"compress": 2,
}
header_d = "D" + base64.b64encode(
compact_json(outer).encode("utf-8")
).decode("ascii")
return {
"headerD": header_d,
"uid": uid,
"aesKey": aes_key_text,
"fingerprint": fingerprint,
"outer": outer,
}
安装依赖:
pip install pycryptodome
项目中的完整命令行示例:
py -3.11 .\tools\smsdk_d_generate.py `
--organization YOUR_ORGANIZATION `
--public-key .\smsdk_public_key.txt `
--device-json .\device_fields.json `
--output .\generated_d.json
generated_d.json 会同时保存:
{
"headerD": "DeyJ...",
"headerLength": 1533,
"uid": "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx",
"aesKey": "0123456789abcdef",
"fingerprint": {},
"outer": {}
}
保存 uid 的目的是方便开发环境进行往返验证。生产代码不应将 AES 密钥、私钥或敏感设备数据写入日志。
七、Python 解码与验证
7.1 解码外层 JSON
D 的外层不需要密钥:
def decode_outer(device_id: str) -> dict:
if not device_id.startswith("D"):
raise ValueError("deviceId 必须以 D 开头")
raw = base64.b64decode(device_id[1:])
return json.loads(raw.decode("utf-8"))
7.2 解密内层 data
如果保留了本次生成使用的 uid,可以还原设备指纹对象:
def decrypt_inner(device_id: str, uid: str) -> dict:
outer = decode_outer(device_id)
key = hashlib.md5(uid.encode()).hexdigest()[:16].encode("ascii")
encrypted = bytes.fromhex(outer["data"])
packed = AES.new(
key,
AES.MODE_CBC,
b"0102030405060708",
).decrypt(encrypted).rstrip(b"\0")
compressed = base64.b64decode(packed)
plain = gzip.decompress(compressed)
return json.loads(plain.decode("utf-8"))
对于网络中任意抓取到的 D,如果没有启动 UUID 或 RSA 私钥,只能解析外层 JSON,不能还原内层设备字段。
八、动态验证结果
为避免修改真实微信缓存,测试使用了隔离的小程序 API 模拟环境:
- 初始存储中没有服务端 SMID;
- 模拟服务端返回成功,但
deviceId为空; - SDK 使用模拟设备字段生成回退数据;
getDeviceId()返回D前缀;- Python 对生成结果执行外层解析和内层解密。
一次测试结果如下:
prefix = D
header length = 1533
os = weapp
encode = 6
compress = 2
outer_matches = True
inner_matches = True
这说明 Python 版本与 SDK 的数据封装、gzip、Base64、AES-CBC 和外层结构一致。
九、容易踩坑的地方
9.1 把验证码变量 d 当成请求头 d
两者只是恰好同名。验证码中的 d 是滑动比例,请求头 d 是设备指纹结果。
9.2 把 smidV2 当成服务端 SMID
smidV2 是本地生成种子;只有 SDK 缓存或服务端响应中的 SMID 才会触发 B + SMID 分支。
9.3 直接对 data 做 Base64 解码
外层 D 使用 Base64,但 outer.data 是 AES 密文的十六进制字符串。正确顺序是:
hex decode -> AES-CBC decrypt -> remove zero padding
-> Base64 decode -> gzip decompress -> JSON
9.4 使用 MD5 原始字节作为 AES key
SDK 使用的是 MD5 十六进制文本的前 16 个字符:
hashlib.md5(uid.encode()).hexdigest()[:16].encode("ascii")
使用 digest()[:16] 会得到完全不同的密文。
9.5 认为格式正确就一定被服务端接受
Python 可以生成结构和密码学流程一致的 D,但服务端仍可能校验:
- 设备字段之间的一致性;
- 时间、会话和网络环境;
tn与指纹对象;- organization、公钥和业务环境;
- SDK 版本与服务端策略。
因此,格式验证成功不等于生产风控接口必然接受。
十、总结
请求头 d 并不是一个简单哈希,而是设备指纹 SDK 的两态输出:
有服务端 SMID:B + SMID
无服务端 SMID:D + Base64(外层加密请求 JSON)
无 SMID 时,核心链路为:
设备字段
-> JSON
-> gzip
-> Base64
-> AES-CBC
-> outer JSON
-> Base64
-> D 前缀
其中 UUID 同时承担两项作用:
- 经 MD5 派生 AES 密钥;
- 经 RSA 公钥加密后写入
ep,供服务端恢复。
通过静态代码追踪、隔离环境运行以及 Python 往返解密验证,可以确认这一生成流程已被完整复现。