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:
We can see that the client makes a WebSocket connection to the remote server, sending launch
events whenever the user hits the ball.
Unfortunately, as the challenge description suggests, we'll realize when we hit level 3 that the game was rigged from the start:
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.
Code:
1Thank you so much a for to playing my game! sdctf{i'm in your walls dfe6e287-73a9-4d0d-9386-ffa8258a8b69}