Master of PHP writeup (Real World CTF 2019)

Another writeup

Before continuing, open another solution in a browser tab (yes, we solved the same challenge twice. Well almost). My teammate @paulcher used CVE-2019-5482 to achieve RCE in this challenge and was very close to success.

Challenge overview

The challenge consists of a single php script that just executes user code from $_POST["rce"] using eval; our goal is to bypass open_basedir and disable_functions protections and execute a command. We're given the Dockerfile and the URL of a remote HTTP endpoint.

We knew how to bypass open_basedir (see the corresponding section above). However, we were not able to find any flaws in the disable_functions list — all tricks like putenv + mail were restricted.

Notably, we're given a copy of the source of the PHP interpreter (it's in C) which is compiled during docker build and the challenge is in the "pwn" category. These facts suggest we'll have to deal with filthy binary stuff.

The bug

After careful diffing with the PHP source on Github, we've found a commit that differs from the source we're given in one meaningful line.

This line is in implementation of ZipArchive::open method and looks like this:

--- /home/inviz/ctf/tools/php-src/ext/zip/php_zip.c     2019-09-15 17:02:18.933895580 +0300
+++ ./ext/zip/php_zip.c 2019-08-20 10:52:03.000000000 +0300
@@ -1380,7 +1380,6 @@
        if (ze_obj->filename) {
-               ze_obj->filename = NULL;

        intern = zip_open(resolved_path, flags, &err);

The ZipArchive class is a class for handling ZIP archives. Its instances are reusable; one can call $zipArchive->open(...) multiple times and work with different files using the same instance of the ZipArchive class. The underlying code of ZipArchive::open is implemented in C.

All methods of ZipArchive class preserve the following invariant: the name of the file that is currently opened is stored in ze_obj->filename as a C string. If it's NULL, no file is opened. The logic of the original open method is as follows:

  1. It checks if ze_obj->filename is NULL. If this is not true, it calls efree on this pointer and replaces it with NULL.
  2. It tries to open the requested file. If it fails, an error is returned.
  3. If the file was successfully opened the method stores a copy of the name of the file that was just opened into ze_obj->filename as a C string.

The patch removes "replaces it with NULL" part from step 1. As we can supply any PHP code we can make the first call to ZipArchive::open succeed and following multiple calls to fail; these calls will perform efree of the same address, resulting in a dangling pointer stored in ze_obj->filename that can be efreed again later. More importantly, we can make PHP interpreter allocate something in its place between calls to efree, making it kinda use-after-free.

Exploitation plan

As you may note the function that releases memory is called efree, not free. This is PHP's internal allocator. Instead of diving deep into its details, we just state a property that any sane allocator has; it's the only property we need for the exploitation.

The property is: if we call free on a block of size S bytes and then call malloc(S) multiple times, we'll eventually get the address we just freed. The PHP allocator uses free lists, so we need to call malloc only once and we will immediately get the just-freed address.

In other words, after executing the following C code:

a = emalloc(size);
b = emalloc(size);

we'll have a == b.

From the PHP code point of view it gives us the following exploit draft:

$size = <some value we define later>;

// The size of the next string in C is $size as "/tmp/" is 5 bytes and
// the terminating zero byte also counts.
$archive_name = "/tmp/".str_repeat("X", $size - 6);

// First we create our ZipArchive object.
$archive = new ZipArchive();

// Next call succeeds and ze_obj->filename of size $size is allocated.
$archive->open($arcive_name,  ZipArchive::CREATE);

// Next call fails as we can't open "/etc/passwd" and
// ze_obj->filename is free'd.
$archive->open("/etc/passwd", ZipArchive::CREATE);

// If "<expression 1>" produces an object with internal representation
// of size $size, it will be allocated on the same place where dangling
// ze_obj->filename points to.
$object1 = <expression 1>

// Next call will free the memory containing internal representation 
// of $object1. However, the variable $object1 remains and we can 
// still use it.
$archive->open("/etc/passwd", ZipArchive::CREATE);

// If "<expression 2>" again produces an object with internal 
// representation of size $size, it will be allocated on the same place 
// where both ze_obj->filename and internal representation
// of $object1 should be.
$object2 = <expression 2>

If both <expression 1> and <expression 2> produce objects with an internal representation of size $size we'll have two objects, $object1 and $object2 whose internal representations are in the same memory region. Note that this can be completely different objects with a different meaning of fields. Also note that after the execution of the code above the memory has been initialized for holding $object2. This means that fields of struct holding $object1 may have been changed.

What we need to do to proceed is to find which objects to use in <expression 1> and <expression 2>.

Struct "gadgets"

Let's recap where we are so far. We need to choose two PHP objects, $object1 and $object2 with the same size of their internal representation (e.g., C structs). After that, we'll use the introduced bug to make these representations be in the same memory. Thus the C structs will get corrupted, resulting in unpredictable effects which we hope to use for our benefit.

The choice for $object1 is simple: just use a string. PHP strings are stored as 32-byte header struct + character bytes in C code, so they have variable size. The header contains the length of the string. PHP strings are mutable, so if we corrupt the length (making it very big if interpreted as an integer), we'll be able to edit the whole heap region after it. Using a string for unrestricted access to memory is pretty standard in the exploitation of interpreters.

The value of $object2 can be anything as long as it satisfies two conditions:

  1. During its initialization, it writes some big integer into the offset where string's length is stored.
  2. It doesn't corrupt other fields of the string header struct too much, so the string remains usable.

An instance of StdClass appears to be suitable. Its internal representation is 40 bytes long.

So substituting the chosen value into the plan above, we have the following:

  1. First, we create an instance of ZipArchive open a file with 40-byte filename and then immediately try to open /etc/passwd as ZIP (it fails). The ze_obj->filename is freed, but the pointer to it remains in the struct.
  2. Next, we create a string of length 8 and store it in some variable (e.g. $memory_view). Its internal representation is 32 + 8 = 40 byte structure which will be allocated in the same memory ze_obj->filename points to.
  3. We call $archive->open("/etc/passwd") again to free the said memory.
  4. We create an instance of StdClass. It will allocate 40 byte internal representation which again will be allocated in the same place. During its initialization, it will corrupt $memory_view's length making it very big.

Now we can read and write the whole region of PHP heap after the struct holding $memory_view string.

It is universally recognized that the best thing to edit in memory is the address of a function which is then called with an argument under your control, and the best new value for it is the address of system. There are plenty of objects that hold callback addresses in PHP. We've chosen _php_conv object.

It is an internal representation of a php filter which is constructed when you open a php://filter file. It contains two fields: address of convert callback and address of destructor. The destructor is called when we call fclose for the file. Its only argument is the pointer to _php_conv object itself.

That means that as long as we don't use the convert callback (we never read from the file), we can just overwrite it with a command, and then we overwrite destructor with the address of libc's system function. When the destructor is called the pointer _php_conv object will be interpreted as char* and the command will be executed.

Note that the size of a pointer is 8 bytes so we have only 7 bytes for our command (plus one terminating zero byte). It looks too small, but we can create files in the /tmp/ directory. We just create /tmp/xx, put our payload there (after #!/bin/sh) and then chmod it allowing it to be executed. All these can be done using corresponding PHP functions, and none of them are restricted.

So what we do after we have huge $memory_view string is this:

  1. We run $f = fopen("php://filter/convert.base64-decode/resource=/etc/passwd"); and _php_conv is created somewhere in the PHP's heap.
  2. We search the memory for two successive addresses pointing to (it is the library where all PHP interpreter's code lies). When we find it, we assume that we've found the _php_conv object.
  3. We write /tmp/xx instead of the first pointer (unneeded convert callback) and change the second pointer (which is destructor) to address of system.
  4. We use fclose($f), and the destructor is called. As we changed its address to system, the command is executed.

To follow the plan above, we need two things:

  1. The address range of so we can detect the filter's struct in the memory.
  2. The address of so we can get the address of system.

To get these addresses, we need to read /proc/self/maps file, which contains the whole memory layout of the process.

open_basedir bypass

It looks like we're back to web exploitation. Bypass a PHP restriction and read a local file, what can be sweeter!

Well, one thing that is even more joyful for my script kiddie's heart is that to achieve this goal we need just to copy-paste a ready-made exploit from one of the past competitions (namely TCTF finals). Here it is:

function bypass_openbasedir() {
    ini_set("open_basedir", "..");
    ini_set("open_basedir", "/");
    if (ini_get("open_basedir") != "/") die("open_basedir bypass failed");

Let's go through this function.

  1. We change the current directory to /tmp. It is allowed as /tmp is contained in initial open_basedir value (which is /tmp:/var/www/html).
  2. We create a directory named posos and go inside it, which is still totally legitimate.
  3. Now we change open_basedir setting to ... One can change open_basedir in runtime as long as the new value is more restrictive than the old one (not less restrictive to be exact). In this case PHP thinks like "Well, currently open_basedir is /tmp:/var/www/html and new value is .. which resolves to /tmp. The new value doesn't allow new things to be accessed, so I accept it". Poor creature.
  4. We execute chdir("..") twice. Both times PHP thinks like "open_basedir is equal to .. and new path .. is under it, so allow". Now our current directory is /.
  5. We change open_basedir again, to /. As it was .. and our current directory is / PHP accepts it again.

So open_basedir bypassed, and now we can read any file we please.

Exploit recap

Now we have all we need to build our exploit.

  1. First, we bypass open_basedir using the function above.
  2. Now we can parse /proc/self/maps and get address ranges of libc and libphp, defeating ASLR. Then we calculate the offset of the system function inside libc and store it into a variable.
  3. Another preparation step is to put commands we want to execute into /tmp/xx file and run chmod on it.
  4. Now we're ready for the exploitation. We create a ZipArchive instance, open a file with a 40-byte name and then immediately try to open an invalid ZIP archive, resulting in efree being called for the file name. We put an 8-character PHP string in the $memory_view variable and then again try to open an invalid archive. After that we call new StdClass() and it is allocated in the same memory. The string $memory_view has an insanely huge length now, allowing us to read and write PHP heap.
  5. We execute $f = fopen("php://filter/convert.base64-decode/resource=/etc/passwd"); and _php_conv is created. We use the $memory_view variable to find it in memory --- that is, to find two successive addresses from the libphp address range we obtained earlier.
  6. We call fclose($f) and destructor is called. This results in our script /tmp/xx being executed.
  7. After we get our back-connect shell, we run /readflag. It gives us a simple challenge we need to solve, and then it prints the flag.

The exploit can be found at