NCTF 2026 WP (ezheap)
前言
通过这道题,新学到不少知识点,所以neise1觉得有必要单独写一篇来讲这道题,本来neise1想打IO的(毕竟当时刚学完,嘻嘻),结果完全没有能正常调用的IO函数,于是就懵了,这还是经大佬指点才搞明白的。
漏洞分析
堆题常见的保护全开,那让我们来看一下程序萝莉,找一下漏洞。
嗯,非常经典的笔记系统类型的题目。
neise1这里发现,漏洞在edit_chunk功能中,在写完数据后,程序会直接把read函数的返回值(也就是读取的数据的字节数)作为索引,在该处写入0,那么在刚好输入了同堆块大小一样的数据时,这个0就会直接写入下一个堆块中,也就是off-by-null。
同时,这里对申请的堆块大小也有限制,这里只能申请1104~1360大小的堆块,这代表着我们在一开始申请的堆块在被free后只能放进unsortbin和largebin中。
利用思路
off-by-null的利用思路就是House Of Einherjar,利用这一位的溢出,覆盖后一堆块的prev_inuse位为0,使得后者在被释放后触发向前合并,让仍在使用中的前一堆块也被放进bin中,由于libc版本是2.39,会有很多检查机制,这里我结合一位师傅的exp仔细讲讲。
1 | add(0, 0x508) |
首先,我们先申请两个堆块,chunk0,chunk1,在这一步操作中,如果chunk1堆块不存在,chunk0堆块释放后进入unsortedbin且与topchunk相邻,是会被topchunk合并的,所以这里要用add分配两个堆块。
随后,释放chunk0,进入unsortedbin,此时,再申请一个比chunk0大的堆块chunk2,这样,分配堆块时,堆管理器遍历一遍unsortedbin后发现没有合适的堆块,就会把unsortedbin中的堆块根据大小放到smallbin和largebin中,这里chunk0会被放到largebin中,而largebin中堆块会有两对指针,fd和bk,fd_nextsize和bk_nextsize,其中,当largebin中只有一个堆块时,fd和bk会指向largebin头(是一个和libc库基址有固定偏移的地址),fd_nextsize和bk_nextsize指向自身(堆地址),所以这里再把chunk0分配出来,让指针保留,通过show泄露libc地址和堆地址。
这里对chunk0和1的大小是有要求的,首先,chunk0申请的大小,最后一个数一定要是8,因为一定是要16字节对齐的,这时堆管理器只会多申请8字节(比如这里申请508,堆块的实际大小会是510),但是prev_size位和size位本身就占了0x10字节,那哪里来的0x508字节给用户使用呢?实际上,这个时候后一堆块的prev_size位的8字节就会被征用,因为它在前一堆块在使用中时是无意义的,但是,这就让我们off-by-null刚好能覆盖掉下一堆快的prev_inuse位。
然后后续我们覆盖prev_inuse位是覆盖一字节,所以要保证chunk1的实际大小(包括chunk头)是以00结尾的。
1 | payload = (p64(p1_hdr) * 4).ljust(0x500, b"A") + p64(0x510) |
这里,我们通过edit,写chunk0的fd和bk字段,让二者都指向chunk0本身,再写入chunk1的prev_size位,并覆盖prev_inuse位。
此时,free(1),堆块一被释放后,堆管理器发现chunk1的prev_inuse位是0,就开始尝试向前合并,根据prev_size位和chunk1本身的堆地址,找到chunk0的位置,并通过unlink,把chunk0从原本的bin的链表中摘出(不过这里chunk0很明显并没有入bin),其中unlink还会检查堆块所在链表的完整性(chunk->fd->bk==chunk&&chunk->bk->fd==chunk),这里我们直接让fd和bk都指向其本身了,所以就可以绕过该检查,完成unlink和向前合并。
House of Corrosion
接下来,我们使用house of corrosion,用largebin attack修改tcache中可容纳的bin的数量(即链表数),tcache可容纳的堆块大小是0x20+0x10*bin数量,所以把bin数量改大就可以容纳更大的堆块,然后,因为tcache_perthread_struct结构体中entries数组存着tcache各个链表的表头指针(也就是说索引为0~63),但正常他肯定不会留出超出63索引值的空间嘛,而tcache_perthread_struct结构体会被分配在堆的最开头嘛,所以超出索引值的那个超大堆块的指针就会放进你分配的第一个堆块中,而你是可以控制这个堆块中的数据的,所以你就可以随便修改这个指针,并通过tcache分配任意堆块到你想要的地方,达成任意读写。
至于largebin,额,分为低版本和高版本,讲清原理比较麻烦,这里高版本利用的话,大致就是因为对fd_nextsize和bk_nextsize指针缺少检查,有空neise1再单独讲一下。
好那我们再进入代码。
1 |
|
这里add(1)就是把unsorted中合并的堆块从头上切下来一块,也就是我们的chunk0,随后add(8)也就是我们的chunk1,再申请几个堆块备用,然后free(0),让chunk0被free,但此时索引1也指向chunk0,所以我们可以做到UAF,然后再申请一个比刚才释放的堆块大的堆块(add(5,0x550)),让chunk0被放进largebin里, 然后free(3)(这里先free(3),还是先edit都可以),这里就是先释放3,然后再把fd,bk,fd_nextsize,bk_nextsize,一股脑全覆盖为我们要写入的地址-0x20,因为我们这里只要求用一个较大值,所以直接把地址覆盖上去也没问题(虽然largebin attack现在本身也只能写堆地址),然后再申请一个大堆块,让刚刚释放的3进入largebin,完成largebin attack。
1 | free(4) |
然后,释放4,这时堆块已经进入tcache了,所以该堆块指针已经在chunk0中了
随后,确定偏移,然后修改该指针为environ,去读取栈地址。
1 | free(8) |
最后,把指针修改指向一个返回地址,分配堆块后写ROP链就好了。
exp
完整exp如下:
1 | from pwn import * |









