猿人学第21题 — 魔改SM3逆向分析-Claude 分析

猿人学第21题 — 魔改SM3逆向分析

1. 目标

  • 地址:https://match.yuanrenxue.cn/match/21
  • 目标:逆向页面中 token 参数的生成算法,脱离浏览器使用 Python 成功请求第一页数据
  • 加密算法:魔改 SM3

2. 抓包分析

页面通过 POST 请求 /api/question/21 获取数据,请求参数:

参数 说明
page 页码
pageSize 固定为 10
kw 空字符串
token SM3 哈希值

请求头中携带 Accept-Time 字段,值为服务端时间戳。

3. JS分析 — 定位加密入口

3.1 页面脚本逻辑

页面内联脚本(Script ID 18)包含以下关键逻辑:

  1. XHR 拦截:重写了 XMLHttpRequest.prototype.open/send/setRequestHeader,将 VM 发出的 /api/match2023/3 请求重定向到真实接口 /api/question/21
  2. 时间戳获取:重写了 Date.now,改为同步请求 /api/getTime 获取服务端时间
  3. 调用 VM:最后执行 call(String(1)) 启动 VM 中的加密流程

3.2 加密JS文件

加密逻辑在 match3.js(525KB),这是一个 VM 混淆的JS文件,核心结构:

1
Navigation 伪造 → VM解释器函数 i(l,s,f,n,t,r,a,u) → 185337个字节码

VM 解释器通过巨大的 if-else 链实现状态机,根据字节码数组执行不同操作。字节码中编码了完整的 SM3 算法实现。

4. Token公式推导

通过浏览器 DevTools 和 preload 脚本注入,hook window.sm3Digest,确定 token 生成公式:

1
token = sm3Digest(timestamp + page)
  • timestamp:通过 GET /api/getTime 获取的服务端时间戳字符串
  • page:页码字符串
  • 两者直接字符串拼接后传入 sm3Digest

5. 魔改SM3逆向过程

5.1 分析方法

由于 _compress 函数是 VM 字节码生成的闭包,无法直接阅读源码。采用以下策略:

  1. Hook辅助函数:通过替换 SM3.prototype 上的 _rotl_ff_gg_t_expand 方法,捕获每次调用的输入输出
  2. 对比标准SM3:用标准SM3计算相同输入的期望值,与浏览器实际值逐项对比
  3. 定位差异:从 IV → T常量 → strToBytes → 压缩函数内部,逐层缩小差异范围

5.2 已验证的标准部分

以下组件经验证与标准SM3完全一致

  • _rotl(x, n) — 32位循环左移
  • _ff(j, x, y, z) — j<16 用 XOR,j≥16 用多数函数
  • _gg(j, x, y, z) — j<16 用 XOR,j≥16 用选择函数
  • _expand(buf) — W 扩展(P1置换 + 循环左移)
  • 消息填充 — 标准 SM3 填充(0x80 + 零填充 + 64位大端长度)

5.3 发现的6处魔改

魔改1:自定义 IV

标准SM3 IV:

1
7380166f 4914b2b9 172442d7 da8a0600 a96f30bc 163138aa e38dee4d b0fb0e4e

魔改 IV:

1
7380067c 7634d2c9 170042d6 da887534 a10c30bc 151137ad e37caa4d eeeb0f4e

发现方法:直接读取 new SM3().reg 数组。

魔改2:自定义 T 常量

标准SM3:T(0-15) = 0x79CC4519,T(16-63) = 0x7A879D8A

魔改:T(0-15) = 0x79DD4519,T(16-63) = 0x7C179D8A

发现方法:Hook _t(j) 函数,捕获返回值。

魔改3:strToBytes 清除LSB

标准SM3 的 strToBytes 是普通的 UTF-8 编码。

魔改版本对每个字节执行 & 0xFE,即清除最低位。

1
2
# 标准: "abc" → [0x61, 0x62, 0x63]
# 魔改: "abc" → [0x60, 0x62, 0x62] (每字节 & 0xFE)

发现方法:调用 sm3.strToBytes("abc") 对比输出字节,发现 0x61→0x60, 0x63→0x62。

魔改4:ss1_input 掩码 & 0xFCFFFFFF

在压缩函数的每一轮中,计算 SS1 前对中间值应用掩码:

1
2
3
ss1_input = (rotl(A, 12) + E + rotl(T, j%32)) & 0xFFFFFFFF
ss1_input = ss1_input & 0xFCFFFFFF # 清除 bit 24, 25
SS1 = rotl(ss1_input, 7)

发现方法:Hook _rotl 捕获 rotl(ss1_input, 7) 的输入值,与标准计算值对比。例如第0轮:标准值 0x1B513D0D,实际值 0x18513D0D,差异 0x1B & 0xFC = 0x18

魔改5:TT1 掩码 & 0xFFFFFFFA

TT1 计算结果清除 bit 0 和 bit 2:

1
TT1 = (FF(j,A,B,C) + D + SS2 + W'[j]) & 0xFFFFFFFA  # 清除 bit 0, 2

发现方法:通过下一轮的 A 值(= 本轮 TT1)反推。第0轮:标准值 0x46B64D4B,实际值 0x46B64D4A(差1);第1轮:标准值 0xB6DFF66F,实际值 0xB6DFF66A(差5)。验证 & 0xFA 对 LSB 字节在所有64轮均成立。

魔改6:TT2 掩码 & 0xFFAFFFFF

TT2 计算结果清除 bit 20 和 bit 22:

1
TT2 = (GG(j,E,F,G) + H + SS1 + W[j]) & 0xFFAFFFFF  # 清除 bit 20, 22

发现方法:TT2 是 rotl(TT2, 9)rotl(TT2, 17) 的输入,直接从 _rotl hook 中获取。第0轮:标准值 0x9F6B4336,实际值 0x9F2B43360x6B & 0xAF = 0x2B)。验证掩码 & 0xFFAFFFFF 在所有64轮均成立。

5.4 掩码汇总

位置 掩码 清除的位 字节位置
ss1_input 0xFCFFFFFF bit 24, 25 字节0 的 bit 0,1
TT1 0xFFFFFFFA bit 0, 2 字节3 的 bit 0,2
TT2 0xFFAFFFFF bit 20, 22 字节1 的 bit 4,6

6. Python 实现

完整实现见 solve.py,核心压缩函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
def compress(reg, buf):
W = expand(buf)
A, B, C, D, E, F, G, H = reg

for j in range(64):
Tj = T(j)
rotlA12 = rotl(A, 12)
rotlTj = rotl(Tj, j % 32)
ss1_input = (rotlA12 + E + rotlTj) & 0xFFFFFFFF
ss1_input = ss1_input & 0xFCFFFFFF # 魔改1

SS1 = rotl(ss1_input, 7)
SS2 = (SS1 ^ rotlA12) & 0xFFFFFFFF

ffVal = FF(j, A, B, C)
ggVal = GG(j, E, F, G)

TT1 = (ffVal + D + SS2 + (W[j] ^ W[j+4])) & 0xFFFFFFFA # 魔改2
TT2 = (ggVal + H + SS1 + W[j]) & 0xFFAFFFFF # 魔改3

D = C
C = rotl(B, 9)
B = A
A = TT1
H = G
G = rotl(F, 19)
F = E
E = P0(TT2)

# XOR with initial values
for i in range(8):
reg[i] = (reg[i] ^ [A,B,C,D,E,F,G,H][i]) & 0xFFFFFFFF

7. 验证结果

4组浏览器对照测试全部通过:

输入 输出
"0" 6cb40d1e1fee0dbbe28c97d57816501d89ecbbc1f8e3a8c1df21892ec8f868c3
"abc" 1fecc55c368568eb24f6d22e2a37246f06c348cca1b735049cb545c2326d36cb
"test" 38fbffd4e19cc051d85ff36c016d00accf9a0536f9833c4c677ad8659f4ccffb
"17741097605571" adac3db429079e917b41a79b7dbdf50d6751f89082a8147f963e0f213bccab68

8. 请求流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
session = requests.Session()
session.headers.update({
'User-Agent': 'yuanrenxue.project',
'Referer': 'https://match.yuanrenxue.cn/match/21',
'Origin': 'https://match.yuanrenxue.cn',
})

# 1. 获取session cookie
session.get('https://match.yuanrenxue.cn/match/21')

# 2. 获取服务端时间戳
timestamp = session.get('https://match.yuanrenxue.cn/api/getTime').text.strip()

# 3. 生成token
page = 1
token = sm3_digest(str(timestamp) + str(page))

# 4. 请求数据
resp = session.post(
'https://match.yuanrenxue.cn/api/question/21',
data={'page': page, 'pageSize': 10, 'kw': '', 'token': token},
headers={'Accept-Time': str(timestamp)},
)

响应示例:

1
{"data": [669355, 488782, 586179, 940330, 159643, 832370, 970259, 603537, 407100, 997065]}

9. 工具链

  • js-reverse MCP:preload 脚本注入、hook SM3 函数、执行浏览器端JS
  • Chrome DevTools MCP:页面导航、脚本分析
  • Chrome Remote Debugging--remote-debugging-port=9222 启动 Chrome
  • Python requests:最终脱浏览器请求验证

10. 完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
import struct
import requests

# Modified SM3 implementation (魔改SM3)

# Custom IV (different from standard)
IV = [
0x7380067c, 0x7634d2c9, 0x170042d6, 0xda887534,
0xa10c30bc, 0x151137ad, 0xe37caa4d, 0xeeeb0f4e,
]

# Custom T constants (different from standard)
T_0_15 = 0x79DD4519
T_16_63 = 0x7C179D8A


def rotl(x, n):
n = n % 32
return ((x << n) | (x >> (32 - n))) & 0xFFFFFFFF


def T(j):
return T_0_15 if j < 16 else T_16_63


def FF(j, X, Y, Z):
if j < 16:
return (X ^ Y ^ Z) & 0xFFFFFFFF
else:
return ((X & Y) | (X & Z) | (Y & Z)) & 0xFFFFFFFF


def GG(j, X, Y, Z):
if j < 16:
return (X ^ Y ^ Z) & 0xFFFFFFFF
else:
return ((X & Y) | ((~X & 0xFFFFFFFF) & Z)) & 0xFFFFFFFF


def P0(x):
return (x ^ rotl(x, 9) ^ rotl(x, 17)) & 0xFFFFFFFF


def P1(x):
return (x ^ rotl(x, 15) ^ rotl(x, 23)) & 0xFFFFFFFF


def expand(B):
W = list(struct.unpack('>16I', B))
for j in range(16, 68):
W.append(P1(W[j-16] ^ W[j-9] ^ rotl(W[j-3], 15)) ^ rotl(W[j-13], 7) ^ W[j-6])
W[-1] &= 0xFFFFFFFF
return W


def compress(reg, buf):
W = expand(buf)
A, B, C, D, E, F, G, H = reg

for j in range(64):
Tj = T(j)
rotlA12 = rotl(A, 12)
rotlTj = rotl(Tj, j % 32)
ss1_input = (rotlA12 + E + rotlTj) & 0xFFFFFFFF

# Modification 1: clear bits 24,25 of ss1_input
ss1_input = ss1_input & 0xFCFFFFFF

SS1 = rotl(ss1_input, 7)
SS2 = (SS1 ^ rotlA12) & 0xFFFFFFFF

ffVal = FF(j, A, B, C)
ggVal = GG(j, E, F, G)

# Modification 2: TT1 clear bits 0,2
TT1 = (ffVal + D + SS2 + (W[j] ^ W[j+4])) & 0xFFFFFFFA
# Modification 3: TT2 clear bits 20,22
TT2 = (ggVal + H + SS1 + W[j]) & 0xFFAFFFFF

D = C
C = rotl(B, 9)
B = A
A = TT1
H = G
G = rotl(F, 19)
F = E
E = P0(TT2)

reg[0] = (reg[0] ^ A) & 0xFFFFFFFF
reg[1] = (reg[1] ^ B) & 0xFFFFFFFF
reg[2] = (reg[2] ^ C) & 0xFFFFFFFF
reg[3] = (reg[3] ^ D) & 0xFFFFFFFF
reg[4] = (reg[4] ^ E) & 0xFFFFFFFF
reg[5] = (reg[5] ^ F) & 0xFFFFFFFF
reg[6] = (reg[6] ^ G) & 0xFFFFFFFF
reg[7] = (reg[7] ^ H) & 0xFFFFFFFF


def str_to_bytes(s):
"""Custom strToBytes: encode as UTF-8, then clear LSB of each byte (& 0xFE)"""
raw = s.encode('utf-8')
return bytes([b & 0xFE for b in raw])


def sm3_digest(message):
"""Compute modified SM3 hash of a string"""
data = str_to_bytes(message)

reg = IV[:]
# Padding
msg_len = len(data)
data += b'\x80'
# Pad to 56 mod 64
while len(data) % 64 != 56:
data += b'\x00'
# Append length in bits as 64-bit big-endian
data += struct.pack('>Q', msg_len * 8)

# Process blocks
for i in range(0, len(data), 64):
compress(reg, data[i:i+64])

return ''.join(f'{v:08x}' for v in reg)


# Test against browser-verified values
test_cases = {
"0": "6cb40d1e1fee0dbbe28c97d57816501d89ecbbc1f8e3a8c1df21892ec8f868c3",
"abc": "1fecc55c368568eb24f6d22e2a37246f06c348cca1b735049cb545c2326d36cb",
"test": "38fbffd4e19cc051d85ff36c016d00accf9a0536f9833c4c677ad8659f4ccffb",
"17741097605571": "adac3db429079e917b41a79b7dbdf50d6751f89082a8147f963e0f213bccab68",
}

print("Testing modified SM3:")
all_pass = True
for msg, expected in test_cases.items():
result = sm3_digest(msg)
ok = result == expected
if not ok:
all_pass = False
print(f" sm3Digest(\"{msg}\") = {result} {'✓' if ok else '✗ expected: ' + expected}")

if all_pass:
print("\nAll tests passed! Now fetching page 1 data...\n")

session = requests.Session()
session.headers.update({
'User-Agent': 'yuanrenxue.project',
'Referer': 'https://match.yuanrenxue.cn/match/21',
'Origin': 'https://match.yuanrenxue.cn',
})

# Need to get a session cookie first
session.get('https://match.yuanrenxue.cn/match/21')

# Get server timestamp
time_resp = session.get('https://match.yuanrenxue.cn/api/getTime')
timestamp = time_resp.text.strip()
print(f"Server timestamp: {timestamp}")

page = 1
token = sm3_digest(str(timestamp) + str(page))
print(f"Token for page {page}: {token}")

resp = session.post(
'https://match.yuanrenxue.cn/api/question/21',
data={
'page': page,
'pageSize': 10,
'kw': '',
'token': token,
},
headers={
'Accept-Time': str(timestamp),
}
)
print(f"Response status: {resp.status_code}")
print(f"Response: {resp.text}")
else:
print("\nSome tests failed, not attempting API request.")


猿人学第21题 — 魔改SM3逆向分析-Claude 分析
https://kingjem.github.io/2026/03/22/AI/猿人学第21题 — 魔改SM3逆向分析-Claude 分析/
作者
Ruhai
发布于
2026年3月22日
许可协议