From 654ca699f996da7820dea110088a4f418168a90f Mon Sep 17 00:00:00 2001 From: Nurlan Turkpenbay <51225765+n-turkpenbay@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:55:29 +0500 Subject: [PATCH 1/5] Add the fourth blog post: setting an AD password over LDAP --- docs/_config.yml | 9 ++ docs/blog/index.md | 2 + .../set-ad-password-over-ldap-unicodepwd.md | 110 ++++++++++++++++++ 3 files changed, 121 insertions(+) create mode 100644 docs/blog/set-ad-password-over-ldap-unicodepwd.md diff --git a/docs/_config.yml b/docs/_config.yml index 32b6ba5..883c0c6 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -180,3 +180,12 @@ defaults: Managing a Samba AD domain from a web UI — samba-tool, RSAT and LDAP tools — and the three Samba specifics every one of them has to handle, including why new admins go unprotected without SDProp. + - scope: + path: blog/set-ad-password-over-ldap-unicodepwd.md + values: + parent: Blog + nav_order: 4 + description: >- + Setting an Active Directory password over LDAP: why userPassword stores plaintext and + changes nothing, how unicodePwd works (quoted, UTF-16LE, over TLS), and what + unwillingToPerform and constraintViolation mean. diff --git a/docs/blog/index.md b/docs/blog/index.md index e2f657d..b45b9b3 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -10,3 +10,5 @@ stands on its own; Axia shows up only where it is the honest answer. — phpLDAPadmin, LAM and the service-account tools, grouped by who enforces permissions. - [Managing Samba AD from the browser](manage-samba-ad-from-the-browser.md) — the web options for a Samba domain, and the three Samba specifics each has to get right. +- [Setting an Active Directory password over LDAP: unicodePwd vs userPassword](set-ad-password-over-ldap-unicodepwd.md) + — why userPassword fails silently, how unicodePwd works, and what the errors mean. diff --git a/docs/blog/set-ad-password-over-ldap-unicodepwd.md b/docs/blog/set-ad-password-over-ldap-unicodepwd.md new file mode 100644 index 0000000..5c4335c --- /dev/null +++ b/docs/blog/set-ad-password-over-ldap-unicodepwd.md @@ -0,0 +1,110 @@ +# Setting an Active Directory password over LDAP: unicodePwd vs userPassword + +You wrote a new password to a user's `userPassword` attribute over LDAP. The server returned +success. The user still signs in with their *old* password, and the new one is sitting in the +directory in plain text. Or you tried `unicodePwd` and got `unwillingToPerform`, or +`constraintViolation`, and no explanation. + +This is one of the most common ways to get LDAP password changes wrong against Active Directory. The +short version: **AD does not set passwords through `userPassword`. It uses `unicodePwd`, with an +exact encoding, over an encrypted connection only.** Here is the whole picture, including the errors +and why each happens. + +## Why `userPassword` doesn't change an AD password + +In OpenLDAP, `userPassword` is *the* password attribute, and (with a hashing policy) writing it sets +the account's password. The habit carries straight over to Active Directory, where it quietly does +the wrong thing. + +In AD, `userPassword` is, by default, an **ordinary string attribute**. Unless the domain's +`dSHeuristics` turns on `fUserPwdSupport`, writing it: + +- does **not** change the password the account signs in with, and +- stores your new value as **plain text** on the object, readable by anyone who can read the + attribute. + +So the write "succeeds", the login is unchanged, and you've leaked a password into the directory. +That is the worst of both worlds, and no error tells you. + +## The right way: `unicodePwd` + +Active Directory sets passwords through the **`unicodePwd`** attribute, and it is fussy about two +things: the encoding and the connection. + +**The encoding.** The value is the password **wrapped in double quotes**, then encoded as +**UTF-16LE** (little-endian). In .NET that's `Encoding.Unicode`: + +```csharp +// Admin reset: one MODIFY that REPLACEs unicodePwd. +byte[] value = Encoding.Unicode.GetBytes($"\"{password}\""); +// e.g. new DirectoryAttributeModification { +// Name = "unicodePwd", +// Operation = DirectoryAttributeOperation.Replace, +// [ value ] } +``` + +The quotes are part of the value, not syntax — forget them and AD rejects it. The encoding is +UTF-16LE specifically; UTF-8 won't do. + +**The connection.** AD accepts `unicodePwd` **only over an encrypted channel** — LDAPS or LDAP with +StartTLS. Over a plain LDAP connection it answers `unwillingToPerform` and changes nothing. This is +deliberate: the password crosses the wire inside the modify request, so AD refuses to carry it in +the clear. + +**Reset vs. self-service change.** There are two shapes: + +- **An administrator resetting someone's password** sends a single `REPLACE` of `unicodePwd` with + the new quoted value. +- **A user changing their own password** sends one MODIFY with two operations: a `DELETE` of the old + quoted value and an `ADD` of the new one. This proves they know the current password and respects + minimum-password-age, which a reset bypasses. + +## OpenLDAP does it differently: the Password Modify extended operation + +If your code talks to both AD and OpenLDAP, note that OpenLDAP's correct path is **not** an +attribute write at all. It's the **Password Modify extended operation** (RFC 3062): you ask the +server to set the password, and it hashes and stores it according to its own policy. Writing +`userPassword` directly on OpenLDAP without a hashing overlay stores the password unhashed — the +same plaintext trap, from the other side. + +So "set an LDAP password" has two correct implementations, and you pick by the directory. + +## How to tell which directory you're talking to + +Don't guess from configuration — ask the server. Read the **root DSE**: + +- Active Directory advertises the capability OID **`1.2.840.113556.1.4.800`** in + `supportedCapabilities`. If it's there, use `unicodePwd`. +- Servers that support Password Modify list **`1.3.6.1.4.1.4203.1.11.1`** in `supportedExtension`. + If that's there (and the AD capability isn't), use the extended operation. + +If neither is present, don't fall back to writing `userPassword` — you'd be writing plaintext. +Better to fail loudly than to store a password in the clear. + +## The errors, and what they mean + +| LDAP result | Code | What it usually means | +| --- | --- | --- | +| `unwillingToPerform` | 53 | You sent `unicodePwd` over a plain connection. Use LDAPS or StartTLS. | +| `constraintViolation` | 19 | The password broke policy: too short, not complex enough, in the history, or below minimum age. Surface it as a clear "password rejected", not "directory error". | +| success, but login unchanged | — | You wrote `userPassword` on AD. Switch to `unicodePwd`. | +| `insufficientAccessRights` | 50 | The binding account may not reset this user. On a reset, that's an ACL/delegation problem. | + +A tool worth using turns `constraintViolation` into a 400-style "that password isn't allowed" rather +than a vague failure, because it's a user mistake, not an outage. + +## Doing it once, correctly + +This is exactly the kind of detail worth writing once, behind an interface, and never again in each +caller. [Nelsar.Axia](https://github.com/Nelsar/Axia) (the project this blog belongs to) decides +`unicodePwd` vs. Password Modify from the root DSE, sends the quoted UTF-16LE value over an encrypted +connection, refuses to write a password at all if neither capability is present, and maps +`constraintViolation` to a clear "password rejected". The same reset flow then works against +Windows AD, Samba AD and OpenLDAP without the caller knowing which it's talking to — see +[what Axia reads and writes in each directory](../directories.md). + +--- + +Nelsar.Axia is an open-source (Apache-2.0) web interface for Active Directory, Samba AD and +OpenLDAP. You can [try it with one Docker command](../installation.md) and watch a password reset go +to the right place. From dd7e0a2eaba6dcf61ca07dda10d273f81001452b Mon Sep 17 00:00:00 2001 From: Nurlan Turkpenbay <51225765+n-turkpenbay@users.noreply.github.com> Date: Mon, 2 Nov 2026 10:57:12 +0500 Subject: [PATCH 2/5] Add the fifth blog post: why memberOf is empty in OpenLDAP --- docs/_config.yml | 9 ++ docs/blog/index.md | 2 + docs/blog/openldap-memberof-empty.md | 131 +++++++++++++++++++++++++++ 3 files changed, 142 insertions(+) create mode 100644 docs/blog/openldap-memberof-empty.md diff --git a/docs/_config.yml b/docs/_config.yml index 883c0c6..57c7c56 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -189,3 +189,12 @@ defaults: Setting an Active Directory password over LDAP: why userPassword stores plaintext and changes nothing, how unicodePwd works (quoted, UTF-16LE, over TLS), and what unwillingToPerform and constraintViolation mean. + - scope: + path: blog/openldap-memberof-empty.md + values: + parent: Blog + nav_order: 5 + description: >- + Why memberOf is empty in OpenLDAP: the memberof overlay, the three cases it still gets + wrong (backfill, object class, nesting), and the reverse group search that works without + it. diff --git a/docs/blog/index.md b/docs/blog/index.md index b45b9b3..25f691e 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -12,3 +12,5 @@ stands on its own; Axia shows up only where it is the honest answer. — the web options for a Samba domain, and the three Samba specifics each has to get right. - [Setting an Active Directory password over LDAP: unicodePwd vs userPassword](set-ad-password-over-ldap-unicodepwd.md) — why userPassword fails silently, how unicodePwd works, and what the errors mean. +- [Why memberOf is empty in OpenLDAP, and how to fix it](openldap-memberof-empty.md) + — the memberof overlay, the cases it still gets wrong, and the reverse search that always works. diff --git a/docs/blog/openldap-memberof-empty.md b/docs/blog/openldap-memberof-empty.md new file mode 100644 index 0000000..e473661 --- /dev/null +++ b/docs/blog/openldap-memberof-empty.md @@ -0,0 +1,131 @@ +# Why memberOf is empty in OpenLDAP, and how to fix it + +You query a user in OpenLDAP, expect to see their groups in `memberOf`, and the attribute isn't +there — or it's empty, even though the user is clearly a member of a group. A filter like +`(memberOf=cn=admins,ou=groups,dc=example,dc=org)` returns nobody. In Active Directory this just +works; in OpenLDAP it doesn't. + +The reason is simple and catches almost everyone: **OpenLDAP does not maintain `memberOf` by +default.** Membership is stored on the *group* (`member` points at the user), not on the user. The +back-reference on the user only exists if you turn it on. Here's how, and — more importantly — why +you probably shouldn't rely on it anyway. + +## Why it's empty + +In Active Directory, `memberOf` is computed and always present. In OpenLDAP, a group is a +`groupOfNames` whose `member` attributes list the users. Nothing writes the reverse link onto each +user unless the **`memberof` overlay** (`slapo-memberof`) is loaded. A default install doesn't load +it, so `memberOf` is simply never populated. + +## Fix 1: enable the memberof overlay + +On a modern `cn=config` (dynamic config) setup, load the module and attach the overlay to your +database. Adjust `{1}mdb` to your actual database (check with +`ldapsearch -Y EXTERNAL -H ldapi:/// -b cn=config olcDatabase`): + +```ldif +# Load the module (once per server) +dn: cn=module{0},cn=config +changetype: modify +add: olcModuleLoad +olcModuleLoad: memberof + +# Attach the overlay to the database +dn: olcOverlay=memberof,olcDatabase={1}mdb,cn=config +changetype: add +objectClass: olcOverlayConfig +objectClass: olcMemberOf +olcOverlay: memberof +olcMemberOfRefInt: TRUE +olcMemberOfGroupOC: groupOfNames +olcMemberOfMemberAD: member +olcMemberOfMemberOfAD: memberOf +``` + +Apply it with `ldapmodify -Y EXTERNAL -H ldapi:/// -f memberof.ldif`. + +Add the **referential integrity (`refint`) overlay** too, so `memberOf` (and `member`) stay correct +when an entry is renamed or deleted — without it, deleting a user leaves dangling `member` values: + +```ldif +dn: cn=module{0},cn=config +changetype: modify +add: olcModuleLoad +olcModuleLoad: refint + +dn: olcOverlay=refint,olcDatabase={1}mdb,cn=config +changetype: add +objectClass: olcOverlayConfig +objectClass: olcRefintConfig +olcOverlay: refint +olcRefintAttribute: memberof member manager owner +``` + +## The three catches the overlay leaves you with + +Turning it on is not the end of it. These are exactly the cases where `memberOf` still comes back +wrong: + +1. **It doesn't backfill.** The overlay only maintains `memberOf` for memberships changed *after* + it's loaded. Users who were already in groups get nothing until their membership is rewritten. + To populate existing entries, re-add the memberships (remove and re-add each `member`), or reload + the database with `slapcat` / `slapadd`. +2. **It watches one object class and one attribute.** By default that's `groupOfNames` with + `member`. If your groups are **`groupOfUniqueNames`** (with `uniqueMember`) or POSIX + **`posixGroup`** (with `memberUid`), the overlay ignores them and `memberOf` stays empty for + those memberships. You can point `olcMemberOfGroupOC` / `olcMemberOfMemberAD` at one alternative, + but not all of them at once. +3. **It doesn't expand nested groups.** `memberOf` lists only the groups the user is a *direct* + member of. A user in a group that is itself a member of another group won't show the outer group. + +On a replicated setup there's a fourth: run the overlay where writes happen, and let replication +carry the resulting `memberOf` to the replicas, rather than adding it independently on each. + +## Fix 2 (the robust one): don't rely on memberOf at all + +If what you actually need is "which groups is this user in?" — for a login, a permission check, an +app — the dependable answer is to **search from the group side**, which works whether or not the +overlay is loaded and whatever object class your groups use. Ask the directory for every group that +names this user, in all three membership forms at once: + +```text +(|(&(objectClass=groupOfNames)(member=)) + (&(objectClass=groupOfUniqueNames)(uniqueMember=)) + (&(objectClass=posixGroup)(memberUid=))) +``` + +A few rules make this safe: + +- **Escape the DN** you substitute in. A name can contain parentheses or an asterisk, and unescaped + they end the filter early or widen it to other people's groups (RFC 4515 escaping). +- It returns **direct** memberships, same as the overlay; expand nesting yourself only if you + actually need it. +- It's one extra query per user, which is fine for a login or a permission check, though not for + listing thousands of users at once. + +This is the approach to prefer in code, because it has no server-side prerequisite and no "did +someone load the overlay?" failure mode. An application that silently gets an empty group list is +worse than one that always asks the right question. + +## How to verify + +- Overlay loaded? `ldapsearch -Y EXTERNAL -H ldapi:/// -b cn=config "(olcOverlay=memberof)"`. +- A user's groups, the reliable way: run the filter above with their DN and uid. +- A specific membership: `ldapsearch -b dc=example,dc=org "(&(objectClass=groupOfNames)(member=))" dn`. + +If the overlay route shows nothing but the reverse search does, you're hitting one of the three +catches above — most often backfill, or the wrong object class. + +## Doing it once, correctly + +[Nelsar.Axia](https://github.com/Nelsar/Axia) (the project this blog belongs to) takes the reverse +approach: it searches for the groups that name the account in all three forms, merges that with +`memberOf` where the directory does provide it, and escapes the DN. So group membership — and the +permissions that hang off it — are right against a default OpenLDAP with no overlay, against one +configured only for `groupOfNames`, and against Active Directory alike. + +--- + +Nelsar.Axia is an open-source (Apache-2.0) web interface for Active Directory, Samba AD and +OpenLDAP. You can [try it with one Docker command](../installation.md) against a demo OpenLDAP and +see group membership resolved without the overlay. From 1b700428ee0465e3ffdff430ed1eeefeb82c5f02 Mon Sep 17 00:00:00 2001 From: Nurlan Turkpenbay <51225765+n-turkpenbay@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:11:45 +0500 Subject: [PATCH 3/5] Add the sixth blog post: the LDAP pass-back attack --- docs/_config.yml | 9 +++ docs/blog/index.md | 2 + docs/blog/ldap-pass-back-attack.md | 108 +++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+) create mode 100644 docs/blog/ldap-pass-back-attack.md diff --git a/docs/_config.yml b/docs/_config.yml index 57c7c56..cf1bfe1 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -198,3 +198,12 @@ defaults: Why memberOf is empty in OpenLDAP: the memberof overlay, the three cases it still gets wrong (backfill, object class, nesting), and the reverse group search that works without it. + - scope: + path: blog/ldap-pass-back-attack.md + values: + parent: Blog + nav_order: 6 + description: >- + The LDAP pass-back attack explained — how a printer or web console can be made to hand + over its stored bind password — and the rule that stops it: re-enter the password when the + endpoint or encryption changes. diff --git a/docs/blog/index.md b/docs/blog/index.md index 25f691e..dfabe39 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -14,3 +14,5 @@ stands on its own; Axia shows up only where it is the honest answer. — why userPassword fails silently, how unicodePwd works, and what the errors mean. - [Why memberOf is empty in OpenLDAP, and how to fix it](openldap-memberof-empty.md) — the memberof overlay, the cases it still gets wrong, and the reverse search that always works. +- [The LDAP pass-back attack, and how to defend a web console](ldap-pass-back-attack.md) + — how a stored bind password gets stolen by repointing the host, and the rule that stops it. diff --git a/docs/blog/ldap-pass-back-attack.md b/docs/blog/ldap-pass-back-attack.md new file mode 100644 index 0000000..ceb1fdb --- /dev/null +++ b/docs/blog/ldap-pass-back-attack.md @@ -0,0 +1,108 @@ +# The LDAP pass-back attack, and how to defend a web console + +Anything that connects to a directory on your behalf — a multifunction printer, a NAS, a web +console — stores a bind account: a username and password it uses to talk to LDAP. The **LDAP +pass-back attack** turns that stored credential against you. You don't steal the password; you make +the device hand it to *you*. + +It's most famous on office printers, where it's almost trivial. It applies, unchanged, to any web +application that stores a directory connection — including ones you build. Here's how it works and +the one rule that stops it. + +## The classic: a printer with an LDAP address book + +A networked printer with "scan to email" usually has an LDAP address book. In its admin page you +configure an LDAP server, a bind DN, and a bind password so it can look up addresses. The password +field, naturally, never shows you the stored value. + +The attack: open that admin page (printers are often unauthenticated or use a default password), +change the **LDAP server address** to a machine you control, and leave everything else. Then trigger +an address-book lookup. The printer connects to *your* machine and performs a **simple bind** — it +sends the stored bind DN and password, in the clear, to the host you just named. You run a throwaway +LDAP listener and read the credentials off the wire. + +The password you never had access to just walked over to you, because the device will bind to +whatever host it's told, with whatever it has stored. + +## The same attack on a web console + +A web console that manages AD or OpenLDAP stores the same thing: directory host, port, encryption +mode, a service account DN and its password. The password is encrypted at rest and the UI never +returns it. It feels safe. + +It isn't, if the console lets someone **change the host while keeping the stored password.** Point +the connection at an attacker-controlled server, leave the password untouched, trigger any operation +that connects, and the server performs a simple bind to the new address — sending the service +account's password exactly as the printer did. + +"But only an administrator can edit connections." True, and it still matters, for two reasons: + +- **A stolen session shouldn't become permanent access.** An admin's token might live an hour; the + service account's password lives for years and usually has *more* rights than the admin. Turning a + one-hour token into the directory's long-lived credential is a big escalation. +- **Least privilege.** Not every administrator of the app should be able to exfiltrate the bind + account that reaches the whole directory. + +## The two variants to close + +There are two ways to make the password leave, and a defense has to cover both: + +1. **A new endpoint.** The connection is about to reach a host (or port, primary or failover) it has + never reached before. That's the printer attack. +2. **Dropping encryption.** The host stays the same, but TLS is turned off. Now the next bind sends + the password in plain text across the network, readable by anyone able to watch it. Same outcome, + different door. + +## The rule that stops it + +State it plainly: **if a connection is about to go somewhere it has never been, or stop being +encrypted, the bind password has to be entered again — and only someone who already knows it can +enter it.** + +In other words, keeping the stored password is allowed only when the destination and the protection +are unchanged. The moment either changes, the stored secret can't be reused; whoever makes the +change has to supply the password, which an attacker repointing the host doesn't have. In code it's +two checks before saving a connection: + +```text +keepsStoredPassword = (no new password supplied) AND (a password is stored) + +if keepsStoredPassword AND wouldReachNewEndpoint(host, port, failoverHosts): + reject — "enter the bind password to point this at a new server" + +if keepsStoredPassword AND wouldDropEncryption(security): + reject — "enter the bind password to send it over an unencrypted connection" +``` + +Removing a failover host or reordering hosts needs no password — nothing new is reached. Adding any +host or port that wasn't there before does. + +## Defending the printers, too + +The console rule is for software you build or choose. For the devices already on your network: + +- Give each device a **dedicated, least-privilege bind account** — read-only, scoped to what it + needs, never a domain admin. If it leaks, the blast radius is small. +- Require **LDAPS or StartTLS** and disable simple bind over plain LDAP, so a lookup can't be + down-graded to cleartext. +- **Lock the device admin interface:** change default passwords, put management on a segment users + can't reach, and don't expose it. +- Prefer directories that support **channel binding and signing** so a bind can't be relayed as + easily. + +The through-line: a stored bind credential is only as safe as your ability to stop the thing holding +it from binding somewhere new. + +## Doing it once, correctly + +[Nelsar.Axia](https://github.com/Nelsar/Axia) (the project this blog belongs to) enforces exactly +the rule above: a directory connection that would reach a new host or port, or drop its encryption, +can't keep its stored bind password — you have to re-enter it, which only someone who knows it can +do. The password is stored with AES-GCM, never returned by the API, and the change is written to the +audit log. See [how Axia connects to directories](../directories.md) and +[its access control](../access-control.md). + +--- + +Nelsar.Axia is an open-source (Apache-2.0) web interface for Active Directory, Samba AD and +OpenLDAP. You can [try it with one Docker command](../installation.md). From b56f4fed3d465bf47cbdd41288893c66d6c0c44e Mon Sep 17 00:00:00 2001 From: Nurlan Turkpenbay <51225765+n-turkpenbay@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:13:28 +0500 Subject: [PATCH 4/5] Add the seventh blog post: adminCount and SDProp --- docs/_config.yml | 9 ++ .../admincount-sdprop-admin-not-protected.md | 116 ++++++++++++++++++ docs/blog/index.md | 2 + 3 files changed, 127 insertions(+) create mode 100644 docs/blog/admincount-sdprop-admin-not-protected.md diff --git a/docs/_config.yml b/docs/_config.yml index cf1bfe1..ce87710 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -207,3 +207,12 @@ defaults: The LDAP pass-back attack explained — how a printer or web console can be made to hand over its stored bind password — and the rule that stops it: re-enter the password when the endpoint or encryption changes. + - scope: + path: blog/admincount-sdprop-admin-not-protected.md + values: + parent: Blog + nav_order: 7 + description: >- + What adminCount and SDProp really do, the three ways relying on adminCount leaves admins + unprotected (the hourly gap, orphaned values, and Samba never setting it), and resolving + protected groups by SID instead. diff --git a/docs/blog/admincount-sdprop-admin-not-protected.md b/docs/blog/admincount-sdprop-admin-not-protected.md new file mode 100644 index 0000000..ec8ab50 --- /dev/null +++ b/docs/blog/admincount-sdprop-admin-not-protected.md @@ -0,0 +1,116 @@ +# adminCount and SDProp: why your newly promoted admin isn't protected + +If you manage Active Directory, you've seen the `adminCount` attribute: `1` on your privileged +accounts, absent on everyone else. It's tempting to treat it as *the* flag for "this is an admin, +handle with care" — scripts filter on it, tools use it to decide who's protected. That shortcut has +three failure modes, and each one is a real security gap. Here's what `adminCount` actually means, +where it lies to you, and what to use instead. + +## What sets adminCount + +`adminCount` is not something you set. It's stamped by **SDProp** (the Security Descriptor +Propagator), a process on the domain controller holding the PDC emulator role. SDProp takes the ACL +on a special container, **AdminSDHolder**, and copies it onto every member of the **protected +groups** — Domain Admins, Enterprise Admins, Schema Admins, Administrators, Account Operators, +Backup Operators, Server Operators, Print Operators, and a few more. On each such account it: + +- disables inheritance and overwrites the ACL with AdminSDHolder's, and +- sets `adminCount = 1`. + +This is what stops a delegated "helpdesk can reset passwords in this OU" permission from applying to +a Domain Admin: SDProp keeps re-stamping the privileged accounts with a locked-down ACL. Useful. +The trouble starts when anything treats `adminCount` as a reliable, live answer to "is this account +privileged?" + +## Failure 1: the up-to-an-hour gap + +SDProp doesn't run continuously. By default it runs **every 60 minutes**. So when you add someone to +Domain Admins, they are *not* marked for up to an hour. For that window: + +- their `adminCount` is absent, and +- their ACL still carries whatever inherited and delegated permissions they had. + +Any tool that decides "is this a protected admin?" by reading `adminCount` will treat a brand-new +Domain Admin as an ordinary user for that hour — and let anyone with a password-reset delegation +reset their password. The most dangerous account in the domain is the least protected, exactly when +it's just been created. + +## Failure 2: adminCount is sticky, so it also lies the other way + +SDProp sets `adminCount = 1`. It does **not** clear it. Remove someone from Domain Admins and their +`adminCount` stays `1`, and their inheritance stays disabled, forever — an **orphaned adminCount**. + +So the attribute gives you false positives as well as false negatives: + +- an account flagged `adminCount=1` may not be an admin at all anymore, and +- its broken inheritance quietly causes "why doesn't this user get the permissions everyone else in + the OU gets?" tickets months later. + +Treating `adminCount=1` as "currently privileged" is wrong in both directions: it misses new admins +and over-counts old ones. + +## Failure 3: Samba AD never sets it at all + +If you run **Samba AD**, this gets worse. Samba does not run SDProp, and never stamps `adminCount` +on an administrator created after the domain was provisioned +([Samba bug 13954](https://bugzilla.samba.org/show_bug.cgi?id=13954)). So on Samba, a tool that +keys protection on `adminCount` protects *almost nobody*: your admins are simply never marked. The +hour-long gap becomes permanent. + +## The right way: resolve protected groups by SID + +The reliable question isn't "does this account have `adminCount=1`?" It's "**is this account a +member of a protected group, right now?**" — answered against the directory, not an attribute it +may or may not have stamped. + +- Identify the protected groups by their **well-known SID**, not by name (names are localized and + renameable): Administrators `S-1-5-32-544`, and the domain groups by RID — Domain Admins `…-512`, + Enterprise Admins `…-519`, Schema Admins `…-518`, and so on. +- Resolve membership through the **whole chain the directory reports**: direct members, groups + nested inside protected groups, and members by **primary group** (a user whose primary group is + Domain Admins has no `member` entry — `primaryGroupID` is how that's expressed). + +That answer is live, correct after a removal, and works identically on Windows AD and Samba AD, +because it asks the directory what is true now instead of reading a cache SDProp may not have +written. + +## Check your own directory + +Find everything currently flagged, and the orphans among them: + +```powershell +# Accounts marked by SDProp at some point +Get-ADObject -LDAPFilter "(adminCount=1)" -Properties adminCount, memberOf + +# Orphaned: adminCount=1 but not actually in a protected group anymore +# (review these — their inheritance is disabled and may need fixing) +``` + +With raw LDAP: + +```text +# Flagged accounts +(adminCount=1) + +# Members of Domain Admins by SID, resolved through nesting (AD extensible match) +(memberOf:1.2.840.113556.1.4.1941:=) +``` + +The `1.2.840.113556.1.4.1941` matching rule (LDAP_MATCHING_RULE_IN_CHAIN) is AD's way to walk the +nested membership chain in one query — the live answer `adminCount` only approximates. + +## Doing it once, correctly + +[Nelsar.Axia](https://github.com/Nelsar/Axia) (the project this blog belongs to) does not trust +`adminCount`. It finds the protected groups by their well-known SID and resolves their members — +nested and by primary group — by asking the directory, so a freshly promoted admin is protected +immediately, a removed one isn't a false positive, and the same logic holds on Samba AD where +`adminCount` is never set. That's what lets it guarantee a helpdesk role can't reset an +administrator's password; see [how it protects administrators](../access-control.md) and +[the Samba specifics](../directories.md#samba-ad). + +--- + +Nelsar.Axia is an open-source (Apache-2.0) web interface for Active Directory, Samba AD and +OpenLDAP. You can [try it with one Docker command](../installation.md) and test the admin-protection +rule for yourself. diff --git a/docs/blog/index.md b/docs/blog/index.md index dfabe39..fb0d593 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -16,3 +16,5 @@ stands on its own; Axia shows up only where it is the honest answer. — the memberof overlay, the cases it still gets wrong, and the reverse search that always works. - [The LDAP pass-back attack, and how to defend a web console](ldap-pass-back-attack.md) — how a stored bind password gets stolen by repointing the host, and the rule that stops it. +- [adminCount and SDProp: why your newly promoted admin isn't protected](admincount-sdprop-admin-not-protected.md) + — what adminCount really means, the three ways it misleads, and resolving protection by SID. From e0704504da4694d3c3b11b166242dae0d0e42b4a Mon Sep 17 00:00:00 2001 From: Nurlan Turkpenbay <51225765+n-turkpenbay@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:21:28 +0500 Subject: [PATCH 5/5] Add the eighth blog post: LDAP authentication done right --- docs/_config.yml | 9 ++ docs/blog/index.md | 2 + docs/blog/ldap-authentication-done-right.md | 103 ++++++++++++++++++++ 3 files changed, 114 insertions(+) create mode 100644 docs/blog/ldap-authentication-done-right.md diff --git a/docs/_config.yml b/docs/_config.yml index ce87710..676cd0e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -216,3 +216,12 @@ defaults: What adminCount and SDProp really do, the three ways relying on adminCount leaves admins unprotected (the hourly gap, orphaned values, and Samba never setting it), and resolving protected groups by SID instead. + - scope: + path: blog/ldap-authentication-done-right.md + values: + parent: Blog + nav_order: 8 + description: >- + The traps in authenticating users against Active Directory and LDAP — search-then-bind, + the empty-password bypass, TLS, authorization from groups, re-checking the session, and + brute-force limits — and when to use OIDC instead. diff --git a/docs/blog/index.md b/docs/blog/index.md index fb0d593..3efc563 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -18,3 +18,5 @@ stands on its own; Axia shows up only where it is the honest answer. — how a stored bind password gets stolen by repointing the host, and the rule that stops it. - [adminCount and SDProp: why your newly promoted admin isn't protected](admincount-sdprop-admin-not-protected.md) — what adminCount really means, the three ways it misleads, and resolving protection by SID. +- [LDAP authentication done right: the traps people hit](ldap-authentication-done-right.md) + — search-then-bind, the empty-password bypass, authorization from groups, and when to use OIDC. diff --git a/docs/blog/ldap-authentication-done-right.md b/docs/blog/ldap-authentication-done-right.md new file mode 100644 index 0000000..94c4868 --- /dev/null +++ b/docs/blog/ldap-authentication-done-right.md @@ -0,0 +1,103 @@ +# LDAP authentication done right: the traps people hit + +Authenticating users against Active Directory or LDAP looks like a solved problem — bind with their +username and password, done. It is solved, and there are about half a dozen ways to get it subtly +wrong, two of which are outright authentication bypasses. This is the list of traps, drawn from +building sign-in for a directory console that has to get it right every time. + +## Trap 1: building the DN instead of searching for it + +The tempting shortcut is to turn the login into a DN directly — +`uid=,ou=people,dc=example,dc=org` — and bind with that. It breaks the moment users live in +more than one OU, and it couples your app to the directory's layout. + +The correct pattern is **search-then-bind**: + +1. Bind with a low-privilege **service account** and **search** for the user by their login, with a + configured filter such as `(&(objectClass=person)(uid={0}))` (or `sAMAccountName` on AD). +2. Take the **DN** from the single result and **bind again** as that DN with the user's password. + +The search is what maps a human-typed login to the real entry, wherever it sits. Two guards on the +filter: it must return **exactly one** entry (zero or many → refuse), and the login you substitute +must be **escaped** (RFC 4515) so a `*` or `(` can't turn the filter into "match anyone." + +## Trap 2: the empty password that logs everyone in + +This one is an authentication bypass, and it's easy to ship. + +In LDAP, a bind request with a DN but an **empty password** is an *unauthenticated bind* (RFC 4513). +Many servers accept it — as an **anonymous** session, which succeeds. So if your code does +"bind as the user's DN with whatever they typed" and the user (or an attacker) submits an **empty +password**, the bind can **succeed**, and naïve code reads that as "authenticated." + +The fix is a one-liner you must not skip: **reject an empty password before you ever call bind.** +Don't rely on the directory to fail it — some won't. + +## Trap 3: sending the password in the clear + +The user's password travels inside the bind request. Over plain LDAP, anyone on the path reads it. +**Require LDAPS or StartTLS** for the whole exchange — the service-account search *and* the user +bind — and verify the server certificate. "We're on the internal network" is not an answer; internal +is where credential sniffing happens. + +## Trap 4: authenticating without authorizing + +Authentication says *who* someone is. It does not say what they may do. The common mistake is to +treat "the bind succeeded" as "this person is allowed in," when the real question is "is this person +in the group that grants access?" + +So resolve **group membership** after authentication and check it. And here a directory gotcha bites: +on OpenLDAP, the user's `memberOf` is often empty, so a membership check against it silently passes +*no one* — or, worse, if you invert the logic, everyone. Resolve groups by searching from the group +side instead; see [why memberOf is empty in OpenLDAP](openldap-memberof-empty.md). Base authorization +on group membership you actually resolved, not on an attribute that may not be maintained. + +## Trap 5: the session outlives the directory + +You authenticate once and issue a token — a cookie, a JWT — good for, say, 60 minutes. But directory +state changes *during* that hour: someone is disabled, or removed from the group that let them in. If +the token alone decides access, a dismissed employee keeps working until it expires. + +So **re-check against the directory on a short interval** (a cached 30-second check is a good +balance): is the account still enabled, still in the right groups? Note that "disabled" is spelled +differently in each directory — the `ACCOUNTDISABLE` bit in `userAccountControl` or an expired +`accountExpires` on AD, `pwdAccountLockedTime` with OpenLDAP's ppolicy, `loginDisabled` on eDirectory +— so check the right one for the directory you're on. + +## Trap 6: brute force, and the lockout DoS + +Password sign-in invites guessing, and the obvious defences each have a twist: + +- A **per-IP** rate limit does little against guessing spread over many addresses, and every attempt + you forward to the directory is a real bind that pushes the account toward the directory's own + lockout. So add a **per-account** limit *below* the directory's lockout threshold (say 5 failures + in 15 minutes), and refuse further attempts before they reach the directory. Then guessing through + your app can't lock the account out in the directory itself. +- But a per-account limit is itself a small **denial-of-service**: an attacker can keep someone from + signing in by burning their attempts. Keep it bounded (a short window, your app only, no directory + lockout, live sessions untouched), and return the **same response** for a wrong password and a + non-existent user, so the limit doesn't become a way to enumerate who exists. + +## When not to authenticate with a password at all + +The safest password flow is the one you don't run. If you can, delegate sign-in to an identity +provider over **OpenID Connect** — Entra ID, AD FS, Keycloak — and let it handle the password, +MFA and conditional access. Your app stops receiving operators' passwords altogether, and you still +take **authorization** from directory groups. Match the user by a stable identifier the directory +holds (`objectSid`, `objectGUID`, Keycloak's `LDAP_ID`), never by email or name, which can change or +be reassigned. A directory that uses SSO can then switch password sign-in off entirely — a second +factor the password form goes around protects nothing. + +## Doing it once, correctly + +[Nelsar.Axia](https://github.com/Nelsar/Axia) (the project this blog belongs to) authenticates its +operators this way: service-account search-then-bind, empty passwords refused before the bind, TLS +required, authorization from directory groups resolved rather than trusted from `memberOf`, a +30-second re-check that ends a session when the account is disabled, and a per-account sign-in limit +kept below the directory's own. OpenID Connect sign-in is on the [roadmap](https://github.com/Nelsar/Axia/blob/main/ROADMAP.md). +See [its access control](../access-control.md) for how roles come from groups. + +--- + +Nelsar.Axia is an open-source (Apache-2.0) web interface for Active Directory, Samba AD and +OpenLDAP. You can [try it with one Docker command](../installation.md).