Skip to content

Add LDAP search variants that carry controls (Fixes #1104) - #1105

Merged
p0dalirius merged 1 commit into
mainfrom
enhancement-ldap-query-with-controls
Sep 1, 2026
Merged

Add LDAP search variants that carry controls (Fixes #1104)#1105
p0dalirius merged 1 commit into
mainfrom
enhancement-ldap-query-with-controls

Conversation

@p0dalirius

Copy link
Copy Markdown
Contributor

Linked Issue

Closes #1104

Root Cause

Query() was written with the control list of its ldap.NewSearchRequest call fixed
at nil, and the five scope wrappers (QueryBaseObject, QuerySingleLevel,
QueryWholeSubtree, QueryChildren, QueryAllNamingContexts) were then built on top
of that signature. Nothing in the package carries a control through to the wire, and
because Session.connection is unexported a caller cannot go around the package and
build its own search either. The result is that an entire class of LDAP search - any
search whose meaning is set by a control - cannot be expressed through this API.

nTSecurityDescriptor is where that bites hardest. Per MS-ADTS 3.1.1.3.4.1.11 the read
is scoped by LDAP_SERVER_SD_FLAGS_OID; without it a domain controller attempts to
include the SACL, and a client that does not hold SE_SECURITY_NAME gets the attribute
back absent from the entry rather than an error. GetNtSecurityDescriptorOf already
knows this and sends OWNER | GROUP | DACL, but it is a ScopeBaseObject read of a
single DN, so reading the descriptor of every object of a naming context costs one
round trip per object.

Fix Description

QueryWithControls takes the control list and holds the body Query used to have.
Query becomes a one-line delegation passing nil, so its behaviour is unchanged by
construction rather than by inspection, and none of its call sites move - which matters,
since Query and its wrappers have 25 call sites inside this repository and many more
across the sibling tool repositories. QueryWholeSubtreeWithControls is added beside
QueryWholeSubtree because the subtree sweep is the shape that actually needs a
control.

The four *_SECURITY_INFORMATION values move from inside the body of
GetNtSecurityDescriptorOf to package level, so a caller building its own SD flags
control does not have to redeclare them, and SECURITY_INFORMATION_DEFAULT names the
everything-but-the-SACL set that an unprivileged read has to ask for.
GetNtSecurityDescriptorOf now uses that constant and sends exactly the value it sent
before.

An additive variant was chosen over changing the signature of Query (which would
touch every call site in every repository) and over a session-level
SetSearchControls([]ldap.Control). The latter would reach all five wrappers without
new functions, but it makes controls sticky and invisible at the call site: a control
set once for one search would silently ride along on every later search of that
session. For something that changes what the server returns, an explicit parameter is
the safer shape.

How Verified

  • go build ./network/ldap/, go vet ./network/ldap/ and gofmt -l network/ldap/ are
    all clean.
  • go test ./network/ldap/ passes.
  • Static: Query at network/ldap/query.go:31 now contains a single return
    delegating with nil, and the request built at
    network/ldap/query.go:88-107 is otherwise byte-for-byte the code that ran before,
    so a caller of Query sends the same search it always did.
  • The control value is unchanged for the one existing sender:
    SECURITY_INFORMATION_DEFAULT is asserted to be 0x7 by
    TestSecurityInformationDefaultExcludesSACL, which is the value
    GetNtSecurityDescriptorOf inlined before this change.
  • Downstream: the new subtree variant is what manticore-aclmonitor reads security
    descriptors with, and it builds and runs against this branch.

Test Coverage

Added: network/ldap/security_descriptors_test.go

  • TestSecurityInformationDefaultExcludesSACL - pins SECURITY_INFORMATION_DEFAULT to
    0x7 and asserts it does not carry SACL_SECURITY_INFORMATION. Letting the SACL into
    that set would make a domain controller return the nTSecurityDescriptor attribute
    empty, with no error, for every client without SE_SECURITY_NAME - a silent failure
    worth a regression test.
  • TestControlMicrosoftSDFlagsGetControlType - checks the OID the control is sent under.
  • TestControlMicrosoftSDFlagsEncode - walks the encoded control and checks the OID and
    the flags integer are where a server reads them, across three cases (default flags,
    with the SACL, and with criticality set, which changes the child count).

The search functions themselves need a bound connection and are not unit-testable here;
the delegation that keeps Query unchanged is verified statically, as described above.

Scope of Change

  • Files changed: network/ldap/query.go, network/ldap/security_descriptors.go, network/ldap/security_descriptors_test.go
  • Submodule pointer updated: no
  • Behavioral changes outside the bug fix: none

Risk and Rollout

Low blast radius. Existing callers reach the same code through one added call frame with
nil controls, and the only behavioural surface that could regress is Query, which is
now defined in terms of the new function rather than duplicated. Safe to merge without a
staged rollout.

Notes

Related to #1103, which reports the other half of the same constraint: SearchWithPaging
is called with a hardcoded page size of 1000 that no caller can change, for the same
underlying reason. That is a separate fix and is not touched here. Note that
QueryWithControls still goes through SearchWithPaging, which appends its own
paged-results control to whatever the caller passed - the behaviour a caller wants here,
but it does mean this cannot be used to send a search with no control at all.

Query built its search request with the control list fixed at nil, and the
five scope wrappers were built on that signature, so no search in the package
could carry an LDAP control. Session.connection is unexported, so a caller
could not issue its own search either.

QueryWithControls takes the control list and holds what Query used to do;
Query is now a one-line delegation with nil, so its behaviour is unchanged by
construction and none of its call sites move. QueryWholeSubtreeWithControls
is the subtree form, which is the shape a security descriptor sweep needs:
reading nTSecurityDescriptor requires LDAP_SERVER_SD_FLAGS_OID, and
GetNtSecurityDescriptorOf can only do it one base object at a time.

The four *_SECURITY_INFORMATION values move out of the body of
GetNtSecurityDescriptorOf to package level, so a caller building its own SD
flags control does not have to redeclare them, and SECURITY_INFORMATION_DEFAULT
names the everything-but-the-SACL set that an unprivileged read has to ask for.
GetNtSecurityDescriptorOf now uses that constant and sends the value it sent
before.
@p0dalirius
p0dalirius merged commit a9c3580 into main Sep 1, 2026
5 checks passed
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