Skip to content

Follow LDAP referrals for enumeration operations in multi-DC forests - #12

Open
j4s0nmo0n wants to merge 2 commits into
logangoins:mainfrom
j4s0nmo0n:feature/adws-referral-follow-reads
Open

Follow LDAP referrals for enumeration operations in multi-DC forests#12
j4s0nmo0n wants to merge 2 commits into
logangoins:mainfrom
j4s0nmo0n:feature/adws-referral-follow-reads

Conversation

@j4s0nmo0n

Copy link
Copy Markdown
Contributor

Depends on #11. This PR contains the commit from #11 as a base;
once #11 is merged, this diff shrinks to just the enumeration wrap.

This is the read-side complement to the write-side referral handling in #11. It wraps the enumeration call site in soa.py so all enumeration options transparently retry on the DC that hosts the target partition when the connected DC returns an LDAP referral.

Approach

The enumeration protocol makes an in-place swap impractical: the enumeration context (enum_ctx) issued by the Enumerate request is scoped to that DC and is invalidated by any Pull sent to another one (the referred DC returns NoSuchEnumCtxGuidExists). Reusing the _swap_endpoint mechanism from #11 mid-enumeration would break the pagination state.

Instead, on ADWSReferralError we open a fresh ADWSConnect pull_client against the referred DC and restart the enumeration from scratch on that new client. Subsequent SID batches within the same query reuse the referred client since re-attempting on the original DC would just produce the same referral.

Scope

Applies to the enumeration call site in soa.py (the SID batch loop at run_cli), which is the code path for every option that goes through the paginated pull:

  • --users, --computers, --groups
  • --admins, --spns, --asreproastable, --rbcds
  • --constrained, --unconstrained
  • -q / --query (raw LDAP queries)

The smaller pulls in getAccountDN and related helpers were already covered by the _pull_with_referral_follow helper from #11.

No change to adws.py: the swap-then-restart approach is intentionally a caller-level pattern to preserve the internal invariants of ADWSConnect.pull() (checkpoints, SID resumption, renewal timer).

Tested end-to-end

Lab: parent/child intra-forest (jjk.local + modulo.jjk.local), account in modulo, connection to dc-curse.jjk.local (parent DC).

--groups:
[!] LDAP referral: partition is hosted on modulo.jjk.local [*] Restarting enumeration on modulo.jjk.local (cross-domain: the enum_ctx is invalidated on swap, so a fresh Enumerate is required) [*] Connecting to 192.168.56.102 for resource:Enumeration [+] ADWS data collection complete: 63 objects saved to .soapy_data ​

--computers:
[+] ADWS data collection complete: 4 objects saved to .soapy_data distinguishedName: CN=SHINJUKU,OU=Domain Controllers,DC=modulo,DC=jjk,DC=local distinguishedName: CN=TESTPC01,CN=Computers,DC=modulo,DC=jjk,DC=local ... ​

Sanity check: --users against jjk.local (single-domain, connected DC hosts the target partition) shows no referral messages and preserves the original behaviour.

Requirements

The referred DC hostname (extracted from ADWSReferralError.target_dc) must resolve. When it does not, the retry surfaces a socket.gaierror with an actionable message pointing to /etc/hosts or -dc.

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
This is the read-side complement to the write-side referral handling
introduced in logangoins#11. It wraps the enumeration call site in soa.py so
--users, --computers, --groups (and any other option that goes through
the ADWSConnect.pull() enumeration path) transparently retry on the
DC that hosts the target partition when the connected DC returns an
LDAP referral.

Approach

The enumeration protocol makes an in-place swap impractical: the
enumeration context (enum_ctx) issued by the Enumerate request is
scoped to that DC and is invalidated by any Pull sent to another one
(the referred DC returns NoSuchEnumCtxGuidExists). Reusing the
_swap_endpoint mechanism from logangoins#11 mid-enumeration would break the
pagination state.

Instead, on ADWSReferralError we open a fresh ADWSConnect pull_client
against the referred DC and restart the enumeration from scratch on
that new client. Subsequent SID batches within the same query reuse
the referred client (referred_client is set once, then all following
iterations of the SID batch loop go through it) since re-attempting
on the original DC would just produce the same referral.

Scope

* Applies only to the enumeration call site in soa.py (the SID batch
  loop at run_cli). This is the code path for --users, --computers,
  --groups, --asreproastable, --rbcds and the other options that use
  the paginated pull.
* The smaller pulls in getAccountDN and friends were already covered
  by the pull_with_referral_follow helper introduced in logangoins#11.
* No change to adws.py: the swap-then-restart approach is intentionally
  a caller-level pattern to preserve the internal invariants of
  ADWSConnect.pull() (checkpoints, SID resumption, renewal timer).

Requirements

The referred DC hostname (extracted from ADWSReferralError.target_dc)
must resolve. When it does not, the retry surfaces a socket.gaierror
with an actionable message pointing to /etc/hosts or -dc.

Testing

Lab: parent/child intra-forest (jjk.local + modulo.jjk.local), account
in modulo, connection to dc-curse.jjk.local (parent DC).

  * --groups collected 63 objects from modulo across 6 pages
  * --computers collected 4 objects (child DC + 3 test machines)
  * --users on jjk.local as sanity check: no referral, unchanged
    behaviour

Depends on

logangoins#11 introduces ADWSReferralError and its target_dc attribute, both
reused here.
@logangoins

Copy link
Copy Markdown
Owner

This is great! An amazing QoL improvement for large scale enumeration. Give me a bit to review and test, and I'll get back to you.

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.

2 participants