alictf2016 Jumble writeup

2016年6月4日作为一个菜鸡参加了alictf2016,是安卓逆向的主场,记忆中有两道未解出的题目,一道是steady,第二次遇到时候做出来了;另一道是jumble,当时我就下定决心,将来有一天我一定要做出来,时隔一年,终于也被我刚出来了。

转载请联系本人,否则作侵权处理。

相关文件链接:https://github.com/LeadroyaL/attachment_repo/tree/master/alictf2016_jumble (Jumble.apk,fix.so,alictf.py,alictf_data.py)

下面说正事,当时是有两支队伍做出来的,PPP和40thieves,这题难度应该是偏高的,当时的压轴题吧,当初JEB打不开,而且解出来的人太少,就弃疗了。

整个apk分为Java和native两部分,均有加密。

首先看一下Java部分。

第一处反调试,MyApp.onCreate()里,直接检测Debug.isDebuggerConnected(),然后exit()。当然,这道题也不用去调试。顺便讲一下绕过方法把,不要用am start -D -n的方法去启动,这个反调试就绕过了。

入口很清晰,在MainActivity里有

1
Check.c(input_string, Check.b(MainActivity.this))

再看看Check.b是什么东西,双击一下,发现卡死了,赶紧停下来,发现卡在了方法Check.c上面,整体如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static String b(Context arg6) {
String v1 = null;
try {
byte[] v2 = arg6.getPackageManager().getPackageInfo(arg6.getPackageName(), 64).signatures[0].toByteArray();
if(v2.length == 0) {
return v1;
}

v1 = Check.a(v2);
}
catch(Exception v0) {
v0.printStackTrace();
}

return v1;
}

public static boolean c(String arg10, String arg11) throws Exception {
// Method was not decompiled
}

显然Check.b()的作用是拿到包的签名,再过一下Check.a(String s)方法,这是个MD5的过程,输出为hexString的MD5,好在出题人帮我们Log了一下这个MD5,免得我们手动去计算了。

得到的值为:37f531665ef482c4e1964fb56973a9de。

接下来就是Java层的难点了,这个无法被decompiled部分,当时题目描述有一句:"looooooong code",显然这个是太长了,JEB无效。

用apktools解一下包,发现这个Check.smali确实很大,983K,只能肉眼读smali,大概理解一下意思。整体结构看起来非常工整,这里有两种思路,一种是删掉99%的smali代码,注意寄存器的变化,让一个程序缩短并且正确返回,再使用apktools打包,用JEB观察反编译出来的代码;另一种思路是硬刚,肉眼看smali,总结流程。这里两种方法都讲一下。

先观察

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
.method public static synthetic c(Ljava/lang/String;Ljava/lang/String;)Z
.locals 10
.annotation system Ldalvik/annotation/Throws;
value = {
Ljava/lang/Exception;
}
.end annotation

const/4 v7, 0x0

invoke-virtual {p0}, Ljava/lang/String;->getBytes()[B

move-result-object v2

const/4 v9, 0x1

const/4 v6, 0x0

invoke-static {v6, p1}, Lcom/ctf/crackme1/StringHolder;->get(ILjava/lang/String;)Ljava/lang/String;

move-result-object v3

invoke-static {v3}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;

move-result-object v0

const/4 v3, 0x3

invoke-static {v3, p1}, Lcom/ctf/crackme1/StringHolder;->get(ILjava/lang/String;)Ljava/lang/String;

move-result-object v3

new-array v4, v9, [Ljava/lang/Class;

const-class v5, [B

aput-object v5, v4, v6

invoke-virtual {v0, v3, v4}, Ljava/lang/Class;->getDeclaredMethod(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;

move-result-object v1

invoke-virtual {v0}, Ljava/lang/Class;->newInstance()Ljava/lang/Object;

move-result-object v8

new-array v3, v9, [Ljava/lang/Object;

aput-object v2, v3, v6

invoke-virtual {v1, v8, v3}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;

const/4 v9, 0x1

const/4 v6, 0x0

invoke-static {v6, p1}, Lcom/ctf/crackme1/StringHolder;->get(ILjava/lang/String;)Ljava/lang/String;

move-result-object v3

invoke-static {v3}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;

move-result-object v0

const/4 v3, 0x4

invoke-static {v3, p1}, Lcom/ctf/crackme1/StringHolder;->get(ILjava/lang/String;)Ljava/lang/String;

move-result-object v3

new-array v4, v9, [Ljava/lang/Class;

const-class v5, [B

aput-object v5, v4, v6

invoke-virtual {v0, v3, v4}, Ljava/lang/Class;->getDeclaredMethod(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;

move-result-object v1

invoke-virtual {v0}, Ljava/lang/Class;->newInstance()Ljava/lang/Object;

move-result-object v8

new-array v3, v9, [Ljava/lang/Object;

aput-object v2, v3, v6

invoke-virtual {v1, v8, v3}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;

一开始拿到了inputString.getBytes(),之后从StringHolder里拿String,forName拿到类,再拿String,再getDeclaredMethod反射拿方法,最后invoke。所以先看看StringHolder.get(int index, String des_key)的返回值吧,用index作为索引去查map,没查到就用md5值作为des_key去解密,得到的固定字符串后放入map并返回。所以我们手动写一下DES解密程序,结果如下:

int index String dec
0 com.ctf.crackme1.util
1 android.os.Debug
2 isDebuggerConnected
3 b <--utils里的b,循环++
4 d <--utils里的d,循环xor
5 e <--utils里的e,高低换位

再看看utils类里的方法,方法a是一个反调试,但好像从来没有被调用过,可能是出题人随手debug时候留下的,同样c、f、g也是一样的,分别调用3种加密算法,出题人debug用。

我们继续看那段很长的代码,懒人方法先试一下,拉到最下面,发现还有一句

1
2
3
4
5
6
7
8
9
    aput-object v2, v3, v6
invoke-virtual {v1, v8, v3}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;

invoke-static {v2}, Lcom/ctf/crackme1/Check;->check([B)Z

move-result v7

return v7
.end method

显然是将结果byet[]v2代入了Check.check()这个native方法中。

懒一点,在第10个invoke后面删掉,直到native check之前,再用apktools打包起来,丢进JEB里看看,哇,真是太感人了!!!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static boolean c(String arg10, String arg11) throws Exception {
byte[] v2 = arg10.getBytes();
Class v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(3, arg11), byte[].class).invoke(v0.newInstance(), v2);
v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(4, arg11), byte[].class).invoke(v0.newInstance(), v2);
v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(5, arg11), byte[].class).invoke(v0.newInstance(), v2);
v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(3, arg11), byte[].class).invoke(v0.newInstance(), v2);
v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(4, arg11), byte[].class).invoke(v0.newInstance(), v2);
v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(3, arg11), byte[].class).invoke(v0.newInstance(), v2);
v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(4, arg11), byte[].class).invoke(v0.newInstance(), v2);
v0 = Class.forName(StringHolder.get(0, arg11));
v0.getDeclaredMethod(StringHolder.get(3, arg11), byte[].class).invoke(v0.newInstance(), v2);
return Check.check(v2);
}

显然是在一直调用utils.b(input_bytes)、utils.d(input_bytes)、utils.e(input_bytes)对输入的字符串进行操作。

第二种,思路就是硬看smali咯,也不难,注释过的如下

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
    const/4 v7, 0x0

invoke-virtual {p0}, Ljava/lang/String;->getBytes()[B

move-result-object v2
// v2 = inputString.getBytes()
const/4 v9, 0x1

const/4 v6, 0x0

invoke-static {v6, p1}, Lcom/ctf/crackme1/StringHolder;->get(ILjava/lang/String;)Ljava/lang/String;

move-result-object v3
// v3 = StringHolder.get(0, key)
invoke-static {v3}, Ljava/lang/Class;->forName(Ljava/lang/String;)Ljava/lang/Class;

move-result-object v0
// v0 = Class.forName(v3)
const/4 v3, 0x3

invoke-static {v3, p1}, Lcom/ctf/crackme1/StringHolder;->get(ILjava/lang/String;)Ljava/lang/String;

move-result-object v3
// v3 = StringHolder.get(3, key)
new-array v4, v9, [Ljava/lang/Class;
// v4 = new Class[1]
const-class v5, [B
// v5 = byte[].class
aput-object v5, v4, v6
// v4[0] = v5
invoke-virtual {v0, v3, v4}, Ljava/lang/Class;->getDeclaredMethod(Ljava/lang/String;[Ljava/lang/Class;)Ljava/lang/reflect/Method;

move-result-object v1
// v1 = getDeclaredMethod(v0, v3, v4)
invoke-virtual {v0}, Ljava/lang/Class;->newInstance()Ljava/lang/Object;

move-result-object v8
// v8 = v0.newInstance()
new-array v3, v9, [Ljava/lang/Object;

aput-object v2, v3, v6
// v3[0] = inputBytes
invoke-virtual {v1, v8, v3}, Ljava/lang/reflect/Method;->invoke(Ljava/lang/Object;[Ljava/lang/Object;)Ljava/lang/Object;
// v1.invoke(v8, v3)

由于3、4、5是随机出现的,即bde也随机出现,所以需要写个正则来提取一下,我写了这样的

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
import re

f = open("Check.smali", 'rb')
data = f.read()
patter = re.compile(
r"move-result-object.*?const/4 .*?0x(\d).*?Lcom/ctf/crackme1/StringHolder;",
flags=re.DOTALL)
res = patter.findall(data)
print res
for i in res:
if i == '0' or i == '1':
res.remove(i)

for i in range(len(res)):
if res[i] == '3':
res[i] = 'b'
elif res[i] == '4':
res[i] = 'd'
elif res[i] == '5':
res[i] = 'e'
else:
pass
print res
print ''.join(res)
print len(res)

结果是bdebdbdbedebebdbdbdebdbdbdedbdedbebdbdedbebedebdbebdbdebebedebedebebebedbedebdbebdbdebdbebdededebebebedbedebebdbdbedebebdbdebdbebebedbededbdedbebebdebebededbedebdbebdedebededebdbdbdbedbedbdedbedbdbebebdedebebdedebebebebebdedebdedededebedbebedbdbedebebebebdbedebdbdebededbebebdbdebebdebedbebebdebdebebdebdbdbdbebdedebedbdbebebebdedebdbdedbdedebdbebedbdbdbdedbdbedbedbdebedbebedebdbdebdbdbebdebebedbebebebdbdbebedebedebebdedebebebebedbdbdbedbdebdededbdebdbedebedebdedebebdbdbebebdedbdedbdbebdedbdedbebdbdebdbdbedbedebdbdbebedbebedebebdbedbedbdedbdebedebebebdedebededbdbededbdebdedebdbdebdbdbdbdbebdebebebdebededbebebdbedbdbdebdebdedebdebededbdbebdbebdebdedebebdebdedbedededbebdedbedbdbedededebebdbebebdedbededbebdbedbdebdedbedbdbdebdebedebdbdbededbdedebdedebdbdedebedebebdbebebdedbdededbedebdbdedebdedededbebdedbedbdbededbdedbdbedededebebebebdedbdebebdbedbebdbdebdbededebebededbdbebedbebedbebdbdedbedbdebdbebdbdebdebedbdbededebdbdbdbebdedebdbededbebdedbdedededbedebdbdebebebdbedebebdebdedededbdbdbebdbe

Java部分到此结束,接下来将一下native的部分。

加载这个so,发现是没有section名字的,总得先看看.init段吧,只有两段可执行的,出了.text段肯定就是.init段了,通过readelf -a也可以看到.init段是在0x9de0的位置。上IDA!

这里有个恶心的地方,整个so都是用llvm去写的,很多逻辑其实都是连蒙带猜,靠逆向经验的积累吧,不过我尽量说的详尽一点。

init段有2个函数,0x2510和0x2588,分别记为init_1和init_2。

init_1用于反调试,定义了3个信号的处理,14、18、17,要么exit,要么破坏flag。

1
2
3
4
5
6
7
8
9
10
11
12
13
void init_array_fun1()
{
void *a2; // ST08_4@1
void *v1; // [sp+18h] [bp-18h]@1
int v2; // [sp+20h] [bp-10h]@1

v1 = p_destroy;
v2 = 0;
j_j_sigaction(14, (const struct sigaction *)&v1, 0);
a2 = p_exit;
j_j_j_bsd_signal(18, (int)p_exit);
j_j_j_bsd_signal(17, (int)a2);
}

init_2有2个功能,一个功能为解密一段函数并且执行,另一个功能为反调试。

1
2
3
4
5
6
7
8
9
int init_array_fun2()
{
int pid; // r0@1

decrypt_function_and_call((int)p_224c, (int)off_A024);
pid = j_j_getpid();
j_j_hook_got((int)&unk_A278, pid, (int)"libc.", (int)"fwrite", (int)p_j_j_xor13, (int)p_xor13);
return 0;
}

0x29BC接收2个参数,分别为 “待解秘函数的地址” 和 “待解秘函数的长度”,解密方法比较简单,是直接进行取反。其实这一大段看不明白,有几个可疑的特征:

  1. 多处mprotect调用,
  2. 我们输入的是待解密的函数地址+1,这里会主动减去一个1,
  3. 待解密函数本身是一片乱码,无论如何P、Analyze,都是无法组成正常语句的,而且待解密函数存在xref
  4. 对长度进行了mod和div等操作,怀疑是为了对其或者别的原因,看起来就是在解密东西了,
  5. 期间对待解密的函数进行了写操作,是0x2D08的循环取反,参数刚好是buffer_ptr和length,于是我们有理由相信,这个函数是用来解密的,在v23 == -1498352302的时候,会调用解密过后的funciont_ptr。

我们稍后再分析这个解密过后的函数0x224c。

init_2函数的第二部分是拿到pid,之后执行一个与"libc."和"fwrite"有关的东西,并且后两个参数指向同一个函数,0x22FC,对某个global buffer进行xor13的操作,猜起来是进行GOT表的hook吧,反正逻辑太复杂,估计逆不出来。

init_2调用got_hook的时候,0x6040被调用,虽然不知道这个是干嘛的,反正可能触发exit()。

解密0x224c很简单,用python去简单处理一下即可,将范围内的全部取反即可(顺便,JNI_Check_check的地方也有一个解密,我们顺便一起解掉好了。

1
2
3
4
5
for i in range(len(data)):
if i >= 0x1CB8 and i < 0x1DB4:
data[i] = chr(~ord(data[i]) & 0xff)
if i >= 0x224C and i < 0x22FC:
data[i] = chr(~ord(data[i]) & 0xff)

之后打开新的so文件,继续分析这个0x224c。

先将0xA254写为0~31,再调用0x26D0函数。

0x26D0是一个反调试函数,检测/proc/pid/status,不断fork自己,pthrace等骚操作,并且有sleep(),管他呢,反正我们也无法调试。

.init段分析完毕,下面我们看JNI_OnLoad。

很不幸,这个函数真的什么都没做,纯属逗你玩,当时我还研究了半天有什么玄机,后来发现好像真的没东西。。。

剩下的只有JNI_Check_check了,仔细分析一下其流程。

上来先定义好几个buffer,意义暂时未知,我们接着往下看。

  1. 栈上有0x20个字节,全部写为0
  2. global 指针,0xA274 _BYTE* calc_ptr = malloc(0x20),之后全部赋值为0
  3. strncpy将输入的flag复制到 calc_ptr上,长度最多为32
  4. 解密并且调用0x1cb8
  5. 调用0x55ec
  6. 将参与0x55ec计算后的结果赋值给calc_ptr[0:16]
  7. calc_ptr[16:32]的元素,加等于其下标
  8. 进行fwrite(注意:此时触发xor13函数)
  9. 与target 0xA004进行对比,32位完全相同则认为success

按顺序分析,解密过的0x1cb8。

先对0xA254(之前被赋值为0~31)进行32次交换,(也就是说什么都没干),之后调用0x2E90,参数分别为(JNI_Check_check,256,calc_ptr,0x20)。

函数0x2E90就是一坨翔。。。完全看不懂,看起来是将JNI_Check_check的raw-data作为_BYTE*去处理的,第二个是256,第三个是待处理数据,第四个也是长度,可能是一种加密算法。之后再仔细观察,最开始进行了memset(stack_buffer_1, 256)的操作,只有2个参数,这就很尴尬,初始数据都不知道,可能是0吧,之后在某次循环里,将raw-data的值分别给了stack_buffer_1,将stack_buffer_2的值赋为0~255。再猜一下,没有硬编码的sbox,与输入字符长度无关,长度为256,限制再255之内,很可能是流密码RC4。(结果好像还真是RC4)那么密钥就是从JNI_Check_check开始的256的字节,明文就是我们输入的String补全0,最后明文被全被覆写为密文。

接下来是check里的第二个加密函数,0x55ec,这个比较直观,因为里面乱点时候不小心点到一个global的数组,其值为0x63,0x7C,0x77,0x7B,刚好是256字节,而且是AES的Sbox。参数1为RC4加密过的数据,参数2为输出结果,参数3为key。这时候遇到另一个问题,AES是128还是192还是256,因为key给的是256位的key,我一开始想当然就解密去了,发现解出来的是错的。

这里可以选择观察加密轮数的方法,0x55ec代码如下:

1
2
3
4
5
6
7
8
9
10
void __fastcall AES_enc(_BYTE *calc_ptr, int a2, int key)
{
void *huge_buffer; // [sp+18h] [bp-20h]@1

*(_DWORD *)off_9F18 = 4;
*off_9F14 = 10;
huge_buffer = j_j_malloc(44 * *off_9F10);
passing_sbox((_BYTE *)key, (int)huge_buffer);
enc_data((int)calc_ptr, a2, (int)huge_buffer);
}

其中pssing_sbox函数第一句是计算轮数

key_round = (*off_9F14 + 1) * *off_9F10;

算出来的结果是44,44代表的就是128位AES加密,果然解密时候就成功了。

好了,之后的步骤就简单了,正常的赋值和加法,最后进行xor,就得到target啦!

呼!结束了!总结一下,我们需要过掉如下的加密:

  1. Java层的1000次bde处理
  2. RC4加密全部
  3. 将前16字节写为,AES-128加密后数据的前16字节
  4. 将后16字节加等于其下标
  5. 将全部字节xor13
  6. 与char target[32]进行比较

剩下的工作就是提取各种参数,写脚本的事情了,这里就不详细阐述了。

PPP能短时间解出这题,真心佩服,学习一个;一年后解出来,也算是一点小小的进步了,分享给大家咯。。。

请容许我小开心一下,一年内的安卓题,终于全都ak掉了,以后可能会分享一下这一年来遇到的一些有趣的安卓题吧,当然鸽掉的可能性很大,hhhhhhh

附:最终脚本(alictf_data.py从文章开头提供的链接下载)

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
import alictf_data

from Crypto.Cipher import AES, ARC4

rc4_key = alictf_data.rc4_key
aes_key = alictf_data.aes_key
target = alictf_data.target
xor_13 = 0x13
bde_list = alictf_data.bde_list

# xor with 0x13
for i in range(32):
target[i] ^= xor_13
print ''.join(chr(i) for i in target).encode('hex')
# 4c3ac171844edf54d61cc0f8176d9e099a4cbc9c0ca12843bbbbcc9f11fe10f2

# target[0:16] AES_decrypt
cipher = AES.new(''.join(chr(i) for i in aes_key), AES.MODE_ECB)
aes_dec = cipher.decrypt(''.join(chr(i) for i in target[0:16]))
print aes_dec.encode('hex')
# eee13b8e3382258ee7c2fbb7d71b3932
for i in range(16):
target[i] = ord(aes_dec[i])

# target[16:32] -= index
for i in range(16, 32):
target[i] = (target[i] - i) & 0xff

# rc4 decrypt
cipher = ARC4.new(''.join(chr(i) for i in rc4_key))
rc4_dec = cipher.decrypt(''.join(chr(i) for i in target[0:32]))
print rc4_dec.encode('hex')
# 998564a1e7a3e50c301aee25f319c9a9b020f94d22d7cc000000000000000000
target = rc4_dec.rstrip('\0')
target = [ord(i) for i in target]


def b_decrypt(in_data):
size = len(in_data)
for ii in range(size - 1, -1, -1):
if ii == 0:
in_data[ii] = (in_data[ii] - in_data[size - 1]) & 0xff
else:
in_data[ii] = (in_data[ii] - in_data[ii - 1]) & 0xff
in_data[ii] = (in_data[ii] - 58) & 0xff
return in_data


def d_decrypt(in_data):
size = len(in_data)
for ii in range(size - 1, -1, -1):
if ii == 0:
in_data[ii] = (in_data[ii] ^ in_data[size - 1]) & 0xff
else:
in_data[ii] = (in_data[ii] ^ in_data[ii - 1]) & 0xff
in_data[ii] ^= 150
return in_data


def e_decrypt(in_data):
size = len(in_data)
for ii in range(size):
in_data[ii] = ((in_data[ii] & 0xf) << 4) | ((in_data[ii] & 0xf0) >> 4)
return in_data

# java decrypt
bde_list = bde_list[::-1]
for i in range(len(bde_list)):
cur = bde_list[i]
if cur == 'b':
target = b_decrypt(target)
elif cur == 'd':
target = d_decrypt(target)
elif cur == 'e':
target = e_decrypt(target)
else:
print "ERROR"

print target
print ''.join(chr(i) for i in target)

# CTF{Y0u_Ar3_Go0d_a7_1t}