GreyCTF Rainbow Road (Challenge Creator Writeup)
Created a web game hacking challenge!

Overview
Description: Greycat’s been feeling kind of blue lately, find the hidden message to cheer him up!
Points: 1000
Solves: 4
Thanks to these teams for taking the effort to solve my cursed misc/web/rev challenge!
Initially, I wanted this to be a fully misc challenge to just randomly solve mazes and see how much people would spam it. But @Oceankoh convinced and inspired a websocket hacking challenge instead!
We are given a maze that changes every 5s. The description hints at us to follow the blue.
If we navigate to the right (specifically at x=200), we notice there’s a huge vertical wall.
But we can get the first 2 and a half letters of the flag by converting whatever colors we have in the maze into the pixels of an image.
So we need to:
- Find some way to get over the great wall
- Map each pixel into an image
- Preferably jump more than one space so we can jump anywhere in the maze
Creation Process
Challenge Repository: https://github.com/jloh02/greyctf-2025-challs-rainbow-road/
Creating the Flag
With PowerPoint, I created a black and white flag.
Then with a python script, convert the black to blue, and fill the rest with a rainbow gradient without blue (This has to use lossless PNG format)
Maze Generation
I used a classic DFS, recursive backtracking algorithm to generate a maze. This was relatively easy to ChatGPT and write code. Since it was already in JavaScript, I had the idea to just create mazes dynamically, and change the maze every 5 seconds. This is much like last iteration’s GreyCTF Maze Runner challenge.
Some additional fixes:
- We make the diagonals (corner wall cells) non-existent to facilitate the
canMoveBetween
wall bug - We add a wall at
x=200
to prevent just a script brute-solving everything - Remove 10% of walls to not have a single path to each cell
Web Sockets
To create a vulnerability, instead of ending the game on disconnect simply, we had the idea to delete user’s current position (currently tracked in a Map
), and allow the user to start moving everywhere. Thus, the “Restart” and “End Game” buttons served to be a disguise to require a endGame
socket listener.
Introducing the Game Hack
After sending a signal to end the game, we can move to anywhere and generate the script. However, an unintended feature required users to constantly send signals to endGame
after each move. This is because a move would re-update the userPositions
map. The conditions for isAdjacent
and canMoveBetween
were crafted cause undefined
user positions to return true
regardless of the input, allowing users to move anywhere as long as they sent endGame
before the move
.
Making it Harder (Sorry)
In order to add an additional step (also because we were lazy to strip comments), we just ran the backend code into minify to condense the distributed code
Bugs!!!
Unfortunately, I initially wrote the challenge in TypeScript and changed it to JavaScript a few days before to convert it to a web challenge. In doing so, we weren’t able to test all inputs to the websocket. Sending a non-integer {x,y} value to move
would cause the backend to crash. In addition, there were probably more obscure edge case bugs which we did not debug in time, and simply restarted the server as I was out in the day (sorry!)
Intended Solve
Deobfuscation and Reversing the Minification
We need to at least be able to read the code. With the help of our buddy ChatGPT, this process is pretty simple!
Prompt
<paste index.min.js>
can you reverse this? and rename variable so it looks correct? i think this is doing some maze related code for the backend. also try to unminify as much as possible
ChatGPT response
The response I got was pretty close to my code but I will use the original source as a reference for simplicity
⚠️ Skip unminify ChatGPT response
const { Server } = require("socket.io");
const fs = require("fs");
const { PNG } = require("pngjs");
const socketToPlayer = new Map();
const mazeColors = [];
let mazeWalls;
// Maze generation using DFS
async function generateMaze() {
const width = mazeColors[0].length;
const height = mazeColors.length;
const mazeWidth = 2 * width + 1;
const mazeHeight = 2 * height + 1;
const walls = Array.from({ length: mazeHeight }, () => Array(mazeWidth).fill(false));
for (let y = 0; y < mazeHeight; y++) {
for (let x = 0; x < mazeWidth; x++) {
if (y % 2 !== 1 || x % 2 !== 1) {
walls[y][x] = true;
}
}
}
const visited = Array.from({ length: height }, () => Array(width).fill(false));
const directions = [
{ dx: 1, dy: 0 },
{ dx: -1, dy: 0 },
{ dx: 0, dy: 1 },
{ dx: 0, dy: -1 }
];
function isValid(x, y) {
return x >= 0 && x < width && y >= 0 && y < height;
}
const stack = [{ x: 0, y: 0 }];
visited[0][0] = true;
walls[1][1] = false;
while (stack.length > 0) {
const { x, y } = stack[stack.length - 1];
const shuffledDirections = directions.sort(() => Math.random() - 0.5);
let moved = false;
for (const { dx, dy } of shuffledDirections) {
const nx = x + dx;
const ny = y + dy;
if (isValid(nx, ny) && !visited[ny][nx]) {
walls[2 * y + dy + 1][2 * x + dx + 1] = false;
walls[2 * ny + 1][2 * nx + 1] = false;
visited[ny][nx] = true;
stack.push({ x: nx, y: ny });
moved = true;
break;
}
}
if (!moved) stack.pop();
}
// Add random holes in the maze
for (let y = 1; y < height - 1; y++) {
for (let x = 1; x < width - 1; x++) {
if (Math.random() < 0.1) walls[2 * y + 1][2 * x] = false;
if (Math.random() < 0.1) walls[2 * y][2 * x + 1] = false;
}
}
// Add hard wall on column 200 (for constraints or challenge)
for (let y = 0; y < mazeHeight; y++) walls[y][200] = true;
mazeWalls = walls;
}
// Viewport window for the client
function getViewport(x, y, radius = 10) {
const half = radius - 1;
const diameter = 2 * half + 1;
const colorGrid = [];
const wallGrid = [];
for (let dy = 0; dy < diameter; dy++) {
const row = [];
const mapY = y - half + dy;
for (let dx = 0; dx < diameter; dx++) {
const mapX = x - half + dx;
row.push(
mapY >= 0 && mapY < mazeColors.length && mapX >= 0 && mapX < mazeColors[0].length
? mazeColors[mapY][mapX]
: ""
);
}
colorGrid.push(row);
}
for (let dy = 0; dy < diameter; dy++) {
const row = [];
const mazeY = 2 * y - (diameter >> 1) + 2 + dy;
for (let dx = 0; dx < diameter; dx++) {
const mazeX = 2 * x - (diameter >> 1) + 2 + dx;
row.push(
mazeY >= 0 && mazeY < mazeWalls.length && mazeX >= 0 && mazeX < mazeWalls[0].length
? mazeWalls[mazeY][mazeX]
: false
);
}
wallGrid.push(row);
}
return { walls: wallGrid, colors: colorGrid };
}
const io = new Server({ cors: { origin: process.env.NODE_ENV === "production" ? undefined : "http://localhost:5173" } });
function movePlayer(position, socket, callback) {
const { x, y } = position;
const current = socketToPlayer.get(socket.id);
if (
x >= 0 && x < mazeColors[0].length &&
y >= 0 && y < mazeColors.length &&
Math.abs(x - current?.x) + Math.abs(y - current?.y) <= 1 &&
!mazeWalls?.[y + current?.y + 1]?.[x + current?.x + 1]
) {
socketToPlayer.set(socket.id, { x, y });
socket.emit("mazeUpdate", getViewport(x, y));
callback?.(true);
}
}
function resetPlayer(socket) {
socketToPlayer.set(socket.id, { x: 0, y: 0 });
socket.emit("mazeUpdate", getViewport(0, 0));
}
function removePlayer(socket) {
socketToPlayer.delete(socket.id);
}
function rgbToHex(r, g, b) {
return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
}
function hexToRgb(hex) {
const match = hex.match(/^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i);
if (!match) throw new Error(`Invalid hex color: ${hex}`);
return {
r: parseInt(match[1], 16),
g: parseInt(match[2], 16),
b: parseInt(match[3], 16)
};
}
function startGameServer() {
io.on("connection", socket => {
socketToPlayer.set(socket.id, { x: 0, y: 0 });
socket.emit("mazeUpdate", getViewport(0, 0));
socket.on("move", (pos, cb) => movePlayer(pos, socket, cb));
socket.on("restart", () => resetPlayer(socket));
socket.on("endGame", () => removePlayer(socket));
socket.on("disconnect", () => removePlayer(socket));
});
setInterval(async () => {
await generateMaze();
for (const [socketId, { x, y }] of socketToPlayer.entries()) {
io.to(socketId).emit("mazeUpdate", { isTimer: true, ...getViewport(x, y) });
}
}, 5000);
}
// Parse input image to generate color map
fs.createReadStream("flag-out.png")
.pipe(new PNG())
.on("parsed", function () {
for (let y = 0; y < this.height; y++) {
const row = [];
for (let x = 0; x < this.width; x++) {
const idx = (this.width * y + x) << 2;
const r = this.data[idx];
const g = this.data[idx + 1];
const b = this.data[idx + 2];
row.push(rgbToHex(r, g, b));
}
mazeColors.push(row);
}
generateMaze().then(() => {
createMazeImage(startGameServer, false);
});
});
// Optional PNG export for visual verification
function createMazeImage(done, shouldWriteToFile = false) {
if (!shouldWriteToFile) return void done();
const height = mazeWalls.length;
const width = mazeWalls[0].length;
const png = new PNG({ width, height });
function setPixel(x, y, r, g, b) {
if (x < 0 || x >= width || y < 0 || y >= height) return;
const idx = (width * y + x) << 2;
png.data[idx] = r;
png.data[idx + 1] = g;
png.data[idx + 2] = b;
png.data[idx + 3] = 255;
}
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (mazeWalls[y][x] || (x % 2 === 0 && y % 2 === 0)) {
setPixel(x, y, 0, 0, 0); // black for wall
} else if (x % 2 === 0 && y % 2 === 0) {
const gridColor = mazeColors[y / 2]?.[x / 2];
if (gridColor) {
const { r, g, b } = hexToRgb(gridColor);
setPixel(x, y, r, g, b);
}
} else {
// Optional path color rendering
let hex;
if (y % 2 === 1 && x % 2 === 0 && y > 0) hex = mazeColors[(y - 1) / 2]?.[x / 2];
else if (y % 2 === 0 && x % 2 === 1 && x > 0) hex = mazeColors[y / 2]?.[(x - 1) / 2];
else if (y % 2 === 1 && x % 2 === 1 && y > 0 && x > 0) hex = mazeColors[(y - 1) / 2]?.[(x - 1) / 2];
if (hex) {
const { r, g, b } = hexToRgb(hex);
setPixel(x, y, r, g, b);
} else {
setPixel(x, y, 0, 0, 0);
}
}
}
}
const outputStream = fs.createWriteStream("maze.png");
png.pack().pipe(outputStream);
outputStream.on("finish", () => {
done();
});
}
io.listen(4000);
Breaking it Down
For ease of reference, I will only be referring my original implementation.
If you write websocket based games enough, you would realize a endGame
endpoint is rather suspicious if we do not even have users. There is no state and each connection is a user - this means a disconnect
handler is usually sufficient to clean up resources (technically not even required if we allow infinite connections in memory)
Examining it tells us that the handleDisconnect
simply deletes the socket’s entry from the map, but does not actually disconnect the websocket
function handleDisconnect(socket) {
userPositions.delete(socket.id);
console.log("A user disconnected with ID:", socket.id);
}
handleDisconnect
is called by both endGame
and disconnect
handlers
socket.on("endGame", () => handleDisconnect(socket));
socket.on("disconnect", () => handleDisconnect(socket));
Exploiting the Bug
After which, we can analyze how we can exploit the move
handler
function handleMove(data, socket, callback) {
const { x, y } = data;
const position = userPositions.get(socket.id);
if (
x >= 0 && x < FLAG_IMAGE[0].length &&
y >= 0 && y < FLAG_IMAGE.length &&
isAdjacent(x, y, position?.x, position?.y) &&
canMoveBetween(x, y, position?.x, position?.y)
) {
userPositions.set(socket.id, { x, y });
socket.emit("mazeUpdate", getSmallMazeData(x, y));
if (callback) callback(true);
}
}
As long as our {x,y} coordinates are in range, we simply need to pass the isAdjacent
and canMoveBetween
checks. You could probably guess this and try a simple proof of concept to jump anywhere after an endGame
. But let’s see what happens to both these checks when position = undefined
because the user’s socket ID is no longer in userPositions
.
When position?.x = undefined
and position?.y = undefined
, we can see in the comments what happens
function isAdjacent(x1, y1, x2, y2) { // x2 = y2 = undefined
// Math.abs(5 - undefined) + Math.abs(undefined) = NaN + NaN = NaN
// NaN > 1 => false
// !(NaN > 1) = true
return (
!(Math.abs(x1 - x2) + Math.abs(y1 - y2) > 1)
);
}
function canMoveTo(x, y) {
// When x = y = undefined
// y < 0 = false
// x < 0 = false
// y >= FLAG_IMAGE.length = false
// x > FLAG_IMAGE[0].length = false
return !(
y < 0 ||
y >= FLAG_IMAGE.length ||
x < 0 ||
x > FLAG_IMAGE[0].length //OH COINCIDENTALLY FOUND A BUG AND IT SHOULD BE >= OOPS
);
}
function canMoveBetween(x1, y1, x2, y2) { // x2 = y2 = undefined
if (!canMoveTo(x1, y1) || !canMoveTo(x2, y2)) return false;
// wallX = x + undefined + 1 = NaN
// wallY = y + undefined + 1 = NaN
// WALLS.at(NaN) = undefined
// WALLS.at(NaN)?.at(NaN) = undefined => false when boolean
// !WALLS.at(NaN)?.at(NaN) = true
const wallX = (x1 + x2 + 1);
const wallY = (y1 + y2 + 1);
return !WALLS.at(wallY)?.at(wallX);
}
With that, this means we can jump anywhere we want to as long as we emit a signal to endGame
before moving.
Solve Script
We can map out the flag by taking the following steps:
- Connect to the socket directly
- Setup
mazeUpdate
listener to wait for maze updates - Iteratively send
endGame
andmove
at various parts of the maze (Timeout because themove
function does not acknowledge the result if it does not approve the move) - Convert the numerous maze updates to a flag image
Access the full solve script here
Minor Caveat: We need to call endGame
after each move
because move
stores the user’s position back into the userPositions
Map.
Flag: grey{iM_blu3_da_b4-dEe_daba-dii1!}