Toaster Week Part 3: TW2.5 Light 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 three, I’ll be covering the first of the three Toaster Wars problems from PlaidCTF 2017: the Light Flag.
Toaster Wars: Going Rogue, Episode 2.5 – Light 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.
Light Flag (225 points): I found a locked box in Undersea Cavern once… and I think I saw a key in Calm Crystal Reef somewhere? Can you break the lock?
Since we’ve reached the Plaid problems now, we have all new source code to work with. A significant part of these three challenges is working through the large quantity of source you’re given (10.5k lines) to figure out what’s relevant. I’ll be largely glossing over that here since I don’t have much to say about it beyond that taking a targeted approach is probably required to solve the problem in a reasonable amount of time.
Unlike in the PicoCTF versions of this game, when you begin this game, you appear in an overworld hub that you can use to select one of several dungeons:
The hint mentions two dungeons, Undersea Cavern and Calm Crystal Reef. As it suggests, in Undersea Cavern a “locked box” will always spawn on 3F…
…while in Calm Crystal Reef a key will always spawn on 3F.
Either item on its own doesn’t do anything, but we can see that the key will transform a locked box in the players inventory into the light flag when used:
export let key: ItemBlueprint = {
name: "Key",
description: "Can be used to open an equipped locked box.",
graphics: "item-key",
actions: {
use: ["use"]
},
handlers: {
use(entity: CrawlEntity, state: InProgressCrawlState, item: Item, held: boolean, eventLog: LogEvent[]): void {
crawl.propagateLogEvent(state, {
type: "message",
entity: {
id: entity.id,
name: entity.name,
graphics: entity.graphics
},
message: `<self>${entity.name}</self> used the <item>Key</item>!`
}, eventLog);
let index = entity.items.held.items.findIndex((it) => it.name === "Locked Box");
if (index >= 0) {
let it = entity.items.held.items[index];
Object.assign(it, lightFlag);
}
}
}
};
So how can we get both in our inventory at the same time?
When you’re done going through one dungeon, you’re not returned to the overworld hub as you might expect; the game just ends. So it’s not as easy as just playing the game twice.
The key here lies in some faulty logic when transitioning from the overworld game state to an in-dungeon (“crawl”) game state. To understand what’s going on here, we first need to understand how the game handles interacting with an entity or hotzone in the overworld. Every interactable entity and hotzone implements a interact
function that returns a JS iterable. This allows an entity to yield
one step of its interaction, then continue when it receives the response from the player, eventually return
ing some value that ends the interaction. For example, the interact
method used by the blender in the overworld yield
s a couple of "speak"
commands, then gets a response from the user and sends them to a dungeon based on their response.
It’s also important to know how the game sets up and tears down these interactions. Here’s all of the relevant code:
/**
* Initializes an overworld scene.
* @param scene - The overworld scene to initialize.
*/
public initOverworld(scene: OverworldScene): void {
/* ...some irrelevant setup code... */
this.socket.removeAllListeners("overworld-interact-entity");
this.socket.removeAllListeners("overworld-interact-hotzone");
this.socket.on("overworld-interact-entity", (id: string) => {
log(`I ent ${this.socket.id}`);
let entities = scene.entities.filter((ent) => ent.id === id);
if (entities.length > 0 && entities[0].interact) {
this.handleInteraction(entities[0].interact());
} else {
log(`I end ${this.socket.id}`);
this.socket.emit("overworld-interact-end");
}
});
this.socket.on("overworld-interact-hotzone", (id: string) => {
log(`I hz ${this.socket.id}`);
let hotzones = scene.hotzones.filter((hz) => hz.id === id);
if (hotzones.length > 0 && hotzones[0].interact) {
this.handleInteraction(hotzones[0].interact());
} else {
log(`I end ${this.socket.id}`);
this.socket.emit("overworld-interact-end");
}
});
}
/**
* Handles an interaction with an entity in the overworld.
* @param interaction - The iterator describing the interaction.
*/
private handleInteraction(interaction: IterableIterator<Interaction>): void {
let advance = ({ value, done }: IteratorResult<Interaction>) => {
if (!value) {
this.socket.emit("overworld-interact-end");
return;
}
switch (value.type) {
case "speak":
log(`I cont ${this.socket.id}`);
this.socket.emit("overworld-interact-continue", value);
this.socket.once("overworld-respond", (response: ClientInteractionResponse) => {
log(`I res ${this.socket.id}`);
if (done) {
log(`I end ${this.socket.id}`);
this.socket.emit("overworld-interact-end");
} else {
advance(interaction.next(response));
}
});
break;
case "crawl":
log(`I end ${this.socket.id}`);
this.initCrawl(value.dungeon);
break;
case "transition":
this.socket.emit("overworld-interact-end");
this.entity.position = value.start.position;
this.initOverworld(value.scene);
break;
}
};
advance(interaction.next());
}
/* ...some irrelevant functions... */
/**
* Initializes a crawl.
* @param dungeonName - The key of the dungeon in which to start the crawl.
*/
private initCrawl( dungeonName: string): void {
this.getLogicNodeAssignment()
.then(() => {
let dungeon = dungeons.get(dungeonName);
this.checkGraphics(dungeon.graphics);
this.socket.removeAllListeners("overworld-interact-entity");
this.socket.removeAllListeners("overworld-interact-hotzone");
/* ...a bunch of irrelevant initalization code... */
});
}
There are a couple of issues of particular note here:
- There is no locking mechanism to ensure that the player only has one active interaction at a time
- Any time an
"overworld-interact-continue"
event is received, it will be forwarded to all active interactions - The
"overworld-interact-continue"
event listener is not cleaned up at all when starting a crawl - The iterators underlying the interactions are not cleaned up at all when starting a crawl
Putting all of this together, we can transport the box from one dungeon to the other by taking the following series of actions:
-
Launch the game
-
Start an interaction with the blender
-
Start an interaction with the hotzone for Undersea Cavern
"Would you like to enter Calm Crystal Reef?"
prompt, and the blender will be waiting on the "Welcome to TW: 2KLutS!"
prompt.
- Send an
"overworld-interact-continue"
event with a value of0
"Yes"
to the hotzone interaction (which launches Undersea Cavern) and simply advances the dialog wth the blender to the "You can visit the dungeons by going up, left, or right, or by talking to me!"
prompt.
-
Play the game to 3F and grab the box
-
Send an
"overworld-interact-continue"
event with any value
"Anyway... would you like to visit a dungeon?"
prompt.
- Send an
"overworld-interact-continue"
event with a value of2
"Sounds good. Have fun in there!"
.
- Send an
"overworld-interact-continue"
event with any value
At this point, all that’s left is to play the game to 3F, grab the key, use it to get the flag, and then use the flag!
In the above video, you can see the initial interaction setup (steps 1-4) from 0:00 to 0:24, the transfer (steps 5-8) from 3:20-3:40, and finally getting the flag at 5:12.
Something you might have noticed in the video is that the gameplay seems to get a little bit weird-looking after entering Calm Crystal Reef. This is because our exploit caused the game to be initialized twice, so all of the event listeners waiting for game inputs have been duplicated and every action we send is applied twice! This makes the second half of the game a little bit harder, but with careful planning it doesn’t present too large of a hurdle.
You might also be wondering at this point if there was a reason we take the box to the key rather than the other way around. In fact, there actually is an important reason for this: our entire game state gets serialized when we start a new crawl, which causes all of the handler functions attached to the items to get dropped when we use this exploit! This means that if we brought the key to the box, then the key’s handler wouldn’t exist anymore and we would be unable to use it. On the other hand, the box is “dumb” in that it doesn’t actually encode any functional logic, so we are able to transport it safely. (This also more broadly means that any item picked up during the first crawl can’t be used during the second, which is why I fully heal in the video right before switching dungeons.)
To conclude, I’d summarize the key takeaways of this problem as:
- Be mindful of cleaning up event listeners when they are no longer relevant
- Ensure you properly dispose of any long-running functional constructs (here, iterators, though the same idea applies to promise chains)
Hopefully you found this writeup interesting! Tomorrow we’ll take a look at the Blazing Flag, where we’ll learn how to steal a session without cross-site scripting!