猿人学第三届第一题 - 迷雾动图之真相 纯算
1. 网络请求分析
首先抓包查看网络请求:

关键发现:请求中仅有一个参数 a 需要解密。
使用 XHR 断点,定位请求发送位置:

2. 调用栈分析
逐级跟踪调用栈,寻找加密逻辑:

堆栈分析:发现三个名为 f 的函数调用
2.1 第一个 f 函数
- 变量监控中可以看到需要计算的密钥
- 推测为核心加密入口

2.2 第二个 f 函数
- 包含
data、a、call、success等变量 - 推测为 AJAX 请求构建位置
- 由于 VMP 高度混淆,无法直接查看详细逻辑

2.3 第三个 f 函数
- 包含
encode、decode等字节转换变量 - 推测第二个 f 和第三个 f 之间为密钥加密位置
3. 密钥定位
继续向上跟踪调用栈,发现大量字节数组:

关键线索:只有 encode 等转换函数,缺少 TextEncoder 关键字。
全局搜索 TextEncoder 定位数组创建位置:

重要发现:
- 定位到
Vr变量 - 多次刷新 + 清理缓存后,
Vr值不变 - 结论:
Vr为固定密钥,直接提取即可 Xr同理
技术判断:这是一个 AES-CBC 加解密器的两个密钥(key 和 iv)。
根据变量关键字查找使用位置:

发现可疑函数调用,传入了 Vr、Xr、Yr 三个参数:
Vr:AES 密钥(已获取)Xr:AES 初始化向量 IV(已获取)Yr:待加密数据(需要继续分析)
4. Yr 参数生成逻辑
定位 Yr 生成位置,在混淆代码中找到关键加密点:
18, 12, 6) : x < bn ? x += function(n, i, s, m, w, S, T, E) {
for (; ; ) {
(n == m && (n -= w) && (i = _n) || 1) && n == S && (n += T) && (i = ((O = ((K = (D = u) && null || e) || V) && o) || r) && ((M = ((F = (($ = _) || y) && c) || U) && l) || p) && ((B = ((z = ((N = A) || g) && h) || r) && d) || p) && ((q = (R = (H = a) && null || v) && null || function(n, f, i, t, r, u, e, o, s, c, l) {
return Yr = new TextEncoder()[n]((Math[//▼・ᴥ・▼ //关键加密点
f]()[i](t) + r)[u](e, b) + o + Date[s](c) + l)
}(D, K, O, $, F, M, N, z, B, H, R)) || f) && (G = t = q));
if (n == E)
return i
}
}
在 Yr 处打断点,控制台输出变量值:
console.log(D, K, O, $, F, M, N, z, B, H, R)
// 输出:
// encode random toString 36 0000000000000000 slice 2 咦~,可带劲~。 now r 俺不中嘞~
逻辑分析:
TextEncoder:字节数组编码器Math.random():随机数生成Date.now():时间戳r:固定值0000000000000000o:固定字符串咦~,可带劲~。l:固定字符串俺不中嘞~
拼接规则:
Yr = TextEncoder(Math.random().toString(36).slice(2, 18) + "0000000000000000" + "咦~,可带劲~。" + Date.now() + "俺不中嘞~")
其中随机数参数为固定值 2, 18。
4.1 Yr 生成函数实现
function getYr() {
// 生成16位随机字符串(36进制)
const rand = (Math.random().toString(36) + "0000000000000000").slice(2, 18);
// 组装完整字符串:随机数 + 固定字符串 + 时间戳 + 固定字符串
const Yrstr = rand + "咦~,可带劲~。" + Date.now() + "俺不中嘞~";
// 转为 Uint8Array 字节数组
const Yr = new TextEncoder().encode(Yrstr);
return { Yr, Yrstr };
}
输出示例:
{
Yr: Uint8Array(62) [
108, 112, 110, 99, 117, 119, 106, 98, 116, 105,
48, 48, 48, 48, 48, 48, 229, 146, 166, 126,
239, 188, 140, 229, 143, 175, 229, 184, 166, 229,
138, 178, 126, 227, 128, 130, 49, 55, 54, 53,
52, 50, 57, 49, 48, 54, 52, 52, 51, 228,
191, 186, 228, 184, 141, 228, 184, 173, 229, 152,
158, 126
],
Yrstr: 'lpncuwjbti000000咦~,可带劲~。1765429106443俺不中嘞~'
}
至此,三个参数全部获取:
Yr:加密前数据Vr:AES 密钥Xr:AES 初始化向量
5. AES 解密验证
使用原生 aes-js 库进行解密测试,验证是否有魔改:
const aesjs = require('aes-js');
// AES 密钥(Vr)
const VrUint8 = new Uint8Array([103, 69, 80, 115, 122, 78, 50, 84, 66, 67, 112, 97, 70, 112, 66, 87]);
// AES 初始化向量(Xr)
const XrUint8 = new Uint8Array([72, 111, 72, 106, 78, 106, 87, 79, 54, 97, 106, 75, 107, 70, 101, 113]);
// 加密后的数据(YUint8)
const YUint8 = new Uint8Array([
44, 14, 14, 137, 41, 247, 53, 103, 33, 226, 82,
205, 216, 162, 192, 141, 64, 229, 88, 9, 215, 192,
43, 84, 246, 202, 18, 99, 48, 161, 101, 45, 58,
109, 85, 157, 93, 227, 192, 61, 98, 223, 134, 67,
164, 60, 73, 110, 5, 184, 3, 119, 211, 231, 85,
141, 71, 212, 53, 120, 111, 3, 146, 248
]);
// 创建 AES-CBC 解密器
var aesCbc = new aesjs.ModeOfOperation.cbc(VrUint8, XrUint8);
// 解密
const decryptedBytes = aesCbc.decrypt(YUint8);
const text = new TextDecoder().decode(decryptedBytes);
console.log(text);
输出结果:
9��� >��l�ff��)���?�(�e��KlaQ|�����9�a��D裱M�K��u�c�
结论:输出为乱码,说明 AES 库被魔改了。
6. So 函数分析(AES 魔改定位)
进入 So 函数,查找可疑的返回值:
( (n, f, o, s, h, v, m, w, b) => k < n ? k < f ? F ? k += o : k += s : (O = ((K = ((D = i) || h) && C) || v) && D == K) && null || O ? k += m : k += s : k < s ? function(n, f, i, s, c, l, h, d) {
for (; ; ) {
n == s && (n += c) && (f = k += o) && //(^・ω・^)
0 || n == l && (n -= h) && (f = (M = function() {
return W = So(t, r, u) // 可疑点:递归调用
}()) && w || (F = e = M));
if (n == d)
return f
}
}
关键发现:W = So(t, r, u) 递归调用,t、r、u 即为传入的三个数组。
继续跟进 W 调用的 So 函数,查找返回值:
if (Zn < j)
( (n, f, i, r, o, s, c, l, d, m, A, g, S, T, E, j) => Zn < n ? Zn < f ? Zn < i ? ((af = tf) || f) && !af ? Zn += r : Zn += o : Zn += function(n, r, o, h, d, a, v, y) {
for (; ; ) {
n == h && (n -= d) && (r = l) && 0 || n == a && (n += v) && (r = ((pf = (yf = (vf = u) && null || Cn) && null || vf == yf) || s) && ((wf = pf && ((mf = On) || c) && (u += mf)) || f) && (_f = wf && ((bf = function() {
return xn = po(t, Pn) // 可疑点:po 函数调用
}()) || i) && (e = bf)));
if (n == y)
return r
}
}
在 xn = po(t, Pn) 处打断点,查看返回值:
[13][
{
"0": 103, "1": 69, "2": 80, "3": 115, "4": 122, "5": 78, "6": 50, "7": 84,
"8": 66, "9": 67, "10": 112, "11": 97, "12": 70, "13": 112, "14": 66, "15": 87
},
// ...此处省略
]
关键发现:
- 返回了一个长度为 13 的数组
- 第一个元素与传入的加密密钥一致
- 确定为 AES-CBC 加密方式
7. AES 源码魔改分析
使用 npm install aes-js 安装原生库,将 node_modules/aes-js/index.js 拷贝出来进行测试。

在源码函数出口处添加测试代码:
var aesCbc = new aesjs.ModeOfOperation.cbc(
[103, 69, 80, 115, 122, 78, 50, 84, 66, 67, 112, 97, 70, 112, 66, 87],
[72, 111, 72, 106, 78, 106, 87, 79, 54, 97, 106, 75, 107, 70, 101, 113]
);
console.log(aesCbc);
复制整份代码到浏览器运行,对比发现:
- 我们的
_Kd与_Ke长度为 11 - 目标网站的长度为 13
单步调试后锁定到 AES.prototype._prepare 函数:
AES.prototype._prepare = function() {
var rounds = numberOfRounds[this.key.length];
if (rounds == null) {
throw new Error('invalid key size (must be 16, 24 or 32 bytes)');
}
// ...省略

查看 numberOfRounds 定义:
var numberOfRounds = {16: 10, 24: 12, 32: 14}
// 16字节密钥:10 + 1 = 11次循环
// 24字节密钥:12 + 1 = 13次循环
// 32字节密钥:14 + 1 = 15次循环
结论:需要使用 24 字节密钥才能达到 13 轮循环。
7.1 修改 AES 源码
AES.prototype._prepare = function() {
var rounds = numberOfRounds[24]; // 固定为 24 字节
if (rounds == null) {
throw new Error('invalid key size (must be 16, 24 or 32 bytes)');
}
// ...省略
再次运行测试:

成功:数组长度变为 13。
7.2 解密验证
const YUint8 = new Uint8Array([
44, 14, 14, 137, 41, 247, 53, 103, 33, 226, 82,
205, 216, 162, 192, 141, 64, 229, 88, 9, 215, 192,
43, 84, 246, 202, 18, 99, 48, 161, 101, 45, 58,
109, 85, 157, 93, 227, 192, 61, 98, 223, 134, 67,
164, 60, 73, 110, 5, 184, 3, 119, 211, 231, 85,
141, 71, 212, 53, 120, 111, 3, 146, 248
]);
var aesCbc = new aesjs.ModeOfOperation.cbc(
[103, 69, 80, 115, 122, 78, 50, 84, 66, 67, 112, 97, 70, 112, 66, 87],
[72, 111, 72, 106, 78, 106, 87, 79, 54, 97, 106, 75, 107, 70, 101, 113]
);
const decryptedBytes = aesCbc.decrypt(YUint8);
const text = new TextDecoder().decode(decryptedBytes);
console.log(text);
输出结果:
cw0yecl4h0r00000咦~,可带劲~。1765430204728俺不中嘞~
成功:解密正常,AES 代码修改完成。
8. 加密实现
8.1 PKCS7 填充分析
加密需要 16 的倍数(64 字节),而 Yr 生成的字节数为 62。
解密查看数组后两个字节:
Uint8Array(64) [
99, 119, 48, 121, 101, 99, 108, 52, 104, 48, 114,
48, 48, 48, 48, 48, 229, 146, 166, 126, 239, 188,
140, 229, 143, 175, 229, 184, 166, 229, 138, 178, 126,
227, 128, 130, 49, 55, 54, 53, 52, 51, 48, 50,
48, 52, 55, 50, 56, 228, 191, 186, 228, 184, 141,
228, 184, 173, 229, 152, 158, 126, 2, 2 // 后两位为 0x02
]
结论:后两个字节为固定补值 0x02(PKCS7 填充)。
8.2 完整加密代码
const aesjs = require('aes-js');
const request = require('request');
/**
* PKCS7 填充函数
* AES-CBC 要求数据长度必须是 16 的倍数
*
* @param {Uint8Array} data - 原始数据
* @returns {Uint8Array} - 填充后的数据
*/
function padToBlock(data) {
// 计算需要填充的字节数(1-16)
const padLen = 16 - (data.length % 16);
// 创建新数组,长度为原数据 + 填充长度
const out = new Uint8Array(data.length + padLen);
// 复制原数据
out.set(data, 0);
// 填充:每个填充字节的值等于填充长度
// 例如:填充 2 个字节,则填充 [0x02, 0x02]
out.fill(padLen, data.length);
return out;
}
/**
* 生成加密 token
*
* 流程:
* 1. 生成随机字符串 + 固定字符串 + 时间戳
* 2. 转为字节数组并进行 PKCS7 填充
* 3. AES-CBC 加密
* 4. 字节数组转字符串
* 5. Base64 编码
*
* @returns {string} - Base64 编码的加密 token
*/
function getToken() {
// 生成 16 位随机字符串(36 进制)
// Math.random() 生成 0-1 随机数
// toString(36) 转为 36 进制字符串(0-9a-z)
// 补充 "0000000000000000" 确保长度足够
// slice(2, 18) 截取 16 位(跳过 "0." 前缀)
const rand = (Math.random().toString(36) + "0000000000000000").slice(2, 18);
// 组装完整字符串
// 格式:随机数(16位) + 固定字符串 + 时间戳(13位) + 固定字符串
const Yrstr = rand + "咦~,可带劲~。" + Date.now() + "俺不中嘞~";
// 转为字节数组并填充到 64 字节(16 的倍数)
const Yr = padToBlock(new TextEncoder().encode(Yrstr));
// AES-CBC 加密
const data = aesCbc.encrypt(Yr);
// 字节数组转字符串(latin1 编码保留原始字节值)
const key = String.fromCharCode(...data);
// Base64 编码
const token = Buffer.from(key, 'latin1').toString('base64');
console.log("生成密钥:", token);
return token;
}
/**
* 创建 AES-CBC 加解密器
*
* 参数1:密钥(Vr)- 16 字节
* 参数2:初始化向量(Xr)- 16 字节
*
* 注意:此处使用魔改后的 aes-js 库(numberOfRounds 固定为 24)
*/
var aesCbc = new aesjs.ModeOfOperation.cbc(
[103, 69, 80, 115, 122, 78, 50, 84, 66, 67, 112, 97, 70, 112, 66, 87], // Vr: gEPszN2TBCpaFpBW
[72, 111, 72, 106, 78, 106, 87, 79, 54, 97, 106, 75, 107, 70, 101, 113] // Xr: HoHjNjWO6ajKkFeq
);
/**
* 发送请求验证
*/
const options = {
method: 'POST',
url: 'https://match2025.yuanrenxue.cn/match2025/topic/1_captcha_jpg',
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
'Accept': 'application/json, text/javascript, */*; q=0.01',
'Accept-Encoding': 'gzip, deflate, br, zstd',
'Content-Type': 'application/x-www-form-urlencoded',
'pragma': 'no-cache',
'cache-control': 'no-cache',
'sec-ch-ua-platform': '"Windows"',
'x-requested-with': 'XMLHttpRequest',
'sec-ch-ua': '"Google Chrome";v="143", "Chromium";v="143", "Not A(Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'origin': 'https://match2025.yuanrenxue.cn',
'sec-fetch-site': 'same-origin',
'sec-fetch-mode': 'cors',
'sec-fetch-dest': 'empty',
'referer': 'https://match2025.yuanrenxue.cn/match2025/topic/1',
'accept-language': 'zh-CN,zh;q=0.9',
'priority': 'u=1, i',
'Cookie': 'test' // 替换为真实 Cookie
},
form: {
'a': getToken() // 加密参数
}
};
request(options, function (error, response, body) {
if (error) throw new Error(error);
if (!error && response.statusCode == 200) {
console.log("测试通过 😀😀😀😀");
} else {
console.log("测试失败 🤡🤡🤡🤡");
}
// console.log(body);
});
8.3 关键技术点说明
数组转字符串 + Base64 编码:
// 字节数组 -> 字符串(latin1 编码)
const key = String.fromCharCode(...data);
// 字符串 -> Base64
const token = Buffer.from(key, 'latin1').toString('base64');
为什么使用 latin1 编码?
- latin1(ISO-8859-1)是单字节编码,每个字节值(0-255)对应一个字符
- 保证字节数组转字符串时不会丢失数据
- 如果使用 UTF-8,某些字节值会被解释为多字节字符,导致数据损坏
此处没有查找代码,而是对最后密钥 Base64 解密后反推而出。
9. 总结
9.1 核心技术点
- AES-CBC 加密:使用魔改的 aes-js 库(numberOfRounds 固定为 24)
- 密钥固定:Vr(key)和 Xr(iv)为固定值
- 动态数据:Yr 由随机数 + 时间戳 + 固定字符串组成
- PKCS7 填充:数据长度必须是 16 的倍数
- Base64 编码:最终输出为 Base64 字符串
9.2 破解流程
1. 抓包分析 → 发现加密参数 a
2. XHR 断点 → 定位调用栈
3. 堆栈分析 → 找到三个 f 函数
4. 全局搜索 → 定位 Vr、Xr 固定密钥
5. 断点调试 → 分析 Yr 生成逻辑
6. 原生库测试 → 发现 AES 魔改
7. 源码分析 → 修改 numberOfRounds
8. 解密验证 → 确认修改正确
9. 加密实现 → 完成完整流程
9.3 关键代码
- Yr 生成:
rand(16位) + "咦~,可带劲~。" + timestamp + "俺不中嘞~" - AES 魔改:
numberOfRounds[24]固定为 24 字节 - PKCS7 填充:
padLen = 16 - (data.length % 16) - Base64 编码:
Buffer.from(key, 'latin1').toString('base64')
至此,第三届第一题完成。

被折叠的 条评论
为什么被折叠?



