Hacked Discord - Bookmarklet Strikes Back

For the past couple of months, I've been hearing about increasing numbers of account takeover attacks in the Discord community. Discord has somehow become a de facto official messenger application among the cryptocurrency community, with new channels oriented around NFTs, popping up like mushrooms.

Hacking Discord accounts has suddenly become a very lucrative business for cybercriminals, who are going in for the kill, to make some easy money. They take over admin accounts in cryptocurrency-oriented communities to spread malware and launch further social engineering attacks. My focus is going to be purely on Discord account security, which should be of concern to everyone using Discord.

In recent weeks I thought the attackers are using some new reverse-proxy phishing techniques to hijack WebSocket sessions with similar tools to Evilginx, but in reality the hacks, I discovered, are much easier to execute than I anticipated.

In this post I will be explaining how the attacks work, what everyone can do to protect themselves and more importantly what Discord can do to mitigate such attacks.

Please bear in mind that this post covers my personal point of view on how I feel the mitigations should be implemented and I am well aware that some of you may have much better ideas. I encourage you to contact me @mrgretzky or at kuba@breakdev.org if you feel I missed anything or was mistaken.

Criticism is welcomed!

The Master Key

When you log in to your Discord account, either by entering your account credentials on the login screen, or by scanning a QR code with your Discord mobile app, Discord will send you your account token, in form of a string of data.

This token is the only key required to access your Discord account.

From now on I will refer to that token as the master token, since it works like a master key for your Discord account. That single line of text, consisting of around 70 characters, is what the attackers are after. When they manage to extract the master token from your account, it is game over and third parties can now freely access your account, bypassing both the login screen and any multi-factor authentication you may've set up.

Now that we know what the attackers are after, let's analyze the attack flow.

Deception Tactics

Attacker's main goal is to convince you in any way possible to reveal your account token, with the most likely approach being social engineering. First, attackers will create a story to set themselves up as a people you can trust, who will resolve your pressing issue, like unban your account on a Discord channel or elevate your community status.

In this thread you can read how attackers managed to convince the target to do a screen share from their computer with Chrome DevTools opened, on the side. DevTools allowed them to extract the master token, through revealing the contents of Discord's LocalStorage.

Discord now purposefully renames the window.localStorage object, when it loads, to make it inaccessible to injected JavaScript. It also hides the token variable, containing the master token's value, from the storage. This was done to prevent attackers from stealing master tokens, by convincing the targets to open LocalStorage through screen share.

Discord also shows a pretty warning, informing users about the risks of pasting unknown code into the DevTools console.

The renaming of localStorage object and concealment of token variable did not fix the issue. Attackers figured out they can force Discord window to reload and then retrieve the data before implemented mitigations are executed.

This is exactly how the most recent bookmarklet attack works. Attackers convince their victim to bookmark JavaScript code in their bookmarks tab and trick them later to click the saved bookmark, with Discord app in focus, to execute attacker's malicious code.

Attacker's code retrieves the victim's Discord master token and sends it to the attacker.

Saved malicious bookmarklet would look like this.

The malicious JavaScript to access the master token looks like this:

javascript:(function(){location.reload();var i = document.createElement('iframe');document.body.appendChild(i);alert(i.contentWindow.localStorage.token)})()

To try it out, open Discord website, go to the console tab in Chrome DevTools (Control+Shift+I) and paste this code into it, which is something you should never do when asked 😉. After you press Enter, you should see the popup box with your master token value in it.

Attackers will exfiltrate the token through Discord WebHooks. This is done to bypass current Content Security Policy rules as obviously discord.com domain is allowed to be connected to from Discord client.

Most users have no idea that saved bookmarks can run malicious JavaScript in context of the website they are currently viewing. This is why this type of attack may be successful among even the security savvy users.

Another much harder approach, the attackers can take, is deploying malware onto target computer. Once you install malware on your PC the consequences can be far greater than just losing your Discord account. Just wanted to make note here that browser's LocalStorage will store the tokens in unencrypted form.

LocalStorage database files for Chrome reside in:

%LOCALAPPDATA%\Google\Chrome\User Data\<PROFILE>\Local Storage\leveldb\
Discord master token found inside one of the LocalStorage database files

Once the attacker retrieves your Discord master token, they can inject it into their browser's LocalStorage, with token as a variable name. It can easily be done using LocalStorage Manager extension for Chrome.

Discord on reload will detect the valid token in its LocalStorage and allow full control over the hijacked account. All this is possible even with multi-factor authentication enabled on hijacked account.

Proposed Mitigations

The fact that a single token, accessible through JavaScript, lets you impersonate and fully control another user's account is what makes the bookmarklet attack possible.

The attacker needs to execute their malicious JavaScript in context of the user's Discord session. This can happen either through exploitation of an XSS vulnerability or by tricking a user into bookmarking and clicking the JavaScript bookmarklet.

There is unfortunately no definitive fix to this problem, but there are definitely ways to increase the costs for attackers and make the attacks much harder to execute.

Modern web services rely heavily on communication with REST APIs and Discord is no different. REST APIs, to conform with the standard, must implement a stateless architecture, meaning that each request from the client to the server must contain all of the information necessary to understand and complete the request.

The session token, included with requests to REST API, is usually embedded within the Authorization HTTP header. We can see that Discord app does it the same way:

Master token sent via Authorization HTTP header

For the web application to be able to include the session token within the Authorization header or request body, the token value itself must be accessible through JavaScript. Token accessible through JavaScript will be always vulnerable to XSS bugs or other JavaScript injection attacks (like bookmarklets).

Some people may not agree with me, but I think that critical authorization tokens should be handled through cookie storage, inaccessible to JavaScript. Support for Authorization header server-side can also be allowed, but it should be optional. For reasons unknown to me, majority of REST API developers ignore token authorization via cookies altogether.

Server should be sending the authorization session token value as a cookie with HttpOnly flag, in Set-Cookie HTTP header.

Storing the session token with HttpOnly flag, in the browser, will make sure that the cookie can never be retrieved from JavaScript code. The session token will be automatically sent with every HTTP request to domains, the cookie was set up for.

The request, which sends authentication token as a cookie, could look like this:

How Discord may be sending the session token via cookies, instead of Authorization header

I've noticed that Discord's functionality does not rely solely on interaction with its API, but the major part of its functionality is handled through WebSocket connections.

The WebSocket connection is established with gateway.discord.gg endpoint, instead of the main discord.com domain.

Discord initiating a WebSocket connection

If the session token cookie was delivered as a response to request delivered to domain discord.com, it would not be possible to set a cookie for domain discord.gg due to security boundaries.

To counter that, Discord would either need to implement some smart routing allowing Discord clients to use WebSocket connections through discord.com domain or it would have to implement authorization using one-time token with discord.gg endpoint, once the user successfully logs in, to have discord.gg  return a valid session token as a cookie, for its own domain.

Right now Discord permits establishing WebSocket connection through gateway.discord.gg to anyone and the authorization token validation happens during internal WebSocket messages exchange.

This means that the session token is also required during WebSocket communication, increasing its reliance on being accessible through JavaScript. This brings me to another mitigation that can be implemented.

Ephemeral Session Tokens

Making a strict requirement for tokens to be delivered as HttpOnly cookies, will never work. There needs to be some way to have authorization tokens accessible to JavaScript and not give the attacker all the keys to the castle once that token gets compromised.

That's why I'd make authentication reliant on two types of tokens:

  1. Authentication token stored only as a cookie with HttpOnly flag, which will be used to authenticate with REST API and to initiate a WebSocket connection.
  2. Session token generated dynamically with short expiration time (few hours), accompanied by a refresh token used for creating new session tokens. Session tokens will be used only in internal WebSocket communication. WebSocket connections will allow to be established only after presenting a valid authentication token as a cookie.

If you wish to learn more about ephemeral session tokens and refresh tokens, I recommend this post.

But, wait! Attacker controls the session anyway!

Before you yell at me about all of these mitigations being futile, because the attacker, with his injected JavaScript, is already able to fully impersonate and control the hacked user session, hear me out.

Me and @buherator exchanged opinions about possible mitigations and he made a good point:

In short - the attacker is able to inject their own JavaScript, which will work in context of the Discord app. Running malicious code in context of Discord allows the attacker to make any request to Discord API, with all required security tokens attached. This also includes cookies stored with HttpOnly flag, since attacker's requests can have them included automatically with withCredentials set to true:

var req = new XmlHttpRequest();
req.open("GET", “http://discord.com/api/v9/do_something”, true);
req.withCredentials = true;
req.send(null);
How attacker could be able to make HTTP requests to Discord API with valid authentication cookies.

No matter how well the tokens are protected, Discord client needs access to all of them, which means attacker executing code, in context of the app, will be able to forge valid client requests, potentially being able to change user's settings or sending spam messages to subscribed channels.

I've been thinking about it for few days and reached a conclusion that implementing the mitigations I mentioned would still be worth it. At the moment the attack is extremely easy to pull off, which is what makes it so dangerous. Ability to forge packets, impersonating a target user is not as bad as being able to completely recreate victim's session within a Discord client remotely.

With token cookie protected with HttpOnly flag, the attacker will only be able to use the token to perform actions, impersonating the hacked user, but they will never be able to exfiltrate the token's value, in order to inject it into their own browser.

In my opinion this will still vastly lower the severity of the attack and will force the attackers to increase the complexity of the attack in technical terms, requiring knowledge of Discord API, in order to perform the attacker's tasks, once the user's session is hijacked.

Another thing to note here is that the attacker will remain in charge of the user's hijacked session only until the Discord app is closed or reloaded. They will not be able to spawn their own session to freely impersonate the hacked user at any time they want. Currently the stolen master token gives the attacker lifetime access to victim's account. Token is invalidated and recreated only when user changes their password.

Takeaways

It is important to note that the attackers are only able to exfiltrate the master token value, using XMLHttpRequest and Discord's Webhooks, by sending it to Discord channels they control. Using WebHooks allows them to comply with Discord's Content Security Policy.

If it weren't for WebHooks, attackers would have to figure out another way to exfiltrate the stolen tokens. At the time of writing the article, the connect-src CSP rules for discord.com, which tell the browser the domains it should only allow Discord app to connect to, are as follows:

connect-src 'self' https://discordapp.com https://discord.com https://connect.facebook.net https://api.greenhouse.io https://api.github.com https://sentry.io https://www.google-analytics.com https://hackerone-api.discord.workers.dev https://*.hcaptcha.com https://hcaptcha.com https://geolocation.onetrust.com/cookieconsentpub/v1/geo/location ws://127.0.0.1:* http://127.0.0.1:*;

There seem to be other services allowed, which could be used to exfiltrate the tokens through them, like GitHub API, Sentry or Google Analytics.

Not sure if Discord client needs access to WebHooks through XMLHttpRequest, but if it doesn't, it may've been a better choice for Discord to host WebHook handlers on a different domain than discord.com and control access to them with additional CSP rules.

There is also one question, which remains unanswered:

Should web browsers still support bookmarklets?

As asked by @zh4ck in his tweet, which triggered my initial curiosity about the attacks.

Bookmarklets were useful in the days when it was convenient to click them to spawn an HTML popup for quickly sharing the URL on social media. These days I'm not sure if anyone still needs them.

I have no idea if letting bookmarks start with javascript: is needed anymore, but it can easily become one of those legacy features that will keep coming back to haunt us.

Wrap-up

I hope you liked the post and hopefully it managed to teach you something.

I am constantly looking for interesting projects to work on. If you think my skills may be of help to you, do reach out, through the contact page.

Until next time!

References