记一次分析某漫接口密钥

前言

记一次分析某漫接口密钥。

对于一个已经年过 25 岁,连女孩子手都没摸过,还经常看纸片人,玩黄油的人来说,某漫真的是一个非常亲民的软件。

虽然它的内容很好用,但不得不说 APP 和网页做的不尽人意,网页虽然丑了点,但还好,APP 的话各种卡死,闪退。当然,对于一个一分钱没支持的人来说,对 APP 和网页更多地是吐槽性质。

在网页端,它的数据是服务器直接在 html 中渲染好再返回的,也就是意味着无法自己写一个站点或者软件来替代。

但是, github 上依然有一些第三方的客户端,能够获取到它的接口。比如:

那么他们是怎么获取数据的呢,答案是通过分析 APP 请求来得到相关的接口,官方的 APP 应该是基于 RN 的,并且通过 http 来获取数据。

当然,官方也不是说就那么容易让大家能够拿到数据,所以它在接口上做了一层加密。本文就是从 0 到 1 来还原官方加密的接口。

正文

准备

首先,我们当然需要一个最新的 APK 安装包。本次测试使用的是 1.7.2 版本。

其次,我们需要 node 环境, java 环境,安卓环境,以及解包 apk 的一个工具 apk-tool

当然,本文不会在解包打包这一过程上讲的很深入,毕竟我只是个切图仔,这些解包的教程都是来源于网络查找。

解包

确定环境变量已经配置了 JDK 。然后我们执行:

1
java -jar .\apktool_2.9.3.jar d .\1.7.2.apk

这一步是把 apk 解压出来:

完成之后我们就得到了一个文件夹,它的内容如下:

接着,我们找到 assets/index.android.bundle 文件,这个文件就是 RN 相关的代码。

bundle 文件

用 VSCode 打开上面解压的文件,可以看到是我们最喜欢的 js 代码。

看到这样的代码,作为一个前端,马上进行格式化,不然看地太头疼了。

那么我们应该如何去分析呢,首先,直接 node 启动它。

发现报错了,看来是少了一个 __fbBatchedBridgeConfig 对象,我们直接 ctrl + f 搜索下,发现只有两处,而且一处是报错信息,很好,我们直接加断点:

然后我们查看右侧调用堆栈,直接拉到最末尾,定位到最开始的执行函数:

发现是一个 __r 的函数,传入了一个数字。

接下来我们需要搞懂 __r 是个什么东西,我们直接在 __r 前面加断点,然后启动:

接着我们单步进入,发现它转到了 34 行的一个名为 o 的函数。

这里的 t 就是我们的 89 ,接着我们看 e 是个什么值。

如果你是前端的话我觉得你应该会很熟悉这些名字,这其实就是模块加载器的那一套。

这里的 i && i.isInitialized ? i.publicModule.exports : d(t, i) 很好理解,就是当模块 i 存在且已被初始化过时返回先前的值,不然就执行一次模块初始化 d(t, i)

我们可以单步进入到 d 函数中,发现它的核心代码是执行 v(e, t) ,从逻辑上看是配置了错误处理的函数情况下执行特定的错误处理逻辑而已,如下:

接着我们单步进入 v 函数,这个 v 函数很长,但我们只需要将重点放在下面画框的部分:

这里的把模块的 factory 赋值给了 g 然后在 try-catch 中执行,我们单步进入 g

这时我们就会发现,这个函数被包裹在了 __d 函数内,如果你是前端,那么此时你应该就能很快明白,__d 就是用来定义模块的,而 __r 用来执行模块。

接着我们搜索 __d 随便在一处位置加断点:

可以看到它是 11 行的函数,我们单步进入:

这个函数的三个参数分别是:

  • r :工厂函数,包含模块初始化的内容。
  • i :模块的 id 。
  • n :模块依赖的其他模块的 id 。

至此,我们大体上理清了代码的组织方式, __d 定义模块, __r 启动模块,全局的入口是通过底部的 __r(89)__r(0) 启动的。

那么分析这个过程有什么用呢,我们都知道,在编写现代的前端项目的时候,一般我们会进行代码逻辑分层,一般我们会单独把接口写在一个文件(模块)内,这样子有利于后续的维护,而且我们也能将无关的模块排除掉,保留只跟接口相关的模块,方便我们后续的分析。

OK ,那么接下来我们应该如何分析呢,想到 js 的 http 请求,我们肯定会想到两个对象, XMLHttpRequestFetch 。这两个可以说是 js 请求的核心。

我们搜索 XMLHttpRequest ,发现只有 6 个结果,并且 5 个结果都是字符串,只有一个是原始的对象调用,如下:

往上滚动,可以发现这个模块的代码就是封装了 XMLHttpRequest 对象,在 12242 行,为模块的起始位置:

结束位置为 12746 ,此时我们可以发现这个模块的 id 为 108 ,且这个模块不依赖其他任何模块,因为它的第三个参数是空数组,如下:

接着我们来执行一下这个模块,看它返回了什么,我们把原来的两行执行模块的代码注释掉:

node 执行后结果如下:

可以发现它就是将 XMLHttpRequest 封装成 fetch 了,并且从 fetch 的的 polyfill: true 可以看出这是一个代码垫片,它对应的函数就是我们前面提到的 O

接下来我们应该要做的就是分析哪些模块依赖了这个模块,我们先把整个依赖放到全局环境中,在 29 行,把 e 放到全局环境:

然后我们在将末尾的 __r 注释掉,换成如下的代码:

接着执行 node :

可以看到只有 107 模块依赖了它,我们在 108 模块的位置往上查找 __d ,发现 107 模块代码非常简单,如下:

可以说就是把 108 重新导出了一遍而已,这样的代码看起来对我们的分析帮助不大。

我们把查找的模块从 108 切换到 107 ,执行 node :

发现有两个模块依赖了它,分别是 102 和 130 ,我们用同样的方法定位到这两个模块:

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
__d(
function (g, r, i, a, m, e, d) {
"use strict";
var t,
n = r(d[0]),
s = n(r(d[1])),
u = n(r(d[2])),
l = n(r(d[3])),
o = r(d[4]);
m.exports = function (n) {
var c, f, p, h, w, b;
return s.default.async(
function (v) {
for (;;)
switch ((v.prev = v.next)) {
case 0:
if (
(t || (t = g.fetch || r(d[5]).fetch),
(c = o()).bundleLoadedFromServer)
) {
v.next = 4;
break;
}
throw new Error("Bundle was not loaded from the packager");
case 4:
return (
(f = n),
(p = l.default.getConstants()),
(h = p.scriptURL) &&
((w = !1),
(f = n.map(function (t) {
return null == t.file
? t
: w ||
((n = t.file), /^http/.test(n) || !/[\\/]/.test(n))
? ((w = !0), t)
: (0, u.default)({}, t, { file: h });
var n;
}))),
(v.next = 9),
s.default.awrap(
t(c.url + "symbolicate", {
method: "POST",
body: JSON.stringify({ stack: f }),
})
)
);
case 9:
return (b = v.sent), (v.next = 12), s.default.awrap(b.json());
case 12:
return v.abrupt("return", v.sent);
case 13:
case "end":
return v.stop();
}
},
null,
null,
null,
Promise
);
};
},
102,
[8, 103, 12, 105, 106, 107]
);

如果你使用过 babel ,那么你可能会比较熟悉,这段代码看起来就是 babel 编译 js 生成器后的样子。

而 130 模块为如下:

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
__d(
function (g, r, i, a, m, e, d) {
"use strict";
var n = r(d[0]).polyfillGlobal;
n("XMLHttpRequest", function () {
return r(d[1]);
}),
n("FormData", function () {
return r(d[2]);
}),
n("fetch", function () {
return r(d[3]).fetch;
}),
n("Headers", function () {
return r(d[3]).Headers;
}),
n("Request", function () {
return r(d[3]).Request;
}),
n("Response", function () {
return r(d[3]).Response;
}),
n("WebSocket", function () {
return r(d[4]);
}),
n("Blob", function () {
return r(d[5]);
}),
n("File", function () {
return r(d[6]);
}),
n("FileReader", function () {
return r(d[7]);
}),
n("URL", function () {
return r(d[8]).URL;
}),
n("URLSearchParams", function () {
return r(d[8]).URLSearchParams;
}),
n("AbortController", function () {
return r(d[9]).AbortController;
}),
n("AbortSignal", function () {
return r(d[9]).AbortSignal;
});
},
130,
[117, 131, 146, 107, 147, 134, 150, 151, 153, 154]
);

这里的 n 就是给全局打上对应的 polyfill ,比如:

1
2
3
n("fetch", function () {
return r(d[3]).fetch;
})

比如上面着个代码会把 fetch 的 polyfill 版本挂到全局,在 node 中就是 global.fetch = r(d[3]).fetch

如果我们接着按上面分析的话,其实就很难分析出个什么内容了,这时候我们就要换另一种方式了,也就是抓包。

抓包

这里我们使用后 mumu 模拟器和 Reqable 软件来抓包。

mumu 模拟器要开启 root 权限和系统盘读写,因为我们要放证书到根目录,这样才能抓到 https 包:

接着我们安装 Reqable ,打开菜单点击证书管理:

按提示进行证书安装,也就是把 Reqable 生成的证书放到 /system/etc/security/cacerts 中。

如果正确操作,那么 Reqable 的页面会有如下提示:

接着我们点击右下角的按钮,开启 VPN :

然后添加相关的 APP ,这样 Reqable 就只会抓这个 APP 的了。

接着启动 APP ,稍等一会儿,可以看到抓了很多的包:

当然,有很多的请求都是静态资源和图片的,我们不管,我们筛选下请求,把 Https 和 2xx 勾上:

经过简单的分析之后,我们就能确定相关的 API 为 https://www.jmeadpoolcdn.life

接着我们找到 /setting 这个接口,这个接口是一个 get 请求,我们可以看到它的响应为加密后的数据:

这时我们再看它的请求头,可以发现两个有意思的头,分别是 tokentokenparam

如果我们直接访问这个接口的话,是不会返回这一串加密后的数据的:

如果我们同样加上这两个头,那么请求就能正常返回:

那么抓包部分已经完成,接下来我们分析 tokentokenparam

token 和 tokenparam

我们打开原来的文件,搜索 token ,发现有 300 多个结果,很难受:

我们换成 tokenparam 再搜索一下,发现之后 7 个结果,非常地 nice :

而且上图定位到的第一项,看起来不就是我们接口的参数吗,看起来胜利的曙光就在眼前了!

可是一看到周围的代码,不禁一阵哆嗦,这看起来就像是混效过的代码,这也太难受了,但是别急,可以发现,这里的 tokentokenparam 对应的 bv 看起来很正常,我们定位过去:

我们发现这两个东西都是经过一段混淆过后的代码生成的,而且从逻辑上可以看出,tokentokenparam 都依赖了一个 P ,而这个 P 调用了 Math 的一个方法,敏锐的搬砖工其实应该明白了,这里的 P 其实就是一个时间戳吗,而 -0x1b05 + 0x17 * -0xdb + 0x329a 就是数字 1000 ,你可以直接把 -0x1b05 + 0x17 * -0xdb + 0x329a 敲到 node 中回车就能看到:

当然,后面我们会以一种更加明确的方式来确定这段逻辑。

回到上图, tokenparam 拼接的后半段还有一段 s[_0x9aade5(0x245)] 这个我们从接口得知了,就是 APP 的版本,也就是 1.7.2 ,所以 tokenparam 我们完全明白,接下来就是搞定 token 的生成逻辑即可。

从上图我们可以得知,要弄清楚 b ,我们要知道两个东西,一个是 c[_0x9aade5(0x2d5)] 这个东西是个什么,另一个就是 f 函数是什么逻辑。

首先看到 f 函数:

在前面可以看到它执行了某个模块,这个模块的索引为 -0x40b * -0x2 + -0x3 * 0xa86 + 0x223 * 0xb ,放到 node 看一下,值为 5 。

这样就能知道这个模块为 651 ,我们找到 651 ,如下:

说实话很难分析这段代码,整个代码都是编译后的,这里我们就先不去看,我们回到前面。

除了 f 之外,另一个需要搞清楚的是 s[_0x9aade5(0x245)] ,这个决定了传给 f 的是什么值。

但是这里我们又很不好判断 _0x9aade5 是个什么东西,该怎么办呢?

想一想我们前面使用过的方法,没错,就是 debugger ,我们直接执行这个 648 的模块:

可以发现它返回了两个方法, fetchGetfetchPost 。而且重要的是它没有报错,这也就意味着,它的初始化逻辑不会有平台相关的代码(比如 react-native 的)。

接着我们在 tokentokenparam 处打上断点:

发现这段逻辑是初始化就需要执行到的,古德古德,那一切就很明白了,无论代码怎样的混淆,它终归是 js 代码。

我们把断点打到变量 v 前,然后在左侧的监视中填写 P 所需的变量:

那么这个 P 其实就是如下的代码:

1
P = Math.floor(k.getTime() / 1000)

没错,和我们推想的一样,这是一个时间戳。

接着我们把代码打到 w 前,此时 vb 都已经计算出来了,我们分别监视需要的变量:

从图中就能看出了 s[_0x9aade5(0x245)] 正是版本信息。而 c[_0x9aade5(0x2d5)] 指向了 c 模块的 token ,它的值为 185Hcomic3PAPP7R

ok,现在我们基本解决了各个变量的含义,最后需要知道的就是 f 函数究竟干了什么,前面我们提到这个函数不是很好理解,这里我的方法很原始,就是猜:

这里可以看到 b 的值非常像 md5 之后的值,所以我们直接试一下 md5 ,需要 md5 的值为 String(P) + c[_0x9aade5(0x2d5)] ,它的值为 1725863585185Hcomic3PAPP7R ,然后随便找一个在线 md5 的工具:

可以看到结果完全符合我们的猜想,正是 md5 之后的值。

至此,我们完成了对接口参数的分析,这时我们可以写一个简单的代码来还原发送请求的流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { createHash } from "node:crypto";

const ts = Math.floor(Date.now() / 1000);
const version = "1.7.2";
const token = "185Hcomic3PAPP7R";

fetch("https://www.jmeadpoolcdn.life/setting", {
headers: {
tokenparam: `${ts},${version}`,
token: createHash("md5")
.update(`${ts}${token}`)
.digest("hex")
.toLowerCase(),
},
})
.then((res) => res.json())
.then((res) => {
console.log(res);
});

node 执行:

可以看到确实返回了相关的加密数据。

解密数据

OK ,到现在,我们分析出了如何正确地发送请求,但是返回的数据还是加密的,这就是我们接下来要搞的内容。

前面我们通过 debugger 来分析了一些逻辑,但是核心的 fetchGetfetchPost 我们无法 debugger ,因为它们两个是函数。

首先我们以 fetchGet 来分析,这个函数有四个参数:

1
2
3
function fetchGet(_0xfefd55, _0x1d11e4, _0x3242a9, _0x1c5f83) {
// ...
}

看到这个函数,如果熟悉 node 的话,很容易就能理解到这里面有两个参数很有可是回调函数,分别代表 successfailure 回调。

那么我们要如何确定呢,最好的方法就是我们能在运行中打印出这四个变量,但是由于我们并不熟悉安卓方面的知识,所以很难搞。

回想我们之前的方法,也就是抓包,没错,我们可以把这四个变量以请求的方式发送出来,这样就能被 Reqable 抓到,从而可以分析代码。

这里我们就要先知道如何将修改后的文件重新打包成 APP 。由于我们不是安卓糕手,这里我们参考了这篇文章:APP 加固添加签名后无法安装

这里我贴出解包和打包的脚本:

1
2
3
4
5
6
7
8
9
10
11
12
# 解包
java -jar .\apktool_2.9.3.jar d .\1.7.2.apk

# 生成自签名,密码为 123456 ,只需执行一次
keytool -genkey -alias jm.keystore -keyalg RSA -validity 20000 -keystore jm.keystore

# 打包
java -jar .\apktool_2.9.3.jar b 1.7.2 -o 1.7.2-rebuild.apk
# 对齐
D:\Android\Sdk\build-tools\34.0.0\zipalign.exe -v -p 4 1.7.2-rebuild.apk 1.7.2-rebuild-zipalign.apk
# 重新签名
D:\Android\Sdk\build-tools\34.0.0\apksigner.bat sign --ks jm.keystore --ks-key-alias jm.keystore --ks-pass pass:123456 --v2-signing-enabled true -v --out 1.7.2-rebuild-zipalign-sign.apk 1.7.2-rebuild-zipalign.apk

最后我们会生成一个 1.7.2-rebuild-zipalign-sign.apk 文件,安装它就好了。

OK ,回到代码,为了简单进行数据发送,我们需要在代码最前面编写一个工具函数,如下:

1
2
3
4
5
6
7
8
var gTestPostFetch = function(query) {
fetch("https://echo.apifox.com/post", {
method: "post",
body: JSON.stringify(query),
}).catch(e => {
fetch("https://echo.apifox.com/get?error=" + e);
});
}

这里我们使用了 apifox 的一个域名,接口不会 404 ,放到最前面,看起来就是下面的样子:

接着定位到 fetchGet 的位置,添加上一段代码:

然后打包,在 Reqable 找到 apifox 的请求,查看请求体:

可以发现前两个参数显示出来了,但是第三第四个参数没有了,这是因为 JSON.stringify 会忽略函数字段。这时我们就要改变一下我们的写法:

1
2
3
4
// 原来的写法:
gTestPostFetch({_0xfefd55:_0xfefd55,_0x1d11e4:_0x1d11e4,_0x3242a9:_0x3242a9,_0x1c5f83:_0x1c5f83});
// 改进后的写法
gTestPostFetch({_0xfefd55:_0xfefd55,_0x1d11e4:_0x1d11e4,_0x3242a9:`${_0x3242a9}`,_0x1c5f83:`${_0x1c5f83}`});

为什么要这么写呢,这是因为 FunctiontoString 会返回其源码:

我们也可以在 node 中简单测试,如下:

改换写法后,我们再次打包测试,结果如下:

接下来,我们就需要定位输出的这个函数了,这里我们只分析第三个参数,即 _0x3242a9 ,从请求中可以看出它指向了 function (_0x804d2e) ,搜索后只有一个结果:

接着我们照葫芦画瓢,我们需要分析这个入参,因为对于一个回调函数,入参肯定就是请求成功的数据:

这时我们发现这个数据已经是解密过后的了,而且我们能确定 n[_0x1ce558(0x15c)][_0x1ce558(0x158)] 这个函数就是 fetchGet ,因为我们是从 fetchGet 出来的。

这时我们就要回到 fetchGet 的逻辑中了,因为 fetchGet 内部肯定会存在某个调用,将解密后的数据传入回调中。

接着我们搜索 _0x3242a9 ,发现只有 4 个结果,很好:

这里面 1 和 3 我们可以基本确定就是重新调用了 fetchGet ,所以 2 的嫌疑就是最大的了。

继续照葫芦画瓢,我们需要知道 _0x544b9d[_0x21fa50(0x277)]_0x5b2124 是什么:

这里可以发现 _0x5b2124 已经是解密后的数据了,这里我们放一边,我们先分析 _0x544b9d[_0x21fa50(0x277)] ,从上图可以看出 _0x544b9d[_0x21fa50(0x277)] 指向了 function (_0x2e3af4,_0xa879c0)

可以看到这个函数只是做了一个透传,传给了 _0x3d0e6e[_0x11a477(0x299)] 这个函数,接着打日志:

发现它指向了 function (_0x217cb4,_0x5e84b3)

可以看到这个函数就是把第一个参数当作函数,然后第二个参数当作第一个函数的参数传进去,所以

1
2
3
4
_0x544b9d[_0x21fa50(0x277)](
_0x3242a9,
_0x5b2124
);

其实就是:

1
_0x3242a9(_0x5b2124);

而我们已经知道了 _0x5b2124 就是解密后的数据,所以接下来我们只要知道 _0x5b2124 是如何计算出来的即可。

ctrl + f 搜索,发现只有几个结果,其中嫌疑最大的是下面这一段:

为什么说它嫌疑大呢,首先这里将 _0x5b2124[_0x21fa50(0x2e3)] 这个值传入了一个未知的函数,我们都知道, _0x5b2124 是解密后的数据,而且 code 字段是不加密的,那么很有可能就是 _0x21fa50(0x2e3) 值为 'data' ,而且此时的 0x5b2124['data'] 是加密的,经过了这个函数后生成了解密数据。其次,这里出现了一个 mode 属性,如果经常需要写加密解密逻辑的搬砖工一看,这很明显就是 AES 相关的代码。

所以这里我们需要分析四个变量,分别是 _0x5b2124[_0x21fa50(0x2e3)]_0x365b18_0x21fa50(0x22e)_0x21fa50(0x2ad)

从上图可以看出,确实符合我们的猜想,就是一个 AES 的解密,这里我们已经确定第一个参数就是加密的数据了,现在我们要分析第二个参数,也就是解密的密钥 ,这里的 _0x365b18 参数看起来就是经过 Cryptojs.enc.Utf8.parse 处理后的数据。

我们稍微往上滚动,就可以发现相关的赋值语句:

这里刚好三个方括号正好对应我们提到的 encUtf8parse ,我们可以输出验证一下:

完全一致!

那么最后需要知道的就是 _0x3b32c4 这个变量了,是由它生成了密钥 。我们往上滚动,就能看到相应的赋值语句:

这里我们可以发现一些之前分析过的变量,比如这个 f ,它就是 md5 函数,而 _0x544b9d[_0x21fa50(0x277)] ,其实就是将第二个参数传给第一个函数执行,也就是:

1
2
3
4
_0x544b9d[_0x21fa50(0x277)](
f,
_0x544b9d[_0x21fa50(0x201)](P, _0x33c5f8)
);

其实就是

1
f(_0x544b9d[_0x21fa50(0x201)](P, _0x33c5f8));

P 我们之前也分析过了,它是一个时间戳,那么最后我们需要知道的就是两个东西了,一个是 _0x33c5f8 ,另一个就是 _0x544b9d[_0x21fa50(0x201)] 这个函数了。

我们输出一下:

可以发现 _0x33c5f8 就是我们之前请求头的那个变量 ,而 _0x544b9d[_0x21fa50(0x201)] 指向了 function (_0x494616,_0x4ba2cf) ,搜索一下:

可以发现这个函数也是透传,指向了 _0x3d0e6e[_0x1059b9(0x2db)] ,我们再输出:

可以看到指向了 function (_0x5b0ae2,_0x36ea91) ,搜索一下:

原来就是两个变量相加…

所以,我们前面已经分析的这个函数:

1
f(_0x544b9d[_0x21fa50(0x201)](P, _0x33c5f8));

其实就是:

1
f(P + _0x33c5f8);

而这个函数(解密的密钥 )的值,其实就是前面提到的请求头的 token

至此,我们基本完成了对响应体解密流程的分析。核心就是通过发送的请求头 token 来生成密钥 ,接着用这个密钥进行 aes 解密即可。

我们可以用代码还原整个流程了:

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
import { createHash, createDecipheriv } from "node:crypto";

const ts = Math.floor(Date.now() / 1000);
const version = "1.7.2";
const token = "185Hcomic3PAPP7R";
const tokenHash = createHash("md5")
.update(`${ts}${token}`)
.digest("hex")
.toLowerCase();

const algorithm = "aes-256-ecb"; // 使用 AES-ECB 模式
const key = Buffer.from(tokenHash);

fetch("https://www.jmeadpoolcdn.life/setting", {
headers: {
tokenparam: `${ts},${version}`,
token: tokenHash,
},
})
.then((res) => res.json())
.then((res) => {
console.log("解密前", res);
const decipher = createDecipheriv(algorithm, key, null);
let decrypted = decipher.update(res.data, "base64", "utf8");
decrypted += decipher.final("utf8");
return {
...res,
data: decrypted,
};
})
.then((res) => {
console.log("解密后:", res);
});

执行之后:

后记

本文仅用于教育、学习和研究目的,旨在帮助开发者和用户理解应用程序的工作原理。本文作者与原始应用程序的开发者、公司或组织无关。所有涉及的代码或技术分析均为个人研究成果,并未用于商业用途或恶意活动。请勿将本文用于任何违反法律或侵犯原开发者权利的活动。本文作者不对他人使用本文内容产生的任何法律或财务后果承担责任。

本来这篇帖子应该在一年前完成的,当时没什么时间就太监了,趁着最近比较闲所以花了点时间给补上了。

写这样的帖子真的挺花费精力…

话说分析这个东西会不会被查水表…