← Back to home

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!

Code (js):

1const express = require("express");
2const http = require("http");
3const app = express();
4const path = require("path");
5const { WebSocketServer } = require("ws");
6const {readFileSync} = require("fs");
7
8const server = http.createServer(app);
9
10const wss = new WebSocketServer({server: server});
11
12const FLAG = readFileSync(path.join(__dirname, "flag.txt")).toString();
13
14wss.on("connection", ws => {
15	console.log("New connection!");
16	let level = 0;
17	let gameState = JSON.parse(JSON.stringify(levels[level]));
18	let interval = setInterval(physicsLoop, 20);
19	let goalTimer = 0;
20
21	function physicsLoop() {
22		applyVelocity(gameState.circle, gameState.rects);
23
24		for (let obj of gameState.rects) {
25			circleRectCollision(gameState.circle, obj);
26		}
27		if (circlesIntersecting(gameState.goal, gameState.circle)) {
28			goalTimer++;
29		} else {
30			goalTimer = 0;
31		}
32		if (goalTimer > 50) {
33			level++;
34			if (level >= levels.length) {
35				ws.send(JSON.stringify({
36					type: "congrats",
37					value: process.env.GZCTF_FLAG
38				}));
39				ws.close();
40				return;
41			}
42			goalTimer = 0;
43			gameState = JSON.parse(JSON.stringify(levels[level]));
44			init();
45		}
46		ws.send(JSON.stringify({
47			type: "ball",
48			value: {
49				x: gameState.circle.x,
50				y: gameState.circle.y,
51				moving: gameState.circle.dx + gameState.circle.dy > 0
52			}
53		}));
54	}
55
56	ws.send(JSON.stringify({
57		type: "start"
58	}));
59
60	function init() {
61		ws.send(JSON.stringify({
62			type: "colliders",
63			value: gameState.rects
64		}));
65		ws.send(JSON.stringify({
66			type: "flag",
67			value: gameState.goal
68		}));
69	}
70	init();
71
72	ws.on("message", data => {
73		let parsed;
74		try {
75			parsed = JSON.parse(data.toString?.());
76		} catch (e) {}
77		if (!parsed) return;
78		
79		switch (parsed?.type) {
80			case "launch":
81				if (!parsed?.value) return;
82				if (typeof parsed?.value?.dx !== "number") return;
83				if (typeof parsed?.value?.dy !== "number") return;
84				launchBall(gameState.circle, parsed.value);
85			break;
86			case "cheat":
87				gameState.circle.x = gameState.goal.x;
88				gameState.circle.y = gameState.goal.y;
89			break;
90		}
91	});
92
93	ws.on("close", () => {
94		clearInterval(interval);
95	});
96})
97
98app.get("/", (req, res)=>{
99	res.sendFile(path.join(__dirname, "public", "index.html"))
100});
101
102const WALL_THICKNESS = 30;
103const MAX_SPEED = 52;
104const DECELERATION = 0.985;
105let levels = [{
106	goal: { x: 230, y: 420, r: 20 },
107	circle: { x: 200, y: 200, dx: 0, dy: 0, r: 12 },
108	rects: [
109		[150, 500, 1000, WALL_THICKNESS],
110		[150, 300, WALL_THICKNESS, 230],
111		[150, 300, 800, WALL_THICKNESS],
112		[1150, 50, WALL_THICKNESS, 480],
113		[150, 50, 1000, WALL_THICKNESS],
114		[150, 50, WALL_THICKNESS, 280],
115		[400, 50, WALL_THICKNESS, 180],
116		[600, 150, WALL_THICKNESS, 150],
117		[800, 50, WALL_THICKNESS, 180],
118		[500, 400, 450, WALL_THICKNESS]
119	]
120},{
121	goal: { x: 340, y: 425, r: 20 },
122	circle: { x: 100, y: 100, dx: 0, dy: 0, r: 12 },
123	rects: [
124		[50, 50, 1200, WALL_THICKNESS],
125		[1200 + WALL_THICKNESS, 50, WALL_THICKNESS, 700 + WALL_THICKNESS],
126		[50, 50, WALL_THICKNESS, 700],
127		[50, 750, 1200, WALL_THICKNESS],
128
129		[50, 120, 1130, WALL_THICKNESS],
130		[1150, 130, WALL_THICKNESS, 580],
131		[120, 680, 1050, WALL_THICKNESS],
132		[120, 200, WALL_THICKNESS, 500],
133
134		[120, 200, 980, WALL_THICKNESS],
135		[1070, 220, WALL_THICKNESS, 400],
136		[200, 590, 900, WALL_THICKNESS],
137		[200, 270, WALL_THICKNESS, 330],
138
139		[200, 270, 800, WALL_THICKNESS],
140		[1000, 270, WALL_THICKNESS, 270],
141		[300, 510, 700, WALL_THICKNESS],
142		[280, 340, WALL_THICKNESS, 200],
143
144		[280, 330, 670, WALL_THICKNESS],
145		[920, 330, WALL_THICKNESS, 140],
146
147		// :3
148		[370, 390, 25, 25],
149		[370, 440, 25, 25],
150		[420, 370, 60, 25],
151		[460, 370, 25, 110],
152		[420, 415, 60, 25],
153		[420, 460, 65, 25],
154	]
155},{
156	goal: { x: 1100, y: 630, r: 20 },
157	circle: { x: 250, y: 250, dx: 0, dy: 0, r: 12 },
158	rects: [
159		[50, 50, 400, WALL_THICKNESS],
160		[50, 400, 420, WALL_THICKNESS],
161		[50, 50, WALL_THICKNESS, 350],
162		[450, 50, WALL_THICKNESS, 380],
163
164		[700, 100, 200, WALL_THICKNESS],
165		[700, 700, 500, WALL_THICKNESS],
166		[700, 100, WALL_THICKNESS, 600],
167		[900, 100, WALL_THICKNESS, 450],
168		[900, 530, 300, WALL_THICKNESS],
169		[1200, 530, WALL_THICKNESS, 200],
170	]
171}];
172
173function circlesIntersecting(circle1, circle2) {	
174	return Math.sqrt((circle2.x - circle1.x) ** 2 + (circle2.y - circle1.y) ** 2) <= circle1.r + circle2.r;
175}
176
177function launchBall(circle, vel) {
178	if (circle.dx === 0 && circle.dy === 0) {
179			circle.dx = vel.dx;
180			circle.dy = vel.dy;
181		if (circle.dx > MAX_SPEED) {
182			circle.dx = MAX_SPEED;
183		} else if (circle.dx < -MAX_SPEED) {
184			circle.dx = -MAX_SPEED;
185		}
186		if (circle.dy > MAX_SPEED) {
187			circle.dy = MAX_SPEED;
188		} else if (circle.dy < -MAX_SPEED) {
189			circle.dy = -MAX_SPEED;
190		}
191	}
192}
193
194function applyVelocity(circle, rects) {
195	let initialX = circle.x;
196	let initialY = circle.y;
197
198	while (initialX === circle.x && initialY === circle.y &&
199		(circle.dy !== 0 || circle.dx !== 0)) {
200		circle.x += circle.dx;
201		circle.y += circle.dy;
202
203		for (let rect of rects) {
204			if (circleRectCollision(circle, rect)) {
205				circle.x = initialX;
206				circle.y = initialY;
207
208				while (circleRectCollision(circle, rect)) {
209					circle.x += (circle.dx / Math.abs(circle.dx)) * circle.r;
210					circle.y += (circle.dy / Math.abs(circle.dy)) * circle.r;
211				}
212			}
213		}
214
215		circle.dx = circle.dx * DECELERATION;
216		circle.dy = circle.dy * DECELERATION;
217		if (Math.abs(circle.dx * DECELERATION) < 0.15 && Math.abs(circle.dy * DECELERATION) < 0.15) {
218			circle.dx = 0;
219			circle.dy = 0;
220		}
221	}
222}
223
224function circleRectCollision(circle, rect) {
225	let closestX = clamp(circle.x, rect[0], rect[0] + rect[2]);
226	let closestY = clamp(circle.y, rect[1], rect[1] + rect[3]);
227
228	let distanceX = circle.x - closestX;
229	let distanceY = circle.y - closestY;
230	let distanceSquared = (distanceX * distanceX) + (distanceY * distanceY);
231
232	if (distanceSquared < (circle.r * circle.r)) {
233		let distance = Math.sqrt(distanceSquared);
234		let overlap = circle.r - distance;
235
236		if (distance > 0) {
237			circle.x += overlap * (distanceX / distance);
238			circle.y += overlap * (distanceY / distance);
239		}
240
241		let velocityMagnitude = Math.sqrt(circle.dx * circle.dx + circle.dy * circle.dy);
242		if (velocityMagnitude > 0) {
243			let normalX = distanceX / distance;
244			let normalY = distanceY / distance;
245			let dotProduct = circle.dx * normalX + circle.dy * normalY;
246
247			if (dotProduct < 0) {
248				circle.dx -= 2 * dotProduct * normalX;
249				circle.dy -= 2 * dotProduct * normalY;
250			}
251		}
252		return true;
253	}
254	return false;
255}
256
257function clamp(value, min, max) {
258	return Math.min(Math.max(value, min), max);
259}
260
261app.use(express.static(path.join(__dirname, "public")))
262
263server.listen(8080);

Looking at the server's event handling logic,

Code (js):

1		switch (parsed?.type) {
2			case "launch":
3				if (!parsed?.value) return;
4				if (typeof parsed?.value?.dx !== "number") return;
5				if (typeof parsed?.value?.dy !== "number") return;
6				launchBall(gameState.circle, parsed.value);
7			break;
8			case "cheat":
9				gameState.circle.x = gameState.goal.x;
10				gameState.circle.y = gameState.goal.y;
11			break;
12		}

we just cheat 3 times to get the flag.

image

Code:

1Thank you so much a for to playing my game! sdctf{i'm in your walls dfe6e287-73a9-4d0d-9386-ffa8258a8b69}