01 Jun 2025

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!

Scoreboard with 4 solves from different teams

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.

A orange and blue maze with a timer

If we navigate to the right (specifically at x=200), we notice there’s a huge vertical wall.

The orange and blue maze but with a 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.

Partial flag with text "gre"

So we need to:

  1. Find some way to get over the great wall
  2. Map each pixel into an image
  3. 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/

⚠️Jump to Intended Solution

Creating the Flag

With PowerPoint, I created a black and white flag.

Flag in black and white

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)

Rainbow flag

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:

  1. Connect to the socket directly
  2. Setup mazeUpdate listener to wait for maze updates
  3. Iteratively send endGame and move at various parts of the maze (Timeout because the move function does not acknowledge the result if it does not approve the move)
  4. 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!}