Post

Fqnovel App Analysis

逆向番茄小说App,抓包API实现文章解析,批量下载

Tools

Decompiler

  • IDA Pro 7.7
  • Jadx Gui 1.5

Dynamic Analysis ToolKit

  • LAMDA(Frida) 7.68

Networking MITM

  • Reqable 2.22.2

Analysis

初步分析

package name: com.dragon.read
version: 6.2.5.32 (62532)
Shell: None

Apk没有加固,可以直接静态分析
.apk改.zip后缀解压,找个包含crypt顺眼的so, 用IDA 32位打开,我选的libdragon_crypt.so, 导出表只有JNI_OnLoad一个函数,说明函数动态注册的,点击跳转到图表视图, 看到decryptString字符串

alt text alt text

继续点击跳转,看到decryptString字符串下方就是16位字符串,大概率就是AES-128密钥,具体算法不清楚,复制密钥打开Jadx全局搜索,找到两处调用,选一处跳转到底就能看到AES加解密的函数,这里的代码都比较简单,可以根据上下文手动重命名函数名方便阅读。

1
2
C44065a.m165138a(m165140a, "ac25c67ddd8f38c1b37a2348828e222e", m165132a))
C49016a.m189630b(registerKeyResponse.data.key, "ac25c67ddd8f38c1b37a2348828e222e")

抓包API

加解密算法初步分析完毕,接下来还是得抓包,找到获取文章,目录的Api。直接WIFI代理抓包会出现网络异常,Jadx全局搜索NO_PROXY(okhttp),可以找到下列函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @Override // com.ttnet.org.chromium.net.AbstractC57144e
    /* renamed from: a */
    public URLConnection mo224456a(URL url) {
        return mo224520a(url, Proxy.NO_PROXY);
    }

    @Override // com.ttnet.org.chromium.net.AbstractC57147h
    /* renamed from: a */
    public URLConnection mo224520a(URL url, Proxy proxy) {
        if (proxy.type() != Proxy.Type.DIRECT) {
        // 如果不是直连就抛出异常,导致无法抓包
            throw new UnsupportedOperationException();
        }
        String protocol = url.getProtocol();
        if ("http".equals(protocol) || "https".equals(protocol)) {
            return new CronetHttpURLConnection(url, this);
        }
        throw new UnsupportedOperationException("Unexpected protocol:" + protocol);
    }

直接使用Frida hook mo224520a函数,强制把传入的proxy都改成DIRECT,就可以绕过抓包检测了

1
2
3
4
5
6
7
8
9
10
    let CronetUrlRequestContext = Java.use("com.ttnet.org.chromium.net.impl.CronetUrlRequestContext");
    let Proxy = Java.use("java.net.Proxy");
    let ProxyType = Java.use("java.net.Proxy$Type");
    let InetSocketAddress = Java.use("java.net.InetSocketAddress");

    CronetUrlRequestContext["a"].overload('java.net.URL', 'java.net.Proxy').implementation = function (url, proxy) {
        let directProxy = Proxy.$new(ProxyType.DIRECT, InetSocketAddress.$new(null, 0));
        let result = this["a"](url, directProxy);
        return result;
    };

通过抓包可以在Jadx中定位到以下几个API

1
2
3
4
5
6
7
8
9
10
11
12
@RpcOperation("$GET /reading/bookapi/search/tab/v:version/")
// 文章搜索
@RpcOperation("$GET /reading/bookapi/directory/all_items/v:version/")
// 获取文章目录
@RpcOperation("$GET /reading/reader/item_summary/mget/v:version/")
// 文章总结,可有可无
@RpcOperation("$GET /reading/reader/batch_full/v:version/")
// 获取章节内容
@RpcOperation("$POST /reading/crypt/registerkey")
// 注册密钥,文章content解密需要
@RpcOperation("$POST /reading/user/privilege/add/v:version/")
// 获取下载权限,批量下载文章需要

文章解密

通过上文抓包可以发现,获取章节内容的API中,content参数是加密的,解密后明文为小说正文,结合之前发现的AES函数,向上查找引用 后可以找到content加解密的部分,API参数不难分析,这里就只概括下大概流程,具体函数可以阅读最后的完整分析代码1

1
2
3
4
5
6
7
8
9
10
11
12
fn decrypt_and_decompress(enc_content: &str, key: &str) -> String {
    let enc_bytes = base64::decode(enc_content);

    let iv = &enc_bytes[0..16];
    let enc_data = &enc_bytes[16..];

    let decrypted = aes::decrypt(enc_data, key, iv);

    let decompressed = gzip::unzip(&decrypted);

    String::from_utf8(decompressed)
}

密钥获取

上面讲完了如何解密content,通过hookDecryptContent.decrypt可以得到这样的打印信息

chapterInfo = …
key = AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH

短时间使用,本地跑跑下载文章什么的,把key写死没有问题,但如果想要长期使用,那么必然还是要研究一下动态获取的方法,那么就要研究研究上面提到的registerkey API了,RegisterKeyRequest类型参数还是比较少的

keyver:

固定写死的1

content:

new_register_key(serverDeviceId, str);
serverDeviceId = AppLog.getServerDeviceId()

这个没有什么好方法动态获取,既然是使用SharedPreferences储存的,可以从/data/data/com.dragon.read/shared_prefs/applog_stats.xml里获取device_id参数就是了,获取一次就行,这个id大概率不会变化,如果想要API可以负载均衡,防止单一账户风控的话可以维护多个device_id,觉得麻烦的话也可以直接使用firda主动调用

1
2
3
4
5
6
Java.perform(function () {
    let AppLog = Java.use("com.ss.android.common.applog.AppLog");
    // 主动调用 getServerDeviceId 方法
    let result = AppLog.getServerDeviceId();
    console.log(`AppLog.getServerDeviceId result=${result}`);
});

获取完serverDeviceId后,再看到new_register_key,这里的ac25c67ddd8f38c1b37a2348828e222e就是我们一开始从so里找到的密钥,是不是非常简单,这样我们就准备好了获取动态文章解密密钥所需要的参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn new_register_key(server_device_id: &u64) -> String {
    let iv = random_bytes(16);
    let merged = [
      server_device_id.to_be_bytes(),
      0u64.to_be_bytes()
      ].concat(); // 合并字节数组
    let encrypted = aes_encrypt(
      &merged,
      "ac25c67ddd8f38c1b37a2348828e222e",
      &iv
      ); // AES-128-CBC 加密
    base64::encode([iv, encrypted].concat())
    // 合并 IV 和加密结果并 Base64 编码
}

接下来看到RegisterKeyRequest返回的RegisterKeyResponse类型,content也是使用固定密钥解密,就是动态文章解密密钥,要注意的是文章keyver要和这里的keyver对比一下,不一样的话要么先重新请求文章,要么先获取最新密钥后,再进行解密,keyver不对应的话是无法解密的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// RegisterKeyRequest
{
  "content": "Kbyuccq/vAeg67rWLOySPh/yiXEtiaU2wrRGjhBpD6ALGSTrbaVBWdV8ZAe6a+rb",
  "keyver": 1
}
// RegisterKeyResponse
{
  "code": 0,
  "data": {
    "key": "IzoRTyqHl7ETrOOLQYa/HTPi1pc6XP4WlVtGSnSFBha1IEiTAf8ztTM/4J9gN8Yx",
    "keyver": 520520111
  },
  "message": "ok"
}

批量下载

单一文章下载分析完了就来讲讲批量下载,一般都是全本下载比较好,单一下载请求多了可能会限制,阅读一下BatchFullRequest可以看到包含一个代表下载类型的enum

1
2
3
4
public enum BatchFullReqType {
    Download(0),
    Preload(1);
}

常规请求可以看到是req_type=1,而下载请求为req_type=0,全局搜索BatchFullReqType.Download后,向上查找引用可以看到一处分散读取的地方,也就意味着单个batch_full请求可以读取30个item_id,比单篇加载效率高多了。

List<List> divideList = ListUtils.divideList(new ArrayList(set), 30);

但是如果直接修改req_type就会发现,API拒绝访问了

1
2
3
4
5
{
  "code": 101009,
  "message": "USER_NO_PERMISSION",
  "data": {}
}

尝试下载后抓包发现,在请求前有一个privilege/add的请求, 全局搜索后可以找到AddPrivilegeRequest这个结构,分析后发现构造参数还是很随意的,unique_key可以使用当前时间戳,privilege_id也是固定值,选择几个不用登录就能使用的privilege_id就行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// AddPrivilegeRequest
{
  "add_count_daily": 0,
  "amount": 1,
  "book_id": "...",
  "privilege_id": 6766572795204735752,
  "from": 1,
  "unique_key": "1520520520520"
}
// AddPrivilegeResponse
{
  "code": 0,
  "data": null,
  "message": "SUCCESS"
}
PrivilegeID
addAutoPagePrivilege6836977122288866051
addNoAdPrivilege6703327401314620167
addShortSeriesNoAdPrivilege7313754740460884790
addTtsConsumptionPrivilege7025948416286921516
addTtsNaturePrivilege6703327493505422087
addVipPrivilege6825868665112494095
consumeReadPrivilege7232191200411783994
addBookDownloadPrivilege6766572795204735752
addFreeVipPrivilege6825868665112494095
addNoAdFreeVipPrivilege6825868665112494095
consumeTtsPrivilege7026654500215608108

Conclusion

番茄小说App逆向并不难,大部分时候都是静态分析的,多点耐心分析函数重命名,逻辑清晰才能更好理解思路。

Full Code

DecryptContent

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
/* renamed from: com.dragon.read.reader.utils.j */
public final class DecryptContent {
    /* renamed from: a */
    public static final String decrypt(ChapterInfo chapterInfo, String key) {
        boolean unzip;
        Intrinsics.checkNotNullParameter(chapterInfo, "<this>");
        Intrinsics.checkNotNullParameter(key, "key");
        String str = chapterInfo.content;
        if (chapterInfo.compress_status > 0) {
            unzip = true;
        } else {
            unzip = false;
        }
        return decryt_content(str, key, unzip, chapterInfo.book_id, chapterInfo.chapter_id);
    }

    /* renamed from: a */
    public static final String decryt_content(String content, String key, boolean unzip, String bookId, String chapterId) {
        boolean null_content;
        Intrinsics.checkNotNullParameter(key, "key");
        String str = content;
        if (str != null && str.length() != 0) {
            null_content = false;
        } else {
            null_content = true;
        }
        if (null_content) {
            return content;
        }
        byte[] bytes = CryptKey.decrypt(content, key);
        if (unzip) {
            long elapsedRealtime = SystemClock.elapsedRealtime();
            byte[] unzip2 = Gzip.unzip(bytes);
            Intrinsics.checkNotNullExpressionValue(unzip2, "decompress(bytes)");
            String str2 = new String(unzip2, Charsets.UTF_8);
            StringBuilder sb = new StringBuilder();
            sb.append("[ReaderSDKBiz] 解压章节内容耗时:");
            sb.append(SystemClock.elapsedRealtime() - elapsedRealtime);
            sb.append(", bookId:");
            if (bookId == null) {
                bookId = "";
            }
            sb.append(bookId);
            sb.append(", chapterId:");
            if (chapterId == null) {
                chapterId = "";
            }
            sb.append(chapterId);
            LogWrapper.log_info(sb.toString(), new Object[0]);
            return str2;
        }
        Intrinsics.checkNotNullExpressionValue(bytes, "bytes");
        return new String(bytes, Charsets.UTF_8);
    }
}

CryptKey

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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/* renamed from: com.dragon.read.util.b.a */
public class CryptKey {
    /* renamed from: a */
    public static String key_timestamp() {
        return "key_timestamp";
    }

    /* renamed from: a */
    public static synchronized Single<String> m189623a(String str) {
        Single<String> register_key;
        synchronized (CryptKey.class) {
            register_key = register_key(str, Integer.MIN_VALUE);
        }
        return register_key;
    }

    /* renamed from: c */
    public static int get_key_timestamp(String str) {
        return crypt_key_kv_(str).getInt(key_timestamp(), 0);
    }

    /* renamed from: a */
    public static String key_(long j2) {
        return "key_" + j2;
    }

    /* renamed from: b */
    public static SharedPreferences crypt_key_kv_(String str) {
        return C39968a.prefix_public_(AppUtils.context(), "crypt_key_kv_" + str);
    }

    /* renamed from: a */
    public static synchronized Single<String> register_key(final String str, final int i2) {
        synchronized (CryptKey.class) {
            final String serverDeviceId = AppLog.getServerDeviceId();
            if (!TextUtils.isEmpty(serverDeviceId) && !TextUtils.isEmpty(str)) {
                return Single.defer(new Callable<SingleSource<? extends String>>() { // from class: com.dragon.read.util.b.a.1
                    @Override // java.util.concurrent.Callable
                    /* renamed from: call, reason: merged with bridge method [inline-methods] */
                    public SingleSource<? extends String> mo239399call() {
                        String string = CryptKey.crypt_key_kv_(str).getString(CryptKey.key_(i2), "");
                        if (TextUtils.isEmpty(string)) {
                            return CryptKey.try_register_key(str, serverDeviceId, i2);
                        }
                        return Single.just(string);
                    }
                });
            }
            return Single.error(new ErrorCodeException(-700, String.format("deviceId = %s, userId =%S 不能为空", serverDeviceId, str)));
        }
    }

    /* renamed from: b */
    public static byte[] dec(String enc_str, String key) {
        byte[] decode = Base64.decode(enc_str, 2);
        return AES.aes_dec(Arrays.copyOfRange(decode, 16, decode.length), key, Arrays.copyOf(decode, 16));
    }

    /* renamed from: c */
    private static String new_register_key(String serverDeviceId, String str) {
        byte[] mergeByte = AES.mergeByte(AES.longToBytes(NumberUtils.parse(serverDeviceId, 0L)), AES.longToBytes(NumberUtils.parse(str, 0L)));
        if (C28820iz.m111225a().enable) {
            return CryptManager.encryptString(mergeByte);
        }
        String random_iv = AES.random_iv();
        return Base64.encodeToString(AES.mergeByte(random_iv.getBytes(), AES.aes_enc(mergeByte, "ac25c67ddd8f38c1b37a2348828e222e", random_iv)), 2);
    }

    /* renamed from: a */
    public static byte[] decrypt(String content, String key) {
        if (C28820iz.m111225a().enable) {
            return CryptManager.decryptString(content, key);
        }
        return dec(content, key);
    }

    /* renamed from: a */
    public static Single<String> try_register_key(final String str, String serverDeviceId, final int keyVer) {
        RegisterKeyRequest registerKeyRequest = new RegisterKeyRequest();
        registerKeyRequest.keyver = 1;
        registerKeyRequest.content = new_register_key(serverDeviceId, str);
        return Single.fromObservable(RPCRegiserKey.post(registerKeyRequest).subscribeOn(Schedulers.onIoScheduler())).map(new Function<RegisterKeyResponse, String>() { // from class: com.dragon.read.util.b.a.2
            @Override // io.reactivex.functions.Function
            /* renamed from: a, reason: avoid collision after fix types in other method and merged with bridge method [inline-methods] */
            public String mo238315apply(RegisterKeyResponse registerKeyResponse) throws Exception {
                String byteToString;
                if (registerKeyResponse.code.getValue() == 0) {
                    int receiveKeyVer = registerKeyResponse.data.keyver;
                    if (C28820iz.m111225a().enable) {
                        byteToString = AES.byteToString(CryptManager.decryptString(registerKeyResponse.data.key));
                    } else {
                        byteToString = AES.byteToString(CryptKey.dec(registerKeyResponse.data.key, "ac25c67ddd8f38c1b37a2348828e222e"));
                    }
                    if (!TextUtils.isEmpty(byteToString)) {
                        int expectKeyVer = keyVer;
                        if (expectKeyVer != Integer.MIN_VALUE && receiveKeyVer != expectKeyVer) {
                            LogWrapper.log_error("期望的keyVersion = %s, 而服务器实际返回的KeyVersion \u3000= %s", Integer.valueOf(expectKeyVer), Integer.valueOf(receiveKeyVer));
                            throw new ErrorCodeException(-702, "fail to fetch key");
                        }
                        CryptKey.crypt_key_kv_(str).edit().putString(CryptKey.key_(receiveKeyVer), byteToString).putInt(CryptKey.key_timestamp(), registerKeyResponse.data.keyRegisterTs).apply();
                        return byteToString;
                    }
                    throw new ErrorCodeException(-701, "crypt key is empty");
                }
                if (registerKeyResponse.code.getValue() == 500003) {
                    return "";
                }
                throw new ErrorCodeException(registerKeyResponse.code.getValue(), registerKeyResponse.message);
            }
        });
    }
}

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
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
/* renamed from: com.dragon.read.reader.e.a */
public class AES {
    /* renamed from: a */
    public static byte[] mergeByte(byte[] bArr, byte[] bArr2) {
        byte[] bArr3 = new byte[bArr.length + bArr2.length];
        System.arraycopy(bArr, 0, bArr3, 0, bArr.length);
        System.arraycopy(bArr2, 0, bArr3, bArr.length, bArr2.length);
        return bArr3;
    }

    /* renamed from: a */
    public static String random_iv() {
        return UUID.randomUUID().toString().substring(0, 16);
    }

    /* renamed from: b */
    public static byte[] stringToByte(String str) {
        if (str != null && str.length() >= 2) {
            String lowerCase = str.toLowerCase();
            int length = lowerCase.length() / 2;
            byte[] bArr = new byte[length];
            for (int i2 = 0; i2 < length; i2++) {
                int i3 = i2 * 2;
                bArr[i2] = (byte) (NumberUtils.parseRadix(lowerCase.substring(i3, i3 + 2), 16) & MotionEventCompat.ACTION_MASK);
            }
            return bArr;
        }
        return new byte[0];
    }

    /* renamed from: c */
    private static IvParameterSpec stringToIV(String str) {
        byte[] bArr;
        if (TextUtils.isEmpty(str)) {
            str = "";
        }
        StringBuffer stringBuffer = new StringBuffer(16);
        stringBuffer.append(str);
        while (stringBuffer.length() < 16) {
            stringBuffer.append("0");
        }
        if (stringBuffer.length() > 16) {
            stringBuffer.setLength(16);
        }
        try {
            bArr = stringBuffer.toString().getBytes(cString.charset);
        } catch (UnsupportedEncodingException e2) {
            e2.printStackTrace();
            bArr = null;
        }
        return new IvParameterSpec(bArr);
    }

    /* renamed from: a */
    public static String byteToString(byte[] bArr) {
        StringBuffer stringBuffer = new StringBuffer(bArr.length * 2);
        for (byte b2 : bArr) {
            String hexString = Integer.toHexString(b2 & 255);
            if (hexString.length() == 1) {
                stringBuffer.append("0");
            }
            stringBuffer.append(hexString);
        }
        return stringBuffer.toString().toUpperCase();
    }

    /* renamed from: a */
    public static SecretKeySpec stringToKey(String str) {
        if (str == null) {
            str = "";
        }
        StringBuffer stringBuffer = new StringBuffer(16);
        stringBuffer.append(str);
        while (stringBuffer.length() < 16) {
            stringBuffer.append("0");
        }
        if (stringBuffer.length() > 16) {
            stringBuffer.setLength(16);
        }
        try {
            stringBuffer.toString().getBytes(cString.charset);
        } catch (UnsupportedEncodingException e2) {
            e2.printStackTrace();
        }
        return new SecretKeySpec(stringToByte(str), "AES");
    }

    /* renamed from: a */
    public static byte[] longToBytes(long j2) {
        byte[] bArr = new byte[8];
        for (int i2 = 0; i2 < 8; i2++) {
            bArr[7 - i2] = (byte) ((j2 >> (64 - (r3 * 8))) & 255);
        }
        return bArr;
    }

    /* renamed from: a */
    public static byte[] aes_enc(byte[] bArr, String str, String str2) {
        try {
            SecretKeySpec stringToKey = stringToKey(str);
            IvParameterSpec stringToIV = stringToIV(str2);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(1, stringToKey, stringToIV);
            return cipher.doFinal(bArr);
        } catch (Exception e2) {
            e2.printStackTrace();
            return new byte[0];
        }
    }

    /* renamed from: a */
    public static byte[] aes_dec(byte[] bArr, String str, byte[] bArr2) {
        try {
            SecretKeySpec stringToKey = stringToKey(str);
            IvParameterSpec ivParameterSpec = new IvParameterSpec(bArr2);
            Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
            cipher.init(2, stringToKey, ivParameterSpec);
            return cipher.doFinal(bArr);
        } catch (Exception e2) {
            e2.printStackTrace();
            return new byte[0];
        }
    }
}
  1. 有些还是还是不太好移植的,想要用python重写发现有问题的话最好先看看是不是有的函数是不是和stdlib写法不一样。 ↩︎

This post is licensed under CC BY 4.0 by the author.

© Rudo. Some rights reserved.

Using the Chirpy theme for Jekyll.