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) {
efree(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:
- It checks if
ze_obj->filename
is NULL. If this is not true, it callsefree
on this pointer and replaces it with NULL. - It tries to open the requested file. If it fails, an error is returned.
- 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 efree
d 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);
efree(a);
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:
- During its initialization, it writes some big integer into the offset where string’s length is stored.
- 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:
- 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). Theze_obj->filename
is freed, but the pointer to it remains in the struct. - 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 memoryze_obj->filename
points to. - We call
$archive->open("/etc/passwd")
again to free the said memory. - 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:
- We run
$f = fopen("php://filter/convert.base64-decode/resource=/etc/passwd");
and_php_conv
is created somewhere in the PHP’s heap. - We search the memory for two successive addresses pointing to
libphp8.so
(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. - We write
/tmp/xx
instead of the first pointer (unneeded convert callback) and change the second pointer (which is destructor) to address ofsystem
. - We use
fclose($f),
and the destructor is called. As we changed its address tosystem
, the command is executed.
To follow the plan above, we need two things:
- The address range of
libphp8.so
so we can detect the filter’s struct in the memory. - The address of
libc.so
so we can get the address ofsystem
.
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() {
chdir("/tmp");
mkdir("posos");
chdir("posos");
ini_set("open_basedir", "..");
ini_get("open_basedir");
chdir("..");
chdir("..");
ini_set("open_basedir", "/");
if (ini_get("open_basedir") != "/") die("open_basedir bypass failed");
}
Let’s go through this function.
- We change the current directory to
/tmp
. It is allowed as/tmp
is contained in initialopen_basedir
value (which is/tmp:/var/www/html
). - We create a directory named
posos
and go inside it, which is still totally legitimate. - Now we change
open_basedir
setting to..
. One can changeopen_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, currentlyopen_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. - 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/
. - 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.
- First, we bypass
open_basedir
using the function above. - Now we can parse
/proc/self/maps
and get address ranges of libc and libphp, defeating ASLR. Then we calculate the offset of thesystem
function insidelibc
and store it into a variable. - Another preparation step is to put commands we want to execute into
/tmp/xx
file and run chmod on it. - 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 inefree
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 callnew 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. - 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. - We call
fclose($f)
and destructor is called. This results in our script/tmp/xx
being executed. - 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 https://gist.github.com/neex/13378d0e7e9f9ab0cff9d9039178d15f.