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) / 32
与 byte_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 ; if data[v10] & 1 : res.append(1 ) else : res.append(0 ) tmp = data[v10] >> 1 data[v10] = tmp out = "" print resfor 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; char *c_md5; char c_ret; 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; int result; int inner_func; int *v10; int pipedes[2 ]; int v12; pipe (pipedes); if ( !fork() (close (pipedes[1 ]), md5 = &input, v6 = read (pipedes[0 ], &input, 0x100 u), *(&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!" , 0x1A u); 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; char *map_ptr; _BYTE *heap_ptr; unsigned int i; int v10; int result; int size_4k; int v14; ret = 0 ; size_4k = 4096 ; map_ptr = mmap (0 , 0x1000 u, 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, 0x1000 u); 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; unsigned int i; unsigned int j; unsigned int left; unsigned int right; signed int jj; signed int magic; unsigned int k; char *p_logcat; unsigned int left2; signed int magic2; unsigned int right2; signed int kk; int p_input_buffer_2; char input_buffer[48 ]; char fail[40 ]; char success[40 ]; char target[32 ]; 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, 0x28 u); 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看看。