记一次分析某ji阅读会员逻辑

前言

记一次分析某ji阅读会员逻辑。

这篇文章按道理应该是去年年中就发的,我实在是太懒了。果灭纳塞🙇。

正文

这次分析的是某ji阅读,分析它的主要原因是当时想学日语,想着搞个 app 来学习。

事实证明完全没用,后面我就没打开过了😂。

ok,这里我们需要使用的工具有:

  • 某ji的 3.4.9 版本 apk 一份,这个可以在豌豆荚下载到。
  • jadx-gui 软件,这个可以点击这里下载。

我们直接把 apk 拖到 jadx 中:

可以发现有 classes.dex 文件,看起来应该是一个标准的安卓应用。

接着我们点开应用,在底部点击我的 tab ,可以看到有一个会员文本:

点击开通后会跳到开通页面:

回到 jadx ,我们先搜索一下“低至”这个文本:

可以看到在资源文件 strings.xml 中有这个文本,在 strings.xml 中定义的文本,在代码中一般是使用 R.strings.xxx 来使用的,我们这里继续搜索 purchase_as_low_as :

可以发现 v4.j.b 这里用到了这个。

小插曲:至于为什么要搜索这个文本呢,根据我们朴素的编程思想,如果不是会员,这个位置就会显示“开通会员”的文本,而如果是会员,那么这里应该是“已是会员的文本”,在 js 代码中就像:

1
2
3
4
5
if (isVip()) {
pTag.innerText = '已是 vvvvvvip '
} else {
pTag.innerText = '开通 vvvvvvip 低至 5 元'
}

这里的 if 条件就是我们需要注意的东西,因为很有可能判断了是否为 vip 。

回到搜索,我们点开 v4.j ,发现了下面这一段代码:

怎么样,是不是感觉非常的熟悉,这不就和我们上面所说的伪代码一样吗😂。

当然,我们先无需注意 D5.c.n0().c() 这个调用,我们先看看 if 里往屏幕上塞了什么文本,根据图里,我们知道调用了 mine_page_user_vip_subscribed_title 这个文本,我们搜索它:

哦吼,看来思路没错,重点就是这个 D5.c.n0().c() 。

但是你先别急,往这段代码往上看,还有一个判断条件是 zA 的:

这时候屏幕前聪明的你一定会想:哥们这不对啊,这个单独的 if ,如果直接返回了,这里不就什么都不显示了吗,按道理这里应该显示的啊。

我们搜索这个 if 内的引用的文本 mine_page_user_vip_outright_title :

恍然大悟😍,这里是永久会员的分支,那么我们很有可能就不用关注 D5.c.n0().c() ,而是转而关注 zA 了,它是以下代码的调用结果:

1
boolean zA = com.mojidict.read.account.a.a();

使用 ctrl + 鼠标左键进入这个类:

可以看到如果要使得 zA 为 true ,那么 h() 必须为 true 并且 b().f() 也要为 true ,我们才能进入永久会员的分支。

ctrl + 鼠标左键跟踪这个 h() :

发现这个是对一个变量 f15521b 的长度做判断,要大于 0 。

我们在这个文件中查找 f15521b ,发现有三个结果,其中一个是定义,一个是上面的 h() ,还有一个:

这里是取了一个 sessionToken 的东西放 f15521b 里了,也就是说 h() 它其实是一层预判断,只有 sessionToken 存在,我们才去读取用户数据看是不是拥有永久会员的权限(猜测)。

那么,这里其实我们其实就不用管了,一般我们使用 app 也是会登录的,所以 h() 在登录情况下一般都是为真的。

接下来我们就需要看 b().f() ,我们猜测,这个调用会验证 f15521b 解析会员状态。

ctrl + 鼠标右键进入 f() :

可以看到先执行了 a() ,然后执行 c().d() ,可以拿到一个字符串,判断这个字符串是否为 activated 。

跟踪一下 d() :

可以看到这里是取了某个内部的字段,看起来像个 getter ,再看上面的对象的属性加了 @SerializedName 注解,什么 identity , canPay 可以看到这个 h 类就是一个类似用户会员状态的对象。

跟踪一下 c() :

可以看到这里使用 d("003-000-00001") 构建了一个非空的值用于默认返回,返回的属性为 f15836b ,在这个页面搜索一下 f15836b =,最后落到了一个 done 的函数:

可以看到这里在和 003-000-00001 做相等比较后进入了这个分支,可以推测,永久会员应该和 003-000-00001 是对应的,搜索 f15836b = 也可以发现只有该处是有效的赋值。

小插曲:如果我们抓包的话,就能得到确定的结果,即 003-000-00001 对应永久会员,这个接口为 app/mojiread/parse/functions/getProducts ,它的返回结果如下:

从代码中可以发现 f15836b 的值从 list 来:

1
gVar.f15836b = (p190t5.h) list.get(i8);

而 list 又是通过 Gson 反序列化 str 变量的来:

1
list = (List) new Gson().fromJson(C1057a.a(str), new h().getType());

str 又是通过取了某个 map 的 result 键值:

1
String str = (String) map.get("result");

再搭配上函数名称,我们基本可以推测这个是接口完成后的回调。那么我们的重点是 Gson 反序列化里的那个 C1057a.a(str) ,因为如果我们抓包的话,会发现接口 app/mojiread/parse/functions/getNPrivileges-v2 的结构符合,并且看起来就是加密的:

小插曲:为什么是这个接口呢,因为其他的接口基本都是明文的。
小插曲2:当时在 android15 上死活没法开着 reqable 调试登录流程,后面用 mumu 开 android12 的模拟器,加上 JustTrustMe 的模块才顺利跑通了。

跟踪进 a(str) :

纳尼, AES ,看来是美美吃鸡了,应该就是这了。

里面比较重要的代码有:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// aes 密钥
byte[] bytes = "06ec6c2a6766a939e519af81e998efa4".getBytes(a.f6235b);
SecretKeySpec secretKeySpec = new SecretKeySpec(bytes, "AES");

// 模式
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");

// iv 向量
StringBuffer stringBuffer = new StringBuffer(16);
stringBuffer.append("6a737b1d3e041ddf");
String string = stringBuffer.toString();
byte[] bytes2 = string.getBytes(a.f6235b);

// 初始化 chiper
cipher.init(2, secretKeySpec, new IvParameterSpec(bytes2));

以及对传入参数 str 的处理:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
byte[] bArr2 = new byte[str.length() / 2];
int length = str.length() / 2;
for (int i8 = 0; i8 < length; i8++) {
int i10 = i8 * 2;
int i11 = i10 + 1;
// 取 (2 * 索引) 位置字符
String strSubstring = str.substring(i10, i11);
// (2 * 索引) 位置字符解析为 16 进制
int i12 = Integer.parseInt(strSubstring, 16);
// 取 (2 * 索引 + 1) 位置字符
String strSubstring2 = str.substring(i11, i10 + 2);
// (2 * 索引 + 1) 位置字符解析为 16 进制
// 加上上一个字符的 16 进制 * 16 ,然后更新 bArr2 对应位置
bArr2[i8] = (byte) ((i12 * 16) + Integer.parseInt(strSubstring2, 16));
}
bArr = bArr2;

最后解密:

1
2
byte[] bArrDoFinal = cipher.doFinal(bArr);
return new String(bArrDoFinal, charsetForName);

最后我们直接把这段代码搬出来,然后用上面 app/mojiread/parse/functions/getNPrivileges-v2 接口返回的数据测试一下。

java 代码:

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
void main() throws Exception {
String input = "091a0d45e4c8acc99ed9fd4db5bb315e0bb4fc9b6a313fe1d7168a204ff3d7de9ffe4816fe1f9fa8a673029febf477c02fcafabf55de5f6f97d4187547661de88ab938d45c46a282505d6949f53b8522194f3709674b30cb8a0381fabec4ce7f358abe2e298ff7d01b534c0bc7f376447a3c8dda9df9b024d8d5fe51714a8a0b4f6855339e09ced393ffada4a82021753ec3f2acefcdb8c58ce5c13497272fc8a305f7857cf2a0added16c5a8e0a9fb6da648ddf45e78016d84ed82e3930c69f494d902721d4c8882cfefc5cef69414468a618f52afb9afd5ac79f16d9badfea49159f6ffe57e67f831d331d88afb0dc2ea0087924d02a006b752e1a8379c492ffc9cd832c03f5f56520ce40a96e5533fb0192836fc9cd53d0eac1f23516e4cd";
byte[] inputByte = translate(input);

SecretKeySpec secretKeySpec = new SecretKeySpec("06ec6c2a6766a939e519af81e998efa4".getBytes(StandardCharsets.UTF_8), "AES");
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(2, secretKeySpec, new IvParameterSpec("6a737b1d3e041ddf".getBytes(StandardCharsets.UTF_8)));
String res = new String(cipher.doFinal(inputByte), StandardCharsets.UTF_8);
System.out.printf("input = %s\n", input);
System.out.printf("res = %s\n", res);
}

static byte[] translate(String str) {
byte[] res = new byte[str.length() / 2];
int length = str.length() / 2;
for (int i = 0; i < length; i++) {
int a = i * 2;
int b = a + 1;
String strSubstring = str.substring(a, b);
int c = Integer.parseInt(strSubstring, 16);
String strSubstring2 = str.substring(b, a + 2);
res[i] = (byte) ((c * 16) + Integer.parseInt(strSubstring2, 16));
}
return res;
}

输出如下:

我把 res 给贴上来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[
{
"identity": "003-000-00001",
"privilege": {
"totalInServiceDays": 0
},
"privilegeStatus": "nonActivated",
"canPay": true
},
{
"identity": "003-001-00001",
"privilege": {
"totalInServiceDays": 0,
"expiresDate": 1749624169786,
"purchaseDate": 1749019369800
},
"privilegeStatus": "expired",
"canPay": true
}
]

后记

未完待续…