堆中的Off-By-One

介绍

严格来说off-by-onr漏洞是一种特殊的溢出漏洞,off-by-one指程序向缓冲区写入时,写入的字节数超过了这个缓冲区本身所申请的字节数并且只越界了一个字节。这种漏洞往往是在字符串操作时边界检查不严谨所导致的,例如

  • 循环语句中的循环次数设置有误。
  • 字符串操作不合适

一般来说,单字节溢出被认为是难以利用的,但是因为Linux的堆管理机制ptmalloc验证的松散性,基于Linux堆的off-by-one漏洞利用起来并不复杂,并且威力强大。

off-by-one利用思路

发生在堆上的off-by-one,根据其是否涉及堆块头的修改可以分为两类,第一类是普通的off-by-one,通常用于修改堆上的指针;第二类则是通过溢出修改堆块头,制造堆块重叠,达到泄露或改写其他数据的目的,通常情况下溢出后修改的是下一个堆块的size域。由于glibc采用了空间复用技术,即将下一个堆块的prev_size域提供给当前chunk使用,所以堆块实际可用的大小(可以由malloc_usable_size()获得)不是固定的,如果堆块是通过mmap分配的,实际可用大小是size减去两倍的字节大小,否则是size减去一倍字节大小。

  • 溢出字节为可控制任意字节:通过修改大小造成块结构之间出现重叠,从而泄露其他块数据,或覆盖其他块数据。也可使用NULL字节溢出的方法
  • 溢出字节为NULL字节:在size为0x100的时候,溢出NULL字节可以使得prev_in_use位被清,这样前块会被认为是free块。(1)这时可以选择使用unlink方法进行处理;(2)另外,这时prev_size域就会启用,就可以伪造precv_size,从而造成块之间发生重叠。此方法的关键在于unlink、的时候没有检查按照precv_size找到的块的大小与prev_size是否一致。

最新版本代码中,已加入针对溢出字节为NULL字节的第二种方法的check,但是在2.28及之前版本并没有该check。

实例

实例一

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

我们自己编写的my_gets函数导致了一个off-by-one漏洞,原因是for循环的边界没有控制好导致写入多执行了一次,这也被称为栅栏错误。

我们使用gdb对程序进行调试,在进行输入前可以看到分配的两个用户区域为16字节的堆块

1
2
3
4
pwndbg> x/10xg 0x555555559290
0x555555559290: 0x0000000000000000 0x0000000000000021
0x5555555592a0: 0x0000000000000000 0x0000000000000000
0x5555555592b0: 0x0000000000000000 0x0000000000000021

当我们执行my_gets进行输入之后,可以看到数据发生了溢出覆盖到了下一个堆块的prev_size域。

1
2
3
4
pwndbg> x/10xg 0x555555559290
0x555555559290: 0x0000000000000000 0x0000000000000021
0x5555555592a0: 0x6161616161616161 0x6161616161616161
0x5555555592b0: 0x0000000000000061 0x0000000000000021

实例二

第二种常见的导致off-by-one的场景就是字符串操作了,常见的原因就是字符串的结束符计算有误

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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;

}

程序乍看上去没有任何问题(不考虑栈溢出),可能很多人在实际的代码中也是这样写的,但是strlen和strcpy的行为不一致却导致了off-by-one的发生。strlen是我们熟悉的计算ascii字符串长度的函数,这个函数在计算字符串长度时是不把结束符'\x00'计算在内的,但是strcpy在复制字符串时会拷贝结束符'\x00'。这就导致了我们在向chunk1中写入了25个字节,我们使用gdb进行调试可以看到这一点。

1
2
3
4
pwndbg> x/10gx 0x555555559290
0x555555559290: 0x0000000000000000 0x0000000000000021
0x5555555592a0: 0x0000000000000000 0x0000000000000000
0x5555555592b0: 0x0000000000000000 0x0000000000000411

在我们输入24个‘a’后执行strcpy。

1
2
3
4
pwndbg> x/10gx 0x555555559290
0x555555559290: 0x0000000000000000 0x0000000000000021
0x5555555592a0: 0x6161616161616161 0x6161616161616161
0x5555555592b0: 0x6161616161616161 0x0000000000000400

可以看到next chunk的size域低字节被结束符'\x00'覆盖,这种又属于off-by-one的一个分支称为NULL byte off-by-one,我们在后面会看到off-by-one与NULL byte off-by-one在利用上的区别。

在libc-2.29之后

由于这两行代码的加入

1
2
if (__glibc_unlikely (chunksize(p) != prevsize))
malloc_printerr ("corrupted size vs. prev_size while consolidating");

由于我们难以控制一个真实chunk的size字段,所以传统的off-by-NULL方法失效。但是,只需要满足被unlink的chunk和下一个chunk相连,所以任然可以伪造fake_chunk。

伪造的方式就是使用large bin遗留的fd_nextsize和bk_nextsize指针。以fd_nextsize为fake_chunk的fd,bk_nextsize为fake_chunk的bk,这样我们可以完全控制该fake_chunk的size字段(这个过程会破坏原large bin chunk的fd指针,但是没关系),同时还可以控制其fd(通过部分覆写fd_nextsize)。通过在后面使用其他的chunk辅助伪造,可以通过该检测

1
2
if (__glibc_unlikely (chunksize(p) != prevsize))
malloc_printerr ("corrupted size vs. prev_size while consolidating");

然后只需要通过unlink的检测就可以了,也就是fd-bk == p && bk->fd == p

如果large bin中仅有一个chunk,那么该chunk的两个nextsize指针都会指向自己

我们可以控制fd_nextsize指向堆上的任意地址,可以很容易地使之指向一个fastbin +0x10 - 0x18,而fastbin中的fd也会指向堆上的一个地址,通过部分覆写该指针也可以使该指针指向之前的large bin + 0x10 ,这样就可以通过fd->bk == p检测。

由于bk_nextsize我们无法修改,所以bk->fd必然在原先的large bin chunk 的fd指针处(这个fd被我们破坏了)。通过fastbin的链表特性可以做到修改这个指针且不影响其他的数据,再部分覆写之就可以通过bk->fd == p的检测了。

然后通过off-by-one向低地址合并就可以实现chunk overlapping了,之后可以leak libc_base 和堆地址,tcache打 __free_hook即可。