diff --git a/docs/_config.yml b/docs/_config.yml index 32b6ba5..676cd0e 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -180,3 +180,48 @@ 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. + - 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. + - 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. + - 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. + - 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/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 e2f657d..3efc563 100644 --- a/docs/blog/index.md +++ b/docs/blog/index.md @@ -10,3 +10,13 @@ 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. +- [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. +- [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). 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). 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. 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.