[NSEC 2022] Marketing Email Template
Published on
Contents
Introduction
During the weekend of May 21st, I participated with my team PolyHx in the NorthSec CTF 2022. This is a write-up about a track of 3 flags around SSTI (Server-Side Template Injection) in Go. The context is that we are auditing an application used to draft marketing emails through a template.
My team ended up being the first to complete the whole track.
Note
The main.go
files can be built and ran and during the CTF I was debugging locally most of the time. Need to setup a templates/
folder containing placeholder templates/index.html
and templates/preview.html
beforehand.
mkdir templates
echo "Index" > templates/index.html
echo "Preview" > templates/preview.html
go build main.go
./main
Also, the URLs of the challenge were only accessible during the event through a VPN.
Level 1 - Baby Steps
Accessing the website (http://dev.email-template.ctf), we are welcomed with an application that allows us to specify a template for a marketing email which can contain variables like {{ .title }}
. There is a link to download the source code of the application, which uses the Go language.
In this case, I approached the web application code by first looking into the exposed server routes as there were not that many.
|
|
GET /
This is the landing page, no user input appears to be considered here. It does use a custom struct[ure] called MyApp
to fetch the contents of a given template.
The struct is composed of only one variable, which is the templateName
of type string
. In Go, there is the concept of exported variables and functions. Being exported, means programmers can refer to the respective item directly when outside the package it was declared in. To be considered as exported, variable and function names have to be declared with a capital letter at the beginning.
MyApp.ReadFile() // OK, wherever.
MyApp.templateName // Illegal outside "main" package.
The code also declares two functions that can be called on a MyApp
instance.
SetTemplateName
is pretty much a setter function, to update the instance’s templateName
value.
ReadFile
is more interesting and has suspicious functionality. It first tries to open a file using the path in the instance’s templateName
. If the file does not exist, it will list all the files and directories present in the directory where templateName
would be.
This route then renders the templates/index.html
with some data (which includes the contents of file templates/preview.html
)
POST /update
Using the struct Update
, the route retrieves the template
value of the JSON object in the request’s body. Then it updates the contents of the file templates/preview.html
with that value. This means that the file and its content should now be considered a user input. No sanitization appears to be present for it.
ANY /preview
The Any
route used here matches all standard HTTP methods. The route gets a few query parameters from the request which will be used to fill a response
map variable. The response
variable will also contain custom
variables which we will revisit later, and a m
variable which is the MyApp
instance used to read the templates.
The templates/preview.html
file gets read through the MyApp
instance and the content is parsed as a template. The template is then Execute
d with the variable response
as its data. The http response will contain the result of executing the template with the data.
The Vulnerability
I had never really heard of SSTI research or payloads in Go before. As such, I did a quick search to find existing research and found one article by Gus Ralph. My main takeaway was that it is possible to call exported functions from the variables that were passed to the template.
Thankfully in this challenge, the template is provided the m
variable of type MyApp
which as we saw contains two exported functions.
We will use as reference the documentation for text/template
. The following excerpt looks interesting:
.Method [Argument...]
The method can be alone or the last element of a chain but,
unlike methods in the middle of a chain, it can take arguments.
The result is the value of calling the method with the
arguments:
dot.Method(Argument1, etc.)
This means if we put in our template the following content:
{{ .m.SetTemplateName "fishinspace" }}
After evaluating this template, the m
instance will have its templateName
changed. Combining this with ReadFile
, we now have a gadget to read any file with sufficient permissions. ReadFile
returns the file’s content, so it will get inserted in the render, allowing us to view it. Let’s also use a variable instead of hardcoding the name, so we do not have to reupload a template everytime.
{{ .m.SetTemplateName .title }}
{{ .m.ReadFile }}
We upload this template and then preview it while making sure to use the GET query parameter title
as our filename. We then get the flag.
Keep in mind, if the file does not exist, the directory will be listed. This will help us enumerate a few other files later.
GET /preview?title=/flag.txt
FLAG-6f040fad14bea1df66d07d8ccc109924
Level 2 - Pass Go and Collect $(sh)
We now get access to a second website (http://dev-7fe5c15819a3af65.email-template.ctf). It is similar to the first one, code is provided again.
|
|
This time, the MyApp
custom struct is gone. Instead of the m
variable, we notice the c
variable of type *gin.Context
being passed to the template. This seems like our best bet, so let’s check the type and its exported fields. The relevant documentation will be used along its source code.
Looking at the documentation for type Context
, the following fields are exported (not including functions):
type Context struct {
Request *http.Request
Writer ResponseWriter
Params Params
// Keys is a key/value pair exclusively for the context of each request.
Keys map[string]any
// Errors is a list of errors attached to all the handlers/middlewares who used this context.
Errors errorMsgs
// Accepted defines a list of manually accepted formats for content negotiation.
Accepted []string
// contains filtered or unexported fields
}
Since we have access to Context.Request
, one could also look into the exported fields of the type http.Request
. Most of my time was spent on trying to find suitable functions in Context
or any of its exported fields.
We are looking for any path that would allows us to read a file. A function of Context
grabbed my attention, namely the FileAttachment
function:
|
|
Let’s upload the following template:
{{ .c.FileAttachment "/flag.txt" "exfil" }}
Previewing our template, we see the following error in our local server’s logs:
template: preview.html:1:5: executing "preview.html" at <.c.FileAttachment>: can't call method/function "FileAttachment" with 0 results
After some troubleshooting, I end up reading the crucial parts of the documentation of text/template
regarding calling methods:
The result is the value of invoking the method with dot as the
receiver, dot.Method(). Such a method must have one return value (of
any type) or two return values, the second of which is an error.
If it has two and the returned error is non-nil, execution terminates
and an error is returned to the caller as the value of Execute.
FileAttachment
has no return value, thus we get an error. We need to explore more and now we know the following requirements:
- The function must return 1 or 2 values if the second one is an
error
- The function must perform some “exploitable” action.
- The function must use argument types we can provide since we cannot create arbitrary types from within a template.
The Vulnerability
After a lot of trial and error, one function looked interesting:
|
|
SaveUploadedFile
fills the first and second requirements as it returns one value and appears to allow arbitrary file upload. As for the third requirement, there is no way to declare a *multipart.FileHeader
object from a template. Would there be?
Enter the FormFile
function:
|
|
The FormFile
function fulfills the first requirement due to returning a *multipart.FileHeader
and an error
as second value. It also fulfills the third requirement as we can easily pass a string
to the function. As for the second requirement, this function fetches a named file from our multipart form request which we control the contents of. Since this returns the exact type expected by SaveUploadedFile
, this is the perfect gadget combination to achieve arbitrary file upload!
{{ .c.SaveUploadedFile (.c.FormFile "fish") .title }}
Testing locally, it works. We preview by sending a multipart form request which contains a file with the form input name fish
and a URL query parameter title
containg a file path. Good thing for us that the parentheses will be considered as only the first return value and not include the error (if it is nil).
|
|
But… then what? This does not allow us to read files.
The Exploitation
At this point, I could not find any other useful and usable function. We need to find a way to push this further. I did not mention it, but on the first level I explored the filesystem of the challenge server while trying to find the flag. I noticed a few things.
The root /
directory had /app/
which contains the challenge’s code. The root also contained /app.bk/
which appeared to be a copy/backup of /app/
.
Since level 1, the challenge offered the possibility of resetting the challenge due to concerns of being stuck in a restart loop. That could be done by visiting the route /reset
. I hadn’t noticed that route in the Go code, so it must be implemented in some other way. When exploring the filesystem in level 1, I found two PHP scripts which Apache pointed at when we visited GET /reset
and GET /source
.
The reset.php
script took care of replacing the /app/
directory with a clean copy of the backup and restarting the challenge service.
|
|
The source.php
script took care of showing us the contents of main.go
. This functionality also wasnt seen in the Go code, so now it makes sense now.
My first train of thought was to try and backdoor /app.bk/main.go
and reset so that we get setup a backdoored version of the challenge. This did not work as we did not have permissions to write in /app.bk/
. Putting a PHP file in /var/www/html/
was also not usable, due to the proxy only taking reset
and source
and I don’t remember if we even had the permissions to do so.
We could try to backdoor /app/main.go
(which we do have permissions for) but this wouldn’t change the program that is already loaded in memory.
Since the start, I had experienced a few times where the server would crash and a message would appear that it is currently restarting. Usually when a Go source is compiled, it is built into an .exe
, but I did not see one in the /app/
directory. Another way to run Go code is through a go run main.go
command which does not create a .exe
file. Therefore, I made a guess that the service would perform a go run main.go
command which would run it as it is then on every service restart.
To recap:
- We have arbitrary file upload.
- We can overwrite
/app/main.go
- We can maybe get our modified version ran if the service restarts (without a full reset).
One way to crash the server would be to reach one of the many calls to log.Fatal
which is described as Fatal is equivalent to Print() followed by a call to os.Exit(1)
. The one I ended up reaching was due to an error after calling c.BindJSON(&update)
on line 47. To reach it, all we need to do is not send a JSON payload with template
in it. An error will occur and the log.Fatal
will be reached, terminating the server.
In my backdoored main.go
, I implemented a very basic web shell on the route GET /
.
|
|
Here was my exploit script.
|
|
This ended up working although I was not too confident. Right after the CTF, I realized I could have looked into the definition of the service. Later, another player confirmed that the file /etc/systemd/system/email-template.service
showed all the necessary information to be confident in this attack. Next time, I will be more careful as that service’s name was mentioned in the reset.php
script and I should have taken a look.
[Unit]
Description=Email Template
[Service]
Type=simple
User=www-data
Group=www-data
Restart=always
RestartSec=5s
WorkingDirectory=/app/
ExecStart=/usr/local/go/bin/go run /app/main.go
[Install]
WantedBy=multi-user.target
With our webshell, it is only a matter of a few commands to get the flag.
FLAG-527afbab4c2ae6ed15d6134328b8bc05
Level 3 - Just a Regular Bypass
We now get access to a third website (http://dev-092f50226ea22197.email-template.ctf). It is similar to the second one, but this time with some input filters. Code is provided again.
|
|
The main difference with the second level is that a filter
function is called before updating the templates/preview.html
file in the POST /update
route.
Let’s look at the three specific regular expressions used to check our input template.
|
|
I like to use Debuggex to help me visualize some regular expressions. Here is what the first regex is checking:
If we test it with our level 2 template as input:
{{ .c.SaveUploadedFile (.c.FormFile "fish") "/app/main.go" }}
It will get matched due to the parentheses and the quotes. I quickly noticed that, after a quote or parenthesis, the regex is looking any character that is not }
. So what if we somehow put }
within a template action and it still stays valid?
{{ .c.SaveUploadedFile (.c.FormFile "}") "/app/main.go" }}
Turns out, }
is a perfectly fine form input name for the server and it does bypass the filter and the other two regexes do not even come into play as the payload does not use those variables.
FLAG-46dd90020d9b9d1270a8ee4be745405e
Alternative
Early on, since level 1, I had examined the code related to customs
|
|
What I noticed was the odd way it allowed us to provide consecutive custom variables.
It starts with the first custom at “index” 1, which ends up being ASCII code 48+1, which is custom1
. So if custom1
exists, it will then check custom2
(ASCII code 48+2 = “2”). It does not stop until the next customX
is not defined. So if we define enough customs in our query parameters, we will end up with the following list:
|
|
Now, if you check the second and third regular expressions, you will notice that they only check for .custom[0-9]+
which only accounts for the ones ending with a digit.
Thus, another unintended solve, would be to use the following template:
{{ with .c.FormFile .customA }}
{{ .c.SaveUploadedFile . .customB }}
{{ end }}
This assumes we are not allowed the characters "`()=|
. That is why we use a with
to bypass the usage of parentheses.
After a bit of research, it turns out that a *multipart.FileHeader
contains an exported field Filename
and in our case, it takes the filename provided by the form input. We can remove the need for a second custom.
|
|
After solving the third level, the challenge author informed me that both options were unintended. Some time after the CTF, the author reached out to me to test a modified regular expression and I found that some parts of it could be bypassed due missing regex flag (?s)
. This is due to the default configuration not matching .
with newline characters, thus my payload was bypassing some checks with a newline (Which, in a template action, is only allowed inside a string)
The third level showcases how hard it can be to get regex right on your first try. There may be other ways to bypass the rules.
Conclusion
It was a good learning experience to explore SSTI in Go as I did not know how it could occur. It does seem to require many conditions to be exploitable due to way templates are designed in Go, but that’s the fun of CTFs to create those kind of situations!
I really enjoyed this challenge due to having to find gadgets within a known open-source library and due to getting practice auditing code which was a fun throwback to my OSWE certification.
The challenge author shared with me that they decided to look into vulnerabilities that can happen in Go after experiencing a challenge I designed and published on RingZer0 CTF with their help. That is how this SSTI challenge came to be. If you are curious, you can try my web challenge Lotto 8/33 on RingZer0 CTF.
Many thanks to the challenge author Marc Olivier Bergeron and the NSEC team ❤️