Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save ky28059/dcd9fcad9f92264d79708255e6f15e70 to your computer and use it in GitHub Desktop.
Save ky28059/dcd9fcad9f92264d79708255e6f15e70 to your computer and use it in GitHub Desktop.

San Diego CTF 2024 — impossible-golf

I found this golf game online but the third level is so hard 😩😩

See if you can beat it!

We're given a golf minigame that looks like this:

image

We can see that the client makes a WebSocket connection to the remote server, sending launch events whenever the user hits the ball.

image

image

Unfortunately, as the challenge description suggests, we'll realize when we hit level 3 that the game was rigged from the start:

image

Sadly, this challenge is truly impossible without server source. Luckily, just a few hours later we're given exactly that!

const express = require("express");
const http = require("http");
const app = express();
const path = require("path");
const { WebSocketServer } = require("ws");
const {readFileSync} = require("fs");

const server = http.createServer(app);

const wss = new WebSocketServer({server: server});

const FLAG = readFileSync(path.join(__dirname, "flag.txt")).toString();

wss.on("connection", ws => {
	console.log("New connection!");
	let level = 0;
	let gameState = JSON.parse(JSON.stringify(levels[level]));
	let interval = setInterval(physicsLoop, 20);
	let goalTimer = 0;

	function physicsLoop() {
		applyVelocity(gameState.circle, gameState.rects);

		for (let obj of gameState.rects) {
			circleRectCollision(gameState.circle, obj);
		}
		if (circlesIntersecting(gameState.goal, gameState.circle)) {
			goalTimer++;
		} else {
			goalTimer = 0;
		}
		if (goalTimer > 50) {
			level++;
			if (level >= levels.length) {
				ws.send(JSON.stringify({
					type: "congrats",
					value: process.env.GZCTF_FLAG
				}));
				ws.close();
				return;
			}
			goalTimer = 0;
			gameState = JSON.parse(JSON.stringify(levels[level]));
			init();
		}
		ws.send(JSON.stringify({
			type: "ball",
			value: {
				x: gameState.circle.x,
				y: gameState.circle.y,
				moving: gameState.circle.dx + gameState.circle.dy > 0
			}
		}));
	}

	ws.send(JSON.stringify({
		type: "start"
	}));

	function init() {
		ws.send(JSON.stringify({
			type: "colliders",
			value: gameState.rects
		}));
		ws.send(JSON.stringify({
			type: "flag",
			value: gameState.goal
		}));
	}
	init();

	ws.on("message", data => {
		let parsed;
		try {
			parsed = JSON.parse(data.toString?.());
		} catch (e) {}
		if (!parsed) return;
		
		switch (parsed?.type) {
			case "launch":
				if (!parsed?.value) return;
				if (typeof parsed?.value?.dx !== "number") return;
				if (typeof parsed?.value?.dy !== "number") return;
				launchBall(gameState.circle, parsed.value);
			break;
			case "cheat":
				gameState.circle.x = gameState.goal.x;
				gameState.circle.y = gameState.goal.y;
			break;
		}
	});

	ws.on("close", () => {
		clearInterval(interval);
	});
})

app.get("/", (req, res)=>{
	res.sendFile(path.join(__dirname, "public", "index.html"))
});

const WALL_THICKNESS = 30;
const MAX_SPEED = 52;
const DECELERATION = 0.985;
let levels = [{
	goal: { x: 230, y: 420, r: 20 },
	circle: { x: 200, y: 200, dx: 0, dy: 0, r: 12 },
	rects: [
		[150, 500, 1000, WALL_THICKNESS],
		[150, 300, WALL_THICKNESS, 230],
		[150, 300, 800, WALL_THICKNESS],
		[1150, 50, WALL_THICKNESS, 480],
		[150, 50, 1000, WALL_THICKNESS],
		[150, 50, WALL_THICKNESS, 280],
		[400, 50, WALL_THICKNESS, 180],
		[600, 150, WALL_THICKNESS, 150],
		[800, 50, WALL_THICKNESS, 180],
		[500, 400, 450, WALL_THICKNESS]
	]
},{
	goal: { x: 340, y: 425, r: 20 },
	circle: { x: 100, y: 100, dx: 0, dy: 0, r: 12 },
	rects: [
		[50, 50, 1200, WALL_THICKNESS],
		[1200 + WALL_THICKNESS, 50, WALL_THICKNESS, 700 + WALL_THICKNESS],
		[50, 50, WALL_THICKNESS, 700],
		[50, 750, 1200, WALL_THICKNESS],

		[50, 120, 1130, WALL_THICKNESS],
		[1150, 130, WALL_THICKNESS, 580],
		[120, 680, 1050, WALL_THICKNESS],
		[120, 200, WALL_THICKNESS, 500],

		[120, 200, 980, WALL_THICKNESS],
		[1070, 220, WALL_THICKNESS, 400],
		[200, 590, 900, WALL_THICKNESS],
		[200, 270, WALL_THICKNESS, 330],

		[200, 270, 800, WALL_THICKNESS],
		[1000, 270, WALL_THICKNESS, 270],
		[300, 510, 700, WALL_THICKNESS],
		[280, 340, WALL_THICKNESS, 200],

		[280, 330, 670, WALL_THICKNESS],
		[920, 330, WALL_THICKNESS, 140],

		// :3
		[370, 390, 25, 25],
		[370, 440, 25, 25],
		[420, 370, 60, 25],
		[460, 370, 25, 110],
		[420, 415, 60, 25],
		[420, 460, 65, 25],
	]
},{
	goal: { x: 1100, y: 630, r: 20 },
	circle: { x: 250, y: 250, dx: 0, dy: 0, r: 12 },
	rects: [
		[50, 50, 400, WALL_THICKNESS],
		[50, 400, 420, WALL_THICKNESS],
		[50, 50, WALL_THICKNESS, 350],
		[450, 50, WALL_THICKNESS, 380],

		[700, 100, 200, WALL_THICKNESS],
		[700, 700, 500, WALL_THICKNESS],
		[700, 100, WALL_THICKNESS, 600],
		[900, 100, WALL_THICKNESS, 450],
		[900, 530, 300, WALL_THICKNESS],
		[1200, 530, WALL_THICKNESS, 200],
	]
}];

function circlesIntersecting(circle1, circle2) {	
	return Math.sqrt((circle2.x - circle1.x) ** 2 + (circle2.y - circle1.y) ** 2) <= circle1.r + circle2.r;
}

function launchBall(circle, vel) {
	if (circle.dx === 0 && circle.dy === 0) {
			circle.dx = vel.dx;
			circle.dy = vel.dy;
		if (circle.dx > MAX_SPEED) {
			circle.dx = MAX_SPEED;
		} else if (circle.dx < -MAX_SPEED) {
			circle.dx = -MAX_SPEED;
		}
		if (circle.dy > MAX_SPEED) {
			circle.dy = MAX_SPEED;
		} else if (circle.dy < -MAX_SPEED) {
			circle.dy = -MAX_SPEED;
		}
	}
}

function applyVelocity(circle, rects) {
	let initialX = circle.x;
	let initialY = circle.y;

	while (initialX === circle.x && initialY === circle.y &&
		(circle.dy !== 0 || circle.dx !== 0)) {
		circle.x += circle.dx;
		circle.y += circle.dy;

		for (let rect of rects) {
			if (circleRectCollision(circle, rect)) {
				circle.x = initialX;
				circle.y = initialY;

				while (circleRectCollision(circle, rect)) {
					circle.x += (circle.dx / Math.abs(circle.dx)) * circle.r;
					circle.y += (circle.dy / Math.abs(circle.dy)) * circle.r;
				}
			}
		}

		circle.dx = circle.dx * DECELERATION;
		circle.dy = circle.dy * DECELERATION;
		if (Math.abs(circle.dx * DECELERATION) < 0.15 && Math.abs(circle.dy * DECELERATION) < 0.15) {
			circle.dx = 0;
			circle.dy = 0;
		}
	}
}

function circleRectCollision(circle, rect) {
	let closestX = clamp(circle.x, rect[0], rect[0] + rect[2]);
	let closestY = clamp(circle.y, rect[1], rect[1] + rect[3]);

	let distanceX = circle.x - closestX;
	let distanceY = circle.y - closestY;
	let distanceSquared = (distanceX * distanceX) + (distanceY * distanceY);

	if (distanceSquared < (circle.r * circle.r)) {
		let distance = Math.sqrt(distanceSquared);
		let overlap = circle.r - distance;

		if (distance > 0) {
			circle.x += overlap * (distanceX / distance);
			circle.y += overlap * (distanceY / distance);
		}

		let velocityMagnitude = Math.sqrt(circle.dx * circle.dx + circle.dy * circle.dy);
		if (velocityMagnitude > 0) {
			let normalX = distanceX / distance;
			let normalY = distanceY / distance;
			let dotProduct = circle.dx * normalX + circle.dy * normalY;

			if (dotProduct < 0) {
				circle.dx -= 2 * dotProduct * normalX;
				circle.dy -= 2 * dotProduct * normalY;
			}
		}
		return true;
	}
	return false;
}

function clamp(value, min, max) {
	return Math.min(Math.max(value, min), max);
}

app.use(express.static(path.join(__dirname, "public")))

server.listen(8080);

Looking at the server's event handling logic,

		switch (parsed?.type) {
			case "launch":
				if (!parsed?.value) return;
				if (typeof parsed?.value?.dx !== "number") return;
				if (typeof parsed?.value?.dy !== "number") return;
				launchBall(gameState.circle, parsed.value);
			break;
			case "cheat":
				gameState.circle.x = gameState.goal.x;
				gameState.circle.y = gameState.goal.y;
			break;
		}

we just cheat 3 times to get the flag.

image

Thank you so much a for to playing my game! sdctf{i'm in your walls dfe6e287-73a9-4d0d-9386-ffa8258a8b69}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment