|
|
发表于 2025-11-14 22:14:16
|
查看: 130 |
回复: 0
0x01 抓包分析
首先打开抓包工具,返回网页,点击登录按钮,触发登录请求
抓取到如下数据包,其中通过 POST 请求 /auth/sign-in 的数据包便是登录请求,
这个数据包的请求头非常多,其中,signature、nonce、time 便是服务器用来校验的。我们只需要在前端找 signature、nonce 这两个的生成算法即可
0x02 前端调试
回到浏览器,在登录页按下 F12 开启开发者模式,设置一个断点拦截登录
点击登录后,触发断点,停在了 index.js 上,
可以看到,上面就是负责构造请求的 JS 方法,其中 w 调用了 r(时间戳) o(请求路径) l(请求方法),那么很可能,这个 w 就是负责生成校验值的函数,给这一行打一个断点,重新触发一次
然后按 F11 跳转到 w 函数内部,来到了 bt
根据本地作用域得知,e 是时间戳,t 是请求路径,a 是请求方法,我们再看它的下下行:
调用 ft 方法传入三个参数,将返回结果赋予变量 o,既然我们知道了 t e a 是什么,那么这个 s + t 也不难理解,就是请求的基础地址和请求路径拼接成一个完整地址
那么这个 o 是什么呢,在下一行可以看到一个 n 定义了一个数组,储存了所有请求头,往下翻即可看到 :
- {
- name: "signature",
- value: o
- }];
复制代码 signature 这个键,值是变量 o ,根据 o = ft(s + t, e, a),即可得出 ft 即为计算 signature 的方法,给这一行打一个断点,按 F11 跳转 ft 函数内部
这里 ft 定义了 o n 两个常量,重点看 n ,这个常量调用了 mt 传入"b397e2wXZHtgb2RvUBh7bnB+bnt8bEEfZ2xSQUFtY0F4G3h4bWhzeA==" 将 mt 返回结果赋给自己。至于这个字符串,根据结尾的"=="可以看出这肯定(可能)是一个 base64 。那这个 mt 函数,无非就两种可能:
这里暂定 mt 函数负责解密,我们继续看 ft 函数的后半部分:
let r = e.replace(o, "") + t + gt() + s + n;
r = r.toLowerCase();
return function(e, t) {
const s = 64
, o = new TextEncoder;
let n = o.encode(t);
if (n.length > s && (n = new Uint8Array(a.arrayBuffer(n))),
n.length < s) {
const e = new Uint8Array(s);
e.set(n),
n = e
}
const r = new Uint8Array(s)
, i = new Uint8Array(s);
for (let a = 0; a < s; a++)
r[a] = 92 ^ n[a],
i[a] = 54 ^ n[a];
const l = new Uint8Array([...i, ...o.encode(e)])
, c = a.arrayBuffer(l)
, d = new Uint8Array([...r, ...new Uint8Array(c)])
, m = a.arrayBuffer(d);
return Array.from(new Uint8Array(m)).map(e => e.toString(16).padStart(2, "0")).join("")
}(r, ( () => {
try {
return mt("aGh+G0dwfHpGUGRmYGxrGUFsZmRyGUMZa19kfUxfRxMfXGAaGxNBbmBhZRpMQUFma20Bbn58YElIYGQTbGdsQkxrfEd8X3xueBocH1JQf2RpSG9B")
} catch (e) {
return ""
}
}
这里将 e(请求路径)、t(时间戳)、gt(Nonce值)、s(请求方法)n(解密base64)全部拼接并转换为小写后赋给 r ,然后定义了一个匿名函数,接收 r 和调用 mt 的返回结果,这里也传给 mt 一个长字符串,那么 mt 是一个负责解密的函数无疑了。该匿名函数首先将解密出的 KEY 填充、派生出内层和外层密钥,然后对数据进行两次 SHA256 哈希运算。最终,函数将计算出的哈希值转换为 十六进制字符串 并返回,得出最终的 signature 值,具体步骤和计算公式:
- 原始字符串r = 请求路径e + 时间戳t + Nonce值gt +请求方法s + 密钥n
复制代码- 内部密钥 = 密钥n XOR 54 (将密钥的每个字节,与 54 这个固定数值进行 XOR 运算,得出内部密钥)
复制代码- 内部哈希 = SHA256(内部密钥 XOR 原始字符串r)
复制代码- signature = SHA256(外部密钥 XOR 内部哈希)
复制代码 光知道算法不行,还不知道 mt 函数到底是什么,给 mt 函数打一个断点,F11 跳转过去
mt 函数接收 e 作为参数,e 就是我们看到的 base64 ,首先 mt 函数调用 atob() 对字符串进行 base64 解密,将解密结果和 "PicaWeb2025" 传给 dt 函数 ,然后将 dt 返回结果赋给 a ,最后将 a 的每个字符的 ASCII 码与固定值 42 进行 XOR 运算,运算结果赋给 s ,然后再调用 atob() 解密 s ,最后将解密出来的明文返回。
那么问题又来了,dt 又是负责什么的,继续断点 F11 跳转 (结果发现 dt 函数就在 mt 函数上面……)
根据图片可以看出来, dt 函数也负责加密,这里的 e 就是 mt 函数传入的 base64 解密结果 ,而这个 t 就是传入的 "PicaWeb2025" 。
这里 dt 定义了一个变量 s 和一个数组 a,然后遍历出 t 每一个字符,每遍历一个字符,就会将当前字符的 ASCII 值,累加到变量 s 中,最后得出种子值。得到种子后,开始利用种子进行伪随机生成,具体代码为:
- s = (9301 * s + 49297) % 233280;
复制代码 然后新的 s 值通过 线性同余法公式 持续生成新的 伪随机数 s,每次循环,利用新的 s 值计算出交换索引e ,最后执行洗牌算法 [a[o],a[e]] = [a[e], a[o]] ,交换数组 a 中第 o 个元素和第 e 个元素的位置。最后用数组 a 来还原 e 的字符顺序,最终返回明文。
至此,我们已经完整地掌握了请求头中 signature 字段的生成算法和全部依赖常量,仅剩下 Nonce 的生成算法没有找出来,下一步开始寻找 Nonce 生成算法
0x03 Nonce 算法
要找 Nonce 这个键,我们回到 mt 函数,来看它下面的代码:
- const ut = () => Math.floor(Date.now() / 1e3).toString()
- , ht = (e=32) => {
- const t = "ABCDEFGHJKMNPQRSTWXYZabcdefhijkmnprstwxyz2345678";
- let a = "";
- for (let s = 0; s < e; s++)
- a += t.charAt(Math.floor(48 * Math.random()));
- return a
- }
- , gt = () => {
- if ("undefined" != typeof window) {
- let e = localStorage.getItem("nonce");
- return e || (e = ht(32).toLowerCase(),
- localStorage.setItem("nonce", e)),
- e
- }
- return ht(32).toLowerCase()
- }
复制代码 其中 ut 负责生成时间戳,ht 负责生成 32 位随机字符串,gt 则是负责判断是否使用本地储存的 Nonce 值还是调用 ht 生成一个新的 Nonce 值。得出结论:Nonce 就是个随机字符串……
至此,我们已经通过逆向找到了请求头中 signature、nonce 的生成算法,写一个 Python 脚本测试一下
接口成功返回正常响应
0x04 结语
回顾整个 PicACG API 签名逆向过程,虽然代码经过了混淆,乍一看非常复杂,但其核心逻辑清晰、路径明确,是一个非常适合练手的项目。 相比于那些使用 WebSocket、动态VM 校验的反爬网站,这网站的加密手段算是其中最简单的了。
至此,本次 PicACG 签名逆向工作圆满结束。所有核心算法和密钥均已掌握。各位可以跟着我的步骤去尝试和复现。 ——逆向的终点,即为工程的起点。
|
|