Portals - Pwn
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:
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
:
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
:
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 childpt
ofnow
for whichpt->year == year
, in that case return; - calls
search(root, year, start=1)
to search recursively the portalpt
for whichpt->year == year
, in the whole tree; if it does NOT find it, then a new portal will be allocated usingcalloc
and will become the new child, OTHERWISE the pointer returned by the search will become the new child. Thestart
parameter is used to handle loops when there is a pointer to theroot
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 callsfree(pt->msg)
, but without NULLing the freed pointer; - calls
free(pt)
, and setsnow->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 usingclosePortal()
from node A while keeping the reference to N in node B. FromGhidra
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 usingleaveMessage()
and letting that function setnow->msgSize
equal to4 * 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 themsg
field, which is affected by UAF, too; since it’s used duringdescribe()
, it’s clear that navigating to a freed portal (which had a message left) usingtakePortal()
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:
- Allocate a first portal and leave a message with N=1000.
- Allocate a second portal and leave a message with N=10.
- Allocate a third portal, and open a portal to the first portal from there.
- Close first portal.
- Close second portal.
- 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.
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}