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字符串
继续点击跳转,看到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"
}
Privilege | ID |
---|---|
addAutoPagePrivilege | 6836977122288866051 |
addNoAdPrivilege | 6703327401314620167 |
addShortSeriesNoAdPrivilege | 7313754740460884790 |
addTtsConsumptionPrivilege | 7025948416286921516 |
addTtsNaturePrivilege | 6703327493505422087 |
addVipPrivilege | 6825868665112494095 |
consumeReadPrivilege | 7232191200411783994 |
addBookDownloadPrivilege | 6766572795204735752 |
addFreeVipPrivilege | 6825868665112494095 |
addNoAdFreeVipPrivilege | 6825868665112494095 |
consumeTtsPrivilege | 7026654500215608108 |
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];
}
}
}
有些还是还是不太好移植的,想要用python重写发现有问题的话最好先看看是不是有的函数是不是和stdlib写法不一样。 ↩︎