Honor --base-dn in BloodHound collection - #1374
Draft
Marshall-Hallenbeck wants to merge 8 commits into
Draft
Conversation
--base-dn is accepted by the ldap protocol but the BloodHound path ignored it, so collection always covered the whole domain. bloodhound's ADDC.search() defaults search_base to ad.baseDN, so setting it scopes the object collection to the given subtree. Applied after dns_resolve(), which sets baseDN from the domain name and would otherwise overwrite it. Queries that pass an explicit search_base, such as the schema and configuration naming contexts, are unaffected. Refs #1374
Marshall-Hallenbeck
force-pushed
the
feat/bloodhound-honour-base-dn
branch
from
August 21, 2026 22:48
5ee1cbf to
600ad89
Compare
Every other DNS lookup in the ldap protocol falls back to the host being targeted when --dns-server is not given. The BloodHound collector was passed args.dns_server raw, so leaving the flag unset put it on the operator's system resolver, which generally cannot answer the _ldap._tcp and _gc._tcp SRV records it needs. The visible symptom is a repeated "Could not find a Global Catalog in this domain! Resolving will be unreliable in forests with multiple domains". Note this only helps where the domain controller also serves DNS, which is the common case but not universal; --dns-server is still needed where DNS lives elsewhere. Refs #1374
bloodhound already handles a domain with no _gc._tcp SRV record by treating the domain controllers as Global Catalogs, but that fallback is guarded by "if options and not options.global_catalog" and this path never passes options, so it cannot run. The result is that every lookup logs "Could not find a Global Catalog in this domain! Resolving will be unreliable in forests with multiple domains" and cross-domain references go unresolved. Seen on a forest where _gc._tcp.<domain> returns NXDOMAIN while the controllers do serve 3268/3269, so the role is held but not published in DNS. Refs #1374
…e-dn is set (#1374) Setting ad.baseDN narrowed every search alike, including the ones whose objects do not live under the given subtree. The domain object and its trusts are children of the naming context, and domain controllers sit in OU=Domain Controllers beside the scoped OU rather than inside it, so those searches returned nothing and collection aborted before it began: Could not find the requested domain nih.gov on this DC, LDAP server reports is domain as nih.gov (you may want to try that?) CollectionException - Specified domain was not found in LDAP The message names the requested and the reported domain, which are identical, because get_domains() raises on an empty result rather than on a mismatch. The search base was hiding the object. The search_base fallback is now chosen per query: root for those three, the operator's subtree for object collection. ad.baseDN keeps the real root, so anything deriving the domain from it is unaffected, and calls that already pass an explicit search_base — schema and configuration naming context — are untouched. Refs #1374 Claude-Session: https://claude.ai/code/session_01BrqBUZro8vxfBMGniTyfDV
…into feat/bloodhound-honour-base-dn
The rationale and the reproduction belong in the PR, not inline. Code unchanged. Refs #1374
certihound builds its search base as CN=Configuration,{base_dn} and derives
base_dn from the target domain when none is given. PKI objects live in the
Configuration NC of the forest root, so in any forest whose root differs from the
target domain the search base does not exist:
noSuchObject: 0000208D: NameErr: DSID-0310028D, problem 2001 (NO_OBJECT)
best match of: 'DC=nih,DC=gov'
Found 0 certificate templates / Found 0 Enterprise CAs
nxc already reads rootDomainNamingContext from the rootDSE into self.forestDN, so
it is passed through as base_dn. Single-domain forests are unaffected: there the
forest root and the target domain are the same DN.
Refs #1374
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
--base-dnis accepted by the ldap protocol, but the--bloodhoundpath never reads it, so collection always covers the whole domain even when a subtree is given. On a large multi-tenant directory that means pulling every object in the forest when only one OU is in scope.bloodhound'sADDC.search()already defaultssearch_basetoad.baseDN, so scoping the collection is a matter of setting that attribute. The change applies it afterad.dns_resolve(), which derivesbaseDNfrom the domain name and would otherwise overwrite it.Queries that pass an explicit
search_base(schema, configuration naming context, and the per-DN lookups inbloodhound/ad/domain.py) are unaffected, so trust and schema enumeration still behave as before. Only the object collections — users, groups, computers, GPOs, containers — are scoped.No new flag, no behavior change when
--base-dnis not supplied.Related but not the same issue: #1364 (
--bloodhoundenumerating the authentication domain rather than the target domain across a trust).AI disclosure, per AI_POLICY.md: written with Claude Code (Opus 5). The AI read the
nxcandbloodhoundsource to locate the defaulting behavior, wrote the patch and this description. Reviewed by me before submission.Relevant source:
bloodhound/ad/domain.py,ADDC.search()—if search_base is None: search_base = self.ad.baseDN.Second commit: default the BloodHound nameserver to the target host
Same function, same class of defect. Every other DNS lookup in the ldap protocol uses
self.args.dns_server or self.host, but the BloodHound collector was passedargs.dns_serverraw, so an unset--dns-serverleft it on the operator's system resolver — which generally cannot answer the_ldap._tcpand_gc._tcpSRV records it needs. Symptom is a repeatedCould not find a Global Catalog in this domain! Resolving will be unreliable in forests with multiple domains.This only helps where the domain controller also serves DNS. That is the common case, but not universal — in the environment this was found in, no in-scope DC had 53 open, so
--dns-serveris still required there.Third commit: apply the Global Catalog fallback when no SRV record exists
bloodhoundalready handles a domain with no_gc._tcpSRV record by treating the domain controllers as Global Catalogs, but the fallback is guarded byif options and not options.global_catalogand this path never passesoptions, so it cannot run. Every lookup then logsCould not find a Global Catalog in this domain!and cross-domain references go unresolved.Found on a forest where
_gc._tcp.<domain>returns NXDOMAIN (authoritative) while the controllers do serve 3268/3269 — the role is held, just not published in DNS.Type of change
Insert an "x" inside the brackets for relevant items (do not delete options)
Setup guide for the review
Any domain-joined AD environment with more than one OU containing principals.
Reproduce the bug:
Before this change the resulting zip contains every object in the domain;
--base-dnhas no effect. After it, the collection is limited to objects under the given DN, and the run logs:Omitting
--base-dnshould produce the same output as before the change.Tested on: Python 3.14, Kali Linux (rolling).
Screenshots (if appropriate):
None.
Checklist:
Insert an "x" inside the brackets for completed and relevant items (do not delete options)
poetry run ruff check ., use--fixto automatically fix what it can)tests/e2e_commands.txtfile if necessary (new modules or features are required to be added to the e2e tests)