NetExec icon indicating copy to clipboard operation
NetExec copied to clipboard

[LDAP] Fix BloodHound collection when DC A/AAAA resolution returns NXDOMAIN

Open xdavimob opened this issue 3 months ago • 8 comments

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 AD object 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 existing from 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' during bloodhound.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)

error1 error2 working

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.txt file 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)

xdavimob avatar Nov 03 '25 15:11 xdavimob

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.

NeffIsBack avatar Nov 03 '25 16:11 NeffIsBack

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?

NeffIsBack avatar Nov 03 '25 16:11 NeffIsBack

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’s AD.domain.ldap_connect() re-resolves self.hostname to 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 like public.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.local resolves fine, LDAP bind is fine, but the later A/AAAA lookup for dc01.public.example.com returns NXDOMAIN → ldap_connect() throws, and NoneType.extend pops 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.NXDOMAIN in ldap_connect() on dc01.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: error1

error2

Fixed: working

xdavimob avatar Nov 03 '25 17:11 xdavimob

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.

xdavimob avatar Nov 03 '25 17:11 xdavimob

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.

NeffIsBack avatar Nov 04 '25 10:11 NeffIsBack

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.

xdavimob avatar Nov 04 '25 14:11 xdavimob

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

NeffIsBack avatar Nov 06 '25 17:11 NeffIsBack

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.

xdavimob avatar Nov 11 '25 18:11 xdavimob