Rating: 5.0
Going to the homepage link in the description, we can see a page with a login/register form.
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).
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
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:
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.
Here we can send the request to the Repeater page, where we can edit and replay the request without problems.
Now we have a valid token, but that's not related to any user!
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.
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