
No flags are included in this writeup.
Overview
Confession Booth looked like a web app challenge, but the actual bug was timing. The application promised a safe place for confessions, with admin moderation in the middle. The hint made the direction clear: if admin access had not happened yet, I had not tried enough times.
| Field | Value |
|---|---|
| Month | January 2026 |
| Points | 30 |
| Main area | Go web app, PostgreSQL, race condition, privilege escalation |
Source Review
The source code showed a Go application with JWT authentication, PostgreSQL, user registration, and admin-only confession approval routes.
The interesting registration flow had two separate operations:
- create the user row
- update the user permission level
Between those operations, the user existed with a NULL permission value. That tiny gap was the whole challenge.
Why NULL Became Admin
The app scanned the permission value into a normal Go integer. In Go, an uninitialized integer defaults to 0. The app’s permission model also used 0 as the admin value.
That created a dangerous chain:
- user row is inserted
- permission is briefly
NULL - login during that window reads the user
NULLbecomes00means admin- JWT is issued with admin privileges
This is a beautiful and painful bug because every piece looks small. Together, they create privilege escalation.
Exploiting The Race
A single request is unlikely to hit the window. The exploit needed concurrency:
- register a new random user
- send many login attempts at the same time
- inspect returned tokens
- test whether any token had admin access
- use admin access on the approval endpoint
Threading may not be enough if the timing is tight. Async HTTP is a better fit because it can fire many requests with less overhead and better coordination.
Root Cause
The root cause was unsafe state transition design:
- user creation and permission assignment were not atomic
- the database schema allowed
NULLin a security-critical column - application code treated
NULLlike a normal integer - the most privileged role used the zero value
The fix is not just “make the race harder.” The fix is to remove the unsafe state entirely.
Takeaways
This challenge is one of my favorite web lessons from the series:
- security-sensitive multi-step writes need transactions
- permission fields should have safe defaults
- admin should not be the zero value
- use nullable database types deliberately, not accidentally
- race conditions become practical when attackers can repeat attempts cheaply
Confession Booth was low-level in the best way. The exploit was not flashy; the bug was in a tiny mismatch between database state and language defaults.