Skip to content

Honor --base-dn in BloodHound collection - #1374

Draft
Marshall-Hallenbeck wants to merge 8 commits into
mainfrom
feat/bloodhound-honour-base-dn
Draft

Honor --base-dn in BloodHound collection#1374
Marshall-Hallenbeck wants to merge 8 commits into
mainfrom
feat/bloodhound-honour-base-dn

Conversation

@Marshall-Hallenbeck

@Marshall-Hallenbeck Marshall-Hallenbeck commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator

Description

--base-dn is accepted by the ldap protocol, but the --bloodhound path 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's ADDC.search() already defaults search_base to ad.baseDN, so scoping the collection is a matter of setting that attribute. The change applies it after ad.dns_resolve(), which derives baseDN from the domain name and would otherwise overwrite it.

Queries that pass an explicit search_base (schema, configuration naming context, and the per-DN lookups in bloodhound/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-dn is not supplied.

Related but not the same issue: #1364 (--bloodhound enumerating 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 nxc and bloodhound source 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 passed args.dns_server raw, so an unset --dns-server left it on the operator's system resolver — which generally cannot answer the _ldap._tcp and _gc._tcp SRV records it needs. Symptom is a repeated Could 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-server is still required there.

Third commit: apply the Global Catalog fallback when no SRV record exists

bloodhound already handles a domain with no _gc._tcp SRV record by treating the domain controllers as Global Catalogs, but the fallback is guarded by if options and not options.global_catalog and this path never passes options, so it cannot run. Every lookup then logs Could 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)

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • Deprecation of feature or functionality
  • This change requires a documentation update
  • This requires a third party update (such as Impacket, Dploot, lsassy, etc)
  • This PR was created with the assistance of AI (list what type of assistance, tool(s)/model(s) in the description)

Setup guide for the review

Any domain-joined AD environment with more than one OU containing principals.

Reproduce the bug:

nxc ldap DC -u USER -p PASS --bloodhound -c Default --base-dn "OU=Sub,DC=example,DC=local"

Before this change the resulting zip contains every object in the domain; --base-dn has no effect. After it, the collection is limited to objects under the given DN, and the run logs:

Scoping BloodHound collection to OU=Sub,DC=example,DC=local

Omitting --base-dn should 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)

  • I have ran Ruff against my changes (poetry: poetry run ruff check ., use --fix to automatically fix what it can)
  • I have added or updated the tests/e2e_commands.txt file if necessary (new modules or features are required to be added to the e2e tests)
  • If reliant on changes of third party dependencies, such as Impacket, dploot, lsassy, etc, I have linked the relevant PRs in those projects
  • I have linked relevant sources that describes the added technique (blog posts, documentation, etc)
  • I have performed a self-review of my own code (not an AI review)
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation (PR here: https://github.com/Pennyw0rth/NetExec-Wiki)

@Marshall-Hallenbeck Marshall-Hallenbeck added the bug-fix This Pull Request fixes a bug label Aug 21, 2026
--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 Marshall-Hallenbeck changed the title Honour --base-dn in BloodHound collection Honor --base-dn in BloodHound collection Aug 21, 2026
@Marshall-Hallenbeck
Marshall-Hallenbeck force-pushed the feat/bloodhound-honour-base-dn branch from 5ee1cbf to 600ad89 Compare August 21, 2026 22:48
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
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug-fix This Pull Request fixes a bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant