Info leaks via buffered output on HTTP redirects

15 minute read

Writing data to the output buffer before deciding that the response to the current HTTP request should actually be a redirect (for example when an unauthenticated user is not allowed to access some content) is an issue not exclusive to PHP but a relatively easy mistake to make in this environment.

After not having been exposed to PHP in quite a while I recently did a security assessment of a PHP application again. During the test this exact issue popped up again, so I want to give a short description on how and why this can lead to information leaks.

Consider the following two files:

index.php

<?php

$logged_in = false;

echo 'Here is some content that only logged-in users should see';

/* ... */

if (!$logged_in) {
    header('Location: /login.php');
}

?>

login.php

<?php

echo 'Here is the login page';

?>

When requesting index.php with your browser, you would usually only see the output of login.php, as the first response from index.php includes a 302 status code and a Location header indicating a redirect to /login.php.

However, if you were to inspect the body of the first response, you would see the contents that index.php produced as they were already written into the output buffer when the HTTP response was sent:

Looking at the 302 response

Unfortunately, the response data of redirections is not shown in Firefox’s developer tools (“No response data available for this request”), so the way you usally identify these issues is to have a proxy running while browsing (e.g. Burp Suite).

What’s the issue?

As you may have already guessed, the issue is that content that is not supposed to reach the client is actually sent to it. This can either happen because the script places data into the output buffer before it calls header() (as in the example above) or that it doesn’t terminate after setting the Location header and produces even more output.

Changing the order of echo and header in the example above would not fix the issue if the header() is not also followed by a termination of the script (e.g. with exit or die()).

During my penetration test it was a relatively simple case of just trying to access another user’s data by modifying a parameter in the query string. Even though the application’s behaviour looked right (redirect to another page) when viewed through a browser, and the developers did properly check the access rights in the code, the data from another user was still being leaked in the response body of the 302 redirect.

How can you modify headers after output is sent?

You might be wondering how you can even set a header after (part of) a response body has already been sent. Obviously, you can’t.

Looking at PHP’s documentation for header() we can read:

Remember that header() must be called before any actual output is sent, either by normal HTML tags, blank lines in a file, or from PHP.

In the small example above the output is not actually sent before the header, but rather buffered before being sent. So as long as your output doesn’t exceed the buffer size you will get the behavior described and won’t experience any warning. The output buffer behavior (and size) can also be configured via php.ini.

You can verify this by increasing the amount of output you produce before trying to set a header. Exceeding the buffer size, you will get the warning PHP Warning: Cannot modify header information - headers already sent by... and the redirect stops working (as you obviously cannot set the Location header after output has already been sent).

Note, though, that if you have the order right (first header(), then your response body) but you forget the termination of the script, you will still have the issue but never get any warning as nothing technically forbids you to have a large body in a redirect response.

How to detect

The easiest way to spot this issue during dynamic analysis is by recording your HTTP requests, then sorting them by status code and looking at the response length. Large response sizes for redirection responses will generally show you that something is off and worth looking into.

Besides recording your manual browsing it also makes sense to look at the response sizes when you’re doing any kind of directory/file brute-forcing and don’t have access to the source code. Seeing an output like the following (gobuster example) would be a good reason to further look into the actual response data:

/index.php            (Status: 302) [Size: 4091] [--> /login.php]   # large
/test.php             (Status: 302) [Size: 0] [--> login.php]       # normal

Like to comment? Feel free to send me an email or reach out on Twitter.

Did this or another article help you? If you like and can afford it, you can buy me a coffee (3 EUR) ☕️ to support me in writing more posts. In case you would like to contribute more or I helped you directly via email or coding/troubleshooting session, you can opt to give a higher amount through the following links or adjust the quantity: 50 EUR, 100 EUR, 500 EUR. All links redirect to Stripe.