-
-
[原创]看雪 2022 KCTF 春季赛 第八题 迷雾散去
-
2022-5-28 06:09 8815
-
(不正常的题,就不能用正常方法做)
Android SDK 开一个 armeabi-v7a 的模拟器,本地 x64dbg 调试 qemu-system-armel.exe 进程
输入公开的name和serial(C8EB85C90E69EDC8 和 3638386461396366623135623535353361323862656630656561326334303931
),点一下按钮
公开的 serial 做 hexdecode 得到 688da9cfb15b5553a28bef0eea2c4091,取后八个字节 ea2c4091,x64dbg 全内存搜索(findallmem 0, "ea2c4091"),找到唯一一个位置,这个位置地址的后三位是 690
向上查看内存,定位到地址后三位是 320 的位置
生成17个输入:
1 2 3 4 5 | print ((b '\0' * 16 ). hex ().encode(). hex ()) for i in range ( 16 ): s = bytearray(b '\0' * 16 ) s[i] = 0xff print (s. hex ().encode(). hex ()) |
name固定为KCTF,把17个输入分别作为serial给程序,提取 320 位置上的 int
对于第一个输入,从 320 处提取到的值是 0x1287;对于后面16个输入,提取到的值依次为 0x1302, ..., 0x118e
则 KCTF name最终的 serial 是:
1 2 3 4 5 | serial = [] for c in [ 0x1302 , 0x12ca , 0x1276 , 0x1338 , 0x1378 , 0x1276 , 0x122e , 0x133e , 0x12c2 , 0x12f6 , 0x1194 , 0x11de , 0x136e , 0x1256 , 0x135a , 0x118e ]: serial.append(( 0x1287 - c + 0xff ) / / 2 ) print (bytes(serial). hex ().encode(). hex ()) |
1 | 3432356538383237303738386163323436323438663964343063393831366663 |
另外此题多解,最终serial中所有奇数位置的值取'4'或'6'不影响结果,总共有256个解。例如:
1 | 3432354538383237303738384143323436323438463944343043393831364643 |
踩坑过程:
ArmVMP,立刻想起了去年的题目以及艰难的做题过程,内心本能的抗拒。
jadx逆java层,基本上就是把输入传给so
注意到从 asserts 提取了 Unknown 文件,file 命令发现是 ELF
Unknown文件没有加混淆,是一个普通的 ptrace 反调试
libcrackme.so
(ps. 高版本IDA对arm和Thumb指令的自动识别效果好了很多;个别识别错误的可以按Alt+G手动修改T寄存器)
.init_proc 没加混淆,大概逻辑是做一些自修改
从 Jni_OnLoad 开始加了很强的混淆,不过看起来与去年的差不多。
例如:
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 | .text: 00003BDC ; jint JNI_OnLoad(JavaVM * vm, void * reserved) .text: 00003BDC EXPORT JNI_OnLoad .text: 00003BDC JNI_OnLoad ; DATA XREF: LOAD: 00000328 ↑o .text: 00003BDC PUSH {LR} ; Push registers .text: 00003BDE BL sub_13990 ; Branch with Link .text: 00003BE2 ADDS R2, R0, R4 ; Rd = Op1 + Op2 ... LOAD: 00013990 sub_13990 ; CODE XREF: .text: 00003BDE ↑p LOAD: 00013990 POP.W {LR} ; Pop registers LOAD: 00013994 LOAD: 00013994 loc_13994 ; DATA XREF: sub_13990 + 1C ↓r LOAD: 00013994 BX PC ; Branch to / from Thumb mode LOAD: 00013996 ; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LOAD: 00013996 MVNS R0, R6 ; Rd = ~Op2 LOAD: 00013998 CODE32 LOAD: 00013998 LOAD: 00013998 loc_13998 ; CODE XREF: sub_13990:loc_13994↑j LOAD: 00013998 SUB SP, SP, #0x44 ; 'D' ; Rd = Op1 - Op2 LOAD: 0001399C PUSH {R0 - PC} ; Push registers LOAD: 000139A0 MOV R10, #0x8000 ; Rd = Op2 LOAD: 000139A4 MOV R5, #0x3C ; '<' ; Rd = Op2 LOAD: 000139A8 MOV R4, #0xF ; Rd = Op2 LOAD: 000139AC LDR R1, loc_13994 ; Load from Memory LOAD: 000139B0 MOV R1, R1,ASR #16 ; Rd = Op2 LOAD: 000139B4 LOAD: 000139B4 loc_139B4 ; CODE XREF: LOAD: 00013A2C ↓j LOAD: 000139B4 AND R2, R1, R10 ; Rd = Op1 & Op2 LOAD: 000139B8 MOV R2, R2,ASR R4 ; Rd = Op2 LOAD: 000139BC EOR R2, R2, #0 ; Rd = Op1 ^ Op2 LOAD: 000139C0 ADD PC, PC, R2,LSL #2 ; 139CC LOAD: 000139C0 ; End of function sub_13990 ; 139C8 LOAD: 000139C0 LOAD: 000139C0 ; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LOAD: 000139C4 ALIGN 8 LOAD: 000139C8 LOAD: 000139C8 ; = = = = = = = = = = = = = = = S U B R O U T I N E = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = = LOAD: 000139C8 LOAD: 000139C8 LOAD: 000139C8 sub_139C8 LOAD: 000139C8 B loc_139DC ; 13A20 LOAD: 000139C8 ; 13a1c LOAD: 000139C8 ; 13a18 LOAD: 000139CC ; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LOAD: 000139CC LDR R3, [SP,R4,LSL #2] ; Load from Memory LOAD: 000139D0 ADD R6, R5, #0x44 ; 'D' ; Rd = Op1 + Op2 LOAD: 000139D4 STR R3, [SP,R6] ; Store to Memory LOAD: 000139D8 SUB R5, R5, #4 ; Rd = Op1 - Op2 LOAD: 000139DC LOAD: 000139DC loc_139DC ; CODE XREF: sub_139C8↑j LOAD: 000139DC ADD PC, PC, R4,LSL #2 ; 13A20 LOAD: 000139DC ; End of function sub_139C8 ; 13a1c LOAD: 000139DC ; 13a18 LOAD: 000139E0 ; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LOAD: 000139E0 NOP ; No Operation LOAD: 000139E4 B loc_13A30 ; Branch LOAD: 000139E8 ; - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - LOAD: 000139E8 NOP ; No Operation LOAD: 000139EC NOP ; No Operation LOAD: 000139F0 NOP ; No Operation LOAD: 000139F4 NOP ; No Operation LOAD: 000139F8 NOP ; No Operation LOAD: 000139FC NOP ; No Operation LOAD: 00013A00 NOP ; No Operation LOAD: 00013A04 NOP ; No Operation LOAD: 00013A08 NOP ; No Operation LOAD: 00013A0C NOP ; No Operation LOAD: 00013A10 NOP ; No Operation LOAD: 00013A14 NOP ; No Operation LOAD: 00013A18 NOP ; No Operation LOAD: 00013A1C NOP ; No Operation LOAD: 00013A20 B loc_13A24 ; Branch |
0x139A4 是一个mask,0x139A8 给 R4 的初始值在 0x139D8 递减
0x139CC 从内存中取值,这块内存是 0x1399C push进栈的寄存器。
0x13998 开辟栈空间,0x139CC 取出来的值在 0x139D0 和 0x139D4 存入这块空间。
所以,这些代码的作用是arm的一条push,其中R4的初始值是要push的寄存器号的mask。
模式特征还比较明显,但是不知道有多少种这样的指令,静态匹配化简的工作量大概率会超出4天的承受力。
GOT表有导入 fork,pthread_create,结合 Unknown 文件的 ptrace,动态调试的坑也不会小。
在三血出来后,感觉题目的可解性似乎比预想的高,开始尝试解题。
根据今年第五题的经历以及熟悉的混淆方法,很可能算法还是老的(或者微调)。
拜读了去年秋季赛第八题的writeup。算法大概是对name会做非常复杂的运算,但是可以动态调试直接提取;serial则直接参与最终比较。
从 这篇文章 得到启示,可以通过改变输入观察内存的变化找突破口。
没有root的真机,apk附带的so只有arm架构,这种文件通常的模拟器都不能运行,只能通过Android Studio 的 AVD Manager 开一个 armeabi-v7a 架构的模拟器。
在这个过程中突然想到直接从 qemu-system-armel.exe 进程中dump内存(因为虚拟机的物理内存会映射在 qemu 的地址空间中),好处是完全无视反调试,还可以下各种数据断点追踪数据流。缺点是难以跟踪程序的控制流,不过在这种级别的代码混淆下还原控制流是自讨苦吃(实测感觉,qemu JIT 出来的代码都比源程序的代码可读性高)。
x64dbg 调试需要忽略所有异常并bypass给qemu,选项->选项,“当以下事件发生时暂停”全部取消勾选,“异常过滤器”添加一个最大的忽略范围并设为不暂停。
公开的 serial 可以做两次 hexdecode 得到 16 字节的值,先全内存搜索,通过命令findallmem 0, "<>",搜索结果在“引用”选项卡查看(x64dbg文档没说清楚这一点,绕了些弯)。
搜不到结果,尝试分段搜索,试出只有搜最后4个字节才能搜到,发现前12字节被故意覆盖了。(最后4个字节没覆盖,大概是出题人留下的线索?)(同时,这也表明可能不再像去年的题一样能够直接从内存中取出正确的serial)
只会搜到一个有效结果,且虚拟地址的后三位总是690。
尝试对serial的16个字节下数据断点,跟踪一会后放弃(基本上每条vmp指令都会push/pop所有通用寄存器,太多了跟踪不过来),但是观察到serial的字节是逐个取的,暗示着每个字节不会影响到其他字节。
dump这附近的内存,分别变换name和serial,再次dump内存做比较。
重点关注serial变化引起的内存变化,向上找,注意到了最近的虚拟地址后三位是320的位置的4字节int值,当serial的某个字节加1时这个值也加了1,感觉不是巧合;继续尝试把某个字节+2,+3,发现这个位置也会相应的加+2,+3等,是一个重大的突破口。
在693位置(即16字节serial的最后一个)下数据断点,输入一个前15字节错误、只有第16字节正确serial,触发数据断点时把320位置的值手动改为正确的值,然后继续,发现程序提示输入正确。
所以320位置的int一定记录了serial所有字节的正确性信息。结合与serial中的字节同加同减的现象,合理猜测这个位置保存的是输入的每个serial字节与正确的字节的差值。随便给几组错误的serial,计算后发现保存的是所有差值的绝对值的求和再加上常量0xbac。
由于每个字节的影响是独立的,因此可以把某个字节先设为0再设为0xff,得到这两次320位置的值,根据差值即可得出正确的值。(例如,设某个位置的真实值是x,赋值0时得到的320位置的值是a,赋值0xff时得到的320位置的值是b,其他字节不变,则a-b就是这个字节在两次赋值中与正确值的距离之差,即a-b=(x-0)-(0xff-x),所以x=(a-b+0xff)//2)
具体脚本参照本文开头。注意输入给程序的serial是两次hexencode后得到的64字节值。多解的原因在于程序验证serial做hexdecode时不区分大小写。
吐槽:
说实话,这种题做起来真的毫无体验,既不能 Hack for fun,又学不到新知识,就单纯是体力活硬搞。(感觉更像是帮出题人免费测试壳的强度)
(当然,如果有vmp自动化脱壳机一切都不一样了,不过目前还未有较为成熟的开源工具可以直接使用;另外4天时间从零写一个是个非常困难的任务,特别是还要针对题目做具体修改,以及debug,性价比远不如体力活硬搞)
回去看了下去年两季赛的所有有解的vmp or代码混淆题,没有一个人是通过写去混淆脚本解题的,全都是调试硬怼或者找弱点侧面突破
其他CTF比赛中很少见到单纯靠变态级别混淆保护代码逻辑的逆向题(而且赛后出题人一般会公开自己写的混淆器、自动化去混淆脚本或相关的开源工具),这大概也是KCTF的特色之一了,可能对攻击方更友好一些。
拿到题前几天直接放了,准备躺平坐等4天零解过完。
结果前三血都出来了(今年怎么这么卷,比去年卷多了……),于是不得不……。