XCTF final 2021 apk writeup

5月30日,学弟喊我去支援XCTF-final的apk,碰巧那几天在写单子,前一天晚上只睡了4个小时,整个人迷迷糊糊的,最后没做出来。今天有空了,稍微复盘一下。

题目

文件见:https://github.com/LeadroyaL/attachment_repo/tree/master/xctf_final_2021

当时的思路(根据回忆)

静态分析

APP 要求使用特定版本的安卓(8,8.1,9),并且要求使用 arm,禁止使用模拟器。说来也是巧,在99%的情况下,手头的机器五花八门什么都有,刚好最近搬家没带手机,但自我认为问题不大,用安卓10进行作答。

因为其他版本运行时 apk 会直接退出(这个 APP 也是挺无语的,flag 正确会退出,错误也会退出,检测不满足环境也要退出)。

经过静态分析后,认为 apk 没有对于 xposed 做检测,于是先用 xposed 把安卓版本检测过掉,就可以开始研究了。

java 层逻辑简单,先加载 library 文件,再动态加载 dex 文件,加载 dex 的过程中将 dexElements 的结构稍微调整了一下,应该是为了加载顺序。

init_array 里有很明显的 armariris 处理痕迹,【强烈推荐】使用先前的脚本可以一键去除 https://gist.github.com/LeadroyaL/9b0bc6f6a908db1adfc48d85ee43451d ,还有一个 pthread 的反调试,检测 frida 的,我不会触发。

JNI_OnLoad 里也有一堆反调试,调整本地环境防止触发,有一个和 libart.so 相关的,附近的代码全都看不懂,还有 mprotect,可能是对 dex 进行一些什么操作,懒得看。之后就是注册 JNI 函数了。

JNI 函数接收 jstring,解码后,过一个 AES 加密,因此明文和密文的长度都是16byte,key 是 wonderfulday!!!! 16byte,加密后返回。与此同时,通过反射会把 MyCrack.crypt 改掉,改为 IgMDcaHeDcHTRr1SUS7urw== 或者 ZmxhZ3RyeWFnYWluP30=

从 icon.png 解密出来 dex,内容很简单,主要是一个 tea 加密算法。输入限定为16byte,依次经过:tea 加密,调用 JNI,tea 加密,base64。最后和 MyCrack.crypt 作对比。

虽然我不知道 MyCrack.crypt 是哪个,但也就两个可能,此时我认为没有需要运行才能确定的东西。

于是编写逆推脚本,经过严谨测试后,发现依然得不到答案。

开始运行

使用 xposed 将 MyCrack.crypt 取出来,发现取出来的永远是 otVvmpP4ZI58pqB26OTaYw==,没有被 JNI 修改过,非常疑惑。

使用 xposed 对 JNI 的输入输出做 hook,验证发现它确实是个标准的 AES。

使用 xposed 对 tea 的输入输出做 hook,验证发现确实是个标准的 tea。

那么只有一种可能,我的安卓 10 刚好触发了它的异常 case。

加上我当时太疲惫太忙,这题就没做出来了。

结尾

比赛结束后,学弟问了做出来的人:“那个so里有很多反调试,然后还改了dex,所以最后step1的加密会变,而且比较的值也会变”。

数日后,看到 null 的 writeup,这题 writeup 写的一塌糊涂,估计是时间紧张没来得及认真写。网上也没看到公开的 writeup,决定抽空搞明白。

第二次研究

端午节假期回家把安卓9的手机拿出来了,重新做一下这道题。

结合上文的分析,此时不再闪退了;使用原先的 xposed 去 dump 数据,发现第二次 tea 的结果变了,并且MyCrack.crypt 也变了,因此确实是用到了特定版本的安卓的特性。

注意两个疑点,一是使用 InMemoryDexClassLoader,可能是不会经过 dex2oat 的,二是无论 flag 对还是错,check 一遍后 APP 又要自动退出。因此猜测是修改了内存中的 dex,并且修改后无法被恢复。

再次分析这个 native 文件,patch 掉反调试,在 pthread 那处写成 BX LR,其他的写成 MOV R0, 0NOP,就可以进行调试了。

在 JNI_OnLoad 里,看起来是找到 libart.so 在内存中的偏移,之后非常复杂看不大懂,有字符串对比,ooxxDexFileartClassLinkerLoadClassMembers。注意此时还没有加载外部 dex,然后存放了一些数据到 rwdata 里。

在 JNI 函数里,除了反调试和 AES,其实之前也看到过,给某几个内存和奇怪的偏移进行奇怪的赋值,如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
if ( dword_4051C )
{
v12 = dword_4051C;
v13 = (dword_4051C + 0x16D496);
*(dword_4051C + 0x16D496) = 0xA1u;
v13[1] = 0x74;
v13[2] = 0x53;
v13[3] = 0x51;
*(v12 + 0x16D526) = 0xC9u;
v14 = sub_B8DC(a1a, aComCtfPlayMycr);
v15 = sub_C812(a1a, v14, aCrypt, aLjavaLangStrin_0);
v16 = sub_C85A(a1a);
sub_C05C(a1a, v16, 0);
v17 = sub_C890(a1a, aIgmdcahedchtrr);
sub_C8BC(a1a, v14, v15, v17);
v18 = sub_C85A(a1a);
sub_C05C(a1a, v18, 0);
}

调试后发现这个指针指向的刚好是 dex。

使用 hexedit 跳到对应的偏移附近,发现数据是 0x12345678,另一处偏移看不大出来。

手动 patch 掉之后,前 4 个字节是int32,第 5 个字节是指向池子的 stringIndex。发现原先的 0x12345678 会变成 0x515364A1,原先的 youaresoclever!! 会变成 zipMatcher

再解密一遍答案就出来了。

这里还有个坑,由于调用顺序是:tea——jni——tea——b64,两个 tea 是不一样的,要稍微留意一下。(这也解释了为什么 check 一遍后就退出,因为第二遍必错)

脚本

Solver.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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
import com.ctf.crack.OOXX;

import javax.crypto.Cipher;
import javax.crypto.spec.SecretKeySpec;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.Base64;

public class Solver {
public static void getDex() {
try {
InputStream fis = new FileInputStream("/tmp/icon.png");
ByteArrayOutputStream baos = new ByteArrayOutputStream();
byte[] buffer = new byte[0x400];
while (true) {
int v0_1 = fis.read(buffer, 0, buffer.length);
if (v0_1 == -1) {
break;
}
baos.write(buffer, 0, v0_1);
}

baos.flush();
byte[] dex = baos.toByteArray();
int i;
for (i = 0; i < dex.length; ++i) {
dex[i] = (byte) (dex[i] ^ -1);
}
new FileOutputStream("/tmp/2.dex").write(dex);
} catch (Exception e) {
e.printStackTrace();
}
}

public static void main(String[] args) throws Exception {
// input -> tea1 -> aes -> tea2 -> b64 -> enc
String target = "IgMDcaHeDcHTRr1SUS7urw==";
byte[] b64 = Base64.getDecoder().decode(target);
byte[] tea2 = OOXX.java_decrypt(b64, false);
Cipher cipher = Cipher.getInstance("AES/ECB/NoPadding");
cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec("wonderfulday!!!!".getBytes(StandardCharsets.UTF_8), "AES"));
byte[] aes = cipher.doFinal(tea2);
byte[] tea1 = OOXX.java_decrypt(aes, true);
System.out.println(new String(tea1));
}
}

OOXX.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
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
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
package com.ctf.crack;

public class OOXX {
private static int[] byteToInt(byte[] arg5, int arg6) {
int[] result = new int[arg5.length >> 2];
int i = 0;
int j;
for (j = arg6; j < arg5.length; j += 4) {
result[i] = OOXX.transform(arg5[j + 3]) | OOXX.transform(arg5[j + 2]) << 8 | OOXX.transform(arg5[j + 1]) << 16 | arg5[j] << 24;
++i;
}

return result;
}

private static byte[] intToByte(int[] arg5, int arg6) {
byte[] result = new byte[arg5.length << 2];
int i = 0;
int j;
for (j = arg6; j < result.length; j += 4) {
result[j + 3] = (byte) (arg5[i] & 0xFF);
result[j + 2] = (byte) (arg5[i] >> 8 & 0xFF);
result[j + 1] = (byte) (arg5[i] >> 16 & 0xFF);
result[j] = (byte) (arg5[i] >> 24 & 0xFF);
++i;
}

return result;
}

public static byte[] tea_encrypt(byte[] arg14, int arg15, int[] i_array) {
int[] tempInt = OOXX.byteToInt(arg14, arg15);
int v0 = tempInt[0];
int v1 = tempInt[1];
int sum = 0;
int k0 = i_array[0];
int k1 = i_array[1];
int k2 = i_array[2];
int k3 = i_array[3];
int i;
for (i = 0; i < 0x20; ++i) {
sum += 305419896;
v0 += (v1 << 4) + k0 ^ v1 + sum ^ (v1 >> 5) + k1;
v1 += (v0 << 4) + k2 ^ v0 + sum ^ (v0 >> 5) + k3;
}

tempInt[0] = v0;
tempInt[1] = v1;
return OOXX.intToByte(tempInt, 0);
}

public static byte[] tea_decrypt(byte[] arg14, int arg15, int[] i_array, boolean before) {
int[] tempInt = OOXX.byteToInt(arg14, arg15);
int v0 = tempInt[0];
int v1 = tempInt[1];
int sum = 0;
int i;
for (i = 0; i < 0x20; ++i) {
if (before)
sum += 305419896;
else
sum += 0x515374A1;
}
int k0 = i_array[0];
int k1 = i_array[1];
int k2 = i_array[2];
int k3 = i_array[3];
for (i = 0; i < 0x20; ++i) {
v1 -= (v0 << 4) + k2 ^ v0 + sum ^ (v0 >> 5) + k3;
v0 -= (v1 << 4) + k0 ^ v1 + sum ^ (v1 >> 5) + k1;
if (before)
sum -= 305419896;
else
sum -= 0x515374A1;
}

tempInt[0] = v0;
tempInt[1] = v1;
return OOXX.intToByte(tempInt, 0);
}

public static byte[] java_decrypt(byte[] data, boolean before) {
String ooxxooxxoo;
if (before) {
ooxxooxxoo = "youaresoclever!!";
} else {
ooxxooxxoo = "zipMatcher";
}
int j;
for (j = 0; j < 16; ++j) {
ooxxooxxoo = ooxxooxxoo + "!";
}

byte[] ooxxooxxooarray = ooxxooxxoo.getBytes();
int[] i_array = new int[16];
int i;
for (i = 0; i < 16; ++i) {
i_array[i] = ooxxooxxooarray[i];
}

if (data.length % 8 != 0) {
return null;
}

byte[] result = new byte[data.length];
int offset;
for (offset = 0; offset < result.length; offset += 8) {
System.arraycopy(OOXX.tea_decrypt(data, offset, i_array, before), 0, ((Object) result), offset, 8);
}

return result;
}

public static byte[] java_encrypt(byte[] data) {
String ooxxooxxoo = "youaresoclever!!";
int j;
for (j = 0; j < 16; ++j) {
ooxxooxxoo = ooxxooxxoo + "!";
}

byte[] ooxxooxxooarray = ooxxooxxoo.getBytes();
int[] i_array = new int[16];
int i;
for (i = 0; i < 16; ++i) {
i_array[i] = ooxxooxxooarray[i];
}

if (data.length % 8 != 0) {
return null;
}

byte[] result = new byte[data.length];
int offset;
for (offset = 0; offset < result.length; offset += 8) {
System.arraycopy(OOXX.tea_encrypt(data, offset, i_array), 0, ((Object) result), offset, 8);
}

return result;
}

private static int transform(byte arg1) {
return arg1 >= 0 ? arg1 : arg1 + 0x100;
}
}