Toaster Week Part 1: TW_GR_E1_ART and TW_GR_E2_EoTDS
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.
I’ll be going in order, so to start off, today I’ll be talking about the first the first two problems from PicoCTF 2017: TW_GR_E1_ART and TW_GR_E2_EoTDS.
Toaster Wars: Going Rogue, Episode 1 – Appliance Rescue Team
100 points (Level 2)
Oh, sweet, they made a spinoff game to Toaster Wars! That last room has a lot of flags in it though. I wonder which is the right one…?
Hint: I think this game is running on a Node.js server. If it’s configured poorly, you may be able to access the server’s source. If my memory serves me correctly, Node servers have a special file that lists dependencies and a start command; maybe you can use that file to figure out where the other files are?
With a little bit of Googling, it isn’t too hard to figure out that the hint is referring to package.json
, a file used in Node projects to list package information including dependencies and scripts. The rest of the hint indicates that the server is probably configured in such a way that we can access this file; and, indeed, requesting /package.json
gives us the following:
{
"name": "rogue-1",
"version": "1.0.0",
"main": "server/serv.js",
"dependencies": {
"beautiful-log": "^1.3.0",
"body-parser": "^1.16.0",
"callsite": "^1.0.0",
"clone": "^2.1.0",
"colors": "^1.1.2",
"cookie-parser": "^1.4.3",
"deep-diff": "^0.3.4",
"dequeue": "^1.0.5",
"express": "^4.14.1",
"mongodb": "^2.2.25",
"morgan": "^1.7.0",
"nconf": "^0.8.4",
"promise": "^7.1.1",
"socket.io": "^1.7.2",
"sprintf": "^0.1.5"
},
"devDependencies": {},
"scripts": {
"prestart": "node server/init.js",
"start": "node server/serv.js"
}
}
This gives us references to two other files, server/serv.js
and server/init.js
, which should be located relative to that file. Requesting those gives us source as well. init.js
isn’t very useful, but serv.js
is:
var express = require("express");
var app = express();
app.use(require("body-parser").json());
app.use(require("cookie-parser")());
// app.use(require("morgan")("dev"));
var http = require("http").Server(app);
var path = require("path");
var fs = require("fs");
var Promise = require("promise");
var logger = require("./logger");
var sprintf = require("sprintf");
var nconf = require("nconf");
var db = require("./db");
var io = require("socket.io")(http);
require("./game")(app, io);
nconf.argv().env();
var PORT = nconf.get("port") || 8888;
app.get("/", function(req, res){
res.status(200);
res.sendFile(path.join(__dirname, "../public/html/index.html"));
});
app.use(express.static(path.join(__dirname, "..")));
http.listen(PORT, function(){
logger.info("[server] Listening on *:" + PORT);
});
process.on("unhandledRejection", (err) => {
logger.error(err.stack);
});
From here we can see references to other files in the same directory in the require
calls: logger.js
, db.js
, and game.js
. Walking the dependencies in those files as well, we can work out the structure of the whole project.
Before we explore that further, though, we should probably take a look at what we’re trying to accomplish. The website hosts a tile-based, turn-based rougelike game in the style of the Mystery Dungeon series:
As the problem description suggests, on the final floor, 4F, there are a lot of flags:
Using the wrong one gives us a failure message, and destroys all of the items on the ground and in our inventory:
Ok, so it seems like our options are to either play this over and over until we guess correctly or to use the source to figure from the source code which flag is correct. I know that some PicoCTF teams actually chose the former option, which I find pretty flattering, but even I can only have so much patience for this game, so let’s opt for the latter.
Looking back at all of the files we found earlier, config.js
has a couple of parts that seem relevant to how the flag item works:
function createFlag(check, location) {
return {
name: "Flag",
description: "Gives you the flag... maybe.",
location: location,
use: 0,
id: check + 100,
sprite: "flag",
effects: [
{
type: "revealFlag",
check: check
},
{
type: "destroyItems"
}
]
};
}
// ...later in the file, in the section pertaining to 4F...
items: Array.from(new Array(83), (_, idx) => {
if (idx >= 2) {
idx++
}
if (idx >= 77) {
idx++;
}
var r = Math.floor(idx / 5) + 1;
var c = (idx % 5) + 1;
return createFlag(idx, { r: r, c: c });
}),
// ...
So it seems like the game instantiates every flag with a check
value related to its index. Now we just need to figure out how the "revealFlag"
effect works! Searching through the files we found before, we can find this reference to "revealFlag"
in game.js
:
case "revealFlag":
if (entity.items[action.item].effects[i].check == 64) {
outcome.flag = process.env["PICO_CTF_FLAG"];
}
break;
So we just need to select the flag with a check
value of 64
! Plugging in 64
to the expressions for r
and c
, we get r = 13
and c = 5
. So we select the item in that location, and voila, we have a flag!
Toaster Wars: Going Rogue, Episode 2 – Explorers of Toffee, Donuts, and Sugar
120 points (Level 2)
Given the relative success of the first release, it was no surprise that a second installment in the TW:GR series was released. I can’t beat this one either, though… those darn spatulas put an induction cooktop in the floor so I can’t get to the flag! Can you get it for me?
Hint: Toasters can’t go through induction cooktops because they’re made of metal. However, it looks like there’s a nice, friendly spatula on the last floor; and better yet, he’s made of rubber! I’m sure he could be persuaded to go pick up the flag and bring it back to you.
The game is mostly the same, but this time, the fourth floor contains a little puzzle involving the AI:
As the problem suggests, thre’s one tile with an induction cooktop that the player is not allowed to move onto (the X
on the minimap). However, the AI may move freely onto this tile.
Fortunately, the same method of getting the source as in the first part still works, so we can check how the enemy AI works. The entire source is in /server/ai.js
, but here’s a summary of what the AI will do:
- If HP is below 25% and we’re holding an item that heals HP, use it (breaking ties by item order).
- If HP is below 25% and we have an attack that heals HP, use it (breaking ties by attack order).
- If energy is below 25% and we’re holding an item that recovers energy, use it (breaking ties by item order).
- If energy is below 25% and we have an attack that recovers energy, use it (breaking ties by attack order).
- If we have an attack that will hit the player, use it (breaking ties randomly among valid attacks).
- If the player is within distance 3 or is in the same room, move towards them. (More on this below.)
- If we’re in a corridor, try to continue moving in the same direction as the previous move. This is done by taking the first valid move out of the list [forward, forward-left, forward-right, left, right].
- Move in a random direction.
Here’s the code for step 6:
// If the player is nearby (at most 3 steps) or is in the same room, move toward them
if (curDist <= 3 || (playerRoom > 0 && playerRoom == entityRoom)) {
var bestDist = curDist;
var bestDir = 0;
for (var i = 0; i < 8; i++) {
if (validMove(state, entity, { type: "move", direction: i })) {
var dir = utils.decodeDirection(i);
var newDist = measureDist(state.player.location, { r: entity.location.r + dir[0], c: entity.location.c + dir[1] });
if (newDist < bestDist) {
bestDist = newDist;
bestDir = i;
}
}
}
entity.lastMoveDir = bestDir;
return { type: "move", direction: bestDir };
}
A careful read of this code tells us that a correct statement of how selecting the best move when the player is in range is the following:
- Among the directions that are valid moves, we select one that minimizes the distance between the resulting position and the player.
- In the case of a tie, we take the earlier direction index (which starts at 0 for east and increases counterclockwise, ending at 7 for southeast).
- Crucially, due to how the variables are initialized, if no valid direction brings us closer than our current position, we move east.
It’s also worth noting that distance is defined as follows:
function measureDist(a, b) {
var r = Math.abs(a.r - b.r);
var c = Math.abs(a.c - b.c);
var m = Math.min(r, c);
var d = Math.max(r, c);
return d + m / 20;
}
So the points (0, 0)
and (3, 2)
are considered to be at a distance of 3 + 2 / 20 = 3.1
.
Finally, one more thing to note in config.js
is that the entire internal of the maze is set as corridors, meaning that step (7) in the above scheme will come into effect if the AI ever gets to it.
At a glance, it seems like the northwest corner of the maze may be impossible to get the AI to navigate around, since it will either be within distance 3 of the player (and thus move toward one of the edges) or it will be alone in a corridor (in which case it will always move straight past the eastern exit). However, due to the default behavior mentioned above when no move will bring the AI closer to the player, if we can put ourself in such a position with the spatula at the 3-way intersection, we can get it to move down the corridor to pick up the flag!
With a little bit of careful planning and trial and error, you can produce a solution like this:
Then all you have to do is get the AI to come out of the maze, attack it so that it drops the flag, and use it:
If you’re still around, thanks for reading, and tune in again tomorrow for the last two Toaster Wars problems from PicoCTF 2017!