记得我以前说过pwnhub的题目都超级便太,这次也是。。。主要也是突然着了魔,非要把题目做出来。然后我一个 pwn 小白硬是看着malloc源码(之前我一直觉得看了源码的都是大佬。。直到我做了这个题目,一边做就一边看。。。)研究出来了(?)真心膜那些两天内就做出来的大佬orz。。。。我真的太菜了
Old Chall
程序逻辑
一个典型的 heap pwn 的题目,我们来看一下其源码:
1 | puts("Size:"); |
其中
1 | struct Name{ |
这里首先申请了一个用来存放Content
的堆块,大小<0x200,之后申请了一个固定大小为0x2c的Name
堆块。其中的content指针指向我们的Content,并且记录下大小,写入tag之后,会读入Name
,这个Name的大小为0x20,但是这个地方读入了0x21个字节,于是造成了一字节溢出————off by one.
申请的Name
数组会放在一个堆上的空间,这里称为name_array
。每次在create_letter
时会使用realloc进行分配。这个name_array
维护一个count
变量,每次添加Name
的时候自动增加1,但是删除的时候不进行数据的变动。
数据打印的逻辑如下:
1 | for ( i = 0; ; ++i ) |
很简单,就是根据count
直接打印name_array
中记录的Name
变量。
删除逻辑为:
1 | free(a1->letters_array[index]->content); |
首先freeContent
,之后将存储Content
的Name
也free掉,并且将当前记录在name_array
指针改为0。并且如果继续申请新的堆块,这边也不会重新利用这些空间。
思路
分析完大致逻辑,可以总结以下几个点
- 存在一个
off by one
的漏洞 --> 可以进行 heap overlap - 当前的堆free之后,array只是将当前指针置为0,再次申请空间也不会重复利用这部分内容(未想到如何利用)
- 每次申请一个
Name
,realloc都会重新申请一个空间(未想到如何利用)
从几个点来看,这个题目的关键应该就是利用这个off by one
的漏洞了。
off by one
这个漏洞的主要利用方式是,由于多写了一个字节,导致会将堆上的size
字段修改,从而导致的一种漏洞。比如说如下的情况:
此时B、C块已经是allocated
的状态。若我们的A块有能力溢出,改写B块的size字段,那么此时我们可以通过将size字段改大,然后将B块free掉,之后重新分配一个修改后的size大小的堆,就能够将B和C包括在堆中。不过这里注意一点:
1 | nextsize = chunksize(nextchunk); |
当free的时候会检查当前的堆块之前或者之后的堆的大小是否合理。
Q&A
-
此时的size大小可随意伪造嘛?
我们知道,检查堆块之前\之后的堆是通过size字段进行查找的,所以如果我们随机伪造size,会导致检查过程中bck\fd
(就是malloc中指向当前堆块前一个\后一个堆块的指针)会落入到一个空白(或者是我们的堆内容)中,这样会导致size变得很奇怪(而且有时候还是落入到不可控的位置等)类似这样的检验很多。所以除非我们知道此size会让指针落入到什么位置,否则我们通常使用overlap的思路,完整的将一个堆块包入。 -
除了1,此时的size有什么注意的?
inuse位(也就是最低位)要标志为1。也就是堆溢出的时候,size总是为单数。 -
为什么不尝试修改一个freed chunk?
其实是可以的,不过修改freed chunk的也需要布置chunk的头部就是了。 -
只需要关注当前fake的chunk嘛
不只,我们需要假设我们是glibc,所以此时fake chunk的next chunk和prev chunk我们都需要考虑在内。包括他们的prev_size和size都要注意
leak
pwn的第一步总是leak,这里我们通过构造堆尝试泄露libc和heap地址(这一步我玩了3天ORZ。。。)这个题目头疼的地方在于:
- 它使用calloc,导致堆中的数据在malloc之后会被memset(0)
- 它能够控制分配大小的堆不能打印;能够打印的堆大小固定为0x30
- 它有一个realloc,并且不断的增大,导致堆的结构经常发生变化
- 具有溢出的地方,正好是具有打印能力的堆块,所以大小不可控。另一个可控大小的堆块只能够在攻击的时候利用。
我承认它成功的恶心到我了。。因为一般的泄露思路都是利用什么free之后重新malloc导致残留指针之类的。或者一般的chunk overlap也是类似的思路,通过将之后已经free的堆块包含,然后打印当前堆块之类的。这个倒好,malloc之后memset了。。。。这样的话这些一般思路都不能用。思来想去,发现有一个办法还行,那就是故意造成堆块之间不对齐,从而让一些指针落入到可以打印的堆块中(想到这点之前,足足浪费了一天以上的时间试错真的是ORZ)
libc
libc的话,我们考虑使用unsorted bin的fd和bk泄露。我们首先构造如下的堆:
1 | add_letter(ph, 0x80, padding, to) |
我们分配0x10用于为realloc
的堆存放下一次分配的内容(这么做好像有缺点,后来会提到),之后将这个两个堆释放掉,在内存中就会存在如下的形式:
其中黄色为具有打印能力的Letter,红色为reallo分配的name_array
为了能够泄露数据,我们必须满足以下条件
- 构造了一个overlapchunk
- overlapcunk将一个已经分配的Letter包括在里面
- 分配的时候,错位分配,让fd落在已分配的Letter中
为了达到这个目的,我们必须要让第一次分配的那个Letter重新被分配到Letter,同时,要让第一次分配的堆上方存在一个Letter。因此根据fastbin FILO的原则,我们通过分配一次0x2c大小的堆块,让Content堆块占用一个fastbin,重新让Letter回到第一次分配的位置,同时我们分配一个大堆,让0x80上方出现一个用于溢出的Letter
1 | # raw_input("First check, we look at as note") |
由于一开始的0x80为unsorted bin,所以此时的0x50堆块依然是落在unsorted bin中,我们使用Letter修改其size字段,将下方的Letter重新包括进去。之后我们错位分配一个0x60大小的堆块,让unsorted bin剩下一个0x20的空间,从而让其fd和bk落入下方的Letter中:
1 | add_letter(ph, 0x60, padding, 'T') |
这之后打印,就能够将fd和bk打印出来,从而泄露libc
heap
做到上面那一步其实pwnhub已经结束了。。。后来问了官方,官方说要用到unsorted bin attack,所以要泄露heap的地址(为什么大佬可以在一天之内做的完呢???)没办法,我们继续利用我们上述的堆块进行泄露。
我们可以发现,刚刚的堆块中剩了一个堆,堆的大小为0x20,如果我们此时分配一个大小大于0x20的堆,就会让这个堆落入到small bin中,所以如果我们能够再次构造一个small bin,并且让这个堆块落入Name
,就能够将heap的地址泄露出来。
我们首先构造一个和Name
一样大的Content,并且在这个堆块的后面分配一个0x40的堆块,这个堆块之后自动分配的Name
用来打印泄露的地址。为了能够overlap 所有的堆块,我们必须保证当前堆块的下一个堆块之后有一个有效的堆块大小,因此我们需要再次分配一个堆块来存放合法的堆块大小。之后,我们将这一步最先分配的堆块释放掉,为一会儿的溢出做准备:
1 | add_letter(ph, 0x2c, padding, to) |
之后重新分配0x30的堆块,从而让Name
和Content的位置调换,进行off by one 的攻击,此时让堆块包括下方的整个堆。不过在实际执行的时候发现,reallloc的堆增加了一个0x20的堆块在中间,因此我们需要分配的堆块实际大小为0x30 + 0x20 + 0x40 + 0x30 = 0xc0
之后我们通过分配一个0xa0大小的堆块,让堆块剩下0x20,并且落入到Letter中,之后再随便分配一个大于这个大小的堆,就能够让这个0x20大小的unsorted bin 落入small bin中,从而实现堆地址的泄露
1 | add_letter(ph, 0xa0, padding, to) |
Pwn
官方给出的解题思路中需要用到的是修改_IO_list_all
。我在
pwn之文件指针处理
中介绍过这个结构体的利用方式,但是这个利用方式在libc2.23之后就被封杀了,于是这里我们介绍另一种绕过的方式。
_IO_LIST_ALL
为了思考方便,我们把源代码贴出来。首先这个结构体定义为:
1 | struct _IO_FILE_plus |
其中这个vtable
中记录的内容为:
1 | struct _IO_jump_t |
FILE的内容则为:
1 | struct _IO_FILE{ |
注意到这个struct _IO_FILE ,这里的地方指向了下一个_IO_FILE结构体。
关于这个变量的使用,我们查看代码,这里我们关注
1 | int |
函数是一个flush
的过程,会调用fp指针中的_IO_OVERFLOW_
宏指向的函数。
这个函数会在glibc检测到memory corruption
的时候被调用。而且这个地方其实不一定要手动触发,如果程序触发了
- libc 执行了 abort
- 执行了exit函数
- 从main函数返回的时候
都将调用这个函数。
所以这里的想法就是:通过unsorted bin attack,修改这个_IO_FILE的_chain指针的值,让它指向一个我们准备好的位置,从而实现攻击。
Unsorted bin attack
我们首先看到malloc中,unsorted bin 在malloc时候的处理流程:
1 | bck = victim->bk; |
发现其实在unsorted bin malloc的时候,程序没有检查当前是否发生了unlink,也就是说此时我们可以将bck
修改成任意一个我们需要的地址,这样的话我们就能够让那个地址的fd偏移处(3*size_t)处变成当前的unsorted_chunks(av)(也就是main_arena中存储的unsorted binn的开始位置)
乍一看这个利用并没有什么用,毕竟我们给指定地址赋值为unsorted_bin(av)也没啥意义。这里有两种办法继续利用
- 修改global_max_fast,从而让很大的堆块也能够变成fastbin,配合fastbin的攻击
- 修改
_IO_LIST_ALL
这里介绍后面那种利用方式。当我们成功的让_IO_LIST_ALL
指向了unsorted_bin(av)之后,此时如果我们去看这个FILE结构体的话,内容如下:
疑问:为什么这个unsorted_bin(av)实际上表示的是真正的unsorted_bin的地址-0x10?
因为此时代码为了方便表示chunk,都是直接使用chunk结构体,则chunk->fd 和 chunk->bk 本身存在偏移,因此需要-0x10
所以这里我们可以知道,此时_chain正好会落到smallbin[5]
的范围上。换句话说,如果这个时候我们有一个0x30大小的smallbin的话,那么这个bin的内容就可以用来作为一个伪造的FILE结构体。
vtable的故事
由于在后来的版本中(libc >= 2.24)中,会对vtable有一个检验:
1 | /* Check if unknown vtable pointers are permitted; otherwise, |
根据我原来的办法,是让这个_vtable变为system之类的,这样的话就会不满足其地址在[__start___libc_IO_vtables, __stop___libc_IO_vtables]
之间了。
所以这里我们也要试着学习一个新的姿势去绕过这个vtable的检测。这里根据大佬的教晦,我们可以使用一些已有的vtable进行攻击。这里使用的就是_IO_str_jumps
。这个玩意儿也是一个struct _IO_jump_t
结构体,并且这个结构体中的_IO_OVERFLOW
有可以利用的地方。(虽然我找了源码,但是发现源码里面体现不出这些内容,于是我这里也模仿大佬用IDA来展示)
由于题目是32bit的,所以以下的偏移仅适用于32bit
1 | if ( fp->_flags & 8 ) |
这里是flag的确定,为了防止程序陷入到这部分内容,我们可以简单的让flags为0,即可达到这个目的。
然后我们观察之后的逻辑:
1 | base = fp->_IO_buf_base; |
可以看到,这里会调用一个在fp中的函数,我们看一下实际上是什么:
可以看到,实际上这个地方call的是一个esi+0x98位置的变量。显然这个位置是我们可控的。同时我们通过设置fp->_IO_buf_base
和fp->_IO_buf_end
两个参数,就能够将参数的位置也确定下来。同时注意到,我们需要保证write_ptr - fp ->_IO_write_base >= offset
,才能够进入调用流程。
然后,根据大佬的博客,我们有一些内容是需要主动绕过的,比如说lock参数需要一个指向0的指针地址,我们可以随便从代码中找到。同时,为了能够保证进入到_IO_OVERFLOW
的逻辑里面,我们需要绕过另一个处限制:
1 | int |
这里可以看到,为了进入这个_IO_OVERFLOW
函数,我们需要让结构体中的_mode
为-1,并且_IO_wrie_ptr
要大于_IO_write_base
。
接下来就是设置vtale和对应的函数地址即可。经过计算发现,此时的vtable+4的位置就是我们需要构造的函数地址,则我们需要伪造的堆大概如下:
1 | fake_bin = p32(0) # flags |
然而这个题目最最最最头疼的地方就在这里。。。在每次我们申请堆块的时候,都会伴随的申请一个0x30的堆块!也就是说,我们必须保证伪造smallbin的时刻,有足够的fastbin(0x30)用于我们unsorted bin attack攻击。这个地方非常头疼。我最后参考了一个大佬的exp,发现这个题目关键的一个点就是,完成smallbin构造后,每次allocate chunk之前,我们要保证delete一个chunk,这样就能够满足每次都有fastbin以防smallbin被name给抢走
Pwn
之后的逻辑有点长。。。参考大佬的做法,首先申请了3个0x10大的chunk,此时观测堆中情况如图
这三个chunk我们之后都会用到,标记位1,2,3。接下来我们进行伪造small bin的工作
fake small bin
我们可以看到,一个假的_IO_FILE_plus
结构体(fake_bin)需要用到0x98的大小,这个大小显然不等于smallbin[5],所以我们需要通过伪造 chunk size,让一个本身很大的unsorted bin
变小,才能够实现伪造。
大致步骤如下:
- 申请一个大的chunk(0xf0)同时前0x50个字节写padding,之后的位置写fake_bin
- 将这个chunk overlap ,修改它的大小为0x80
- 申请0x50大小的unsorted bin。由于之后需要overlap到当前堆,所以堆上需要伪造两个堆头(overlap chunk的size,写在prev_size,以及当前伪造堆的堆头chunk,以及这个fake chunk的nextchunk的prevsize 和 size)
这样,我们就会剩下一个"0x30"大小的unsorted bin,并且有完整的_IO_FILE_plus
结构体。之后我么们申请一个大一点的堆块,就能够让这个0x30的堆块落入到unsorted bin 中
*调试技巧:gdb中,print 结构体名 指针 可强行将当前指针指向的地址作为结构体解析
unsorted bin attack
这边我们用到前两个堆块,并且还要用到之前提到的0x50的堆(这个堆的大小超过fasatbin,能够落入到unsorted bin中,这点很重要)
我们遵循先free在allocated的原则就能够保证此时不会将之前的smallbin浪费。同时我们要记得,unsorted bin 是FIFO,所以此时为了不让攻击失败(基本上 off one byte的时候都会让chunk落入到unsorted bin),所以我们free的顺序非常关键,一定是先free攻击堆块,后free 0x50这个用来触发main_arena的堆块
这里的逻辑如下:
- 通过堆块1,修改堆块2的大小为0xe0
- 此时free堆块2,并且紧接着free掉之前的0x50,然后分配0xe0。如果此时之前在fake small bin的时候伪造的头部没问题的话,此时就能复写bk指针,完成攻击!
由于此时的bk已经被破坏,下一次malloc堆块的时候就会因为无法通过检测,进入_malloc_printerr
,从而触发_IO_flush_all_lockp
,完成getshell!
总结
关于heap
- free一个堆的时候,会检测当前堆的prev和next chunk,尤其是next chunk。需要准备一个prev size,size(inuse)和当前size chunk的prev size
- overlap一个堆的时候,我们应该作为当前如果系统这么分配堆的话,其前后应当是什么样子来考虑。比如head,已经对应的fd,bk等
- unsorted bin attack 在 32bit 下的时候应当准备的是smallbin[5],而64bit的才是准备smallbin[4]。根本原因在于
_IO_FILE_plus
的结构体元素在64bit下和32bit下大小一致。
关于pwn
- pwn不能瞎调试。。不能瞎调试。。不能瞎调试。。
- 做题目之前要想清楚自己的payload,包括每一个offset的具体位置
- 如果需要的话就要调试,包括libc.so里面的那些
MISC
- 每一次做pwnhub的题目都要熬夜太伤身体了
- 居然有大佬一天内做出来太变态了吧。。。
- 花了太多时间的根本原因在于不清楚攻击的具体形式,导致一知半解的调试,浪费很多的时间。比如_IO_FILE_plus的结构体构造。其实应该试着写一个c程序看了一下当前的FILE结构体到底应该是什么样子,会减少很多调试的时间
exp
实在是没力气部署调试远程了。。。就放个本地的吧
1 | # -*- coding:utf-8 -*- |
参考博客
http://blog.hac425.top/2018/04/23/pwn-with-glbc-heap.html
大佬学弟的博客,强势安利一波(这里预言,大佬以后可能会写书)