记一次分析某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 | if (isVip()) { |
这里的 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 | // aes 密钥 |
以及对传入参数 str 的处理:
1 | byte[] bArr2 = new byte[str.length() / 2]; |
最后解密:
1 | byte[] bArrDoFinal = cipher.doFinal(bArr); |
最后我们直接把这段代码搬出来,然后用上面 app/mojiread/parse/functions/getNPrivileges-v2 接口返回的数据测试一下。
java 代码:
1 | void main() throws Exception { |
输出如下:

我把 res 给贴上来:
1 | [ |
后记
未完待续…