Skip to content

Follow LDAP referrals for write operations in multi-DC forests - #11

Open
j4s0nmo0n wants to merge 1 commit into
logangoins:mainfrom
j4s0nmo0n:fix/adws-referral-follow
Open

Follow LDAP referrals for write operations in multi-DC forests#11
j4s0nmo0n wants to merge 1 commit into
logangoins:mainfrom
j4s0nmo0n:fix/adws-referral-follow

Conversation

@j4s0nmo0n

Copy link
Copy Markdown
Contributor

Hello Logan

Context

In a multi-DC forest, an ADWS request can hit a DC that does not host the target partition. In that case, ADWS answers with Win32ErrorCode 8235 (ERROR_DS_REFERRAL) and points to another DC. Before this change, the client surfaced the response as a raw XML dump and the operation died there.

This PR makes the ADWSConnect transport swap to the referred DC and retry, transparently to callers. Scope is intentionally limited to write operations; enumerations (--users, --computers, --groups) are a separate problem because the enumeration context is invalidated after a swap and the whole Enumerate + Pull cycle needs to be restarted.

I plan to send that as a follow-up PR.

Design

ADWSConnect._swap_endpoint(target_dc, hops) - reconnects the NMF transport to another DC, updates self._fqdn (embedded in SOAP payloads), bounded by MAX_REFERRAL_HOPS (default 3) to avoid loops on misconfigured forests.

ADWSConnect.put() - catches ADWSReferralError, swaps, rebuilds the payload with the new fqdn, retries. Transparently covers --rbcd, --asrep, --spn, --disable-account, --dns-modify, --dns-remove, --dns-tombstone, --dns-resurrect, --shadow-creds add/remove.

_pull_with_referral_follow(pull_client, query, basedn, attributes) - small helper in ad_dns_manager_adws.py that wraps pull_client.pull() with the same retry pattern. Used by find_dns_node, add_dns_record_adws's dnsZone enumeration, shadow_credentials, and getAccountDN.

Raw SOAP send/recv sites - add_dns_record_adws (Create), remove_dns_record_adws (Delete), and _set_dnstombstoned_replace_boolean (Put tombstone) construct their own SOAP payloads outside of put(). Each is wrapped with a retry loop that rebuilds the payload with the updated fqdn after each swap.

Proactive DC discovery - for add_dns_record_adws and add_computer, a lightweight pull on the target container discovers which DC hosts the partition, and the subsequent Create is routed to that DC directly. This avoids CouldntFindParentObjectForCreation, which AD returns instead of a referral when the parent DN doesn't exist on the queried DC (as opposed to a partition it knows about but doesn't host).

getAccountDN(return_target_fqdn=True) - optionally returns the fqdn of the DC that resolved the account. delete_computer uses this to route the Delete to the DC that actually holds the object.

Tested end-to-end

Parent/child forest, using an account from the child domain against the parent DC:

$ SOAPy modulo.jjk.local/ino:'P@ssword!'@dc-curse.jjk.local \
    --dns-add TEST.modulo.jjk.local --dns-ip 192.168.56.100
...
[*] Using distingushedName: CN=MicrosoftDNS,DC=DomainDnsZones,DC=modulo,DC=jjk,DC=local
[!] LDAP referral (hop 1/3): following to domaindnszones.modulo.jjk.local
[*] Reconnecting to domaindnszones.modulo.jjk.local (192.168.56.102)
[*] Routing Create toward domaindnszones.modulo.jjk.local (192.168.56.102) which hosts the DomainDnsZones partition
[+] Created dnsNode DC=TEST,DC=modulo.jjk.local,CN=MicrosoftDNS,DC=DomainDnsZones,DC=modulo,DC=jjk,DC=local and added A record 192.168.56.100

$ nslookup TEST.modulo.jjk.local 192.168.56.102
Name: TEST.modulo.jjk.local
Address: 192.168.56.100

Same DC and account, --shadow-creds list --shadow-target ino:

[*] Connecting to dc-curse.jjk.local for resource:Enumeration
[!] LDAP referral (hop 1/3): following to modulo.jjk.local
[*] Reconnecting to modulo.jjk.local (192.168.56.102)
[+] Target found: CN=ino,CN=Users,DC=modulo,DC=jjk,DC=local
[*] No KeyCredentials found (attribute is empty or no read permissions)

Same DC and account, --addcomputer:

[*] Creating computer account TESTPCe3dded$ in CN=Computers,DC=modulo,DC=jjk,DC=local via ADWS ResourceFactory
[!] LDAP referral (hop 1/3): following to modulo.jjk.local
[*] Reconnecting to modulo.jjk.local (192.168.56.102)
[*] Routing Create toward modulo.jjk.local (192.168.56.102) which hosts the target container
[+] Computer TESTPCe3dded$ successfully created in CN=TESTPCe3dded,CN=Computers,DC=modulo,DC=jjk,DC=local

Notes

  • MAX_REFERRAL_HOPS is exposed as a class attribute so it can be tuned if needed.
  • When DNS can't resolve the referred DC, the error message names the DC and suggests /etc/hosts or -dc, instead of the previous opaque Win32ErrorCode 8235.
  • No new external dependencies; only socket.gethostbyname.
  • Read-side enumerations (--users, --computers, --groups) still bubble up ADWSReferralError cleanly (with a readable message), just not automatically followed. Follow-up PR planned.

Happy to split, rework, or narrow the scope if you'd prefer a smaller diff to review.

ADWS returns Win32ErrorCode 8235 (ERROR_DS_REFERRAL) when a DC does not
host the partition targeted by a request. Previously this surfaced as an
opaque XML dump and killed the operation. This change makes the ADWS
client swap to the referred DC and retry, transparently to callers.

Design:

* ADWSConnect._swap_endpoint(target_dc, hops) reconnects the NMF
  transport to another DC, updates self._fqdn (used in SOAP payloads),
  and is bounded by ADWSConnect.MAX_REFERRAL_HOPS (default 3) to prevent
  loops on misconfigured forests.

* ADWSConnect.put() catches ADWSReferralError, swaps, rebuilds the
  payload with the new fqdn, and retries. This transparently covers
  --rbcd, --asrep, --spn, --disable-account, --dns-modify, --dns-remove,
  --dns-tombstone, --dns-resurrect, and --shadow-creds add/remove.

* ad_dns_manager_adws._pull_with_referral_follow wraps pull_client.pull
  with the same retry pattern. Used by find_dns_node, add_dns_record's
  dnsZone enumeration, and shadow_credentials target discovery.

* add_dns_record_adws, remove_dns_record_adws, and _set_dnstombstoned
  have their raw SOAP send/recv wrapped with a retry loop that rebuilds
  the payload after each swap.

* add_dns_record_adws and add_computer proactively discover the DC that
  hosts the target container (via a lightweight pull) and route the
  subsequent Create there, avoiding CouldntFindParentObjectForCreation
  which AD returns instead of a referral when the parent DN doesn't
  exist on the queried DC.

* getAccountDN can now return the fqdn of the DC that resolved the
  account, letting delete_computer route the Delete to the right DC.

Scope: write operations only. Enumerations (--users, --computers,
--groups) require restarting the whole Enumerate+Pull cycle after swap
because the enumeration context is invalidated; that is out of scope
here and will be addressed separately.

Tested end-to-end in a parent/child forest (jjk.local / modulo.jjk.local):

* --dns-add creates the record on the child DC via the parent DC
* --shadow-creds list/add reach the target across the referral
* --addcomputer creates the machine account in the child domain from
  the parent DC
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