Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/_config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
116 changes: 116 additions & 0 deletions docs/blog/admincount-sdprop-admin-not-protected.md
Original file line number Diff line number Diff line change
@@ -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:=<DN of the protected group>)
```

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.
10 changes: 10 additions & 0 deletions docs/blog/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
103 changes: 103 additions & 0 deletions docs/blog/ldap-authentication-done-right.md
Original file line number Diff line number Diff line change
@@ -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=<login>,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).
Loading
Loading