Wiz: 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")
}
- There exists a race condition at the time of user creation/registration, we need to exploit this race condition to get the admin token.
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.
Note #