This is a writeup describing the solution to a small XSS challenge I posted on Twitter in May 2024
The challenge
The challange page https://sandbox-iframe-ctf.glitch.me allows for arbitrary HTML in the search parameter xss
as a Base64 encoded string. The HTML will be put inside a sandboxed iframe on the same page. The page will also add a flag to the hash
portion of the URL upon visiting the site. The mission was to leak this flag in the hash
and show the value in an alert box.
Step 0: the setup
Let’s summarize what’s happening here. The challenge combined three steps to allow us to access the flag (stored in the hash/fragment part of the URL) from inside the sandboxed iframe.
The challenge page did not sanitize the payload inside the base64 string. The only sanitization was done on the base64 string itself to protect it from attacks breaking out in the DOM.
The two protections in place were instead a strict CSP and the sandbox iframe attribute.
The iframe sandbox contains the allow-scripts
and allow-modals
values, ensuring that Javascript can execute. The lack of allow-same-origin
should, however, prevent access to the top window, and the origin
inside the iframe will instead be null
.
The setup allows us to throw a regular <script>
tag into the iframe and have it evaluated. The fact that the content is loaded through srcdoc
and not the src attribute will, however, make the iframe document inherit the parent page CSP. Leaving us with scripts that passes this CSP
script-src 'self'
'nonce-ae692e51fd5d5528c59c78334c035454'
'unsafe-eval'
https://cdnjs.cloudflare.com/ajax/libs/Base64/1.3.0/base64.min.js;
Step 1: Bypass CSP
A CSP feature exists that is documented but sometimes overlooked. When a CSP directive is specified with a value like https://example.com/path/morepath/,
the content will only be allowed to load from any path under that subpath. And given a value like https://example.com/exactfile.js
, you restrict content to that file exactly.
This rule is, however, not enforced when a valid request returns a redirect. After a redirect (given by a URL that is allowed by the CSP), the new request will only match against the base domain on any CSP directives. Given the two examples above, we can load anything from https://example.com
after a redirect.
The feature described above might initially look like a flaw in CSP but is, in fact, a design choice to prevent other side channel information leakage. See this part of the specification https://www.w3.org/TR/CSP3/#source-list-paths-and-redirects and this blog post that triggered this change https://homakov.blogspot.com/2014/01/using-content-security-policy-for-evil.html.
When looking to bypass CSP, this is a great tool to have in your toolbelt. Also, remember that it can be useful even if you can not get an XSS. You might be able to loosen up frame-src
, form-action
or any other directive using the same technique.
In the given challenge, I tried to hint at this by adding an open redirect (that will be allowed as script-src
due to the value 'self'
in the script-src
directive) and the unnecessary dependency loaded with a full path. To escalate this to a proper XSS, all that was needed was to take advantage of other scripts hosted on the same CDN that could now be loaded using a script tag like this
<script src="/redirect?url=https://cdnjs.cloudflare.com/ajax/libs/htmx/1.9.12/htmx.min.js"></script>
Even if https://cdnjs.cloudflare.com/ajax/libs/htmx/1.9.12/htmx.min.js
is not a valid path, given the current CSP, after the redirect browsers will only check that the content is loaded from https://cdnjs.cloudflare.com.
I decided to go with HTMX for an execution gadget, as it’s the new and cool kid on the block. A lot of people went with the more classic <script src="/redirect?url=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.8/angular.min.js"></script>
for an Angular bypass.
The two XSS versions look like this.
<script src="/redirect?url=https://cdnjs.cloudflare.com/ajax/libs/htmx/1.9.12/htmx.min.js"></script>
<img src=x hx-on:error="alert(1)">
and Angular
<script src="/redirect?url=https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.0.8/angular.min.js"></script>
<div ng-app ng-csp>{{$on.constructor('alert(1)')()}}</div>
Step 2: accessing parent iframe
We now have the ability to execute arbitrary Javascript, but we are still stuck inside a sandboxed iframe, giving us the origin null
and no access to the parent window.
Well that “no access” is a truth with some modifications. First of we are executing on the domain about:srcdoc
as the content of the iframe is given through the srcdoc
attribute and this differs slightly from loading an external resource. I already mentioned that the iframe document will inherit the CSP from the parent page.
The hint I gave to some people that asked nicely was
Try to do a
fetch("")
call from inside the iframe and look at the request; what is happening and why?
Doing a fetch("")
from inside the sandboxed iframe will make a request like this
https://sandbox-iframe-ctf.glitch.me/?xss=PHNjc..BASE64..
the request will fail due to CORS errors. Think about it, we are getting a CORS error as we are making a request to the origin https://sandbox-iframe-ctf.glitch.me
from the origin null
so that makes sense, but how did the iframe know about the parent window URL?
Quick detour. When a browser encounters a request that lack scheme and base domain it will be treated as a relative URL. Time for more docs: https://www.w3.org/TR/WD-html40-970917/htmlweb.html tells us
A relative URL (defined in [RFC1808]) doesn’t contain any protocol or machine information. Its path generally refers to a resource on the same machine as the current document. Relative URLs may contain relative path components (“…” means one level up in the hierarchy defined by the path), and may contain fragment identifiers. Relative URLs are resolved to full URLs using a base URL. [RFC1808] defines the normative algorithm for this process.
Browsers will do two steps when deciding on a base URL
Check if there is a base
tag in the document and use that as the base URL
Use the fallback base URL
https://html.spec.whatwg.org/multipage/urls-and-fetching.html#fallback-base-url
From these specifications, we can see that about:
domains will use something called about base URL
, this value depends on the browser implementation. All browsers have concluded that this should mean “the top documents base URL”. See this thread for a discussion on this https://github.com/whatwg/html/issues/421 and this discussion about the specific issue we are going to abuse here https://github.com/whatwg/html/issues/8105
So how do we access this baseURL? There are multiple ways to achieve this. First, just look at how browsers use the value internally when creating an anchor
tag
We can see in the logs that the link with an empty href
attribute will inherit the URL from the parent window. The anchor
will inherit the hostname
and the search
portion of the URL but not the hash
.
However, there is an easier way to access a document’s baseURL (or URI, as we should probably call it from now on): the baseURI
node attribute: https://developer.mozilla.org/en-US/docs/Web/API/Node/baseURI.
As it happens all DOM nodes
have this attribute baseURI
that will return the full base URL
and this value will also contain the hash
. The easiest way to access this is to just use the document
node like this document.baseURI
.
Step 3: putting everything together
We now have all the pieces we need. A full payload would look something like this
<script src="/redirect?url=https://cdnjs.cloudflare.com/ajax/libs/htmx/1.9.12/htmx.min.js"></script>
<img src=x hx-on:error="alert(document.baseURI.split('#')[1])">
Implications
This trick rarely allows you to do anything useful as the URL is often not that valuable to leak. However, the setup is not uncommon in sites that host “HTML builders,” and combining it with Frans Rosens’ “OAuth dirty dancing,” it is possible to leak some hash
-based access tokens.
Thanks for all the great solutions, and sorry for the delay in writing the writeup!