WHCTF2017 android writeup

WHCTF 2017,flappy大佬们出的题,感觉不错,两道apk,有一个拿了全场一血,分享一下writeup。拖了很久忘了发了,惭愧惭愧

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

相关文件链接:https://github.com/LeadroyaL/attachment_repo/tree/master/whctf2017/(FindMyMorse.apk, LoopCrpyto.apk)

Find My Morse

打开之后随便点击几下屏幕,偶尔绿偶尔红,Logcat也会有输出,猜测是根据点击屏幕的时间长短来处理。

用C++写的native activity,manifest里什么东西都没有,dex里只有support的包,逻辑全都在libnative.so里。

看一下导入表,大概率是使用EGL去绘制一些东西,以及gettimeofday来计时。

看一下导出表,入口在android_main,注册了2个函数,sub_F3ECBF70看起来是onCreate,初始化EGL,sub_F3ECC154看起来是onTouch做check。

猜测:AMotionEvent_getAction拿到的是onKeyDown和onKeyUp的事件,分别调用gettimeofday,拿到按压的持续时长,并且按下时 _*(v3+40)=0_,会将屏幕主动调整为颜色A。

大于0.2ms是1,小于0.2ms是0,这与题目说的Morse很像。

之后 _index = 7 * (*(v3 + 52) % 4) + *(v3 + 52) / 32byte_F3ED0008[index] 做 xor ,最后bit相同时候认为本次check成功,_*(v3+40)=0_ 颜色调整为A,_byte_F3ED0008[index] >>= 1_*(v3 + 52)++,一旦*(v3 + 52)大于224,就认为全部判定成功。若本次判定失败,则重置_*(v3 + 52) = 0_,设定颜色_*(v3 + 44) = 0x3F800000_为颜色B。最后,如果上文一直没有return,就会复制一段rodata到bss段上,将_byte_F3ED0008[28]_初始化。

综上,_*(v3 + 52)_最初为0,经过224次变化,每次变化会加一,而_index = 7 * (*(v3 + 52) % 4) + *(v3 + 52) / 32_,dump出_byte_F3ED0008[index]_index_的序列,即可还原出flag的bit顺序。

之后遇到一个小问题,还原出来的224bit = 28 * 8bit = 32*7bit,而0对应0还是1,所以共有4种组合,最后得到32个字符最合理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
data = [0xA7,0xD6,0x61,0xB5,0x6E,0xBB,0xBA,0xE3,0xA9,0xDD,0xC4,0x77,0x6F,0xEE,0xEC,0xFF,0x62,0xC3,0xCF,0xDA,0x53,0xCE,0xFF,0x71,0x71,0x14,0xFF,0xF2]
res = []
for i in range(224):
v10 = 7 * (i % 4) + i / 32;
#print v10
if data[v10] & 1:
res.append(1)
else:
res.append(0)
tmp = data[v10] >> 1
data[v10] = tmp

out = ""
print res
#res.reverse()
for i in range(len(res)):
if i % 7 == 0:
out += chr(tmp)
tmp = 0
tmp = tmp << 1
if res[i]:
tmp = tmp 1
out += chr(tmp)
print out

LoopCrypto

Java层的字符串都是加密的,具体方式不知道,但解密函数已经存在于dex了,复制粘贴出来可以直接用,这里就不细说了。 Java层,取签名的md5,转为hexString,小写作为参数带入JNI;输入的字符串作为参数带入JNI。即Decode.check(input, md5)。 之后进入native,init_array里有1个函数,用于反调试,先对bss段的数据进行xor 0xe9的操作,得到明文后继续,解密结果是

1
2
3
.rodata:0000F130 aProcDStatus    DCB "/proc/%d/status",0
.rodata:0000F140 aR DCB "r",0
.rodata:0000F142 aTracerpid DCB "TracerPid",0

fork一个进程出来,读一下主进程是否被Trace,从而kill进程,并且check-sleep-check-sleep循环。绕过方法很多,例如patch掉so文件,kill掉这个被fork出来的进程。 然后是JNI_OnLoad,首先使用Java层的函数解密3个String,可以用之前写好的JVM来解密掉,分别是

1
2
3
decrypt_by_java(env, &unk_BD9B, 30, 87, v7);  // com/a/sample/loopcrypto/Decode
decrypt_by_java(env, &unk_BDBA, 5, 122, v6); // check
decrypt_by_java(env, &unk_BDC0, 56, 49, v5); // (Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;

之后注册JNI方法,就是我们的入口了,逻辑很简单,拿值,进check,返回值存在第三个参数里。

1
2
3
4
5
6
7
8
9
10
11
12
13
int __fastcall jni_check(JNIEnv *env, int jobject, int input, int md5)
{
char *c_input; // r5@1
char *c_md5; // r6@1
char c_ret; // [sp+4h] [bp-110h]@1

c_input = ((*env)->GetStringUTFChars)(env, input, 0);
c_md5 = ((*env)->GetStringUTFChars)(env, md5, 0);
real_check(c_input, c_md5, &c_ret);
((*env)->ReleaseStringUTFChars)(env, input, c_input);
((*env)->ReleaseStringUTFChars)(env, md5, c_md5);
return ((*env)->NewStringUTF)(env, &c_ret);
}

又是要fork线程,原来的线程使用pipe的一端进行读,fork出来的线程执行check,并且向另一端写返回值,主要逻辑在fork出来的线程里

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
int __fastcall real_check(char *input, char *md5, char *ret)
{
ssize_t v6; // r0@2
int result; // r0@2
int inner_func; // r0@3
int *v10; // [sp+4h] [bp-118h]@6
int pipedes[2]; // [sp+104h] [bp-18h]@1
int v12; // [sp+10Ch] [bp-10h]@2

pipe(pipedes);
if ( !fork()
(close(pipedes[1]),
md5 = &input,
v6 = read(pipedes[0], &input, 0x100u),
*(&input + v6) = 0,
_aeabi_memcpy(ret, &input, v6 + 1),
result = _stack_chk_guard - v12,
_stack_chk_guard != v12) )
{
ptrace(0);
close(pipedes[0]);
inner_func = decryp_code_map(off_12004, 622, md5);
if ( !inner_func )
{
write(pipedes[1], "You changed the signature!", 0x1Au);
exit(1);
}
v10 = pipedes;
((inner_func + 1))(&input);
exit(0);
}
return result;
}

ida也没有好的调试方法,只能动态将eip改为fork出来的进程然后跟一遍。先ptrace自己,防止被调试,然后解密一段code,需要使用md5,解密后解压,然后执行,参数是input。

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
int __fastcall decryp_code_map(char *data, int size_data, char *md5)
{
int ret; // r4@1
char *map_ptr; // r8@1
_BYTE *heap_ptr; // r4@2
unsigned int i; // r0@3
int v10; // r5@5
int result; // r0@8
int size_4k; // [sp+8h] [bp-18h]@1
int v14; // [sp+10h] [bp-10h]@8

ret = 0;
size_4k = 4096;
map_ptr = mmap(0, 0x1000u, 7, 34, -1, 0);
if ( map_ptr != -1 )
{
heap_ptr = malloc(size_data + 1);
if ( size_data )
{
i = 0;
do
{
heap_ptr[i] = data[i] ^ md5[i % 32];
++i;
}
while ( i < size_data );
}
v10 = zlib_decompress(map_ptr, &size_4k, heap_ptr, &size_data);
free(heap_ptr);
if ( v10 )
{
munmap(map_ptr, 0x1000u);
ret = 0;
}
else
{
ret = map_ptr;
}
}
result = _stack_chk_guard - v14;
if ( _stack_chk_guard == v14 )
result = ret;
return result;
}

解密函数先 mmap 一段可执行的内存 map_ptr ,再 malloc 一段 heap_ptr ,将密文与 md5 进行 xor ,存入 heap_ptr ,再进行 1.2.11 版本的zlib解压,解压出来的内容存入 map_ptr 。此时 map_ptr 就是真正的 check 逻辑了。

我们同样 patch 掉so文件,alt+G 切换为thumb,就可以使用F5。

主要是最上面的一堆赋值和常量比较难猜,最后猜出来是 48-40-40-32 个byte,然后对其中后3个buffer进行了赋值操作,第一个buffer是input输入进去的。

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
int __fastcall sub_F1B4(__int64 *a1, int a2, int a3, int a4, int a5, int a6, int a7, int a8, int a9, int a10, int a11, int a12, int (__fastcall *a13)(_DWORD, int, int, int))
{
char *p_input_buffer; // r2@1
unsigned int i; // r1@1
unsigned int j; // r9@5
unsigned int left; // r5@6
unsigned int right; // r7@6
signed int jj; // r4@6
signed int magic; // r1@6 char *logcat_buffer; // r12@14
unsigned int k; // r9@17
char *p_logcat; // r8@18
unsigned int left2; // r7@18
signed int magic2; // r5@18
unsigned int right2; // r6@18
signed int kk; // r1@18 unsigned int input_len; // [sp+4h] [bp-B0h]@4
int p_input_buffer_2; // [sp+8h] [bp-ACh]@6
char input_buffer[48]; // [sp+Ch] [bp-A8h]@1
char fail[40]; // [sp+3Ch] [bp-78h]@1
char success[40]; // [sp+64h] [bp-50h]@1
char target[32]; // [sp+8Ch] [bp-28h]@1


i = 0;
v15 = *a1;
do
{
if ( !*(_BYTE *)(v15 + i) )
break;
input_buffer[i] = *(_BYTE *)(v15 + i);
++i;
}
while ( i < 0x27 );
input_buffer[i] = 0;
input_len = i + 1;
if ( i != -1 )
{
j = 0;
do
{
left = *(_DWORD *)&p_input_buffer[j];
p_input_buffer_2 = (int)&p_input_buffer[j];
right = *(_DWORD *)&p_input_buffer[j + 4];
jj = 32;
magic = 0x9E3779B9;
do
{
--jj;
left += (16 * right + 'galf') ^ (magic + right) ^ ((right >> 5) + 'iHt{');
v21 = magic + left;
magic -= 0x61C88647;
right += v21 ^ (16 * left + '$!_S') ^ ((left >> 5) + '7%n_');
}
while ( jj );
p_input_buffer = input_buffer;
*(_DWORD *)&input_buffer[j] = left;
j += 8;
*(_DWORD *)(p_input_buffer_2 + 4) = right;
}
while ( j < input_len );
if ( input_len )
{
if ( (unsigned __int8)input_buffer[0] != 164 )
{
LABEL_14:
logcat_buffer = fail;
goto LABEL_17;
}
v22 = 1;
while ( v22 < input_len )
{
v23 = (unsigned __int8)target[v22];
v24 = (unsigned __int8)input_buffer[v22++];
if ( v24 != v23 )
goto LABEL_14;
}
}
}
logcat_buffer = success;
if ( !input_len )
logcat_buffer = fail;
LABEL_17:
k = 0;
do
{
p_logcat = &logcat_buffer[k];
left2 = *(_DWORD *)&logcat_buffer[k];
magic2 = 0xC6EF3720;
right2 = *(_DWORD *)&logcat_buffer[k + 4];
kk = 32;
do
{
--kk;
right2 -= (16 * left2 + '$!_S') ^ ((left2 >> 5) + '7%n_') ^ (magic2 + left2);
v32 = magic2 + right2;
magic2 += 0x61C88647;
left2 -= v32 ^ (16 * right2 + 'galf') ^ ((right2 >> 5) + 'iHt{');
}
while ( kk );
k += 8;
*(_DWORD *)p_logcat = left2;
*((_DWORD *)p_logcat + 1) = right2;
}
while ( k < 0x28 );
v33 = linux_eabi_syscall(__NR_write, *(_DWORD *)(HIDWORD(v15) + 4), logcat_buffer, 0x28u);
return a13(0, v34, v35, v36);
}

逻辑是:将input存放到input_buffer里,最后添加0,最长长度为0x27,算上0的话0x28。经过一个骚操作,将加密后的数据写入input_buffer,该骚操作是8byte一个block,分成2个int32,循环0x20次。之后与常量进行对比,先比1byte,再比input_len个byte。全对走一个流程,有错走另一个流程,这里猜测是输出 success 和 fail 。

这个算法看起来无法用肉眼去逆,而且有几个可疑的数字,和4个可疑的字符串,查阅资料发现是TEA算法,以前听说过这种加密,还是_Space@ROIS_大佬很久之前和我提起过QQ用的这种加密方式,但没有研究过,于是找一份解密代码,解密一下enc_success和enc_fail,成功了。去解密enc_flag,得到flag。

1
flag{LOoKN9_An_3@&9_s%Lue?!?!}

后记:

最开始zlib解压那段数据时候,一直失败,后来是dump出来的,留下了一点遗憾。详细研究一下这个uncompress函数,是静态编译进去的1.2.11版的zlib,而安卓里、python里使用的是1.2.8,对比一下源码里的函数。

与之前参数不同,从手机里拖出来的zlib函数:

1
2
3
j_inflateInit_((int)&v8, (int)"1.2.8", 56)
j_inflateInit2_(a1, 15, a2, a3);
j_inflateInit2_(v5, 31, (int)"1.2.8", 56);

libcheck.so里的zlib

1
inflateInit_(&v13, -15, "1.2.11", '8'); 

+15 和 -15表示windowBit,最后都进入了 j_inflateReset2(v10, -15/+15);

里面对第二个数字进行了判断,如果为负数则取绝对值,正数的话计算取一个东西,所以其实+15和-15其实没有区别。

但1.2.8的解压规则和1.2.11规则不一样,所以使用正常的1.2.11,参数给默认,按理说就可以解压出来了,然而我没这个闲情逸致去尝试,感兴趣的朋友可以去解密一下那段dump看看。