Last year, Paulos Yibelo came with a fun showcase of a technique dubbed "Double-Clickjacking". I later expanded on that with some more vulnerable targets, and this is the 2nd and likely last post I'll write about it. But this process is something I really like, using small browser features in creative combinations to make convincing ways of breaking security boundaries.

Working on my 1st blog post and the people talking about it later gave me an idea: make the ultimate double-clickjacking proof of concept. One that's convincing utilizing many browser tricks, but also flexible to quickly adapt it to a target to which you want to report an issue. At this point, I had tried enough variations already to learn what works and what doesn't to fuse into one final solution.

This writeup is structured a little differently from my usual ones, we'll start with the final result and then go through all the tricks that made it happen. Below is how I could take over someone's Gitlab account by them playing Flappy Bird:

Below is a link to the source code if you want to jump ahead:

https://github.com/JorianWoltjer/popup-research/tree/main/ultimate-poc/gitlab

Idea: Moving popup in background

As you can see in the video, it doesn't rely on holding space anymore like my last writeup, not even double-clicking specifically. Instead, it relies on a user repeatedly clicking on any position of your site, while still not requiring any iframes.
The TLDR of how this works is: The target page is opened in a popup under the attacker's page, which moves with the user's cursor until the time is right, and it comes into focus right under the user who's about to click.

We can use moveTo() to position the button right underneath the user's cursor even while they are moving it. This is possible because the user stays on the attacker's page who receives mousemove events with X and Y positions, and a popup window in the browser can be moved even while it isn't in focus. Let's make a quick first example of this:

HTML

<script>
  onclick = () => {
    onclick = null;
    w = window.open("/popup", "", "width=100,height=100");
  
    onmousemove = (e) => {
      w.moveTo(e.screenX, e.screenY);
    }
  }
</script>

After clicking once, the smaller popup window seems to move with our mouse. Even after minimizing, the moment we look at it again, it has moved (and has been moving) to our mouse cursor. This is great, but it falls apart once we try to make a real exploit with this using a cross-origin URL:

JavaScript

w = window.open("https://example.com", "", "width=100,height=100");

The popup doesn't move anymore, and we receive a warning in the DevTools Console:

Uncaught SecurityError: Failed to read a named property 'moveTo' from Window: Blocked a frame with origin "https://r.jtw.sh" from accessing a cross-origin frame.

While this is impossible, we still have access to .location to redirect the popup to another URL, maybe a same-origin one. At this point, we're able to move the window again wherever we want, and then redirect it back to the target (either through another .location or just history.back() if it supports it). Most of the time, the target will take a bit to load, so this can't be completely real-time anymore. But with a good implementation, this isn't a problem in the end because we still need to wait for the user to click in quick succession.

Now that the popup is always ready behind the user's cursor, we can bring it into focus whenever we detect the user repeatedly clicking. To focus it without redirecting, a useful trick is re-opening a window with the same name. If we make the URL "" (empty), the existing popup won't redirect but it will instantly gain focus!

JavaScript

onclick = () => {
  onclick = null;
  // First open, minimize it to simulate background popup
  w = window.open("https://example.com", "example", "width=100,height=100");

  onfocus = () => {
    onfocus = null;
    setTimeout(() => {
      // After 3 seconds, re-open a window with the same name, which should show the already-loaded https://example.com
      window.open("", "example");
    }, 3000)
  }
}

While this requires user activation, in our case the user is going to have clicked soon before we want to trigger the focus, so this won't be a problem. Now that we have a theory, we should put it into practice.

With putting it into practice comes one slightly tricky bit: positioning the window perfectly so that the target button is in the center of the cursor. This is because we have to calculate the offset from the top-left of the window to the button to displace its moveTo() commands accordingly, but also things like the height of the navigation bar for different browsers and resolutions, as well as multiple monitor setups giving wildly different coordinates.

We'll do this step-by-step. First, we simply need to get the mouse position as we did earlier with the mousedown event's screenX and screenY (you cannot get this position at will, you must keep track of it via events). The nice thing is that this already takes care of multiple monitors by applying screen.availWidth and availHeight internally. The top-left of the window is now locked to our cursor position.
We need to offset it up slightly to skip the navigation bar, luckily this is easy to calculate the height of by subtracting innerHeight from outerHeight.
Finally, we need to measure where the button will show up on the page and offset the position by that much so that the cursor perfectly lands on it. We can create the popup and then use DevTools to get the exact position of some button element. It has a getBoundingClientRect() method returning .x and .y values which are the offset from the top-left of the page to the top-left of the element. In order to land the cursor in the middle, we also need to add half of the .width and .height which are its dimensions.

JavaScript

onclick = () => {
  onclick = null;
  w = window.open("/popup.html", "", "width=500,height=300");
  w.onload = () => {
    navbarHeight = w.outerHeight - w.innerHeight;
    button = w.document.querySelector("button").getBoundingClientRect();

    onmousemove = (e) => {
      const x = e.screenX - button.x - button.width / 2;
      const y = e.screenY - button.y - button.height / 2 - navbarHeight;
      w.moveTo(x, y);
      w.resizeTo(500, 300);
    };
  };
};

Below is a video slowly enabling showing all parts, to end up with a button that fully tracks the mouse wherever it goes:

In a real scenario with a cross-origin window, we cannot measure these sizes dynamically because we cannot access the DOM. That's why we have to calculate navbarHeight while it is still same-origin and pre-calculate the other button dimensions in hopes that the victim's browser will render it the same.
This is one simple option, but we can actually do better with a bit more effort (and who doesn't like that?). On the real authorization page, hitting Ctrl+S will save the page as a file. We can then host this recreation on the same origin as our exploit, so that we can simulate how the victim's browser will render it and exactly where the button will end up! This is much more reliable across setups and we've essentially implemented all the logic above already.

You may also notice while moving a popup around, that it cannot be moved partially off-screen, it will always snap to the edge. This limits the area we can cover. The larger the popup window, the smaller our area of free movement, so we'll try to keep it as small as possible.

One tip that can help minimize the dimensions is using a hash (#) fragment in the URL pointing at an element down low on the page with a specific id= attribute. This will automatically scroll to the element, for better or for worse. If you can get an element near the button with an ID, you can scroll down to it while making the height very small to be more flexible. Use the following snippet to find all potential elements with IDs, then hover over them in the Console to see where they are located on the page.

JavaScript

document.querySelectorAll(`[id]`).forEach(e => console.log(e))

When you find an ID, put its value in the hash fragment, like id="footer" -> /authorize?...#footer.
With all this talk about a popup in the background, a very rightful question would be: "How do you get a popup under the attacker's page in the first place?"

Real Popunder

To answer this question, in my previous post I used the fact that the victim was already holding space to get infinite user activations and quickly window.open() a popup window and then another back to the main page. This is known as a "popunder", but unfortunately for us, our current idea only gives some clicks.

Amazingly, after sharing my post NDevTK first came with a cool Popunder bug in Chrome that required manually closing an "Open File" dialog. But Renwa came in clutch right after, sharing a proof of concept for a completely automatic Popunder that "won't be patched that soon" šŸ¤ž.
The main file looks like this:

HTML

<body onclick="x()">
  <h1>Click Here</h1>
  <script>
    function x(){
      let params = `width=300,height=300,left=-1000,top=-1000`;

      open('//example.com', 'test', params);

      location='./goog.html';
    }
  </script>
</body>

It opens a popup window to https://example.com and then redirects itself to another page, which handles gaining back focus without user interaction:

HTML

<!DOCTYPE html>
<html lang="en">
<head>
    <title>PopUnder POC</title>
</head>
<body>
    <h1>PopUnder POC</h1>
    <div id="g_id_onload"
         data-client_id="384756754840-qot2bab06l0kihekcu3h76iri7h75eat.apps.googleusercontent.com"
         data-callback="handleCredentialResponse">
    </div>
    <div class="g_id_signin" data-type="standard"></div>

    <script src="https://accounts.google.com/gsi/client" async defer></script>
    <script>
        function handleCredentialResponse(response) {
            // Handle the signed-in user info here
            console.log("Encoded JWT ID token: " + response.credential);
            // You would typically send this token to your server for verification
        }
    </script>
</body>
</html>

This uses a very interesting method of importing some script from google.com with a sign-in flow. In the browser, you quickly see a prompt like this:

Interestingly, this prompt is something implemented straight into the Chrome browser. If you are logged in to a Google account, this prompt will appear and suggest an account. The appearance of this popup will take away focus from our example https://example.com popup, without user interaction!

While it works great on the rx32.io domain, this Google client and thus the "Sign in with" popup working is limited to specific configured ones. For an actual attack on our site, we must set up such a client. The steps for this are as follows:

  1. Open the Clients page of the Google Cloud Console
  2. Click Create Client and for Application type, select Web application
  3. Add Authorized JavaScript origins being the domains that are allowed to use this client (eg. your attacker's domain)

Note: For localhost origins, you must add both the plain domain and the domain with the port you plan to host it on, like http://localhost and http://localhost:8000

Then, we need to make a page that opens a popup with window.open() and then quickly adds the https://accounts.google.com/gsi/client script to bring the focus back to the main window.

HTML

<style>
  .g_id_signin, #g_id_onload {
    display: none;
  }
</style>
<div id="g_id_onload" data-client_id="465150997505-j3lj5fpbdddjomrd9omtjhjkg3pfor3l.apps.googleusercontent.com"></div>
<div class="g_id_signin" data-type="standard"></div>
<script>
  function triggerPrompt() {
    const script = document.createElement("script");
    script.src = "https://accounts.google.com/gsi/client";
    script.async = true;
    script.defer = true;
    document.head.appendChild(script);
  }

  onclick = () => {
    window.open("https://example.com", "", "width=1,height=1,top=9999,left=9999");
    triggerPrompt();
  };
</script>

Fake Cloudflare Captcha

We're here to make "The Ultimate PoC", so we need the most convincing way of getting a user to click. One click that may have even brought you to the blog post you're reading right now is Cloudflare's Turnstile captcha, one that takes over the full page while "Checking if the site connection is secure". If it cannot automatically verify, it requires the click of a checkbox, and looks like this:

Just kidding, that's my recreation of it! In fact, I copied the entire HTML and CSS and recreated some of the animations manually to make it look absolutely perfect. Upon the first click of the checkbox, the spinner starts and the user waits for it to complete. At the same time, a tiny popup window is opened in the bottom right with the same background color as the Cloudflare page, so you barely notice. This is quickly hidden by the popunder trick we found above, and once focus is back on our window, we can act as if the captcha is "completed" and send the victim to the real underlying page.

JavaScript

captcha.onclick = () => {
  // Open tiny popup in bottom right corner
  const blob = new Blob([`<body bgcolor=222222>`], { type: "text/html" });
  w = window.open(URL.createObjectURL(blob), "popup", "width=1,height=1,left=9999,top=9999");
  w.resizeTo(1, 1);
  w.moveTo(9999, 9999);

  // Trigger Google sign in prompt on main page to take focus
  triggerPrompt();
  captcha.className = "spinner"

  // Wait for blur event of the popup window, meaning popunder was successful
  w.onblur = () => {
    captcha.className = "checkmark"
    location = "/next-page";
  };
}

This is probably the most common click users are used to doing on websites, without thinking, they just want to get to the content as fast as possible. That makes it a perfect candidate to deceive with!

Window reference across navigations

At the end of the action, we redirect to /next-page to hide the "Sign in with Google" popup leftover from our popunder. This is a slight problem though, we just created a beautiful window reference to a tiny popup behind the main page, but that JavaScript variable is lost after navigating.

Luckily, we can use a clever trick. By calling window.open("", "popup") with "popup" being the same name as when we originally opened it, it will re-use the background popup. Trying this from inside the console will focus the popup again, which is exactly what we don't want to happen, it needs to stay in the background. But this time we can actually make use of the popup blocker. After navigating to /next-page, an attempt to call window.open() while the user hasn't interacted with anything yet will be denied by the popup blocker. But interestingly, it still gives you a reference to the window! It doesn't even show the popup blocker UI when it matches an already-existing window with the same name.

That means all we need to do to get our window reference back is:

HTML

<script>
w = window.open("", "popup");
</script>

Then, we can do whatever on our page, and focus the popup again with the same code but while the user has activation.

Triggering the popup

So when do we trigger the popup to appear in the foreground? While the user repeatedly clicks on the same spot. I think there are many creative ways of achieving this, one I was inspired by was a proof of concept using Flappy Bird. While the details of that implementation are not related to this idea, I realized that that is a great way of getting a user to click repeatedly in the same spot. We can make a small game window in the center of the screen that runs a Flappy Bird recreation where clicking will flap the bird up. While the user is focused on the game, they are likely to keep clicking their mouse to make it through the pipes and are unlikely to move it, perfect for our goal.

To be even more evil, we can rig the random level generation to always make a set of pipes that go from low to high like this:

Level

ā•©   ā•‘   
──╮ ā•©   ╦  ...
╦ ╰───X ā•‘

Right at the X, we will trigger the popup to come up from the background under the user's cursor. While they desperately spam-click their way to the high pipe, they will accidentally click on the now-focused popup instead, and authorize their account away to the attacker.

To avoid wasting an attempt to trick the user, a few other conditions are set to ensure there are no dumb errors after trying to focus the popup. It should work 100% of the time.

  • The user must not have moved their mouse recently to give the target time to load. Remember, every time the user moves their cursor position and we want to use moveTo() to match it, we need to redirect the popup to our origin and then back to the target URL in order to do so.
  • The cursor position must be in a smaller bounding box so that the popup is fully able to fit onto the screen, otherwise, it would be pushed off to a side which makes them miss the button.

This means the user may have to play multiple rounds until they don't move their mouse too much or have their cursor at the wrong spot. This is not a problem, because users will naturally try and beat their high score.

I've showcased this proof of concept with a few colleagues and all of them fell for it, most didn't notice the popunder and were especially surprised while playing the Flappy Bird game and suddenly they clicked on the authorization. While it is still as little obvious after it happens, the window closes itself quickly and it's already too late, an attacker can use the authorization to completely drain your account and use it for malicious purposes in an automated way, that's the whole point of this API anyway.

What's next?

This shows yet another way of exploiting the same problem: rapid context switching. A webpage has a surprising amount of control over what's focused and can use this to predict actions and redirect them to other unintended screens. Paulos explains some mitigations that a webpage can do, simply disabling buttons before some mouse movement happened or after a short delay starting from focus (which is also how Gitlab fixed this issue recently). There is also an idea that the browser could implement a more generic protection for this, similar to what's already in place for certain popups in the browser like permissions or other kinds of UI elements. However, this will likely take a long time to develop, let alone ship.

This post intends to provide an adaptable proof of concept for this kind of attack that can help show how convincing it can become and to get the worst cases of this vulnerability fixed by web applications. I also wanted to explain some of the implementation details in this post to showcase how you can improve your own client-side exploits and make them more convincing. If your attack requires a bit of user interaction, think creatively and take the time to implement a good proof of concept so that the company understands what an actual malicious actor could do with it. I hope you also learned some useful generic tricks to implement straight into your PoC's.

About what could be improved upon double-clickjacking still, is a browser-independent version that works great on both Chrome and Firefox, this one is specifically targeted at the most commonly used Chrome browser. The popunder bug, for example, needs a Firefox counterpart (any more NDevTK's or Renwa's in chat?). The protection of a timeout after focusing the page also feels like it should have some combination of tricks to bypass. This is hard because while we can bring the window to the front to focus it and then hide it again with a popunder, this must happen right in the user's face, as we cannot change its position or size without reloading the page again. It also isn't possible to use the Back/forward cache to prevent reloading since our page still has a reference to the window, and this feature is disabled then.

As a last note, I want to tell you to be understanding if companies don't want to fix it. It lies in a gray area of user interaction where I personally think: if the access you gain with it is large and there are hardly any other preconditions, it's a significant enough risk to warrant a fix. However, all companies' threat models are different.