猿人学第三届第一题 - 迷雾动图之真相

猿人学第三届第一题 - 迷雾动图之真相 纯算

1. 网络请求分析

首先抓包查看网络请求:

在这里插入图片描述


关键发现:请求中仅有一个参数 a 需要解密。

使用 XHR 断点,定位请求发送位置:

在这里插入图片描述


2. 调用栈分析

逐级跟踪调用栈,寻找加密逻辑:

在这里插入图片描述

堆栈分析:发现三个名为 f 的函数调用

2.1 第一个 f 函数

  • 变量监控中可以看到需要计算的密钥
  • 推测为核心加密入口

在这里插入图片描述

2.2 第二个 f 函数

  • 包含 dataacallsuccess 等变量
  • 推测为 AJAX 请求构建位置
  • 由于 VMP 高度混淆,无法直接查看详细逻辑

在这里插入图片描述

2.3 第三个 f 函数

  • 包含 encodedecode 等字节转换变量
  • 推测第二个 f 和第三个 f 之间为密钥加密位置

3. 密钥定位

继续向上跟踪调用栈,发现大量字节数组:

在这里插入图片描述

关键线索:只有 encode 等转换函数,缺少 TextEncoder 关键字。

全局搜索 TextEncoder 定位数组创建位置:

在这里插入图片描述

重要发现

  • 定位到 Vr 变量
  • 多次刷新 + 清理缓存后,Vr 值不变
  • 结论:Vr 为固定密钥,直接提取即可
  • Xr 同理

技术判断:这是一个 AES-CBC 加解密器的两个密钥(key 和 iv)。

根据变量关键字查找使用位置:

在这里插入图片描述

发现可疑函数调用,传入了 VrXrYr 三个参数:

  • 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:固定值 0000000000000000
  • o:固定字符串 咦~,可带劲~。
  • 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) 递归调用,tru 即为传入的三个数组。

继续跟进 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 核心技术点

  1. AES-CBC 加密:使用魔改的 aes-js 库(numberOfRounds 固定为 24)
  2. 密钥固定:Vr(key)和 Xr(iv)为固定值
  3. 动态数据:Yr 由随机数 + 时间戳 + 固定字符串组成
  4. PKCS7 填充:数据长度必须是 16 的倍数
  5. 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')

至此,第三届第一题完成。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值