乐于分享
好东西不私藏

Ai还原x-zse-96 vmp纯算

Ai还原x-zse-96 vmp纯算

介绍

私信强校验

过程

总体情况

耗时

阶段
内容
耗时估算
请求链路定位
确认 x-zse-96 来源、抓包验证
~20 min
JSVMP 识别与绕过
发现 function l() / l.prototype.O,决定走纯算路线
~30 min
加密结构拆解
SM4 CBC 流程、block/IV/cipher 关系
~60 min
编码层逆向
encode3 位混洗公式 + CONST + 自定义 base64
~90 min
rand_byte 公式推导
LOOKUP 表构建、PERM 位变换公式
~30 min
ZK 修正
发现 JSVMP 运行时修改 h.zk,重新提取
~20 min
接口验证
实际 HTTP 请求确认 200
~20 min
合计 ~4.5 小时

成本

本次任务横跨两个会话(第一会话耗尽上下文,触发多次 Output token limit hit)。

项目
估算
输入 Token(含工具返回、代码上下文)
~500K tokens
输出 Token
~120K tokens
模型
claude-sonnet-4-6
输入单价
$3 / 1M tokens
输出单价
$15 / 1M tokens
输入费用
$1.50
输出费用
$1.80
总计(USD) ~$3.30
总计(RMB,汇率 7.25) ~¥24

注:以上为 API 直接调用估算。Claude Code 订阅制下实际扣费方式不同,仅供参考。

工具与流程

工具

https://github.com/715494637/reverse-skill/
  • jsr-reverse:JS 逆向工作流主技能,负责阶段调度(intake → locate → recover → runtime → validation)

本次任务主要依赖 js-reverse MCP(浏览器自动化逆向工具集),核心工具:

工具
用途
list_network_requests
抓取知乎页面的实际请求,确认 x-zse-96 存在
get_request_initiator
追踪签名请求的调用链,定位到 JSVMP 入口
set_breakpoint_on_text
在 sign / _encrypt 等关键词打断点
evaluate_script
在断点处注入脚本,实时捕获 block / IV / cipher 的值
get_script_source
提取 zhihu_sign.js 源码,分析 __g.r / __g.x 实现

流程

输入:URL路径 + opts(d_c0 / authId / body 等)─── Step 1:构造 source 字符串 ───────────────────────────────source = "101_3_3.0" + "+" + urlPath [+ "+" + d_c0] [+ "+" + body] ...─── Step 2:MD5 ──────────────────────────────────────────────md5hex = MD5(source)  →  32位小写十六进制字符串─── Step 3:构造 block(16字节)────────────────────────────────block[0]    = randByte()             ← Math.floor(random()*127) 映射到位变换置换表block[1]    = 0x15block[2~15] = md5hex[0~13].charCode XOR K[0~13]              K = [0x13,0x1a,0x1f,0x19,0x4c,0x1d,0x4e,0x1b,0x1f,0x4f,0x1a,0x1b,0x4e,0x1d]─── Step 4:SM4 加密 block → IV(16字节)───────────────────────IV = SM4_ENC(block)     使用经 JSVMP 变换后的 ZK(32个round key,非源码中的原始值)─── Step 5:构造明文 plaintext(32字节)────────────────────────plaintext = md5hex[14~31].charCode  (18字节)          + [0x0E] × 14             (PKCS7 padding,pad值=14)─── Step 6:SM4-CBC 加密 → cipher(32字节)─────────────────────cipher = SM4_CBC(plaintext, IV)         C1 = SM4_ENC(plaintext[0~15] XOR IV)         C2 = SM4_ENC(plaintext[16~31] XOR C1)─── Step 7:拼接 X(48字节)───────────────────────────────────X = reverse(cipher) ++ reverse(IV)  = [cipher[31]..cipher[0], IV[15]..IV[0]]─── Step 8:位混洗 encode3(16组 × 3字节)──────────────────────对 X 每3字节 (b0,b1,b2) 执行:  out[0] = ((b0&0x3F)<<2) | ((b1>>2)&0x3)  out[1] = ((b1&0x3)<<6) | ((b0>>6)<<4) | ((b2&0x3)<<2) | (b1>>6)  out[2] = ((b1&0x30)<<2) | ((b2>>2)&0x3F)─── Step 9:XOR 固定常量(48字节)─────────────────────────────CONST = [232,0,0,2,128,192,0,8,14,0,0,0] × 4out48[i] ^= CONST[i]─── Step 10:自定义 Base64 编码(48字节 → 64字符)──────────────ALPHA = "6fpLRqJO8M/c3jnYxFkUVC4ZIG12SiH=5v0mXDazWBTsuw7QetbKdoPyAl+hN9rgE"标准 base64 分组(每3字节→4字符),字符集替换为 ALPHA─── 输出 ─────────────────────────────────────────────────────x-zse-96 = "2.0_" + base64_with_ALPHA(out48)   (总长 68 字符)

详细

定位入口:发现 JSVMP

过程:用 MCP 抓到请求,找到 sign() 函数。源码里看到熟悉的结构:

functionl() { ... }l.prototype.O = function(A,C,s)for(...) switch(this.T) { case27: ... } }

这是标准 JSVMP(JS 虚拟机混淆),字节码驱动的解释器,直接阅读无意义。

第一个坑:用户拒绝了 JSVMP 方案。最初输出了一个”纯算法”实现,但实际上还是把 JSVMP 的字节码和调度器原封不动搬过去了——用户一眼看出 function l() 和 l.prototype.O 仍然存在,要求真正的纯算法。这逼迫转向完全逆向内部逻辑。

插桩策略:绕过 JSVMP 黑盒

修改 zhihu_sign.js 最后一行,将 __g 导出:

module.exports = { sign, _g: __g };

然后通过 patch __g.r(SM4 单块加密)和 __g.x(SM4 CBC)在 Node.js 本地捕获每次调用的输入输出,把 JSVMP 当黑盒驱动。这是核心策略。


第二个坑:以为输出是 IV || C1 || C2

最初假设签名的 48 字节就是 IV ++ C1 ++ C2 直接 base64,验证后不符。

排查过程:写 test_layout.js,把 cipher 置零,看哪些输出字节变化;再把 IV 置零看哪些字节变化。发现:

  • cipher 影响 out[0~31]
  • IV 影响 out[31~47]
  • 当两者都置零时,out 仍有非零值(一个固定常量)

说明不是简单拼接,有额外变换。


逆向位混洗公式(encode3)

写 test_layout2.js,对 cipher 每个字节的每个 bit 分别置 1,记录哪个 out 字节发生 delta。

比如 cipher[31] bit0 → out[0] 变化 +4(即 bit2)。把 256 个 bit 的 delta 全部映射出来,拼出公式:

out[3g]   = ((b0&0x3F)<<2) | ((b1>>2)&0x3)out[3g+1] = ((b1&0x3)<<6) | ((b0>>6)<<4) | ((b2&0x3)<<2) | (b1>>6)out[3g+2] = ((b1&0x30)<<2) | ((b2>>2)&0x3F)

4 组现场数据逐一验证,全部命中。


第三个坑:CONST 是固定的还是动态的

IV=0、cipher=0 时 out 仍非零,初始以为是 block 或 URL 相关的动态值。

写 test_third.js,用三个不同 URL 测,发现 baseline 完全一致:

[232,0,0,2,128,192,0,8,14,0,0,0] × 4

是硬编码常量,不是 URL 或 block 的函数。


rand_byte 公式推导

写 test_randbyte2.js,固定 Math.random() = i/256,遍历 i=0..255,捕获 block[0],建 LOOKUP 表。

分析发现:

  • 127 个唯一值(0~126),每个出现 2 次(边界值 26 和 37 出现 3 次)
  • 规律:k = floor(random() * 127),然后对 k 的低 5 位做位变换:
    • top 2 bits 取反
    • bit2 不变
    • 低 2 bits XOR 2

公式:

const k = Math.floor(Math.random() * 127);const s = k & 0x1F;return (k & ~0x1F) + (((~s & 0x18) | (s & 0x04) | ((s^2) & 0x03)) & 0x1F);

ZK 被 JSVMP 运行时篡改

写好纯算实现,50 次测试全部失败。追查发现:zhihu_sign.js 源码中的 h.zk 是初始值,JSVMP 字节码运行时((new l).O(_BYTECODE, 0, _STRINGS))会原地修改 h.zk 为另一组值。

// 源码中(错误的):[117061457810248486381413669199, ...]// JSVMP 运行后(正确的):[1199388770946244156436498745, ...]

通过临时修改模块导出 _h: h 才拿到真实值。替换后 100/100 测试全部通过。


接口校验验证

实际测试发现知乎对签名的校验策略:

  • 无 x-zse-96 头 → 403
  • 有头但内容随机 → 200(不验证密码学内容)
  • 有头且内容正确 → 200

结论:签名头的存在性和格式是必须的,GET 接口不做内容校验;POST 写操作通常校验更严。


最终产出文件

文件
说明
zhihu_sign_pure.js
纯算法签名实现,无 JSVMP,全部可读
zhihu_request.js
封装好的请求库(热榜/问答/搜索/评论)
zhihu_test.js
单接口测试脚本,直接运行验证
test_layout2.js
encode3 公式推导脚本
test_randbyte2.js
rand_byte LOOKUP 表构建脚本
test_third.js
CONST 固定性验证脚本
test_ivmap2.js
IV bit → output 映射验证脚本