unlink
unlink
unlink是什么?
unlink其实是libc中定义的一个宏,定义如下:
|
何时执行unlink
在执行free()函数时执行了 _int_free()函数,在_int_free()函数中调用了unlink宏,大概的意思如下(注意_int_free()是函数不是宏):
|
堆释放
1 //gcc -g test.c -o test |
这里申请了7个chunk,接着依次释放了first_chunk、second_chunk、third_chunk。这里为什么释放这几个chunk呢,因为地址相邻的chunk释放之后会进行合并,地址不相邻的时候不会合并。由于申请的是0x80的chunk,所以在释放之后不会进fastbin而是先进unsortbin。
- first_bk -> second
- second_fd -> first 、 second_bk -> third
- third_fd -> second

unlink其实是想把second_chunk摘掉,如果second_chunk被摘掉,那么就会变成下面这样:

chunk状态检查
现在我们用的大多数linux都会对chunk状态进行检查,以免造成二次释放或者二次申请的问题。
但是检查的流程本身就存在一些问题,能够让我们进行利用。回顾一下以往我们做的题,大部分都是顺着原有的执行流程走,但是通过修改执行所用的数据来改变执行走向。unlink同样可以以这种方式进行利用,由于unlink是在free()函数中调用的,所以我们只看chunk空闲时都需要检查写什么
check1
检查与被释放chunk相邻高地址的chunk的prevsize的值是否等于被释放chunk的size大小
如果一个块属于空闲状态,那么相邻高地址块的prev_size为前一个块的大小
check2
检查与被释放chunk相邻高地址的chunk的size的P标志位是否为0
如果一个块属于空闲状态,那么相邻高地址块的size的P标志位为0,
check3
检查前后被释放chunk的fd和bk
检查前一个释放的bk是否指向自己,后一个的fd是否指向自己
例题

很怪的程序,没有任何提示或菜单,只能静态分析,操作成功才有回显
静态分析
create

edit

输入数据长度没做检测,堆溢出警告
delete

指针置空,无uaf
check
检查是否被使用

动态调试
创建3个堆块试试,结果发现出现了6个(加上top_chunk的话理应出现4个),多出来的两个chunk其实是由于程序本身没有进行 setbuf 操作,所以在执行输入输出操作的时候会申请缓冲区,即初次使用fget()函数和printf()函数的时候

这样一来我们可能就无法堆chunk1做什么操作了,因为chunk1是被两个io_chunk包围住的,我们也不能够控制io_chunk。即使一定要利用chunk1的话也只能先通过堆溢出先覆盖io_chunk,但是这样太麻烦了。所以我们可以考虑更加容易利用的chunk2和chunk3,由chunk2溢出至chunk3,也方便控制流程走向
创建fake_chunk
利用unlink,势必要有一个空闲块。可以构造空闲块。如果我们在chunk2的data部分伪造一个fake_chunk,并且这个fake_chunk处于释放状态。通过堆溢出的方式和修改chunk3的prev_size和size的P标志位,释放chunk3的时向前合并,这样就能触发unlink了:
地址相邻的chunk释放之后会进行合并

放在chunk2的data块的chunk大小至少为0x30
0x8(prev_size) + 0x8(size) + 0x8(fd) + 0x8(bk) + 0x8(next_prev) + 0x8(next_size) = 0x30 |
回忆前面的3个check:
为了能够使得在释放chunk3的时候能够向前合并fake_chunk,并且绕过检查,那么chunk3的prev_size就要等于fake_chunk的size大小,即0x30,这样才能说明前一个chunk(fake_chunk)是释放状态
如果想要触发unlink,那么chunk3的大小就必须超过fast_bin的最大值,所以chunk3的size就至少是0x90,并且chunk3的size的P标志位必须为0:
- prev_size:我们其实只想通过释放chunk3的时候向前合并fake_chunk,并不需要合并chunk2,所以fake_chunk的prev_size置零就行
- size:其实fake_chunk仅仅需要fd和bk完成unlink流程就可以了,后面的next_prev和next_size仅仅为了检查时候用,所以size的大小为0x20就行
- next_prev:这里其实就是为了绕过检查,证明fake_chunk是一个空闲块,所以next_prev要等于size,即0x20
- next_size:没啥用,不检查这里,用字符串占位就好
上图的fake_chunk的next_prev和next_size指的是:fd_nextsize,bk_nextsize
注意:只有空闲的(被free掉的)large_chunk才有fd与bk指针,fd_nextsize,bk_nextsize也是如此。


- fake_fd = first_prev_addr
(?) - fake_bk = third_prev_addr
(?) - third_fd = fake_prev_addr
(✔️) - first_bk = fake_prev_addr
(✔️)
所有创建的chunk的data起始地址都记录在ptr_list[]数组中




释放chunk3触发unlink
为什么释放chunk3就会触发unlink呢?首先fake_chunk与chunk3的地址相邻的,由于我们伪造的fake_chunk是空闲状态,所以在释放chunk3的过程中会发生向前合并,也就是说chunk3要与fake_chunk合并成一个大chunk。但是fake_chunk在我们构造的时候为了绕过检查,所以不得不与first_chunk和third_chunk组成双向链表,所以chunk3就需要横刀夺爱将first_chunk从双向链表中抢过来:
chunk3抢fake_chunk的过程其实就相当于将fake_chunk从双向链表中摘除的过程,那么也就相当于执行unlink的过程,接下来我们看fake_chunk被摘除后发生了什么:

fake_chunk被摘除之后首先执行的就是first_bk = third_addr,也就是说first_chunk的bk由原来指向fake_chunk地址更改成指向third_chunk地址
接下来执行third_fd = first_addr,即third_chunk的fd由由原来指向fake_chunk地址更改成first_chunk地址
这里需要注意的是third_chunk的fd与first_chunk的bk更改的其实是一个位置,但是由于third_fd = first_addr后执行,所以此处内容会从0x602140被覆盖成0x602138

因为整个过程中chunk1并没有使用过,所以s[1]并无大碍。但是s[2]的位置原本是chunk2的data指针,经过unlink之后变成了0x602138。最后就是s[3]了,这里原本是chunk3的data指针,但是由于前面为了触发unlink,所以chunk3被释放了,所以s[3]中被置空
那么我们去想,如果我们在主界面选择修改功能,并且选择修改chunk2,那么实际上输入的内容并不会写进chunk2,而是写进0x602138:
payload = 'a' * 8 + p64(hollkelf.got['free']) + p64(hollkelf.got['puts']) + p64(hollkelf.got['atoi']) |

这样一来free()函数、puts()函数、atoi()函数就已经在s[]数组中部署好了。可以看到如果再次修改s[0]的话其实修改的是free()函数的真实地址,再次修改s[1]的话其实修改的是puts()函数的真实地址,再次修改s[3]的话其实修改的是atoi()函数的真实地址。
那么接下来,如果将s[0],即free()函数got中的真实地址修改成puts_plt的话,释放调用free()函数就相当于调用puts()函数了。那么如果释放的是s[1]的话就可以泄露出puts()函数的真实地址了:
接下来就是释放s[1]了,虽然是调用free(puts_got),但实际上是puts(puts_got),需要注意的是我们接收泄露的地址的时候需要用\x00补全,并且用u64()转换一下才能用:
到了最后一步了,还是用前面的思路,我们将部署在s[2]中的atoi_got中的地址修改成前面找到的system()函数地址,这样一来在接收字符串调用atoi()函数的时候实际上调用的是system()函数:
最后我们在等待输入的时候输入/bin/sh字符串的地址就可以了,看起来是atoi(/bin/sh),但实际上执行的是system(/bin/sh)
from pwn import * |