TSG CTF 2023 – Upside-down cake

Hello everyone, I hope you’re doing well. I come back with some javascript web CTF challenge writeup. In this case, it’s a beginner ones. The CTF was published last weekend from the University of Tokio. TSG is the official computer society of The University of Tokyo, and also the name of the CTF team organized by its members. Let’s started with some basic static code review.

import {serve} from '@hono/node-server';
import {serveStatic} from '@hono/node-server/serve-static';
import {Hono} from 'hono';

const flag = process.env.FLAG ?? 'DUMMY{DUMMY}';

const validatePalindrome = (string) => {
	if (string.length < 1000) {
		return 'too short';
	}

	for (const i of Array(string.length).keys()) {
		const original = string[i];
		const reverse = string[string.length - i - 1];

		if (original !== reverse || typeof original !== 'string') {
			return 'not palindrome';
		}
	}

	return null;
}

const app = new Hono();

app.get('/', serveStatic({root: '.'}));

app.post('/', async (c) => {
	const {palindrome} = await c.req.json();
	const error = validatePalindrome(palindrome);
	if (error) {
		c.status(400);
		return c.text(error);
	}
	return c.text(`I love you! Flag is ${flag}`);
});

app.port = 12349;

serve(app);

In first instance, this application is listened on the 12349 port that handles both GET and POST requests in the root path. In the frontend it can be observed that a user should send a string denominated palindrome. Examples are civic, radar, level, rotor, kayak, madam, and refer. The longest common ones are rotator, deified, racecar and reviver; longer examples such as redivider, kinnikinnik and tattarrattat are orders of magnitude rarer.

The final solution is the flag. However, the flag in on a variable environment on the remote ctf challenge, so we need to figure out how we can achieve it.

Following the code review, the problem that we have it’s the first check on the validatePalindrome function. Since the string.length must be higher than 1000, it could be possible to sent something like this: [kinnikinnik …]. However, the first problem that we have was that nginx.conf is set up as maximum body size as 100.

events {
	worker_connections 1024;
}

http {
	server {
		listen 0.0.0.0:12349;
		client_max_body_size 100;
		location / {
			proxy_pass http://app:12349;
			proxy_read_timeout 5s;
		}
	}
}

Next on the code it was observed a loop iteration on the json user input passed on the POST request. Basically, the string is reversed and check that it’s equal to the original, and also, the type of data must to be a string. For example, we could use civic and the reverse string will be civic as well. If all check passed, the return value will be null. Since the null value it’s not a string, we shouldn’t receive a bad request (400 status code) on the response and we could manage to leak the flag following the flow execution.

But, we continue having the same problem for solving it. The string length should be higher than 1000. The issue in this code snippet was on the input json validation. Since the validatePalindrome function is using the string as an object, we can take advantage of that for bypassing the first check because of a type confusion (string.length value is a string comparison with an integer). However, it could be bypassed as well if the field value was an integer. Furthermore, we sent an object in the json field value using burpsuite and can be observed bellow that we bypassed the first check:

Finally, we need to bypass the checks on the loop iteration. Since the first index on the loop is zero, we can abuse that for setting the original variable like this: "0":"civic". Furthermore, the reverse variable is the string.lenght-i-1 that means the following "999":"civic". Since the original and reverse are equal the final check is bypassed achieving the flag.

Hopefully this will be useful. Thanks in advance!. @naivenom.