Dreams was an exploitation challenge from ångstromCTF 2022. We are given an ELF binary and a glibc shared object, version 2.31.
1
2
3
4
5
6
$ checksec ./dreams
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
When ran, the program gives the user 3 options:
1
2
3
4
5
6
7
8
9
$ ./dreams
Welcome to the dream tracker.
Sleep is where the deepest desires and most pushed-aside feelings of humankind are brought out.
Confide a month of your time.
----- MENU -----
1. Sleep
2. Sell
3. Visit a psychiatrist
>
Reverse engineering
The binary itself is quite simple. If we look at it in Ghidra, we see the following decompiled output after correcting type information:
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
void main()
{
/* ... */
dreams = (dream_t **)malloc((long)(MAX_DREAMS << 3));
/* ... */
opt = 0;
while (true) {
while (true) {
menu();
printf("> ");
__isoc99_scanf("%d",&opt);
getchar();
if (opt != 3) break;
psychiatrist();
}
if (3 < opt) break;
if (opt == 1) {
gosleep();
}
else {
if (opt != 2) break;
sell();
}
}
puts("Invalid input!");
exit(1);
Here dream_t
is just a user-added structure with the following layout:
1
2
3
4
typedef struct {
char date[8];
char about[20];
} dream_t;
dreams
is an array of MAX_DREAMS pointers to dream_t
structures. MAX_DREAMS is a global variable set to 5. The three options shown in the menu correspond to the gosleep()
, sell()
and psychiatrist()
functions.
gosleep()
allocates a new structure and copies the pointer todreams[i]
.i
is user-controlled and bounds-checked. The pointer atdreams[i]
must be NULL beforehand. The structure fields are also initialized with user-controlled values, butdream->date
is terminated with a null byte.sell()
frees the pointer atdreams[i]
,i
being again user-controlled and bounds-checked.psychiatrist()
first prints theabout
field of the structre atdreams[i]
, and then allows the user to overwrite thedate
field.i
is again user-controlled, but not bounds-checked.
Since sell()
does not set pointers to NULL after freeing them, and gosleep()
will only use slots that are set to NULL, we can only do MAX_DREAMS allocations.
Exploitation
Due to several flaws in the program, we are able to do the following:
- We can attempt to double free the same pointer. Since we are using glibc 2.31, tcache protection will not allow us to do this easily.
- We can use-after-free read/write to structures with
psychiatrist()
, as the pointers indreams
are not set to NULL after being freed. We have to do both a read and a write due to how that function works. - We can read/write out of bounds with
psychiatrist()
, sincei
is not bounds-checked, but again we have to do both.
In order to get execution, our plan is to use our ability to read and write out of bounds to overwrite __free_hook
with a pointer to the system()
function. Once this is done, we can free a structure containing a string like /bin/sh
to get a shell.
Heap exploitation theory
Since we are going to target the heap allocator here by use-after-freeing memory chunks, we need to understand it works. Since in glibc 2.31, tcache is used. Without tcache, a heap chunk has the following layout (malloc/malloc.c:1048):
1
2
3
4
5
6
7
8
9
10
11
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size; /* Size of previous chunk (if free). */
INTERNAL_SIZE_T mchunk_size; /* Size in bytes, including overhead. */
struct malloc_chunk* fd; /* double links -- used only if free. */
struct malloc_chunk* bk;
/* Only used for large blocks: pointer to next larger size. */
struct malloc_chunk* fd_nextsize; /* double links -- used only if free. */
struct malloc_chunk* bk_nextsize;
}
Once a chunk of memory, previously returned by malloc, is freed, the fields of malloc_chunk
will be written over the old user data. More precisely, fd
will be written right at the beginning of the chunk, meaning the two previous fields are placed before the pointer that was given to the user. In fact, those two previous fields are set when the chunk is created.
However, with tcache enabled, the following structure is used (malloc/malloc.c:2892):
1
2
3
4
5
6
7
/* We overlay this structure on the user-data portion of a chunk when the chunk is stored in the per-thread cache. */
typedef struct tcache_entry
{
struct tcache_entry *next;
/* This field exists to detect double frees. */
struct tcache_perthread_struct *key;
} tcache_entry;
Meaning the actual malloc_chunk
structure looks like this:
1
2
3
4
5
6
7
8
9
10
struct malloc_chunk {
INTERNAL_SIZE_T mchunk_prev_size;
INTERNAL_SIZE_T mchunk_size;
struct tcache_entry *next; // <------- user pointer (old fd)
struct tcache_perthread_struct *key;
struct malloc_chunk* fd_nextsize;
struct malloc_chunk* bk_nextsize;
}
When a chunk is freed, the key
field will have a pointer to the tcache structure itself (malloc/malloc.c:2924):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* Caller must ensure that we know tc_idx is valid and there's room
for more chunks. */
static __always_inline void
tcache_put (mchunkptr chunk, size_t tc_idx)
{
tcache_entry *e = (tcache_entry *) chunk2mem (chunk);
/* Mark this chunk as "in the tcache" so the test in _int_free will
detect a double free. */
e->key = tcache;
e->next = tcache->entries[tc_idx];
tcache->entries[tc_idx] = e;
++(tcache->counts[tc_idx]);
}
This field is checked before freeing a chunk, which is why we can’t just do a double free (malloc/malloc.c:4193):
1
2
3
4
5
6
7
8
9
static void
_int_free (mstate av, mchunkptr p, int have_lock)
{
/* ... */
if (__glibc_unlikely (e->key == tcache))
{
if ( /* ... */)
malloc_printerr ("free(): double free detected in tcache 2");
/* ... */
Keep in mind now that, once we free a structure, the chunk->next
field will be written over the dream->date
field, and chunk->key
will be readable through the dream->about
field.
Arbitrary write primitive
To get a write primitive, we need to perform a tcache poisoning attack. The idea here is to confuse the allocator into returning through malloc()
a pointer to an area we want to write to.
To do this, we will abuse the chunk->next
field, which points to the next free chunk. Thus, we can allocate a chunk, free it, overwrite it’s next
field with psychiatrist()
to the address we want to write to, and then attempt to allocate a new chunk, which should return our desired pointer.
Apparently we will actually need to create two chunks, free them both and poison one of them, since there need to be enough entries in the tcache.
Heap pointer leak
We need a heap leak to find out where dreams
resides. Once we have this, reading/writing with an out of bounds i
in dreams[i]
becomes much easier.
As seen before, the chunk->key
field overlaps with dream->about
, which can be read with psychiatrist()
. We just need to allocate a structure, free it and read it to get the pointer to glibc’s tcache.
We found that the tcache structre is 0x10 bytes above the heap base, so substracting 16 to this pointer gets us the heap base. Adding 0x2a0 to the heap base should get us the address of dreams
.
(Semi)arbitrary read primitive
To get reads we can abuse the psychiatrist()
function with an out of bounds i
. We won’t be able to read at dreams[i]
, but rather at wherever the pointer at dreams[i]
points to (dreams
is an array of pointers).
There is a complicated way in which we can get a truly arbitrary read: we can place the address we actually want to read at in a dream_t
structure, and then have dreams[i]
point to that structure, which in turn contains the desired pointer.
However, this won’t be necessary, as we just need this primitive to get a libc pointer leak (explained below).
libc pointer leak
In order to get a libc leak without the complicated setup mentioned above, we need a pointer to something which itself contains a pointer to libc, so we can read from it with the technique explained above. Thankfully, there’s a great candidate in the .bss section: stdout
.
1
2
3
4
5
6
7
(gdb) info address stdout
Symbol "stdout" is static storage at address 0x404018.
(gdb) x/1a 0x404018
0x404018 <stdout@@GLIBC_2.2.5>: 0x7f8ccea706a0 <_IO_2_1_stdout_>
(gdb) x/2a 0x7f8ccea706a0
0x7f8ccea706a0 <_IO_2_1_stdout_>: 0xfbad2887 0x7f8ccea70723 <_IO_2_1_stdout_+131>
(gdb)
Therefore, we can use psychiatrist()
to read at 0x404018
to get the libc leak (0x7f8ccea70723
above), and from there the libc base address. We will also need to preserve that first value (0xfbad2887
) when doing the write required by the function.
Exploitation steps
Once we have our desired primitives, we can formulate an exploit:
- Use our write primitive detailed above to overwrite the MAX_DREAMS variable, as we will need more allocations for the next steps. There is no PIE (thus no ASLR is used), meaning that we can just plug in the address of the global variable.
- Get a heap pointer leak to enable our read primitive.
- Use our read primitive to get libc’s base address. Knowing where
dreams
is and wherestdout
is, we can calculate an adequate value fori
indreams[i]
. - Use our write primitive to overwrite
__free_hook
in libc to point to thesystem()
function, also in libc. Now, the next time we free a structure, the contents of that structure will be passed tosystem()
, giving us code execution.
You can find our full exploit here.
1
2
3
4
5
6
7
8
9
10
11
12
$ python3 solve.py
[+] Opening connection to challs.actf.co on port 31227: Done
> heap_base: 0x164f000
> libc base: 0x7fd24c137000
> system@libc = 0x7fd24c1892c0
> __free_hook@libc = 0x7fd24c325e48
[*] Switching to interactive mode
$ ls
flag.txt
run
$ cat flag.txt
actf{hav3_you_4ny_dreams_y0u'd_like_to_s3ll?_cb72f5211336}