Skip to content

fix(dns): resolve each address family separately when AF_UNSPEC fails - #1382

Draft
Marshall-Hallenbeck wants to merge 2 commits into
mainfrom
fix/dns-af-unspec-fallback
Draft

fix(dns): resolve each address family separately when AF_UNSPEC fails#1382
Marshall-Hallenbeck wants to merge 2 commits into
mainfrom
fix/dns-af-unspec-fallback

Conversation

@Marshall-Hallenbeck

Copy link
Copy Markdown
Collaborator

Description

get_host_addr_info() resolved hostnames two different ways, and only one of them was resilient.

The dnspython branch (used with --dns-server / --dns-tcp) queries A and AAAA as two separate lookups, each in its own try/except, so one family failing still yields a usable address. The system-resolver branch made a single AF_UNSPEC getaddrinfo() call with no try/except and no per-family fallback.

That asymmetry matters because glibc fails an AF_UNSPEC lookup outright with EAI_AGAIN when one family's UDP answer is truncated, even when each family resolves fine on its own. Against a domain with many A and AAAA records:

AF_INET    OK   21 results   <dc ip>
AF_INET6   OK   25 results   <dc ipv6>
AF_UNSPEC  FAIL [Errno -3] Temporary failure in name resolution

NetExec then treated the domain as unresolvable, Connection.resolver() returned None, and ldap.py left self.kdcHost = None. Every Kerberos operation broke, reporting the confusing:

Error retrieving TGT for domain.tld\user from None

--dns-server does not help, because impacket opens its Kerberos socket through the OS resolver, not through NetExec's dnspython resolver.

This makes the system-resolver branch behave like the dnspython branch: try AF_UNSPEC, and only if that yields nothing, retry AF_INET and AF_INET6 separately. Two smaller issues in the same loop are fixed along the way — the first address of each family is now kept rather than the last (matching answers[0] in the dnspython branch), and canonname is taken from the first record rather than whatever the last loop iteration happened to hold, which could be empty and silently blanked a link-local IPv6 target.

No new dependencies. No linked issue; this was found in the field rather than reported.

Second commit, unrelated to the DNS fix: tests/test_smb_signing.py currently fails on main with 5 × TypeError: _is_signing_required() got an unexpected keyword argument 'smbv1'. 2536442 changed _is_signing_required from (self, conn, smbv1) to a method reading self.conn / self.smbv1, but the tests were not updated. Included so this branch's suite is green; happy to split it out if you'd rather take it separately.

tests/e2e_commands.txt is unchanged — this fixes existing resolution behaviour and adds no module or flag.

AI disclosure: Claude Code (Opus 5). The diagnosis, the patch, the unit tests, and the first draft of this description were produced by the model, working from a live failure I hit during an engagement. I directed the work, reviewed the diff, and verified the fix by hand against a real DC (see the setup section below). Stating the extent plainly rather than understating it, per the project AI policy.

Type of change

  • 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

Tested on Kali Linux 6.12.25-amd64, Python 3.12, against a live Windows Server 2022 (Build 20348) DC in a large AD forest.

Reproducing the bug. You need a domain whose apex has enough A + AAAA records that the combined answer exceeds the 512-byte non-EDNS UDP limit, and a resolver config without options edns0. The forest I hit this on publishes 21 A and 25 AAAA records for the domain name (AAAA answer = 764 bytes).

  1. Confirm the resolver state — the bug needs AF_UNSPEC to fail while each family succeeds:
python3 -c "
import socket
for fam,name in ((socket.AF_INET,'A'),(socket.AF_INET6,'AAAA'),(socket.AF_UNSPEC,'UNSPEC')):
    try: print(name,'OK',len(socket.getaddrinfo('<domain>',None,fam,socket.SOCK_DGRAM)))
    except Exception as e: print(name,'FAIL',e)
"
  1. On main, run any Kerberos operation without --kdcHost:
nxc ldap <dc-ip> -u <user> -p <pass> --kerberoast out.txt --debug

Before the fix it ends with DEBUG TGT: None and Error retrieving TGT for <domain>\<user> from None. After the fix the same command resolves the KDC and roasts normally.

If you cannot reproduce the DNS conditions, tests/test_dns_resolution.py simulates them with a stubbed getaddrinfo; 6 of its 8 tests fail on main and all 8 pass with this change.

Screenshots (if appropriate)

Before, on main (no --kdcHost, no --dns-server):

[20:56:22] INFO  LDAP  <dc-ip>  389  <DC>  Some other OSError occured: [Errno Connection error
                  (DOMAIN.TLD:88)] [Errno -3] Temporary failure in name resolution   kerberos.py:170
           DEBUG TGT: None                                                              ldap.py:1093
[20:56:22] INFO  LDAP  <dc-ip>  389  <DC>  Error retrieving TGT for domain.tld\user from None
                                                                                        ldap.py:1128

After, same host, same command, glibc AF_UNSPEC still failing:

AF_UNSPEC FAIL [Errno -3] Temporary failure in name resolution
INFO  Resolved domain: domain.tld with dns, kdcHost: <dc-ip>     ldap.py:270
      $krb5tgs$18$<account>$DOMAIN.TLD$*domain.tld\<account>*...

Test suite:

$ python -m pytest tests/ -q --ignore=tests/e2e_tests.py
53 passed

$ python -m ruff check .
All checks passed!

Checklist:

  • 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)

Sources for the resolver behaviour: resolv.conf(5) — see the edns0 and single-request options, which exist precisely because of the parallel A/AAAA truncation problem — and getaddrinfo(3) for EAI_AGAIN.

get_host_addr_info made a single AF_UNSPEC getaddrinfo call with no
fallback, while the dnspython branch already queried A and AAAA
separately. glibc fails an AF_UNSPEC lookup with EAI_AGAIN when one
family's UDP answer is truncated, so a host that resolves fine per
family was reported as unresolvable. That left kdcHost unset and broke
kerberoasting and every other Kerberos operation against the domain.

Retry each family on its own before giving up, keep the first address of
each family to match the dnspython branch, and take canonname from the
first record rather than the last loop iteration.
2536442 changed _is_signing_required from (self, conn, smbv1) to a
method reading self.conn and self.smbv1, but the tests kept passing conn
and smbv1 as arguments and failed with TypeError.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant