今日校园APP逆向分析

为了做个自动填表工具,抓包、分析了下今日校园。本篇文章非常长,讲述了整个逆向分析的过程。分析过程对逆向和反逆向或许都有启发。

准备阶段

疫情每日填报实在是太烦了,这种机械化的事情,交给软件去做最好了。

打开 HttpCanary 抓包,什么都看不到,而且今日校园表现是断网的样子。我的手机是安装了 HttpCanary 的根证书的。看来今日校园用了 SSL Pinning 来阻止抓包

这一步其实是比较好办的,对于常见的 http 库,可以用 Xposed + JustTrustMe (还有一个类似的 Xposed 插件:Unpinning)来 hook 信任证书相关的函数,让程序即使是抓包软件的证书也表现为信任,就可以抓包了。

但我的 K30 升级到 Android 11 后,尝试了很多次都装不上 Magisk 和 Xposed。我又没有其他的安卓设备,所以就只能用虚拟机了。

然而今日校园连电脑上的安卓模拟器都防住了,安卓模拟器一打开今日校园就直接闪退。把apk解压缩看了看,发现只有armabi-v7a。说明今日校园无法在 X86 的安卓上运行

电脑的安卓模拟器不行,那就试试手机的安卓模拟器。我用的是 VMOS Prp(虚拟大师),直接装了个 Android 5.x 的系统,省的要想办法绕开 SSL Pinning。

VMOS Pro 还是挺好用的,连 ADB 之类的都考虑好了,设置页面能一键打开。

抓包软件我用的是 Fiddler。给 VMOS 的 Wifi 设置代理服务器,装个 Fiddler 的证书,就能开始抓包了。

这一步有个小问题,VMOS 的设置页面是找不到 Wifi 设置的。要看到 Wifi 设置页面,可以装个 Wifi 大师之类的软件。或者用 ADB 执行下面的命令:

1
adb shell am start -a android.intent.action.MAIN -n  com.android.settings/.wifi.WifiSettings

上面这个命令能直接打开 Wifi 设置页面。

抓包分析

刚打开今日校园,Fiddler 就刷屏了,大多数都是各种安卓Sdk的请求(无语)。屏蔽了十几个域名后,终于只剩下今日校园的请求了。

不得不说,今日校园对安全还是很看重的,抓包看到的内容里,无论是请求还是响应,就没有明文的字段。

不仅是没有明文字段,连应该明文的地方,都用了 “p”、”s” 这种简短的字符,防止被猜测出含义。不知道今日校园是如何做到这一步的,如果是手动混淆,那程序员实在太惨了。

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
POST https://mobile.campushoy.com/app/auth/dynamic/secret/getSecretKey/v-8213 HTTP/1.1
sessionTokenKey: szFn6zAbjjU=
SessionToken: szFn6zAbjjU=
clientType: cpdaily_student
tenantId:
User-Agent: Mozilla/5.0 (Linux; Android 5.1.1; vmos Build/LMY48G; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.100 Mobile Safari/537.36 okhttp/3.12.4
deviceType: 1
CpdailyClientType: CPDAILY
CpdailyStandAlone: 0
CpdailyInfo: XvWN4SWqyX648L13hW5koOHt5AfBN6jFTi4zR23WludYuPZfzB8fDc5UZUar HHOmexyCccMic4Ad+D7MhoJRl1OJiSDRaFQXyGhY3RphecMkXaPEB+NoKx9O wYd5FSPdIgNvb5nxh7xns6eTidSC+FdD6lZSI3k15PI8BP/iRwLIWe+C6Kb3 /uyzTwXsv4w1
Content-Type: application/json; charset=UTF-8
Content-Length: 221
Host: mobile.campushoy.com
Connection: Keep-Alive
Accept-Encoding: gzip
Cookie: acw_tc=2f624a7116118599368446428e16406bf20229eef5cf7fa3bf7a0952dd1bed

{"p":"RyTf8OmNAEbj6Z2gakUc/WT0jxkW8lfkprxy9B4htHXG/S+87jGVfnoZ9t7a cGslGlxLmd0m3JAhdzH4PApmOTRbxm9AED93VleWHiaGaUVPfJu4KVLQLB5T xThWZyz2ZFG+AbUsgItCSkLRBn9m3FGV/D6CYaaxHXu9Mvv4zLw=","s":"b837371696c3773494d25acecd543ca3"}
HTTP/1.1 200
Date: Thu, 28 Jan 2021 18:55:40 GMT
Content-Type: application/json;charset=UTF-8
Connection: keep-alive
Content-Length: 209

{"errCode":0,"errMsg":null,"data":"R1vtCBeFahygfP0Ik2Ojs47NZq3JF85Nfo/apHc5AcRzxa8JehCBq+0uZn+Cl7Mm+i7u13eQyUauW2dZA+5OVKzqP51FvYWuHb0BpjsdcDimVJQv8xr9lUBHk8vRrvkVZKbHgXkmUXvfLCxPlzHiRgckeXSXCIaTwYk7XWjKeQQ="}

JADX 反编译 APK 静态分析

Frida-dexdump脱壳

既然有加密,那就没办法了,必须要逆向分析一下了。

从抓包的情况来看,对今日校园是可以比较放心的。加密那么复杂的 APP 会没有加壳吗?所以我压根没看加壳情况,直接用 frida-dexdump 脱壳。

这一步有一个点需要注意,由于是在手机上运行的 frida-server,需要做一个端口转发,才能在电脑上用 frida-dexdump。不然 frida-dexdump 是找不到 frida-server 的。frida-server 默认的监听端口是 27042,可以用 adb 做一个转发。

1
adb forward tcp:27042 tcp:27042

如果你足够熟悉网络协议相关的知识,这不是个问题。

frida-dexdump 真的是我用过的最好用的傻瓜式工具,很轻松地帮我整出了 29 个 dex 文件。

这里有个小技巧,把这些 dex 文件重命名为 classes.dex、classes1.dex、…, 然后压缩成 zip,把后缀改为 apk。再用 jadx 打开这个 apk,就能一次载入所有的 dex 文件了。(或者使用 jadx 1.2,这个版本可以打开多个 dex 文件,我也是后来才发现)

以上都是些基本操作了。

分析getSecretKey请求

接下来就是用 jadx 去逆向分析加密的算法。

打开那 29 个 dex 文件,反编译后搜索文本。(我载入的时候有一个 dex 文件有问题,jadx 1.2 无法载入,排除之后才能载入。jadx 1.1 可以载入,但也是提示有错误,无法得到 smali 代码。暂时不清楚什么原因。)

先搜索前面提到的第一个请求 auth/dynamic/secret/getSecretKey,发现如下代码:

1
2
@POST("app/auth/dynamic/secret/getSecretKey/v-8213")
Observable<Wrapper<String>> getSecretKey(@Body Map<String, String> map);

搜索一下用例,只有一个:

1
2
3
4
5
6
7
8
/* renamed from: l */
public static void m66682l(uy0<String> uy0) {
HashMap hashMap = new HashMap();
String e = ku1.m75647e(((Object) UUID.randomUUID()) + HiAnalyticsConstant.REPORT_VAL_SEPARATOR + ku1.m75649g(UIUtils.getContext()), UIUtils.getContext());
hashMap.put("p", e);
hashMap.put("s", ss0.m80546a("p=" + e + ContainerUtils.FIELD_DELIMITER + ku1.m75648f(UIUtils.getContext())));
f59478a.mo101637z(f59479b.getSecretKey(hashMap), uy0);
}

前面抓包时也看到,请求体是个 Json,有 “p” 和 “s” 两个字段,和反编译的代码完全对上了。

代码里的 HiAnalyticsConstant.REPORT_VAL_SEPARATORku1.m75649g() 都是字符串常量,分别是 “|” 和 “firstv”。

因此第一个参数 p 的内容已经出来了:

1
p = ku1.m75647e( UUID.randomUUID() + "|" + "firstv" );

点进去看一眼 ku1.m75647e 函数:

1
2
3
4
/* renamed from: e */
public static String m75647e(String str, Object obj) {
return ju1.m75051d(JniUtils.encodeByRSAPubKey(str.getBytes(), obj));
}

其中 ju1.m75051d() 函数是 base64 编码的函数(熟悉base64编码的话,代码的特征很明显,一眼看出)。

另一个函数就不怎么好分析了,来自 JniUtils 类的静态函数,很可能是个 native 函数,函数具体实现在 native 层。

点进去一看,果然如此:

1
public static native byte[] encodeByRSAPubKey(byte[] bArr, Object obj);

不过从名字可以看出很多的内容,比如函数的本质是用Public Key 的 RSA 加密,而且函数没有传递 Public Key,说明 Public Key 和函数的实现是在一起的。因此只要能在 so 文件里找到 Public Key 就可以自己加密了。

另一个参数 s 就不再赘述了,他比较简单,是对 p 参数的值算了下 MD5:

1
s = MD5("p=" + p + "&" + JniUtils.gets());

其中这个 JniUtils.gets() 函数不用参数还返回了一个字符串。可以猜测这个字符串是固定值。(后面的分析也证实,这个函数的 s 应该是 Salt 的意思,给 MD5 加盐的)

总览一下这个 JniUtils,可以感受一下今日校园的加密方法有多么丰富。AES、DES都是很常见的,一般的 APP 都是用其中的一种。今日校园AES、DES、RSA三种常见的加密方法都用上了。除此之外还有一些摘要算法,MD5、SHA1之类的,这些到不是很重要。

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
public class JniUtils {
static {
System.loadLibrary("crypto");
System.loadLibrary("cipher");
}

public static native byte[] decodeByAES(byte[] bArr, byte[] bArr2);

public static native byte[] decodeByDES(String str, byte[] bArr, Object obj, boolean z);

public static native byte[] decodeByRSAPrivateKey(byte[] bArr, Object obj);

public static native byte[] decodeByRSAPubKey(byte[] bArr, byte[] bArr2, Object obj);

public static native byte[] encodeByAES(byte[] bArr, byte[] bArr2);

public static native byte[] encodeByDES(String str, byte[] bArr, Object obj, boolean z);

public static native byte[] encodeByHmacSHA1(Context context, byte[] bArr);

public static native byte[] encodeByRSAPrivateKey(byte[] bArr, byte[] bArr2, Object obj);

public static native byte[] encodeByRSAPubKey(byte[] bArr, Object obj);

public static native String encodeBySHA1(byte[] bArr);

public static native String encodeBySHA224(byte[] bArr);

public static native String encodeBySHA256(byte[] bArr);

public static native String encodeBySHA384(byte[] bArr);

public static native String encodeBySHA512(byte[] bArr);

public static native String getfdkey(String str, Object obj);

public static native String gets(Object obj);

public static native String md5(byte[] bArr);

public static native String sha1OfApk(Context context);

public static native byte[] signByRSAPrivateKey(byte[] bArr, byte[] bArr2);

public static native int verifyByRSAPubKey(byte[] bArr, byte[] bArr2, byte[] bArr3);

public static native boolean verifySha1OfApk(Context context);

public static native byte[] xOr(byte[] bArr);
}

IDA反编译.so进行静态分析

接下来自然是用 IDA 分析 .so 文件了。这个 JniUtils 涉及到两个 .so 文件, libcrypto.so 和 libcipher.so。其中 JniUtils 相关的的代码在 libcipher.so 文件里。

IDA 的 Exports 窗口,可以看到导出的函数

这里又有一个小技巧,由于是 JNI 相关的代码,可以插入一些结构体让反编译的伪代码更加清晰。在 structures 窗口按下快捷键 insert,就可以插入结构体了,JNI 相关的结构体是标准结构体,IDA 自带。

插入和 JNI 相关的结构体

分析gets函数

先从 gets() 开始入手,它明显比较简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
jstring __fastcall Java_com_wisorg_wisedu_utils_JniUtils_gets(JNIEnv *env, jclass instance, jobject context)
{
JNIEnv *v3; // ST08_4
char *v4; // r0

v3 = env;
v4 = getSalt();
return _JNIEnv::NewStringUTF(v3, (const unsigned __int8 *)v4);
}

char *getSalt()
{
char *v0; // ST14_4

v0 = (char *)malloc(0x400u);
strcpy(v0, (const char *)sk1); // sk1="2cf24dba5fb0a30e26e8"
_strcat_chk(v0, sk2, -1); // sk2="3b2ac5b9"
_strcat_chk(v0, sk3, -1); // sk3="e29e1b161e5c1fa7425e73"
_strcat_chk(v0, sk4, -1); // sk4="043362938b9824"
return v0;
}

这两个函数的伪代码都很清晰,不多说明,本质就是返回字符串 “2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824”

分析encodeByRSAPubKey函数

然后再去看 encodeByRSAPubKey 函数。

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
jbyteArray __fastcall Java_com_wisorg_wisedu_utils_JniUtils_encodeByRSAPubKey(JNIEnv *env, jclass instance, jbyteArray src_, jobject context)
{
int v4; // ST50_4
int v5; // r1
int v6; // STF4_4
int v7; // r0
int v9; // [sp+14h] [bp-FCh]
_jbyteArray *v10; // [sp+38h] [bp-D8h]
int i; // [sp+3Ch] [bp-D4h]
jbyte *buf; // [sp+40h] [bp-D0h]
void *v13; // [sp+44h] [bp-CCh]
void *ptr; // [sp+48h] [bp-C8h]
int v15; // [sp+4Ch] [bp-C4h]
int v16; // [sp+54h] [bp-BCh]
int v17; // [sp+58h] [bp-B8h]
jsize length; // [sp+60h] [bp-B0h]
jsize size; // [sp+6Ch] [bp-A4h]
jbyte *elems; // [sp+70h] [bp-A0h]
char *v21; // [sp+74h] [bp-9Ch]
unsigned __int8 *s1; // [sp+78h] [bp-98h]
_jbyteArray *array; // [sp+80h] [bp-90h]
_JNIEnv *enva; // [sp+88h] [bp-88h]
_jbyteArray *v25; // [sp+8Ch] [bp-84h]

enva = env;
array = src_;
s1 = sha1OfApk(env, context);
if ( !strcmp((const char *)s1, (const char *)signatureOfApk) )
{
v21 = getUk();
elems = _JNIEnv::GetByteArrayElements(enva, array, 0);
size = _JNIEnv::GetArrayLength(enva, &array->0);
length = 0;
v17 = 0;
v4 = BIO_new_mem_buf(v21);
v16 = PEM_read_bio_RSA_PUBKEY(v4, 0, 0, 0);
BIO_free_all(v4);
v15 = RSA_size(v16);
ptr = malloc(size);
v13 = malloc(v15);
buf = (jbyte *)malloc((size / (v15 - 11) + 1) * v15);
_aeabi_memset();
_aeabi_memset();
_aeabi_memcpy();
for ( i = 0; ; ++i )
{
v5 = i;
if ( i > size / (v15 - 11) )
break;
v5 = i;
if ( i == size / (v15 - 11) )
{
v5 = size % (v15 - 11);
v9 = size % (v15 - 11);
}
else
{
v9 = v15 - 11;
}
if ( !v9 )
break;
_aeabi_memset();
v6 = RSA_public_encrypt(v9, (int)ptr + v17, (int)v13, v16, 1);
_aeabi_memcpy();
length += v6;
v17 += v9;
}
v7 = RSA_free(v16, v5);
CRYPTO_cleanup_all_ex_data(v7);
_JNIEnv::ReleaseByteArrayElements(enva, array, elems, 0);
v10 = _JNIEnv::NewByteArray(enva, length);
_JNIEnv::SetByteArrayRegion(enva, v10, 0, length, buf);
free(ptr);
free(v13);
free(buf);
if ( v21 )
free(v21);
if ( s1 )
free(s1);
v25 = v10;
}
else
{
if ( s1 )
free(s1);
v25 = 0;
}
return v25;
}

虽然和分析的函数没很大关系,但是有个小发现。注意到这两行代码:

1
2
s1 = sha1OfApk(env, context);
if ( !strcmp((const char *)s1, (const char *)signatureOfApk) )

可以猜测今日校园还检测了 apk 的签名,防止 apk 被修改。

对于我们关注的 Public Key,关键在这几行代码:

1
2
3
4
5
v21 = getUk();
...
v4 = BIO_new_mem_buf(v21);
...
v16 = PEM_read_bio_RSA_PUBKEY(v4, 0, 0, 0);

可以知道 Public Key 来自 getUk 函数,而且还是 PEM 格式的。

1
2
3
4
5
6
7
8
9
10
11
12
13
char *getUk()
{
char *v0; // ST1C_4

v0 = (char *)malloc(0x800u);
strcpy(v0, (const char *)uk0); // -----BEGIN PUBLIC KEY-----
_strcat_chk(v0, uk1, -1); // MIGfMA0GC...
_strcat_chk(v0, uk2, -1); //
_strcat_chk(v0, uk3, -1); //
_strcat_chk(v0, uk4, -1); //
_strcat_chk(v0, uk5, -1); // -----END PRIVATE KEY-----
return v0;
}

轻松得到 Public Key,至此第一个请求 getSecretKey 分析的差不多了。

解密请求返回的数据

java层面的分析

多分析几个请求可以发现,大多数请求的响应内容都是同一个 Json 结构:

1
2
3
4
5
{
"errCode":0,
"errMsg":"",
"data":"*被加密的内容*"
}

关于被加密的内容的解密方法,分析过程是比较复杂的。

首先是今日校园发送网络请求都不是同步处理的,而是选择了回调一类的做法。对于不同的请求,都从 BaseCommPresenter 类派生出一个子类,其中的 onNextDo 方法就是处理网络请求的响应结果的。即发送了网络请求之后就不管了,等服务器响应之后,再调用 onNextDo 方法,这个 onNextDo 方法可以理解为回调函数。

分析了数个 onNextDo 方法后,就可以总结出如何解密 data 字段了。

  1. 除了 getSecretKey 请求之外,所有加密的 data 字段,都用 JniUtils.decodeByDES 来解密。

  2. getSecretKey 请求的 data 字段是被加密的,要用 JniUtils.decodeByRSAPrivateKey 来解密。

通过上面两点,其实还能得到一个信息。就是 JniUtils.decodeByDES 很可能会用到 getSecretKey 返回的数据才能解密,不然没有必要用两种解密方法。(分析证明,这个猜测是对的。虽然当时分析的时候我并没立刻想到这一点,但是对分析过程没影响,所以没想到这个信息也问题不大。)

通过查找 decodeByDES 的用例,可以得知这个函数的第一个参数是密钥,第二个参数是要解密的字节数组。

1
public static native byte[] decodeByDES(String str, byte[] bArr, Object obj, boolean z);

而这个密钥来自于下面这个函数:

1
2
3
4
5
6
7
8
public static String m66579k() {
String string = SPCacheUtil.getString("chk", "");
if (!TextUtils.isEmpty(string)) {
return new String(ms0.m76724h(string)); // 如果不为空,返回ms0.m76724h(chk)
}
LoginV6Helper.m66682l(new C20331h()); // LoginV6Helper.m66682l 是发起 getSecretKey 请求的,而 C20331h 类的 onNextDo 函数说明了要用 decodeByRSAPrivateKey 来解密 getSecretKey 的数据。
return "1234";
}

问题是,chk 是什么?ms0.m76724h 又是什么?

很容易能找到 chk 是怎么来的,就是上面提到的 C20331h 类中的 onNextDo 函数,调用了 m12286L 把 chk 给存起来了。(chk 是 getSecretKey 的返回值之一)

1
2
3
4
5
public static void m12286L(String str) {
if (!TextUtils.isEmpty(str)) {
SPCacheUtil.putString("chk", ms0.m17919n(str.getBytes()));
}
}

ms0.m76724h 我也不知道是什么,因为反编译出错了。(🐶) 我选择不管这个,继续分析别的。

IDA分析decodeByDES

熟悉 AES 和 DES 算法的应该清楚,这类算法重要的是四个参数:模式、填充、密钥和初始向量(Initialization Vector, 变量名常为iv)。这四个参数只有密钥是 java 层提供的,其他的都藏在 native 层,需要静态分析得到。

今日校园用的是 OpenSSL 库来实现的 DES 算法,其中模式、填充和初始向量都很好找,不再赘述。密钥方面有些特别,因为 java 层传入的 key 不是最终的密钥,还被处理了一下。

这是处理 key 相关的代码,其中 iscpdaily 是 decodeByDES 的最后一个参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if ( iscpdaily )
{
if ( v15 )
{
l1 = getcKey(); // 返回常量字符串 "OKXv"
ptr = getak(enva, l1, o2);
}
else
{
ptr = getcKey();
}
}
else if ( v15 )
{
v7 = getfKey(); // 返回常量字符串"b63X"
ptr = getak(enva, v7, o2);
}
else
{
ptr = getfKey();
}

getcKey 和 getfKey 的定义如下:

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
unsigned __int8 *getcKey()
{
signed int i; // [sp+4h] [bp-14h]
_BYTE *v2; // [sp+8h] [bp-10h]

v2 = malloc(0x100u);
for ( i = 0; i <= 3; ++i )
{
switch ( i )
{
case 0:
v2[i] = 'O';
break;
case 1:
v2[i] = 'K';
break;
case 2:
v2[i] = 'X';
break;
case 3:
v2[i] = 'v';
break;
default:
continue;
}
}
v2[4] = 0;
return v2;
}

unsigned __int8 *getfKey()
{
signed int i; // [sp+4h] [bp-14h]
_BYTE *v2; // [sp+8h] [bp-10h]

v2 = malloc(0x100u);
for ( i = 0; i <= 3; ++i )
{
switch ( i )
{
case 0:
v2[i] = 'b';
break;
case 1:
v2[i] = '6';
break;
case 2:
v2[i] = '3';
break;
case 3:
v2[i] = 'X';
break;
default:
continue;
}
}
v2[4] = 0;
return v2;
}
这里也有个小技巧,IDA 默认显示的是 `v2[i] = 98;` 这种数字,但是其实很好猜测它是个字符。右键这个数字,点击 `char` 就能显示数字对应的 ASCII 字符了。

关键是 getak 函数,其定义反编译出错了,而且很长我没看懂。

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
// local variable allocation has failed, the output may be wrong!
unsigned __int8 *__cdecl getak(JNIEnv *env, unsigned __int8 *l1, const unsigned __int8 *o2)
{
void *v3; // r0
char *v4; // r1
int v5; // r0
int v6; // r1
int v7; // r0
int v8; // r0
char *v9; // r1
unsigned __int8 *result; // r0
int v11; // t1
unsigned __int8 *v12; // [sp+0h] [bp-110h]
int v13; // [sp+4h] [bp-10Ch]
unsigned __int8 *v14; // [sp+8h] [bp-108h]
char *v15; // [sp+Ch] [bp-104h]
size_t v16; // [sp+10h] [bp-100h]
char *v17; // [sp+14h] [bp-FCh]
char v18; // [sp+18h] [bp-F8h]
int vt; // [sp+20h] [bp-F0h]
unsigned __int8 *rets; // [sp+24h] [bp-ECh]
unsigned int __vla_expr0; // [sp+28h] [bp-E8h]
const unsigned __int8 *v22; // [sp+2Ch] [bp-E4h]
int len; // [sp+30h] [bp-E0h]
const unsigned __int8 *o2a; // [sp+34h] [bp-DCh]
unsigned __int8 *l1a; // [sp+38h] [bp-D8h]
JNIEnv *enva; // [sp+3Ch] [bp-D4h]
unsigned __int8 *v27; // [sp+40h] [bp-D0h]
size_t v28; // [sp+44h] [bp-CCh]
int v29; // [sp+48h] [bp-C8h]
int v30; // [sp+4Ch] [bp-C4h]
const unsigned __int8 *v31; // [sp+50h] [bp-C0h]
size_t v32; // [sp+54h] [bp-BCh]
char *dest; // [sp+64h] [bp-ACh]
char *v34; // [sp+68h] [bp-A8h]
int v35; // [sp+6Ch] [bp-A4h]
int v36; // [sp+70h] [bp-A0h]
unsigned __int8 *v37; // [sp+74h] [bp-9Ch]
int v38; // [sp+78h] [bp-98h]
char *src; // [sp+7Ch] [bp-94h]
int v40; // [sp+80h] [bp-90h]
char *v41; // [sp+84h] [bp-8Ch]
char *v42; // [sp+88h] [bp-88h]
_BYTE *v43; // [sp+8Ch] [bp-84h]
int v44; // [sp+90h] [bp-80h]
int v45; // [sp+94h] [bp-7Ch]
__int64 buf2; // [sp+98h] [bp-78h]
_BYTE value2[56]; // [sp+A0h] [bp-70h] OVERLAPPED
unsigned __int8 value1[50]; // [sp+D8h] [bp-38h]

o2a = (const unsigned __int8 *)env;
len = (int)l1;
v22 = o2;
v27 = l1;
enva = (JNIEnv *)-1;
l1a = (unsigned __int8 *)-1;
v17 = &v18;
v28 = strlen((const char *)l1);
v31 = v22;
v30 = -1;
v29 = -1;
v16 = v28;
v32 = strlen((const char *)v22);
__vla_expr0 = v32 + v16;
rets = (unsigned __int8 *)&v12;
vt = v32 + v16;
v15 = (char *)&v12 - ((v32 + v16 + 7) & 0xFFFFFFF8);
v14 = (unsigned __int8 *)&v12 - ((v32 + v16 + 7) & 0xFFFFFFF8);
v3 = malloc(v32 + v16 + 1);
v4 = v17;
*((_DWORD *)v17 + 1) = v3;
v5 = *((_DWORD *)v4 + 6);
dest = v15;
*((_DWORD *)v4 + 18) = -1;
*((_DWORD *)v4 + 17) = v5;
*((_DWORD *)v4 + 16) = *((_DWORD *)v4 + 18);
if ( *((_DWORD *)v4 + 16) == -1 )
v34 = strcpy(dest, *((const char **)v17 + 17));
else
v34 = (char *)_strcpy_chk(dest, *((_DWORD *)v17 + 17), *((_DWORD *)v17 + 16));
v6 = *((_DWORD *)v17 + 5);
v37 = v14;
v36 = -1;
v35 = v6;
v7 = _strcat_chk(v14, v6, -1);
*(_QWORD *)&value1[24] = 0LL;
*(_QWORD *)&value1[32] = 0LL;
*(_QWORD *)&value1[8] = 0LL;
*(_QWORD *)&value1[16] = 0LL;
*(_QWORD *)&value2[48] = 0LL;
*(_QWORD *)value1 = 0LL;
*(_WORD *)&value1[40] = 0;
*(_QWORD *)&value2[24] = 0LL;
*(_QWORD *)&value2[32] = 0LL;
*(_QWORD *)&value2[8] = 0LL;
*(_QWORD *)&value2[16] = 0LL;
buf2 = 0LL;
*(_QWORD *)value2 = 0LL;
*(_WORD *)&value2[40] = 0;
*(_DWORD *)v17 = 0;
v13 = v7;
v8 = getStr1Str2(v14, &value2[48], (unsigned __int8 *)&buf2);
v9 = v17;
*(_DWORD *)v17 = v8;
if ( *(_DWORD *)v9 == 1 )
{
v41 = (char *)*((_DWORD *)v17 + 1);
v40 = -1;
src = (char *)&buf2;
v38 = -1;
v42 = strcpy(v41, (const char *)&buf2);
v45 = *((_DWORD *)v17 + 1);
v44 = -1;
v43 = &value2[48];
_strcat_chk(v45, &value2[48], -1);
}
free(*((void **)v17 + 6));
result = (unsigned __int8 *)*((_DWORD *)v17 + 1);
v11 = *((_DWORD *)v17 + 3);
v12 = (unsigned __int8 *)*((_DWORD *)v17 + 1);
if ( _stack_chk_guard == *(_DWORD *)&value1[44] )
result = v12;
return result;
}

不过我倒是看懂了 getak 函数调用的 getStr1Str2 函数:

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
int __fastcall getStr1Str2(unsigned __int8 *souce, unsigned __int8 *buf1, unsigned __int8 *buf2)
{
unsigned __int8 *v3; // ST28_4
unsigned __int8 *v4; // r1
unsigned __int8 *v5; // r1
signed int j; // [sp+4h] [bp-44h]
signed int i; // [sp+8h] [bp-40h]
signed int v9; // [sp+Ch] [bp-3Ch]
unsigned __int8 *v10; // [sp+14h] [bp-34h]
unsigned __int8 *v11; // [sp+18h] [bp-30h]
unsigned __int8 *v12; // [sp+1Ch] [bp-2Ch]
unsigned __int8 *v13; // [sp+20h] [bp-28h]
unsigned __int8 *v14; // [sp+24h] [bp-24h]

v3 = souce;
v14 = buf1;
v13 = buf2;
v12 = souce;
v11 = buf1;
v10 = buf2;
v9 = strlen((const char *)souce);
if ( !v3 || !v14 || !v13 )
return -1;
for ( i = 0; i < v9; i += 2 )
{
v4 = v10++;
*v4 = v12[i];
}
for ( j = 1; j < v9; j += 2 )
{
v5 = v11++;
*v5 = v12[j];
}
return 1;
}

其实就是把奇数位置的字符取出来,把偶数位置的字符取出来。

getak 函数没看懂,可就没法分析了呀。所以我找大佬,写了个 frida 脚本,hook 了这个 getak 函数,直接观察 getak 函数的参数和返回值。

frida hook

这下全搞明白了。这个 “okTq” 字符串,就是 getSecretKey 请求得到的字符串,也就是前面提到的所谓的 chk

而 getak 函数的返回值,就是 li 的奇数位置的字符 + o2 的奇数位置的字符 + li 的偶数位置的字符 + o2 的偶数位置的字符。

至此,decodeByDES 函数用到的 DES 算法分析完毕。

其他

前面提到 GetSecretKey 请求的参数是通过 encodeByRSAPubKey 函数加密的。但在得到 chk 之后,今日校园许多请求的参数都是用 encodeByDES 函数加密的,而且加密密钥有很多种,但大多能在 java 代码里找到。这在后续的分析中很容易发现。

分析总结

反逆向

今日校园为了安全做了如下措施:

  1. 限制软件运行的设备(只能在 Android Arm 平台运行)。
  2. apk 加壳
  3. SSL Pinning
  4. 在代码层面对请求的参数进行混淆
  5. 对请求的参数和响应都进行了加密,而且加密算法多样,密钥多样。
  6. 将加密解密移动到 native 层(也是为了效率)
  7. 对 apk 签名进行校验,防止 apk 被篡改。
  8. 反动态调试(我尝试过用 IDA 动态调试,确认有反调试)

从效果来看,这些措施是有效的,因为这些措施逆向的难度是比较大的。

但也是有代价的。

首先限制运行的设备降低了用户体验。万一有 Android x86 的用户呢。(😁)

其次是非常多的加密,会降低APP的响应速度而且增加手机耗电,服务器方面也会增加很多计算开销。

最后是代码层面对请求参数进行混淆。如果是人工混淆,那显然是非常麻烦而且不利于代码维护的。自动混淆,则多增加了一个环节,稳健性可能降低。

值得推荐的反逆向措施:

  1. 对 apk 进行加壳

  2. HTTPS + SSL Pinning 可以有效防止中间人攻击。

  3. 适当加密请求和响应的内容。

逆向

有一台有 root 权限的安卓设备很重要。没有也没关系,可以用 VMOS 虚拟机,但是比较麻烦。

frida 是个好工具,对于静态分析困难的地方,可以尝试用 frida hook,观察运行值。如果 hook 能成功分析出结果,那么还能省去反-反调试的功夫。

关于绕过 SSL Pinning 的方法还有很多(Hook、修改 apk 等),需要多研究。

fidller 在解码 gzip 时经常崩溃。

感想

本篇文章的重点不是讲解逆向的工具如何使用,也不是各种加密解密的算法,而是一次逆向分析的思路与完整过程。目的是让逆向新人了解一次完整的逆向需要哪些基本知识,从而达到对技能树查漏补缺的作用。

这其实是我第二次进行安卓APP逆向。本次逆向分析能流畅进行,我认为与我的计算机常识和C++水平是分不开的。我想一定的计算机常识和C/C++水平对逆向分析是很重要的。

我自己也是逆向新人,也看过一些针对新人的逆向教程,常因教程不详细而产生过许多疑惑。本篇文章已尽可能地详细,但因精力有限,难免有照顾不到的地方。希望本篇文章能给读者有所启发,

作者

uint128.com

发布于

2021-02-04

更新于

2022-08-22

许可协议

评论