Flash CTF – Trading Places

This challenge presents itself as a web trading platform. We’re given a link to the website, and nothing more, so this will be a black-box challenge. The description implies that cryptography and “cryptographic standards” might be involved, but that’s about it. Let’s take a peek!

Playing with the website

When we visit the link, we’re greeted with a very simple webpage. It’s just a login prompt. Some text mentions:

Note: Guest login is available with username guest, password guest.

If we try those credentials, we’re taken to a “trading platform” – just one pretty fake-looking graph – which helpfully tells us:

Guest preview mode enabled – admin login required to make trades.

So we can assume the challenge is to log in as admin. If we log back out, we can guess admin/admin or such, but to no avail; we just get an Incorrect username or password popup. Won’t be quite that easy.

Taking a closer look

Let’s look at the client-side source code of this webpage. Hit Ctrl+U, or open the browser’s Developer Tools.

It’s thankfully quite simple! There’s some boilerplate, some inline CSS, a big chunk of JavaScript, and a very simple HTML login form. The JavaScript seems to be the interesting part.

import * as jose
    from 'https://cdn.jsdelivr.net/npm/jose@latest/dist/browser/index.js';
const { SignJWT } = jose;

window.onload = function () {
    document.getElementById('login-form').onsubmit = async (e) => {
        e.preventDefault();
        const formData = new FormData(e.target);
        const user = formData.get('username');
        const pass = formData.get('password');
        const response = await fetch('/login', {
            method: 'POST',
            body: new URLSearchParams({
                username: user,
                password: pass
            }),
            headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
        });
        let resp = await response.json();
        if ("success" in resp) {
            const token = new TextEncoder().encode(response.headers.get("jwt"));
            const jwt = await new SignJWT({ sub: user })
                .setProtectedHeader({ alg: "HS256" })
                .setIssuedAt()
                .setExpirationTime("2h")
                .sign(token);
            document.cookie = "jwt=" + jwt;
            window.location.href = "/dashboard";
        } else {
            alert("Incorrect username or password.");
        }
    };
}

Let’s see if we can figure out what’s going on here.

First, the Jose library is imported.

jose is JavaScript module for JSON Object Signing and Encryption, providing support for JSON Web Tokens (JWT), JSON Web Signature (JWS), JSON Web Encryption (JWE), JSON Web Key (JWK), JSON Web Key Set (JWKS), and more

(Remember the reference to cryptography in the challenge description?)

When the login form is submitted, grab the username and password fields, and then make a POST request to /login with them in the body of the request. When we get a response, if it was successful (meaning the username and password are correct)…

We grab the jwt header from the response. We use it to sign, client-side, a JSON object consisting of one key, sub (subject), whose value is the username we logged in with. The resultant, signed JWT – JSON Web Token – is placed into a cookie for this website, and we navigate the browser to the /dashboard page.

I might have lost you near the end if you’re not familiar with JWTs. What are they?

JSON Web Tokens

These pages may be a useful introduction to JWTs:

Basically (handwaving quite a bit), the idea of a JWT is to leverage cryptographic signatures so that you don’t have to validate a login/session token against a database with every request a user makes. Instead, upon a successful login, the server generates a JSON object – usually a small one, perhaps containing just a username, a start time, and an expiration time – and then signs it with a secret that only the server knows. The JWT – that signed JSON object – is delivered to the client, which stores it.

Now, every time the client makes an authenticated request, it passes the JWT. To validate the client, the server does not need to do a full database lookup, it just needs to check the cryptographic signature of the JWT against the server-side secret to know that it was indeed generated by the server and has not been tampered with. Once that’s validated, the server can trust the data inside the JWT – that is, that the request really did come from the user specified in the JWT’s payload.

If the client were to tamper with the JWT, say by changing their username in the payload from “guest” to “admin”, the cryptographic signature would no longer validate correctly, and the server would reject that request.

JWTs have some famous flaws and constraints, mostly dealing with invalidation/revocation issues, but they’re still decent and fairly common. But here, though, they’ve just been implemented completely incorrectly.

What did they do wrong?

Crucially, JWTs should be generated server-side, upon a login. The client should just get a token that it then passes to the server whenever it needs to. The fact that this client-side code seems to be generating/signing a JWT immediately indicates something weird is up.

Indeed, on closer inspection (the Network requests tab of your browser devtools may help), it seems like on a successful login, the server is just passing us its secret value (via a response header), and letting us sign our own token with it. There is nothing stopping us, the enterprising user, from taking the secret and using it to sign whatever we want to. So let’s do that.

Letting ourselves in

There’s two ways you could solve this. I’ll show how to solve it “by hand”, copying out the secret, generating our own JWT, and inserting the new one into the browser. You could probably also just grab and edit the JS in the page – after all, it already does all the work of extracting headers, signing JWTs, setting cookies – but have it set the subject as admin instead of the username we logged in with. I couldn’t figure out how to do that cleanly, so it’s… left as an exercise to the reader.

Anyways, the Debugger tool at https://jwt.io/ will be of use. You can leave the default algorithm (HS256).

On the challenge site, get the developer tools open, go to the Network tab, and enable Persist Logs / Preserve Log (so the Fetch request doesn’t disappear after the page navigates away). Clear the logs, then sign in with guest/guest. Find the POST request to /login. In the Headers sub-tab, under Response Headers, you’ll see one named jwt, with the value jwt_z3bXPravDcYhjy5mhYgYLbWRoAPkPyn. This is the secret (astute solvers may have noticed that it does not match JWT format, which is always three Base64 strings separated by periods). Copy the secret, and paste it into the secret field on the jwt.io Debugger.

Now (not strictly necessary, but might as well) hop over to the Storage (Firefox) or Application (Chromium) tab, and find the current host under Cookies. There should be a cookie, also named jwt. Grab its value, and paste it into the jwt.io Debugger. You’ll notice that it says Signature Verified – we really do have the secret corresponding to this JWT.

So, underneath Payload, just change guest to admin. You’ll see the JWT on the left changes, but still says Signature Verified, because the tool is re-signing our new payload with the secret value.

Paste the resultant JWT into the jwt cookie, and refresh the dashboard page. You should see a flag appear!