# Off by null

# 原理

​ 在读入数据的时候,对数据长度的检查不严谨,导致了一个 NULL 字节的溢出。在堆块 size 为 0x100 时,溢出 NULL 字节会导致 prev_inuse 位被清零,该堆块的前一个堆块会被认为是 free(未分配)状态。因此可以通过 unlink 实现任意地址写,或者伪造 prev_size 达到 UAF 的效果。

# 流程

  1. 开辟三个堆块分别为 chunk0、chunk1、chunk2。
  2. 编辑 chunk1,输入特定数据使其溢出一个 NULL(\x00)字节,覆盖 chunk2 的 prev_inuse 位为 0,这时 chunk1 就会被认为 free 掉了。

# Unlink

​ Unlink 是把 free 掉的 chunk 从所属的 bins 链中,卸下来的操作(当然还包括一系列的检测机制)。它是在 free 掉一块 chunk (除 fastbin 的 chunk 外)之后,glibc 检查这块 chunk 相邻的上下两块 chunk 的 free 状态之后,做出的堆块合并引起的。

BK-P-FD==>BK-FD    P
// 三个堆块
FD=P->fd
BK=P->bk
// 确定堆块顺序
FD->bk=BK<=>P->fd->bk=P->bk
BK->fd=FD<=>P->bk->fd=P->fd
// 将 P 前后两个 chunk 相连,从而分离中间的 chunk。

# 堆块合并

# 向前合并(高地址)

c
// 假设这里 p 为 chunk2
if (!prev_inuse(p)) {  
    prevsize = p->prev_size;  
    size += prevsize;  
    // 确定堆块合并后的 size
    p = chunk_at_offset(p, -((long) prevsize));  
    // 通过 pre_size 找到前一个 chunk
    unlink(av, p, bck, fwd);  
    // 通过 unlink 合并堆块
}

​ 在 free chunk2 的时候。会先检测 chunk3 的 prev_inuse 值。如果为 0,说明 chunk2 已经被 free,直接返回;如果为 1,则会检测 chunk2 的 prev_inuse 值。如果 chunk2 的 prev_inuse 值为 1,说明 chunk1 被占用,则跳过向前合并,如果为 0,会根据 chunk2 的 prev_size 值找到 chunk1,合并这两个堆块,完成向前合并。

# 向后合并(低地址)

if (nextchunk != av->top) {
    nextinuse = inuse_bit_at_offset(nextchunk, nextsize);
    // 获得下一个 chunk 的 prev_inuse
    if (!nextinuse) {
        unlink(av, nextchunk, bck, fwd);
        // 通过 unlink 合并堆块
        size += nextsize;
        // 确定堆块合并后的 size
    } else
        clear_inuse_bit_at_offset(nextchunk, 0);

​ 在 chunk2 向前合并完成后,会检测下一个堆块是否为 top chunk,如果是,直接与 top chunk 合并;如果不是,继续检测 chunk3 的下一个堆块 chunk4 的 prev_inuse 值,如果为 1,说明 chunk3 被占用,跳过向后合并;如果为 0,合并 chunk3,完成向后合并。

# PS:

  1. 在堆块合并时,无论向前向后都只合并相邻的堆块,不会继续合并更前或更后的堆块。
  2. 属于 fastbin 大小的堆块在 free 时不会合并,而是直接放入 fastbin。

# 伪造 chunk 并绕过 unlink 检测

​ 可以伪造 chunk 并通过 unlink 来修改 GOT 表从而 getshell。但是 unlink 会对 chunk 的 size 和双向链表完整性进行检查。

# size 检查

if (__builtin_expect (chunksize(P) != prev_size (next_chunk(P)), 0))
      malloc_printerr ("corrupted size vs. prev_size");
//P 自己包含 size 信息,P 的下一个 chunk 也包含了 P 的 size 信息,将两者进行比较。

# 双向链表完整性检查

if (__builtin_expect (FD->bk != P || BK->fd != P, 0))
  malloc_printerr (check_action, "corrupted double-linked list", P, AV);
// 检查 P 前一个 chunk 的 bk 和后一个 chunk 的 fd 是否都指向 P。

# 流程

  1. 开辟三个堆块 chunk0、chunk1、chunk2。chunk2 应大于 fastbin 的范围。
  2. 在 chunk1 中伪造一个 chunk,并通过 off by null 修改 chunk2 的 prev_size 和 size 来绕过 size 检查
  3. 因为伪造的 chunk 并不在链表中,所以让伪造的 chunk 的 fd 和 bk 都指向自己,就能绕过双向链表完整性检查
// 在绕过双向链表完整性检查时,通常按照以下公式设置伪造 chunk 的 fd 和 bk。
fd=chunk_addr-0x18
bk=chunk_addr-0x10
// 会有以下效果
chunk->fd->bk==*(chunk->fd+0x18)==*(&chunk_addr-0x18+0x18)==chunk_addr
chunk->bk->fd==*(chunk->bk+0x10)==*(&chunk_addr-0x10+0x10)==chunk_addr
// 满足了检查条件
  1. free chunk2,unlink 会合并伪造的 chunk 和 chunk2。

  2. 这时 bss 段上对应 chunk2 的指针 ptr 会从 [ptr] 修改为 [&ptr-0x18],就可以通过编辑 chunk2 对 GOT 表进行修改,改变其他 chunk 的指针。

    chunk2

    presize = 0

    chunk0

    fd: ptr-0x18 bk : ptr-0x10

    chunk1

# libc2.29 之后

​ 值得一提的是,在 libc2.29 之后,glibc 在向前合并时对 presize 添加了检测,导致我们无法修改正常 chunk 的 size,因此无法伪造 prev_size。

// 设这里的 p 为 chunk1
if (!prev_inuse(p)) 
{
    prevsize = prev_size (p);
    size += prevsize;
    p = chunk_at_offset(p, -((long) prevsize));
    if (__glibc_unlikely (chunksize(p) != prevsize))
// 这里检测了 chunk0 的 size 和 chunk1 的 prevsize 是否相等
        malloc_printerr ("corrupted size vs. prev_size 	   while consolidating");
    unlink_chunk (av, p);
}

​ 按照我们上面的思路,如果我们修改了 chunk2 的 prev_size,使其与伪造的 chunk 的 size 相同,这里会检查 chunk2 的 prev_size 和真正的 chunk1 的 size 是否相同,结果自然是不同的。

​ 如果要伪造一个可以绕过检查的 chunk,需要利用到 largebin 残留的 fd_nextsize 和 bk_nextsize 两个指针,smallbin 残留的 bk 指针,以及 fastbin 的 fd 指针,会更加复杂。

更新于