某书 X-s 签名逆向分析 – VM 保护下的 RC4 算法还原
0x01 抓包定位
首先抓包分析 /api/sns/web/v1/homefeed 接口,发现请求头中有两个关键签名参数:
- X-s: 主签名
- X-t: 时间戳
全局搜索 X-s,定位到签名生成位置:
|
1 2 3 4 5 6 7 8 |
try { var _ = "X-s", b = "X-t", x = getRealUrl(a, c, d), p = seccore_signv2; p && (r.headers[_] = p(x, s), r.headers[b] = +new Date + "") } |
0x02 签名函数分析
跟进 seccore_signv2:

签名流程很清晰:url + body → MD5 → mnsv2 加密 → JSON 包装 → 自定义 Base64
核心就是 window.mnsv2。
0x03 定位 VM 入口
这是最难的一步。window.mnsv2 只是个入口,实际执行的解释器藏在别处。
VM 的典型特征:
- 一个超长的 Base64 串或十六进制字符串(字节码)
- while(true) + switch 或多层 if-else 结构(解释器)
- 虚拟寄存器、栈操作
我使用自研的逆向 MCP 工具,通过 Hook + 调用栈追踪:
|
1 2 3 4 5 6 7 |
// 发现 22 个随机命名的全局函数 for (k in window) { if (/^_[a-f0-9]{20,}$/.test(k)) console.log(k); } // Hook 其中一个,观察调用栈 hook_function("window._0c6b9e549fef9ab9b4798ad1f12ea82b", logStack=true) |
最终定位到 VM 文件:
|
1 |
https://fe-static.xxxcdn.com/…/ds.js |
0x04 AST 解混淆
VM 解释器代码当然是混淆的。使用 Babel AST 配合 AI 进行解混淆:
解混淆后得到可读的解释器代码。
0x05 字节码提取
在代码中找到这样的字符串:
|
1 |
var bytecode = "ABt7CAAUSAAACADfSAAACAD1SAAACAAH…" // 约 6KB |
这就是 VM 的字节码,通过自定义的 switch 基于栈来执行。
基于栈的 VM 只是一种实现方式,还有基于寄存器的 VM,原理类似。
0x06 构建操作码表
分析解释器的 switch-case,构建操作码映射:
| 0x00 | PUSH | 压栈 |
| 0x01 | POP | 出栈 |
| 0x02 | ADD | 加法 |
| 0x03 | CALL | 调用函数 |
| … | … | … |
这一步用 AI 辅助分析非常高效。
0x07 反编译字节码
有了操作码表,就可以对字节码进行反编译:
; mns0301 字节码反编译
0000: PUSH_CONST "xh`)" ; 魔数
0004: PUSH_LOCAL r0
0008: CALL build_header
000C: PUSH_CONST 135
0010: CALL alloc_buffer
…
这里需要多次动态调试来验证反编译结果的正确性,同样交给 AI 完成。
0x08 函数分割与分析
反编译产出一个大文件,需要分割成独立函数:
|
1 2 3 4 5 |
output/mns0301/functions/ ├── build_input.js # 构建 135 字节输入 ├── rc4_encrypt.js # RC4 加密 ├── custom_base64.js # 自定义 Base64 └── main.js # 主流程 |
有些厂商会在这层再加控制流平坦化,这时需要在浏览器中 trace 执行流程。
0x09 算法还原
最后,结合静态分析和动态调试,还原完整算法。
输入数据结构 (135 bytes)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
偏移 大小 字段 说明 ───────────────────────────────────────────────── [0-3] 4B Header "xh`)" 魔数 [4-7] 4B Random 随机数 (little-endian) [8-15] 8B Timestamp1 当前时间戳 ms [16-23] 8B Timestamp2 页面加载时间戳 ms [24-27] 4B Field3 固定值 (15-17) [28-31] 4B Field4 计数器 [32-35] 4B Field5 计数器 [36-43] 8B Double 随机浮点数 [44-96] 53B a1 "4" + a1_cookie (53字节) [97] 1B 分隔符 '\\n' [98-107] 10B xsecappid "xxx-pc-web" [108] 1B 分隔符 '\\x01' [109-134] 26B Extra 1随机 + 17固定 + 8随机 ───────────────────────────────────────────────── |
加密流程
|
1 |
输入构建 (135B) → RC4 加密 (预置 S-box) → 自定义 Base64 → "mns0301_" + result |
预置 S-box (256 bytes)
|
1 |
SBOX = [108, 71, 200, 252, 102, 41, 228, 110, 198, 188, 243, 68, …] |
自定义 Base64 表
|
1 |
MfgqrsbcyzPQRStuvC7mn501HIJBo2DEFTKdeNOwxWXYZap89+/A4UVLhijkl63G |
XYS_ 外层编码
Base64 表:
|
1 |
ZmserbBoHQtNP+wOcza/LpngG8yJq42KWYj0DSfdikx3VT16IlUAFM97hECvuRX5 |
JSON 结构:
{
"x0": "4.3.0", // 版本号
"x1": "xxx-pc-web", // appId
"x2": "Mac OS", // 平台
"x3": "mns0301_…", // mns0301 签名
"x4": "object" // 参数类型
}
0x0A 风控检测机制
核心原理:签名即数据上报
关键点:mns0301 使用 RC4 + 预置 S-box 加密,服务器持有相同的 S-box,可以完全解密还原原始数据。
签名不只是"防篡改",更是"行为数据上报通道"。
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
┌─────────────────────────────────────────────────────────────┐ │ 风控数据流 │ ├─────────────────────────────────────────────────────────────┤ │ 浏览器端 服务器端 │ │ ──────── ──────── │ │ 采集行为数据 │ │ ↓ │ │ 写入 135B 输入结构 │ │ ↓ │ │ RC4 加密 (预置 S-box) │ │ ↓ │ │ Base64 编码 → X-s 签名 ──────→ Base64 解码 │ │ ↓ │ │ RC4 解密 (相同 S-box) │ │ ↓ │ │ 还原 135B 原始数据 │ │ ↓ │ │ 分析行为特征 → 风控判定 │ └─────────────────────────────────────────────────────────────┘ |
签名中携带的风控数据
回顾 135 字节输入结构,关键字段都是风控相关:
|
1 2 3 4 5 6 7 8 9 10 |
偏移 字段 风控用途 ───────────────────────────────────────────────── [4-7] Random 请求唯一标识,防重放 [8-15] Timestamp1 当前时间戳,检测时间异常 [16-23] Timestamp2 页面加载时间,计算停留时长 [24-27] Field3 行为标记位 [28-31] Field4 ★ 点击计数器 [32-35] Field5 ★ mouseenter 计数器 [36-43] Double 随机数,增加熵值 ───────────────────────────────────────────────── |
服务器风控判定逻辑(推测)
服务器解密签名后,可以进行以下判断:
| 页面停留时长 | > 几秒 | 0 或极短 |
| 点击计数 | 有累积 | 始终为 0 |
| mouseenter 计数 | 有累积 | 始终为 0 |
| 点击/mouseenter 比例 | 接近 1:N | 异常比例 |
| 请求间隔 | > 100ms | < 77ms 或完全一致 |
| 时间戳连续性 | 递增 | 跳跃或回退 |
行为采集与请求的关联
核心机制:行为数据全局累积,每次请求时打包进签名
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
┌──────────────────────────────────────────────────────────────┐ │ 页面生命周期 │ ├──────────────────────────────────────────────────────────────┤ │ 页面加载 │ │ ↓ │ │ VM 初始化,对关键 DOM 元素挂载监听器 │ │ │ │ │ ├── #search-input (搜索框,鼠标必经区域) │ │ ├── #header-area (头部,几乎必经) │ │ ├── #homefeed_recommend (首页推荐,主内容区) │ │ └── #explore-guide-refresh (刷新按钮) │ │ ↓ │ │ 用户操作页面 → 计数器累积到全局变量 │ │ ↓ │ │ 发起 API 请求时,签名函数读取计数器写入 X-s │ └──────────────────────────────────────────────────────────────┘ |
为什么选这些元素? 都是用户正常浏览必然会经过的区域。如果一个"用户"发了 100 个请求,但从未触碰过这些核心元素 → 爬虫。
服务器如何判定风控
服务器通过对比多次请求中的计数器变化来判断:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
正常用户: ─────────────────────────────────────────────────── 请求1: 解密 → 点击=0, mouseenter=2, 停留=2s (刚进入) 请求2: 解密 → 点击=3, mouseenter=8, 停留=15s (正常浏览) 请求3: 解密 → 点击=5, mouseenter=12, 停留=30s (持续交互) ↑ 计数器递增,停留时长增加 → ✅ 正常 爬虫/脚本: ─────────────────────────────────────────────────── 请求1: 解密 → 点击=0, mouseenter=0, 停留=0s 请求2: 解密 → 点击=0, mouseenter=0, 停留=0s 请求3: 解密 → 点击=0, mouseenter=0, 停留=0s ↑ 全是 0,没有任何页面交互 → ❌ 异常 |
VM 中的采集代码
从反汇编还原的逻辑:
|
1 2 3 4 5 6 7 8 9 10 11 |
// 对关键元素添加监听 document.querySelector("#search-input") .addEventListener("mouseenter", () => { window._xxx[6] = Date.now(); // 记录进入时间 }) .addEventListener("click", () => { let interval = Date.now() – window._xxx[6]; if (interval >= 77) { // 间隔 >= 77ms 才计入 window._xxx[13]++; // 点击计数器 +1 } }); |
77ms 阈值的意义
; 反汇编关键代码
PUSH_INT8 77
LT ; if (interval < 77) 不计入有效点击
人类点击的物理极限约 50-100ms,77ms 是一个合理的阈值:
- < 77ms:判定为程序触发,不计入计数器
- ≥ 77ms:判定为人类操作,计数器 +1
纯协议爬虫的挑战
对于不使用浏览器、直接发 HTTP 请求的纯协议爬虫,这套风控机制带来了显著挑战:
问题:计数器不能恒定
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
❌ 错误做法 1:计数器始终为 0 ─────────────────────────────────────── 请求1: 点击=0, mouseenter=0 → 服务器:没有任何交互,爬虫 请求2: 点击=0, mouseenter=0 请求3: 点击=0, mouseenter=0 ❌ 错误做法 2:计数器固定值 ─────────────────────────────────────── 请求1: 点击=5, mouseenter=10 → 服务器:数值不变,爬虫 请求2: 点击=5, mouseenter=10 请求3: 点击=5, mouseenter=10 ❌ 错误做法 3:计数器随机但无规律 ─────────────────────────────────────── 请求1: 点击=3, mouseenter=8 → 服务器:数值跳跃/回退,爬虫 请求2: 点击=7, mouseenter=2 (mouseenter 不应该减少) 请求3: 点击=1, mouseenter=15 (点击不应该减少) |
纯协议必须维护会话状态
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
class SessionState: def __init__(self): self.click_count = 0 self.mouseenter_count = 0 self.page_load_time = int(time.time() * 1000) def before_request(self): # 模拟用户行为:每次请求前增加计数器 self.click_count += random.randint(1, 3) self.mouseenter_count += random.randint(2, 5) def get_sign_params(self): return { 'click': self.click_count, 'mouseenter': self.mouseenter_count, 'page_load': self.page_load_time, 'current': int(time.time() * 1000) } |
这套风控的设计意图
| 简单脚本 | ❌ 无法绕过 | 计数器为 0,立即识别 |
| 固定参数 | ❌ 无法绕过 | 多次请求数值不变 |
| 随机参数 | ⚠️ 可能被识别 | 数值变化不符合真实规律 |
| 状态维护 | ✅ 可绕过 | 需要理解业务逻辑,成本高 |
| 浏览器自动化 | ✅ 可绕过 | 真实触发事件,但效率低 |
结论:纯协议爬虫必须维护会话状态,模拟计数器的单调递增趋势,这大大增加了爬虫的开发成本。
0x0B Python 实现
|
1 2 3 4 5 6 7 8 9 10 11 |
from output.x_sign import XSign # 初始化 x_sign = XSign(a1="your_a1_cookie", platform="Mac OS") # 生成签名 url = "/api/sns/web/v1/homefeed" params = {"cursor_score": "", "num": 27} headers = x_sign.get_headers(url, params) # {'X-s': 'XYS_…', 'X-t': '1767359305641'} |
0x0C 总结
逆向分析流程
|
1 2 3 4 5 6 7 8 9 |
┌─────────────────────────────────────────────────────────┐ │ 逆向分析流程 │ ├─────────────────────────────────────────────────────────┤ │ 抓包定位 → 函数分析 → 定位 VM 入口 │ │ ↓ │ │ AST 解混淆 → 提取字节码 → 构建操作码 │ │ ↓ │ │ 反编译 → 函数分割 → 算法还原 → Python 实现 │ └─────────────────────────────────────────────────────────┘ |
架构总览
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
┌─────────────────────────────────────────────────────────────┐ │ 某书签名系统 │ ├─────────────────────────────────────────────────────────────┤ │ seccore_signv2(url, body) │ │ │ │ │ ▼ │ │ ┌─────────┐ ┌─────────────┐ ┌──────────────┐ │ │ │ MD5 │ → │ mnsv2 │ → │ customBase64 │ → X-s │ │ └─────────┘ └──────┬──────┘ └──────────────┘ │ │ │ │ │ ▼ │ │ ┌──────────┐ │ │ │ mns0301 │ │ │ │ (RC4) │ │ │ └────┬─────┘ │ │ │ │ │ ▼ │ │ ┌──────────────┐ │ │ │ RC4 (JS) │ │ │ │ 预置 S-box │ │ │ └──────────────┘ │ └─────────────────────────────────────────────────────────────┘ |
mns0301 使用 RC4 流密码 + 预置 S-box,整体难度适中,核心在于定位 VM 入口和还原字节码逻辑。å

