0x00 前言

将源字符串复制到目标缓冲区可能会导致off-by-one,发生的条件是源字符串长度等于目标缓冲区长度,此时将在目标缓冲区上方复制单个NULL字节,由于目标缓冲区位于堆中,因此单个NULL字节可能会覆盖下一个块的块头,这可能会导致任意代码执行。

通过对Linux glibc堆分配器ptmalloc2的学习,我们了解到可以通过off-by-one覆盖堆块头的size字段,从而改变该堆的大小或inuse位,利用堆块覆盖或unlink达到利用的目的。

堆段按照用户堆存储器的请求被划分为多个块,每个块都有自己的块头(由malloc_chunk表示),malloc_chunk结构包含以下四个元素:

  1. prev_size:如果前一个块是空闲的,则该字段包含前一个块的大小。否则,如果分配了之前的块,则该字段包含之前块的用户数据。
  2. size:此字段包含已分配块的大小。该字段的最后3位包含标识信息:

    • prev_inuse(P) - 分配上一个块时设置该位
    • is_mmapped(M) - 当块是mmap’d时设置此位
    • non_main_arena(N) - 当此块属于thread arena
  3. fd:指向同一个bin中的下一个chunk

  4. bk:指向同一个bin中的上一个块

由于glibc堆分配器的字节对齐机制,并不是所有的off-by-one漏洞都可以利用。

例如:32位系统中,按照8字节进行对齐,如果malloc(512),那么实际会分配512+4*2=520字节,其中8字节为prev_size和size字段的大小,off-by-one只会覆盖prev_size字段最低字节造成无法利用;如果malloc(508),由于508+8=516字节,516字节不满足8字节对齐,并且考虑到下一块的prev_size字段(4字节)在前一块已分配时,可以填充前一块数据,实际上只会分配508+4=512字节,off-by-one会覆盖size字段最低字节,此时可以利用。同理,64位系统中,按照16字节进行对齐,如果malloc(512),那么实际会分配512+8*2=528字节;如果malloc(504),那么实际会分配504+8=512字节。

0x01 堆上的off-by-one利用技巧

若堆上的off-by-one可以利用,那么有两类利用方式:

一类是覆盖size字段inuse位,在free时触发unlink机制;第二类是覆盖size字段最低字节,从而改变堆块大小,使该堆块包含后一块,free掉该堆块之后,再malloc(稍大于该堆块加后一块的大小),就可对后一块进行读写。

  1. 利用unlink机制

此类利用方式需分两种情况:

(1)Small bin unlink

(2)Large bin unlink:利用方式在glibc 2.20版之后已经失效,该攻击最早出现在2014年Google Project Zero项目的一篇文章中The poisoned NUL byte, 2014 edition。在Linux堆漏洞之Double-free中已经讲过unlink宏,其中只讲到unlink Small bin时进行的操作,只需绕过第一层双向循环链表检查就可以利用unlink。如果unlink Large bin,由于Large bin块含有字段fd_nextsize和bk_nextsize,在绕过第一层双向循环链表检查还会进行第二次双向循环链表检查。但是在glibc早期版本(2.19之前),第二次双向循环链表检查只通过断言(assert)形式,属于调试信息,不能真正的对漏洞进行有效的防护。从而可以利用Large bin unlink导致一次任意地址写,然后利用overwriting tls_dtor_list实现漏洞利用。在程序main()函数结束调用exit()函数时,会遍历tls_dtor_list调用一些处理收尾工作的函数,如果通过overwriting tls_dtor_list使其指向伪造的tls_dtor_list,就可以调用自己的函数(如system(‘/bin/sh’))。在当前版本的glibc(2.23)中,unlink宏在unlink Large bin 时会进行双向链表检查,而且在__call_dtors_list中获取tls_dtor_list时也做了一些限制,导致很难利用Large bin unlink。

  1. 利用堆块覆盖

这一类攻击主要是获取对目标堆块的读写,利用方式分两种情况:一种是覆盖最低字节为任意数(off-by-one overwrite freed or allocated),另一种是覆盖最低字节为NULL(off-by-one NULL byte)

off-by-one overwrite or allocated:

1
2
3
4
          _____________________                          
         |      |      |       |                       
         |  A   |   B  |   C   |                     
         |______|______|_______|                                         

如上所示,堆块A、B、C,其中堆块A已分配且含有off-by-one漏洞,堆块B已释放,堆块C为目标堆块,需要对堆块C可读写。可以通过堆块A的off-by-one漏洞覆盖堆块B size字段的最低字节(不改变inuse位),使堆块B的长度可以包含堆块C。然后在malloc(B+C),就可以获取堆块B的原来指针,从而可以对目标堆块进行读写。 如果堆块A、B、C都是已分配,可以释放掉堆块B,将问题转化为前面一种情况,同样可以解决。

off-by-one overwrite NULL byte:

1
2
3
4
              ______|_____B_______|_____
             |      |    |    |   |     |
             |  A   | B1 | B2 |   | C   |
             |______|____|____|___|_____|

这类漏洞在实际中很常见,如使用strcpy()进行复制时未考虑字符串长度。如上所示,堆块A、B、C,其中堆块A已分配且含有off-by-one漏洞,堆块B、C已分配,堆块B2为目标堆块,需要对堆块B2可读写。利用方法:先释放掉堆块B,然后通过堆块A的off-by-one漏洞覆盖堆块B size字段的最低字节为NULL,减小堆块B的size字段值 (如果堆块B的size字段未改变,再次分配时,堆块C的prev_size字段会改变,造成漏洞无法利用)

再申请两个较小的堆块B1和B2(B1+B2<B),这时堆块C的prev_size大小仍然是堆块B的大小,释放掉堆块B1和堆块C时就会导致堆块B和堆块C进行合并,然后再malloc(B+C)大小的堆块就可以得到原来堆块B的地址,从而可以对堆块B2进行读写。

0x02 利用实例

off-by-one NULL byte 漏洞代码:

 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
#include <stdio.h>
#include <string.h>
#include <malloc.h>

int main(int argc, char* argv[])
{
    void *A,*B,*C;
    void *B1,*B2;
    void *Overlapping;
    A = malloc(0x100-8);
    B = malloc(0x200);
    C = malloc(0x100);
    printf("chunk B address: %x,  C address: %x\n", B, C);

    free(B);
    ((char *)A)[0x100 - 8] = '\x00';    // off-by-one NULL byte

    B1=malloc(0x100);
    B2=malloc(0x80);
    printf("chunk B1 address: %x,  B2 address: %x\n", B1, B2);
    free(B1);
    free(C);
    Overlapping = malloc(0x300);  
    printf("new malloced chunk: %x\n", Overlapping);
    return 0;
}