麦当劳小程序请求头 D 值生成机制,非数美滑块D值
本文最后更新于22 天前,其中的信息可能已经过时,如有错误请5Yqg5b6u5L+ha2Fud2s2NjY=

本文记录一次针对微信小程序请求头 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 值生成流程

请求发送前调用 SMSdk.getDeviceId()
是否存在服务端 SMID?
返回 B + SMID
生成启动 UUID uid
采集微信设备与运行环境数据
组装 fingerprint,计算 tn,执行 shortBox 裁剪
fingerprint JSON → gzip → Base64 → AES-CBC / ZeroPadding → data(hex)
RSA PKCS#1 v1.5(publicKey, uid) → Base64 → ep
组装 outer:appId / organization / ep / data / os / encode / compress
返回 “D” + Base64(JSON.stringify(outer))
写入请求头 d(最多 3000 字符)

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 模拟环境:

  1. 初始存储中没有服务端 SMID;
  2. 模拟服务端返回成功,但 deviceId 为空;
  3. SDK 使用模拟设备字段生成回退数据;
  4. getDeviceId() 返回 D 前缀;
  5. 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 同时承担两项作用:

  1. 经 MD5 派生 AES 密钥;
  2. 经 RSA 公钥加密后写入 ep,供服务端恢复。

通过静态代码追踪、隔离环境运行以及 Python 往返解密验证,可以确认这一生成流程已被完整复现。

文末附加内容
上一篇