[LDAP] Fix BloodHound collection when DC A/AAAA resolution returns NXDOMAIN
Description
This PR fixes BloodHound collection failures when the connected DC is reachable and SRV queries succeed, but the DC FQDN has no A/AAAA records (NXDOMAIN). We now:
- Seed the BloodHound
ADobject with the currently bound controller/KDC IP, and - Reuse that cached IP during
ldap_connect()before falling back to DNS resolution.
This mirrors the behavior of bloodhound-python -dc <fqdn> -ns <ip> and prevents the earlier Failed to resolve LDAP server IP → 'NoneType' object has no attribute 'extend' chain.
No new dependencies. Minor refactor only:
- Use
resolver.NXDOMAIN(aligned with existingfrom dns import resolver) - Replace
setattr(...)with direct assignment + safe dict init
Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
Setup guide for the review
Environment (local):
- Python: 3.12.x
- OS: Ubuntu 24.04 (WSL2 OK)
Target(s) tested:
- AD domain
example.local - DC:
dc01.example.local(SRV records present, A/AAAA missing)
How to reproduce the bug (pre-fix):
poetry install
poetry run nxc ldap 192.0.2.10 \
-d example.local -u alice -p 'Sup3rS3cret!' \
--bloodhound --collection All --dns-server 192.0.2.10
Observed (pre-fix):
-
domain.py:111 Failed to resolve LDAP server IP - Then
AttributeError: 'NoneType' object has no attribute 'extend'duringbloodhound.run()→self.pdc.prefetch_info()→ldap_connect()NXDOMAIN
How to verify (post-fix):
PYTHONPATH=$(pwd) poetry run nxc ldap 192.0.2.10 \
-d example.local -u alice -p 'Sup3rS3cret!' \
--bloodhound --collection All --dns-server 192.0.2.10 --debug
Expected (post-fix):
- Collection completes and outputs
<...>_bloodhound.zip - If other non-DC hosts also lack A/AAAA, you may still see benign resolution warnings (matches upstream BloodHound behavior)
Screenshots
(if appropriate)
Checklist
- [x] I have ran Ruff against my changes (
poetry run python -m ruff check . --preview), and fixed issues - [x] I have performed a self-review of my own code and added comments where useful
- [ ] I have added or updated the
tests/e2e_commands.txtfile if necessary (No new user-facing command flags; e2e still pass locally) - [ ] New and existing e2e tests pass locally with my changes (36 tests passing locally)
- [ ] If reliant on changes of third party dependencies (Impacket/dploot/lsassy/etc), I have linked related PRs (Not applicable)
- [ ] I have made corresponding documentation changes (PR to NetExec-Wiki) (Optional: a one-liner note under the BloodHound section about IP reuse when DC A/AAAA is missing)
Hi and thanks for the PR!
However, i am not sure what the original cause of the fix should be. Are environments without A/AAAA records for the DC even working? I am not sure if AD wouldn't just break apart if that was the case.
Also the fix seems really hacky, overwriting functions of bloodhound-python and what not. Why do we need to do this and what is the goal of these patches?
Thanks for the quick review!
What’s the actual root cause? AD itself is healthy. The failure happens specifically in the collector’s DNS path:
- The LDAP bind from NetExec to the DC succeeds (we already have the DC’s IP and the correct BaseDN).
- Later,
bloodhound-python’sAD.domain.ldap_connect()re-resolvesself.hostnameto A/AAAA before starting LDAP paging. - In environments with split-horizon / multiple DNS suffixes (e.g., internal zone
corp.local+ public suffix in the search list likepublic.example.com), that re-resolution may hit a name that doesn’t have an A/AAAA in the DNS server we’re querying (NXDOMAIN), even though SRV lookups and the original LDAP bind worked. - In my repro, SRV
_ldap._tcp.dc._msdcs.corp.localresolves fine, LDAP bind is fine, but the later A/AAAA lookup fordc01.public.example.comreturns NXDOMAIN →ldap_connect()throws, andNoneType.extendpops up a bit later in the call chain.
So: the domain works; it’s just that this particular resolution step in the collection pipeline is brittle when the DC hostname being tried doesn’t have an A/AAAA in the queried nameserver. Windows clients survive this because they already have SRV + cached DC IPs and don’t require this extra A/AAAA hop to keep functioning.
Why the “hacky” patch?
I agree it’s not ideal. There’s no official way today to pass a “use this DC IP you already connected to” hint into bloodhound-python from NetExec, and bloodhound-python always tries A/AAAA on self.hostname. The monkey-patch was a pragmatic stopgap to avoid aborting a working collection just because an A/AAAA is missing for a different suffix.
What’s the goal? Make NetExec’s BloodHound collection robust when:
-
SRV/LDAP bind worked (we have a good DC IP),
-
but a later A/AAAA lookup for a particular FQDN returns NXDOMAIN.
-
Result pre-fix: SRV/LDAP bind ok, then
resolver.NXDOMAINinldap_connect()ondc01.public.example.com→ collection aborts.
Why this is safe We only fall back to the already-proven DC IP NetExec used for the successful bind, and only when A/AAAA re-resolution fails. No change for “normal” environments. No new deps.
Screenshots:
Error:
Fixed:
Another context of my environment: I'm testing on an AD hosted in a VM on Azure, so we have a local DNS: corp.local
But, when nxc makes the requests, it resolves to: public.corp.com
Therefore, we receive an NXDOMAIN error.
This is my situation, which is why I resolved it specifically this way; if this scenario doesn't exist, it will remain in the original format.
It is hard to wrap my head around what exactly is happening in your environment. Please post the full stacktrace. A few thoughts from my side:
- As far as i can tell bloodhound-python does indeed resolve SRV entries, at least when querying ldap and gc information
- What happens if you run bloodhound-python manually?
- As far as i know the DNS server should be the target domain controller. If that is not configured please try to run the command with
--dns-server
Overall this looks more like an issue with bloodhound-python than with the way NetExec handles the execution of it. Perhaps we should try to fix it in there, but i am happy to be convinced otherwise.
Yes, my environment is really not the most ideal, nor the most organized; it's an AD WinServer 2016 that's most likely poorly configured lol.
Using bloodhound-python, it managed to collect the data directly, so I looked at how it handles NXDOMAIN and tried to replicate the functionality in nxc. I made the correction more as a suggestion for improvement; it's really something that can be done using other alternatives as well.
I will send the complete stack trace:
(venv) ➜ NetExec git:(main) ✗ nxc ldap 192.168.100.10 -d corp.local -u davi.teste -p 'P@assw0rd@2026' --bloodhound --collection All --dns-server 192.168.100.10
LDAP 192.168.100.10 389 AD01 [*] Windows 10 / Server 2016 Build 14393 (name:AD01) (domain:corp.local) (signing:None) (channel binding:No TLS cert)
LDAP 192.168.100.10 389 AD01 [+] corp.local\davi.teste:P@assw0rd@2026 (Pwn3d!)
LDAP 192.168.100.10 389 AD01 Resolved collection methods: group, trusts, localadmin, container, psremote, dcom, rdp, acl, session, objectprops
[13:42:41] ERROR Exception while calling proto_flow() on target 192.168.100.10: The DNS query name does not exist: connection.py:188
AD01.corpinvestimentos.com.br.
╭────────────────────────────── Traceback (most recent call last) ──────────────────────────────╮
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/nxc/connection.py:180 │
│ in __init__ │
│ │
│ 177 │ │ self.logger.info(f"Socket info: host={self.host}, hostname={self.hostname}, │
│ kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local │
│ ipv6={self.is_link_local_ipv6}") │
│ 178 │ │ │
│ 179 │ │ try: │
│ ❱ 180 │ │ │ self.proto_flow() │
│ 181 │ │ except Exception as e: │
│ 182 │ │ │ if "ERROR_DEPENDENT_SERVICES_RUNNING" in str(e): │
│ 183 │ │ │ │ self.logger.error(f"Exception while calling proto_flow() on target │
│ {target}: {e}") │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/nxc/connection.py:254 │
│ in proto_flow │
│ │
│ 251 │ │ │ │ │ self.call_modules() │
│ 252 │ │ │ │ else: │
│ 253 │ │ │ │ │ self.logger.debug("Calling command arguments") │
│ ❱ 254 │ │ │ │ │ self.call_cmd_args() │
│ 255 │ │ │ self.disconnect() │
│ 256 │ │
│ 257 │ def call_cmd_args(self): │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/nxc/connection.py:276 │
│ in call_cmd_args │
│ │
│ 273 │ │ for attr, value in vars(self.args).items(): │
│ 274 │ │ │ if hasattr(self, attr) and callable(getattr(self, attr)) and value is not │
│ False and value is not None: │
│ 275 │ │ │ │ self.logger.debug(f"Calling {attr}()") │
│ ❱ 276 │ │ │ │ getattr(self, attr)() │
│ 277 │ │
│ 278 │ def call_modules(self): │
│ 279 │ │ """Calls modules and performs various actions based on the module's attributes. │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/nxc/protocols/ldap.py: │
│ 1664 in bloodhound │
│ │
│ 1661 │ │ bloodhound = BloodHound(ad, self.hostname, self.host, self.port) │
│ 1662 │ │ bloodhound.connect() │
│ 1663 │ │ │
│ ❱ 1664 │ │ bloodhound.run( │
│ 1665 │ │ │ collect=collect, │
│ 1666 │ │ │ num_workers=10, │
│ 1667 │ │ │ disable_pooling=False, │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/nxc/protocols/ldap/blo │
│ odhound.py:68 in run │
│ │
│ 65 │ │ │
│ 66 │ │ if "group" in collect or "objectprops" in collect or "acl" in collect: │
│ 67 │ │ │ # Fetch domains for later, computers if needed │
│ ❱ 68 │ │ │ self.pdc.prefetch_info( │
│ 69 │ │ │ │ "objectprops" in collect, │
│ 70 │ │ │ │ "acl" in collect, │
│ 71 │ │ │ │ cache_computers=do_computer_enum, │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/bloodhound/ad/domain.p │
│ y:576 in prefetch_info │
│ │
│ 573 │ │ return entries │
│ 574 │ │
│ 575 │ def prefetch_info(self, props=False, acls=False, cache_computers=False): │
│ ❱ 576 │ │ self.get_objecttype() │
│ 577 │ │ self.get_domains(acl=acls) │
│ 578 │ │ self.get_forest_domains() │
│ 579 │ │ if cache_computers: │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/bloodhound/ad/domain.p │
│ y:259 in get_objecttype │
│ │
│ 256 │ │ self.objecttype_guid_map = dict() │
│ 257 │ │ │
│ 258 │ │ if self.ldap is None: │
│ ❱ 259 │ │ │ self.ldap_connect() │
│ 260 │ │ │
│ 261 │ │ sresult = │
│ self.ldap.extend.standard.paged_search(self.ldap.server.info.other['schemaNamingContext │
│ ][0], │
│ 262 │ │ │ │ │ │ │ │ │ │ │ │ │ │ '(objectClass=*)', │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/bloodhound/ad/domain.p │
│ y:66 in ldap_connect │
│ │
│ 63 │ │ │
│ 64 │ │ # Convert the hostname to an IP, this prevents ldap3 from doing it │
│ 65 │ │ # which doesn't use our custom nameservers │
│ ❱ 66 │ │ q = self.ad.dnsresolver.query(self.hostname, tcp=self.ad.dns_tcp) │
│ 67 │ │ for r in q: │
│ 68 │ │ │ ip = r.address │
│ 69 │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/dns/resolver.py:1371 │
│ in query │
│ │
│ 1368 │ │ │ DeprecationWarning, │
│ 1369 │ │ │ stacklevel=2, │
│ 1370 │ │ ) │
│ ❱ 1371 │ │ return self.resolve( │
│ 1372 │ │ │ qname, │
│ 1373 │ │ │ rdtype, │
│ 1374 │ │ │ rdclass, │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/dns/resolver.py:1314 │
│ in resolve │
│ │
│ 1311 │ │ ) │
│ 1312 │ │ start = time.time() │
│ 1313 │ │ while True: │
│ ❱ 1314 │ │ │ (request, answer) = resolution.next_request() │
│ 1315 │ │ │ # Note we need to say "if answer is not None" and not just │
│ 1316 │ │ │ # "if answer" because answer implements __len__, and python │
│ 1317 │ │ │ # will call that. We want to return if we have an answer │
│ │
│ /home/dmx/.local/share/pipx/venvs/netexec/lib/python3.12/site-packages/dns/resolver.py:758 in │
│ next_request │
│ │
│ 755 │ │ # it's only NXDOMAINs as anything else would have returned │
│ 756 │ │ # before now.) │
│ 757 │ │ # │
│ ❱ 758 │ │ raise NXDOMAIN(qnames=self.qnames_to_try, responses=self.nxdomain_responses) │
│ 759 │ │
│ 760 │ def next_nameserver(self) -> Tuple[dns.nameserver.Nameserver, bool, float]: │
│ 761 │ │ if self.retry_with_tcp: │
╰───────────────────────────────────────────────────────────────────────────────────────────────╯
NXDOMAIN: The DNS query name does not exist: AD01.corpinvest.com.br.
So, my implementation doesn't change the original logic at all; the only difference is if the user has a problem similar to mine with DNS resolution, as you can see, even when passing the --dns-server option, I still got an error.
Unfortunately i don't have the time to do a deep dive at the moment, but i wonder what the difference is between the normal execution and the execution via NetExec is. There bloodhound-python should have the same information as NetExec, but there must be an execution difference. Perhaps a bug in bloodhound tho
Yes, it might be a problem with bloodhound-python, but I used it and it worked fine; I didn't receive that NXDOMAIN error. So for me, the problem isn't with it.