本文仅用于研究自己有权分析的客户端代码、协议兼容和安全设计。请勿将文中的方法用于未经授权的接口访问、绕过访问控制或破坏性操作。
本文的分析对象是两步路户外网,实际网站地址为 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 胶水代码。它主要完成四件事:
- 加载
encryption_bg.wasm; - 把 JavaScript 字符串编码为 UTF-8 并写入 WASM 内存;
- 调用 WASM 导出的
sign_v1; - 把 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 在签名前会检查:
- 当前全局对象必须能识别为
Window; 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 的速率限制;
- 风险控制与异常行为检测;
- 将真正的秘密保留在服务端。
十二、总结
这次分析的关键不在于某一条反汇编指令,而在于把多层信息组合起来:
- 从 Axios 拦截器确定
request_data的来源; - 从 wasm-bindgen 胶水代码确认参数的空值语义;
- 从 WASM 导入表找到时间、随机数和浏览器环境依赖;
- 从静态字符串识别 Rust 密码学库与固定常量;
- 从 WAT 和伪代码还原字段顺序及算法分支;
- 固定时间和随机源,读取中间值;
- 使用固定测试向量验证 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 就能生成完全一致的结果。