逆向两步路 WebAssembly 中的 sign_v1
本文最后更新于35 天前,其中的信息可能已经过时,如有错误请5Yqg5b6u5L+ha2Fud2s2NjY=

本文仅用于研究自己有权分析的客户端代码、协议兼容和安全设计。请勿将文中的方法用于未经授权的接口访问、绕过访问控制或破坏性操作。

本文的分析对象是两步路户外网,实际网站地址为 https://www.2bulu.com/

前言

在分析一个 Web 前端项目时,我遇到了这样的请求签名调用:

const sign_ret = sign_v1(request_data);

config.headers["x-web-signature"] = sign_ret["signature"];
config.headers["x-web-cnonce"] = sign_ret["cnonce"];
config.headers["x-web-timestamp"] = sign_ret["timestamp"];

sign_v1 并没有以普通 JavaScript 源码出现。JavaScript 文件只负责参数传递,真正的签名算法被编译进了 WebAssembly 模块。

本文记录如何从请求拦截器出发,沿着 wasm-bindgen 胶水代码进入 WASM,最终还原出完整签名流程,并用 Python 标准库实现可验证的纯计算版本。

分析目录中只有三个原始文件:

encrypt_wrap.js
encryption.js
encryption_bg.wasm

最终确认,签名过程由 UUID、随机因子、请求数据和时间戳共同参与,并依次使用 SHA、Base64 和 HMAC-SHA512。

一、从请求拦截器定位入口

第一步是在当前目录搜索 sign_v1 和 request_data

rg -n "sign_v1|request_data" .

调用入口位于 encrypt_wrap.js 的 Axios 请求拦截器中。

GET 请求

if (config.method === 'get') {
    if (config.params instanceof URLSearchParams) {
        request_data = config.params.toString()
    } else if (config.params instanceof Object) {
        config.params = new URLSearchParams(config.params)
        request_data = config.params.toString()
    } else {
        request_data = config.params;
    }
}

GET 请求签名的不是完整 URL,而是查询参数字符串,例如:

page=1&size=20&keyword=test

这里没有额外的字段排序逻辑,参数顺序由 URLSearchParams 的构造和迭代顺序决定。

POST 请求

if (config.data instanceof FormData) {
    let jsonData = {};
    config.data.forEach((value, key) => jsonData[key] = value);
    request_data = JSON.stringify(jsonData);
    config.data = request_data;
} else {
    request_data = config.data;
}

当请求体为 FormData 时,代码先将其转成普通对象,再执行 JSON.stringify()。如果存在同名字段,后面的值会覆盖前面的值。

非 FormData 数据会被原样传给 sign_v1。因此复现时必须确认传入的是字符串、对象还是已经序列化的 JSON,不能只根据 HTTP 请求体的表面形式推测。

写入请求头

签名成功后,结果被放入三个请求头:

x-web-signature
x-web-cnonce
x-web-timestamp

请求方法、URL 路径和请求头本身都没有直接参与后续摘要计算。

二、识别 wasm-bindgen 胶水代码

encryption.js 是经过压缩的 wasm-bindgen 胶水代码。它主要完成四件事:

  1. 加载 encryption_bg.wasm
  2. 把 JavaScript 字符串编码为 UTF-8 并写入 WASM 内存;
  3. 调用 WASM 导出的 sign_v1
  4. 把 WASM 返回的对象还原成 JavaScript 对象。

核心包装逻辑可以简化为:

wasm_bindgen.sign_v1 = function (value) {
    const ptr = value == null
        ? 0
        : passStringToWasm(value);

    wasm.sign_v1(returnArea, ptr, stringLength);
    return takeObjectFromHeap(returnValue);
};

这里有一个重要细节:

null == value

同时匹配 null 和 undefined。因此 WASM 内部存在两条不同分支:

  • request_data 有值,包括空字符串 ""
  • request_data 为 null 或 undefined

空字符串不等于空值,这一点会直接改变摘要算法。

三、检查 WASM 的导入、导出和静态字符串

通过 WebAssembly API 可以列出模块结构:

const fs = require('fs');

const bytes = fs.readFileSync('encryption_bg.wasm');
const module = new WebAssembly.Module(bytes);

console.log(WebAssembly.Module.imports(module));
console.log(WebAssembly.Module.exports(module));

模块导出了:

memory
sign_v1
decrypt_v1
__wbindgen_malloc
__wbindgen_realloc
__wbindgen_add_to_stack_pointer

与 sign_v1 相关的导入包括:

Date.now
crypto.getRandomValues
Window.location.hostname
console.log

这说明签名至少依赖当前时间、浏览器随机数和浏览器环境。

继续提取 WASM 中的可打印字符串,可以找到:

src\features\api_signature.rs
ring-0.16.20\src\digest.rs
ring-0.16.20\src\hmac.rs
base64-0.22.1
uuid-1.13.1
rand-0.9.0
rand_chacha-0.9.0
[FROM WASM] random_factor:
[FROM WASM] timestamp:
[FROM WASM] sha256_result_str:
[FROM WASM] b64_encoded_sig:

同时还能找到一个固定的 64 字符常量:

0d4b48a48ea63a6c848434d9b3b0b43719cc7c520bc66c140c77e32d7b4b8176

这些字符串已经勾勒出了算法轮廓:随机数、时间戳、摘要、Base64 和 HMAC。

四、反汇编并定位 sign_v1

使用 WABT 工具可以把 WASM 转成 WAT 或伪代码:

wasm2wat encryption_bg.wasm -o encryption_bg.wat
wasm-decompile encryption_bg.wasm -o encryption_bg.dcmp

导出表显示:

(export "sign_v1" (func 81))

在反编译结果中继续追踪 func 81,可以识别出以下关键操作:

  • 调用 crypto.getRandomValues 生成 UUID v4;
  • 使用 Rust rand 生成随机因子;
  • 调用 Date.now()
  • 通过 | 拼接多个动态参数;
  • 初始化 SHA-256 或 SHA-512 上下文;
  • 对摘要结果执行标准 Base64;
  • 使用固定 ASCII 密钥计算 HMAC-SHA512;
  • 再次执行标准 Base64并返回对象。

浏览器环境检查

WASM 在签名前会检查:

  1. 当前全局对象必须能识别为 Window
  2. window.location.hostname 必须包含以下字符串之一:
localhost
2bulu.com

两步路网站实际使用的主机名是:

www.2bulu.com

它包含 2bulu.com,因此可以通过这项检查。反编译结果显示这里采用的是字符串包含判断,并不是要求主机名必须逐字等于 2bulu.com

从安全设计角度看,包含判断比严格的域名相等或规范化后缀判断更宽松,不应被视为可靠的安全边界。

不满足条件时会返回 invalid window 或 invalid browser environment 错误。

这项检查只限制原 WASM 的运行环境,不属于密码学签名原文的一部分。Python 复现不需要模拟 Window

五、使用固定时间和随机源验证中间值

仅靠静态反汇编容易在格式化参数顺序上出错。为了得到可重复结果,可以在 Node.js 测试环境中固定 Date.now() 和 crypto.getRandomValues()

示意代码如下:

class FixedDate extends Date {
    static now() {
        return 1710000000123;
    }
}

const fakeCrypto = {
    getRandomValues(array) {
        for (let i = 0; i < array.length; i++) {
            array[i] = (i * 17 + 3) & 0xff;
        }
        return array;
    }
};

模块中的调试日志被一个恒为假的 PROD 与 UAT 比较关闭。只在临时 WASM 副本中打开该日志后,可以观察到:

[FROM WASM] random_factor: 37363
[FROM WASM] timestamp: 1710000000123
[FROM WASM] sha256_result_str: Q8VmJ3YkCqfVC6SB/B75F7UVoHupoXZy+auzqFamCJJIB15ToQj0CEL7nU0l1YjgARbVGL0TwVBdGuI2D8QCXg==
[FROM WASM] b64_encoded_sig: bWeh/zOHN9NEYBdZu+1/12+NAnxuc+i8ZaZBUwxfLLcnf5AsWAV/6YE7K4d12RDQroks9bDF9yOVMSaS2nyq6g==

这里的日志名 sha256_result_str 具有误导性:当 request_data 有值时,实际摘要算法是 SHA-512;只有空值分支使用 SHA-256。

六、完整签名算法

最终还原出的数据流如下:

uuid32 ───────────────┐
random_factor ────────┤
request_data ─────────┼─> 使用 "|" 拼接 ─> SHA ─> Base64
timestamp10 ──────────┘                         │
                                                v
固定 ASCII 密钥 ───────────────────────> HMAC-SHA512 ─> Base64

1. UUID

生成标准 UUID v4,然后移除连字符并转换为小写:

02f1e0cf-bead-4c8b-ba69-584736251403

转换后:

02f1e0cfbead4c8bba69584736251403

记为 uuid32

2. 随机因子

随机因子记为 random_factor,范围为:

0 <= random_factor < 1_000_000

它以十进制形式参与摘要,例如:

37363

3. 时间戳

WASM 先取得毫秒时间戳:

String(Date.now())

然后取字符串前 10 个字符:

String(Date.now()).slice(0, 10)

例如:

1710000000123 -> 1710000000

在当前日期范围内,这与 Unix 秒时间戳相同,但实现本质上是字符串截断,不是数学除法。

4. request_data 有值的分支

只要 request_data 不是 null 或 undefined,包括空字符串,就构造:

uuid32|random_factor|request_data|timestamp10

然后执行:

digest_raw = SHA512(source_utf8)
digest_base64 = Base64(digest_raw)

5. request_data 为空值的分支

当 request_data 为 null 或 undefined 时,原文中不包含请求数据字段:

uuid32|random_factor|timestamp10

这一分支使用 SHA-256:

digest_raw = SHA256(source_utf8)
digest_base64 = Base64(digest_raw)

因此下面两个输入的结果不同:

sign_v1("")
sign_v1(null)

6. HMAC-SHA512

固定密钥为:

0d4b48a48ea63a6c848434d9b3b0b43719cc7c520bc66c140c77e32d7b4b8176

它看起来像十六进制,但 WASM 将它作为 64 个 ASCII 字节使用,并没有先做十六进制解码。

HMAC 的消息不是原始请求,也不是原始摘要,而是上一阶段得到的 Base64 字符串:

signature_raw = HMAC-SHA512(
    key = ASCII(fixed_key),
    message = UTF8(digest_base64)
)

signature = Base64(signature_raw)

7. cnonce

cnonce 的构造方式为:

cnonce = uuid32 + decimal(random_factor * 20252)

中间没有分隔符。

例如:

random_factor = 37363
37363 * 20252 = 756675476

最终得到:

02f1e0cfbead4c8bba69584736251403756675476

需要特别注意:参与 SHA 摘要的是未乘之前的 random_factor,而 cnonce 中保存的是乘以 20252 后的十进制结果。

8. 返回结构

最终返回:

{
    cnonce: uuid32 + String(random_factor * 20252),
    signature: signature,
    timestamp: timestamp10
}

七、Python 标准库纯计算实现

下面的实现不依赖 Node.js、浏览器或 WASM,摘要和 HMAC 全部使用 Python 标准库完成。

为了便于测试,函数允许注入固定 UUID、随机因子和时间戳。正常使用时不传这些参数即可。

import base64
import hashlib
import hmac
import secrets
import time
import uuid
from typing import Optional


HMAC_KEY = (
    "0d4b48a48ea63a6c848434d9b3b0b437"
    "19cc7c520bc66c140c77e32d7b4b8176"
).encode("ascii")


def sign_v1(
    request_data: Optional[str],
    *,
    uuid_hex: Optional[str] = None,
    random_factor: Optional[int] = None,
    timestamp: Optional[str] = None,
) -> dict[str, str]:
    """使用 Python 标准库复现 WASM sign_v1。"""

    if request_data is not None and not isinstance(request_data, str):
        raise TypeError("request_data 必须是字符串或 None")

    if uuid_hex is None:
        uuid_hex = uuid.uuid4().hex
    else:
        uuid_hex = uuid_hex.replace("-", "").lower()

    if len(uuid_hex) != 32:
        raise ValueError("uuid_hex 必须是 32 位无连字符 UUID")

    if random_factor is None:
        random_factor = secrets.randbelow(1_000_000)

    if not 0 <= random_factor < 1_000_000:
        raise ValueError("random_factor 必须位于 [0, 1000000) 区间")

    if timestamp is None:
        timestamp_ms = time.time_ns() // 1_000_000
        timestamp = str(timestamp_ms)[:10]
    else:
        timestamp = str(timestamp)[:10]

    if len(timestamp) != 10 or not timestamp.isdigit():
        raise ValueError("timestamp 必须是 10 位数字字符串")

    if request_data is None:
        source = f"{uuid_hex}|{random_factor}|{timestamp}"
        digest_raw = hashlib.sha256(
            source.encode("utf-8")
        ).digest()
    else:
        source = (
            f"{uuid_hex}|{random_factor}|"
            f"{request_data}|{timestamp}"
        )
        digest_raw = hashlib.sha512(
            source.encode("utf-8")
        ).digest()

    digest_base64 = base64.b64encode(digest_raw)

    signature_raw = hmac.new(
        HMAC_KEY,
        digest_base64,
        hashlib.sha512,
    ).digest()

    signature = base64.b64encode(
        signature_raw
    ).decode("ascii")

    return {
        "cnonce": uuid_hex + str(random_factor * 20252),
        "signature": signature,
        "timestamp": timestamp,
    }

八、固定测试向量

密码学实现不能只看代码“感觉正确”,必须使用固定输入做逐字符比较。

测试参数:

request_data  = MARKERabcXYZ
uuid          = 02f1e0cfbead4c8bba69584736251403
random_factor = 37363
timestamp     = 1710000000

测试代码:

result = sign_v1(
    "MARKERabcXYZ",
    uuid_hex="02f1e0cfbead4c8bba69584736251403",
    random_factor=37363,
    timestamp="1710000000",
)

print(result["cnonce"])
print(result["signature"])
print(result["timestamp"])

Python 与 WASM 都会输出:

cnonce:
02f1e0cfbead4c8bba69584736251403756675476

signature:
bWeh/zOHN9NEYBdZu+1/12+NAnxuc+i8ZaZBUwxfLLcnf5AsWAV/6YE7K4d12RDQroks9bDF9yOVMSaS2nyq6g==

timestamp:
1710000000

这说明在 request_data、UUID、随机因子和时间戳完全相同时,Python 生成的结果与 WASM 逐字符一致。

正常调用时,两边的 UUID、随机因子和时间戳都会变化,因此每次输出不会相同。这不代表算法不兼容。

九、如何构造一致的 request_data

复现签名时,最常见的错误不是 SHA 或 HMAC,而是参与签名的字符串与实际请求不一致。

GET 查询参数

常见参数可以使用:

from urllib.parse import urlencode

request_data = urlencode({
    "page": 1,
    "size": 20,
    "keyword": "测试内容",
})

但需要注意,不同 URL 编码器对空格、波浪号、数组和重复字段的处理可能不同。最可靠的方法是比较浏览器最终生成的查询字符串,而不是假设两个库一定完全一致。

POST JSON

对于普通 JSON,可以使用紧凑格式:

import json

request_data = json.dumps(
    {
        "name": "测试",
        "page": 1,
    },
    ensure_ascii=False,
    separators=(",", ":"),
)

这通常接近浏览器的 JSON.stringify()

{"name":"测试","page":1}

不过以下差异仍可能影响签名:

  • 字段顺序;
  • 是否包含空格和换行;
  • 中文是否写成 测试 或 \u6d4b\u8bd5
  • 浮点数的格式;
  • null、空字符串和缺失字段的区别;
  • 数组及重复表单字段的序列化方式。

原则只有一个:参与签名的 UTF-8 字节必须与目标客户端使用的字节完全相同。

十、常见误区

误区 1:把固定密钥当作十六进制解码

错误写法:

key = bytes.fromhex(HMAC_KEY_TEXT)

正确写法:

key = HMAC_KEY_TEXT.encode("ascii")

两者分别是 32 字节和 64 字节,HMAC 结果完全不同。

误区 2:直接对原文做 HMAC

真实流程中间还有一层 SHA 和一层 Base64:

source
  -> SHA-512 或 SHA-256
  -> Base64
  -> HMAC-SHA512
  -> Base64

缺少任何一步都无法得到相同签名。

误区 3:对摘要的十六进制字符串做 HMAC

HMAC 的消息是摘要的标准 Base64 字符串,不是 digest.hex()

误区 4:用 cnonce 中的乘积参与 SHA

SHA 原文使用原始 random_factor。乘以 20252 的值只用于构造 cnonce

误区 5:把空字符串当作 None

"" 进入 SHA-512 四字段分支;None 进入 SHA-256 三字段分支。

误区 6:忽略字段顺序和序列化差异

签名验证比较的是最终字节,不是 JSON 对象的语义。内容相同但字符串不同,签名仍然不同。

十一、安全设计上的启示

这个实现可以阻挡一部分随意构造请求的行为,但它不能作为真正的客户端身份认证。

原因很直接:

  • 签名算法运行在用户设备上;
  • WASM 可以被下载和反汇编;
  • HMAC 密钥也随客户端一起分发;
  • 合法客户端能够完成的计算,研究者最终也可以复现。

因此,客户端硬编码 HMAC 密钥更适合被视为请求完整性标记或提高分析成本的手段,而不应被当作服务器端秘密。

更可靠的服务端安全措施包括:

  • 登录态和短期访问令牌;
  • 服务端权限校验;
  • 防重放窗口和一次性随机数记录;
  • 按账号、设备和 IP 的速率限制;
  • 风险控制与异常行为检测;
  • 将真正的秘密保留在服务端。

十二、总结

这次分析的关键不在于某一条反汇编指令,而在于把多层信息组合起来:

  1. 从 Axios 拦截器确定 request_data 的来源;
  2. 从 wasm-bindgen 胶水代码确认参数的空值语义;
  3. 从 WASM 导入表找到时间、随机数和浏览器环境依赖;
  4. 从静态字符串识别 Rust 密码学库与固定常量;
  5. 从 WAT 和伪代码还原字段顺序及算法分支;
  6. 固定时间和随机源,读取中间值;
  7. 使用固定测试向量验证 Python 实现。

最终签名公式可以概括为:

有 request_data:
source = uuid32 + "|" + random_factor + "|" + request_data + "|" + timestamp10
digest_base64 = Base64(SHA512(source))

无 request_data:
source = uuid32 + "|" + random_factor + "|" + timestamp10
digest_base64 = Base64(SHA256(source))

signature = Base64(HMAC-SHA512(ASCII_KEY, digest_base64))
cnonce = uuid32 + decimal(random_factor * 20252)

只要参与计算的字符串、UUID、随机因子和时间戳相同,Python 与原 WASM 就能生成完全一致的结果。

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