Скоростные Пазлы – 2 (Speed Puzzles – 2)
Скоростные Пазлы – 2 (Speed Puzzles – 2)
Platform: bug-makers | Category: Web | Type: Challenge | Difficulty: Easy | OS: NA | Author: XESXOR | Date: 2026-05-26 | Status: Solved Techniques: api_contract_recon, fingerprint_matching, full_loop_automation, hungarian_assignment, original_image_disclosure
Summary
Task: an Express jigsaw game gives 60s to assemble 64 shuffled tiles, but /api/new-game leaks the full solved originalImage to the client. Solution: split the original into an 8x8 grid, fingerprint-match every cell to a piece via the Hungarian algorithm, and POST the ordering in one automated fetch-compute-submit loop (~1s).
Скоростные Пазлы – 2 (Speed Puzzles – 2) — bug-makers CTF
Description
Чтобы получить флаг, нужно успеть собрать весь пазл за 1 минуту. Количество пазлов: 64 шт
English summary: An Express-based jigsaw-puzzle web game. To get the flag you must assemble the whole 64-piece (8x8) puzzle within a 60-second server-side timer. Manual drag-and-drop of 64 tiles in under a minute is infeasible, which forces automation — and the API hands the solution to the client.
Target: https://fast-puzzles-2.t1.bug-makers.ru
Recon
- The landing page is a small static client (
index.html,style.css,script.js) served by an Express app (X-Powered-By: Express). - Reading
script.jsrevealed the JSON API contract:GET /api/new-game— starts a game, returns the puzzle data.POST /api/check-solutionwith{"solution": [<piece IDs in board row-major order>]}— validates an ordering.
- A wrong submission returns
{"success":false,"flag":false,"time":"<elapsed>","timeUp":false}, a correct one returns the flag, and an expired timer returnstimeUp:true.
Mechanics / API
GET /api/new-game returns:
success: truepuzzle: array of 64 objects, each{id (UUID), image (data:image/jpeg;base64 of a single shuffled tile), width, height}originalImage:data:image/jpeg;base64of the full original solved image (the flaw)gridSize: 8(8x8 grid = 64 pieces)pieceSize: tile pixel size (~71–74); original image was 592x592
POST /api/check-solution:
- wrong →
{"success":false,"flag":false,...} - correct →
{"success":true,"flag":"BugCTF{...}",...} - timeout →
timeUp:true
A 60-second server-side timer runs per game.
Vulnerability / root cause
The server discloses the fully-assembled original image to the client inside the new-game response. The intended challenge is to solve the jigsaw by hand, but since the answer image is already present client-side, an attacker can reconstruct the correct ordering by image matching instead of solving the puzzle. Combined with machine-speed API submission, the 60-second timer is trivially beaten (solved in ~1 second).
This is a classic client-side answer disclosure in an API-driven web game: never send the solution to the client.
Exploitation
Approach that worked on the first try:
GET /api/new-gamein a singlerequests.Session().- Decode the base64
originalImage; split it into an 8x8 grid of cells (cell width =origWidth/8, height =origHeight/8). - Decode each of the 64 piece JPEGs.
- Build a small fixed-size fingerprint per region: resize each grid cell and each piece to 8x8 RGB and flatten to a feature vector.
- Build a cost matrix of squared Euclidean distance (SSD) between every grid-cell fingerprint and every piece fingerprint.
- Solve the optimal assignment with
scipy.optimize.linear_sum_assignment(Hungarian algorithm) to map each board position → piece ID. - Emit piece IDs in row-major board order and
POST /api/check-solution.
Total wall time ~2.4s including network; server-reported solve time ~0.98s. The underlying image was an anime picture (hence the flag).
python read-only • copy allowed#!/usr/bin/env python3 import requests, base64, io, json, time from PIL import Image import numpy as np BASE = "https://fast-puzzles-2.t1.bug-makers.ru" s = requests.Session() t0 = time.time() r = s.get(BASE + "/api/new-game") d = r.json() grid = d["gridSize"] ps = d["pieceSize"] pieces = d["puzzle"] orig_b64 = d["originalImage"].split(",", 1)[1] orig = Image.open(io.BytesIO(base64.b64decode(orig_b64))).convert("RGB") ow, oh = orig.size orig_np = np.asarray(orig, dtype=np.int16) def decode_piece(p): b = base64.b64decode(p["image"].split(",",1)[1]) return Image.open(io.BytesIO(b)).convert("RGB") piece_imgs = {p["id"]: decode_piece(p) for p in pieces} cw = ow / grid ch = oh / grid N = 8 def fp_from_array(arr): img = Image.fromarray(np.asarray(arr, dtype=np.uint8)) img = img.resize((N, N), Image.BILINEAR) return np.asarray(img, dtype=np.float32).flatten() cell_fps = {} for row in range(grid): for col in range(grid): x0 = int(round(col*cw)); x1 = int(round((col+1)*cw)) y0 = int(round(row*ch)); y1 = int(round((row+1)*ch)) crop = orig_np[y0:y1, x0:x1] cell_fps[(row,col)] = fp_from_array(crop) piece_fps = {pid: fp_from_array(np.asarray(img, dtype=np.uint8)) for pid, img in piece_imgs.items()} positions = [(r,c) for r in range(grid) for c in range(grid)] ids = list(piece_imgs.keys()) cost = np.zeros((len(positions), len(ids)), dtype=np.float64) for i,pos in enumerate(positions): cf = cell_fps[pos] for j,pid in enumerate(ids): cost[i,j] = np.sum((cf - piece_fps[pid])**2) from scipy.optimize import linear_sum_assignment rind, cind = linear_sum_assignment(cost) assign = {positions[i]: ids[j] for i,j in zip(rind,cind)} solution = [assign[(r,c)] for r in range(grid) for c in range(grid)] r2 = s.post(BASE + "/api/check-solution", json={"solution": solution}) print(r2.text)
If scipy is unavailable, a greedy nearest-match fallback (assign each cell its lowest-SSD remaining piece) also works for this image.
Key Indicators / Takeaways
Use this technique when:
- An API-driven web game returns both the shuffled pieces and the solved/original artifact (
originalImage) in the samenew-gameresponse — read the JS to find the contract first. - A challenge imposes a human time limit (here 60s for 64 tiles) that is infeasible by hand, signaling that full fetch→compute→submit automation in one session is the intended path.
- You need robust jigsaw/permutation reassembly: downscaled fixed-size fingerprints + a cost matrix + the Hungarian algorithm (
scipy.optimize.linear_sum_assignment) gives optimal cell→piece matching fast.