堆中的off-by-one
堆中的off-by-one
参考资料
1 | https://hollk.blog.csdn.net/article/details/108116618 |
简介及定义
off-by-one这项技术不仅适合用于堆,而且适用于栈。但是在CTF中最常见的还是在堆中的应用。严格来说off-by-one是一种特殊的溢出漏洞,当程序在缓冲区写入并溢出的时候,写入的字节数超过了这个缓冲区本身所申请的字节数并且只越界了一个字节。
漏洞原理
off-by-one这种漏洞的形成和整数溢出很相似,往往都是由于对边界的检查不够严谨,当然也不排除和写入的size正好就多了一个字节的情况,边界验证不严谨通常有两种情况:
- 使用循环语句向堆块写入数据时,循环的次数设置错误,导致多写了一个字节,这篇文章讲的就是这个漏洞
- 堆字符串长度判断有误
举例讲解原理
1、循环边界不严谨:
1 | int my_gets(char *ptr,int size) |
看一下这个例子的流程,首先创建了两个char类型的指针chunk1,chunk2,并且分别创建了两个16个字节的堆,接着向my_gets函数重传入了chunk1和16。my_gets函数的作用是从外界接收字符串并讲字符串存放进chunk1的堆中。但是在存入的时候发生了边界不严谨的情况for(i=0;i<=size;i++)。i从0开始,但是i<=16,也就是说循环实际上是走了17次的,这就导致了chunk1会溢出一个字节
2、对字符串长度判断有误
1 | int main(void) |
创建了一个40字节的字符串buffer,又创建了一个24字节的堆chunk1。因为字符串会带上一个\x00结束符。strcpy函数在拷贝的时候也会将结束符\x00存入堆块中,所以说chunk1中一共写了25个字节,这就导致了chunk1溢出了一个字节。
实例讲解
题目附件
1 | https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/heap/off_by_one/Asis_2016_b00ks |
首先检查下文件的保护情况:
可以看到这是64位程序,除了canary保护其它全开。为了方便讲解,需要关闭一下ASLR保护,如果不关,libc的地址会变,方法如下(需要在root权限下执行):
1 | echo 0 > /proc/sys/kernel/randomize_va_space |
重启虚拟机之后ASLR会自动开启
程序流程
需要先给上程序任意用户读写执行权限,接着运行一下
1 | chmod +777 b00ks |
是一个菜单程序,先输入作者名,接着出来几个选项:
- 创建图书
- 删除图书
- 修改图书内容
- 打印图书内容
- 更改作者名
- 退出
1、创建图书:
可以通过payload自动化调用程序中的creat函数,以代替我我们手动创建图书,使用payload表示如下
2、删除图书
使用删除功能需要输入输入图书的id,图书的id是创建图书的时候就从1分配的,可以创建payload的deletebook函数来进行自动化删除。
3、修改图书的类型
修改图书类型需要输入两个内容:图书id,新的图书类型,payload如下:
4、打印图书的信息
直接打印一下图书的数据信息,当然,和上面的一样:
打印信息只需要输入程序功能的序号4就会打印出所有的图书信息:图书id、书名、书的类型、作者名。使用payload传入程序功能序号4就行了。
5、修改作者名
这个功能只需要我们输入新的作者名,可以使用payload中向程序中传入5以执行修改作者名的功能。
IDA静态分析
大概的内容看完了,接下来就该看IDA的静态分析了。
IDA在堆中的作用很大,可以帮助我们分析出程序的漏洞。
main函数分析
将文件载入到IDA中,首先来看main函数:
如图所示,已经对main函数进行了注释,接下来重点看程序的主要功能,首先进入sub_B6D(),大致的语句注释如下:
再进入到read_data_heap函数查看一下:
从上图中可以看出存在for循环,参数传入的是32,循环实际上是运行了33次。所以根据文章开头的说法,可以知道这里存在着单字节溢出漏洞。
返回,看一下name_ptr:
可以看见这个变量的指针保存在offset的0x202018处。记下这个地址,之后会使用到。
创建图书功能分析
1 | __int64 sub_F55() |
看一下sub_B24(),这个函数主要用来为创建的书籍进行分配的ID:
回到前一个函数,有:
从这个if语句,我们可以得到一个booK结构体
1 | struct book |
双击上图中的book_struct_ptr,进入:
还记得这个图吗?off_202018中存放的是作者名,而off_202010中存放的是每个图书的指针。还记的前面留下来的问题吗:为什么sub_9F5()函数循环写入33次会造成off-by-one的影响?因为off_202018和off_202010是紧紧连在一起的,如果我们将作者名输入32个字节的字符串,那么循环多出来的一次会将\x00写到off_202010的起始第一个字节,也就是说会将创建的第一本图书的低字节覆盖掉。
这么讲可能没有什么画面感,没有关系,后面还会根据动态调试进行进一步演示和讲解。
修改图书内容功能分析
查看main函数中的sub_E17()函数:
大致内容就是输入edit_id,根据这个变量对录入的book结构体的内容进行修改
删除图书功能
接下来是删除图书功能,进入函数sub_BBD
这个函数也是通过book_id来对图书的信息进行删除,对struct结构体中的内容进行了free。
打印图书的信息功能分析
进入函数sub_D1F():
这个函数对图书的每一个结构体进行了遍历,并打印处book结构体信息。
修改作者名功能分析
直接调用read_data_heap函数将新输入的作者名写入到堆中,用户名的长度依然是32字节。
总结
- 作者名存放在off_202018指针中,这个指针一共32个字节
- 图书结构体指针存放在off_202010指针中
- sub_9F5()函数存在off-by-one漏洞,在首次创建作者名或修改作者名的时候,如果填写32个字节的字符串,那么就会导致
\x00
溢出到off_202018的低位
借用下大佬文章的图片,大致的理论布局图如下:
思路讲解及动态调试
先关闭ASLR保护
定位作者名
gdb打开程序并按’r’运行起来,在首次创建作者名的位置,输入32个字节的字符串将存放作者名的off_202018空间填满:
接着我们ctrl + c进入调试界面,在这里我们需要定位刚才输入的作者名,方式有两种:
1、因为我们知道作者名存放在off_202018中,所以我们只需要知道代码段的基地址在加上off_202018的偏移就可以找到存放作者名的指针了,首先我们输入命令vmmap查看一下代码段的起始地址
可以看到第一个可读可执行的红色就是代码段了,并且能够找到这个代码段的起始位置为0x555555554000
可以看到off_202018的偏移为0x202018
,那么将代码段起始地址加上off_202018的偏移就可以得到存放作者名地址的指针了:
0x555555554000 + 0x202018 = 0x555555756018
使用命令x/16gx 0x555555756018
查看一下:
可以看到0x555555756018中存放的是存放用户名的地址0x555555756040,0x555555756040的位置正好就是我们刚才输入的32个字节的作者名
2、直接输入命令‘search 作者名’
可以看到我们的字符串存放在0x555555756040
位置,我们直接输入命令x/16gx 0x555555756040
就可以看到字符串了
红色框住的就是\x00覆盖的第33个字节。
泄露出图书结构体指针
接下来输入命令c回到程序执行界面,输入1创建两个图书:
- 图书1:书名大小 = 208,书名book_name1,类型大小 = 200,内容book_desc1
- 图书2:书名大小 = 135168,书名book_name2,类型大小 = 135168,内容book_desc2
1 | 为什么要将书1的书名大小设为208呢?书2的书名和内容要设为135168呢?之后再说 |
接着我们输入命令ctrl + c
回到调试界面,这次我们定位一下两个书结构体的位置,因为图书的结构体指针存放在off_202010中,所以还是用老方法数据段起始地址加上偏移
1 | 0x555555554000 + 0x202010 = 0x555555756010 |
输入命令x/16gx 0x555555756010
查看一下off_202010:
可以看到0x555555756060中存放的就是图书1的结构体指针,紧跟着的就是图书2的结构体指针。还有一点需要注意的是我们输入的作者名也紧紧地贴在两个结构体指针前面,这是因为存放这两个东西的off_202010和off_202018是挨着的
由于作者名和book1结构体相连,所以事实上book1结构体指针的低位e0将原有作者名溢出的\x00覆盖掉了。因此它会将book1的结构体地址打印出来(由于\x00存在,不会打印出book2的地址)
我们试一下,在输入命令c,输入4打印图书信息。
可以看到book1的结构体指针已经被泄露出来了。
记录一下此时的book1结构体指针。
编写部分exp自动化完成上述操作:
1 | create_book(64, "book1", 200, "description1") |
覆盖原有结构体指针
首先看一下book1结构体内容,输入命令x/20gx 0x55555575d0
可以看到上半部分就是book1的结构体,下半部分就是book2的结构体,我们直接看book1的结构体:
- 0x5555557575d0: book_id
- 0x5555557575d8: book_name1
- 0x5555557575e0: book_desc1
我们需要注意的是0x5555557575e0这个地址里面存放的是book1_desc,拿出你的记事本把这一点记下来
接下来我们去想一下,既然book1的结构体指针低位能够覆盖作者名的\x00,那么作者名的\x00是不是也能够覆盖结构体指针的低位呢?正好这个程序还有修改作者名的功能,而且在前面ida分析的时候发现新的作者名依然还会存放在202018的位置。那么我们预想一下book1的结构体指针是0x5555557575d0那么被覆盖之后就会变成0x555555757500,不就是book_desc1的位置吗?
那么我们就修改一下作者名验证一下
指向book1结构体指针变为0x555555757500了,这个地址正好指向的是book_desc1,所以这就是为是什么前面size填写208的原因(经调试出来的)。
可以看到book1原有的结构体指针0x5555557575d0被\x00覆盖成了0x555555757500,现在拿出你的记事本,0x555555757500的位置就是刚才book1_desc的位置,这也是为什么要将book1_name设置成208的原因。那么我们去想想一想,调用一本书的流程:首先是和图书id对应着图书的结构体指针,结构体指针对应着结构体,结构体带动其中的成员变量。那么上图我们通过\x00覆盖之后原有的结构体指针变成了0x555555757500,那么程序就会去0x555555757500的位置寻找结构体。如果我们在原有的book1的book1_desc的位置伪造一个结构体,然后在进行\x00覆盖,那么就把伪造的结构体当做book1来实现.
伪造结构体并泄露book2书名、内容地址
book1_size为什么填208已经作了解释,那为什么book2的name_size和desc_size为135168。这是因为我们申请一个超大块的空间,使得堆以mmap的形式进行扩展,那么mmap申请的这个空间会以单独的段形式表示。只有接近或超过top_chunk的size大小的时候才会使用mmap进行拓展,具体的数字需要尝试几次。
如果发现下图出现mmap的话,就可以判断输入多大的值了。如果没有出现标识的话,可以查看是否存在紫色不断变换空间大小的data段。
由于关闭了ASLR保护,所以libc.so的基地址就不会发生改变了,因为book2的结构体成员的地址所属为mmap申请的空间,那么mmap地址不变、libc.so基地址不变,就会导致book2成员变量中的地址所在位置距离libc.so的偏移不变。那么如果可以通过泄露book2结构体成员变量中的地址的话,减去这个这个偏移就会得到libc.so的基地址。一旦得到了libc.so的基地址,我们可以利用pwntools的工具找到一些可以利用的函数了。
借用一下holk大佬的图修改一下。
从部署伪造的结构体开始。
首先是fake_book1_id,既然我们想要完全将原有的book1替换掉,那么fake_book1_id就必须为1,这样才能按照第一个book的形式替代原有的book1。接下来是fake_book1_name,这里我们将指向book2_name的地址,fake_book1_desc指向book2_desc的地址。这样一来我们再一次执行打印功能的时候就会将book2_name和book2_desc的地址打印出来了。
获得book1结构体指针后,需要计算下book_name2和book_desc2的地址
用0x55555575d0减去0x5555557608得到0x38是book_name2偏移,同理0x55555575d0减去0x5555557610得0x40是book_desc2的偏移
这样一来,结尾加上oxffff
作为结束符,我们伪造的结构体就完成了:
1 | payload = p64(1) + p64(book1_addr + 0x38) + p64(book1_addr + 0x40) + p64(0xffff) |
我们部署好结构体之后还需要重新修改一下作者名,因为我们的结构体是写在原book1_desc中的,所以按照攻击流程上来说应该先部署伪造的结构体,然后再使用\x00
覆盖book1结构体指针,使指针指向我们伪造的结构体
使用代码将上诉操作进行编写:
1 | book1_addr = 0x5555557575d0 |
计算libc基地址、freehook、onegadget
既然我们得到了book2_name_addr和book2_des_addr就可以通过前面的方法去计算libc基地址了。ctrl + c
回到调试界面,使用命令vmmap
查看一下
具有可读可执行的.so就是我们要找的libc.so了,它的起始地址是0x7ffff7bcd000
接下来可以任选book2_name_addr或book2_des_addr其中一个计算偏移
1 | 0x7ffff7f98010->0x7ffff7a0d000 is -0x58b010 bytes (-0xb1602 words) |
在得到libc基地址之后就可以通过pwntools查找函数了,可以直接利用pwntools查找free_hook函数
1 | free_hook = libc_base + libc.symbols["__free_hook"] |
接着使用命令one_gadget /lib/x86_64-linux-gnu/libc.so.6
查看一下:
1 | # 0x45216 execve("/bin/sh", rsp+0x30, environ) |
挂钩子
因为我们选择的是book2_des_addr,所以思路就是先向伪造的结构体fake_book1的desc中部署free_hook,然后再向book2的desc中写onegadget,最后在释放book2的时候就可以触发execve(’/bin/sh’)了
EXP:
1 | from pwn import * |
这个对版本也是有要求的,有的时候高版本打不通。