Rating: 5.0

challenge description

Going to the homepage link in the description, we can see a page with a login/register form.

home

After registering, we can see a textbox with a string and we can see that a cookie is set with a JWT (Json Web Token).

saved_cookie

We can get the content of the token by going on https://jwt.io/#debugger-io. Here we can see the that it contains the User-ID of the account that's logged in

decoded_cookie

Now, looking at the source of the challenge, we can see that the objective is to visit the endpoint /vault while being "unrestricted"

app.get("/vault", (req, res) => {
    if (!res.locals.user) {
        res.status(401).send("Log in first");
        return;
    }
    const user = users.get(res.locals.user.uid);
    res.type("text/plain").send(user.restricted ? user.vault : flag);
});

Going back on the site while being logged in, the content of user.vault is none other than the content of the text area in the home:

set_vault

vault

As we have seen before, after login or registration, the cookie will be set with the UID of the user and signed with a random key.

Since we already know the format of the secret key (0.[0-9]+), I've started to try to break the jwt token by using jwtcrack, to try to forge a valid token. This tool uses a brute-force attack (so it tries every possible combination of the specified charset) to retrieve the secret used to sign the token.

It can be lauched with: docker run -it --rm jwtcrack [token] [charset] [maxlen] [algorithm]

So, in our case: docker run -it --rm jwtcrack eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOiIwLjM0Nzc5Mjc3MTY2MDg4MjIiLCJpYXQiOjE2NTE1NzcwNDh9.asR5mHIczZ-s1tZHCoCNoLKtPsPFu8S46adyRwOYa-U 0.123456789 20 sha256

After a day without anything (and thanks to a nudge from VCT on Discord), I've turned back to the source code.

In express, every app has an app.use function that's used as middleware and is called on every request.

app.use((req, res, next) => {
    try {
        res.locals.user = jwt.verify(req.cookies.token, jwtKey, {
            algorithms: ["HS256"],
        });
    } catch (err) {
        if (req.cookies.token) {
            res.clearCookie("token");
        }
    }
    next();
});

We can notice that the only condition set the user variable, is to use a valid jwt as a cookie.

Also, the login function (as opposed to the register function) never verifies that the fields of the request are actually defined.

app.post("/login", (req, res) => {
    const user = users.get(users.lookup(req.body.username));
    if (user && user.password === req.body.password) {
        res.cookie(
            "token",
            jwt.sign({ uid: user.uid }, jwtKey, { algorithm: "HS256" })
        );
        res.redirect("/");
    } else {
        res.redirect("/?e=" + encodeURIComponent("Invalid username/password"));
    }
});

We can use this to our advantage: in theory by making a login request without having a body, it will generate a valid token

Let's try our hypothesis: we can use a tool called "Burp" to intercept our login request and modify its content.

alogin

login

Here we can send the request to the Repeater page, where we can edit and replay the request without problems.

9repeater

login_no_creds

Now we have a valid token, but that's not related to any user!

decoded jwt

We can see why this worked by carefully reading the functions called in the login method:

const user = users.get(users.lookup(req.body.username));

//~~~~

get(uid) {
    return this.users[uid] ?? {};
}
lookup(username) {
    return this.usernames[username];
}

In javascript, some objects when used in a boolean statement are always evaluated to true and others to false. These objects are called Truthy and Falsy respectively.

Here we can see two examples of these kinds of objects:

In the lookup function, if we pass an undefined value, it will return undefined.

As for the get(uid), il will try to evaluate this.users[undefined], but since undefined is a Falsy, it will return an empty object.

So, now we have

if (user && user.password === req.body.password)

where user is an empty object so a Truthy.

The same goes for the password check: if we don't send a password in the request and since the empty object won't have a password attribute, both will be evaluated to undefined so undefined === undefined which is true and will be created a correct cookie.

Now, when accessing the vault endpoint, user.restricted will be undefined too, and since undefined is a Falsy, instead of the vault content, we'll get the flag!

So everything we need to do is to set the cookie with the value obtained by making the empty /login request, and then we can read the flag successfully.

set_cookie

flag


Now, this isn't the supposed way to solve the CTF: there's a "Use after free" vulnerability present (as indicated in the flag text) that enables you to use the cookie of a deleted user

In this case, the exploit works mostly the same, it's also based on the possibility to use a valid signed token in the request, but instead of using an empty one, is of a deleted user.

This causes the script to retrieve an undefined user object and therefore the checks will fail in a similar way

Original writeup (https://gist.github.com/SalScotto/a4580f132148804dce058af18b6e9a2f).