[Auth] User-bind mode does not check the bound account is the one whose groups it returns (privilege escalation) #40

Closed
opened 2026-11-09 14:39:22 -05:00 by dohertj2 · 1 comment
Owner

From the scadaproj#15 shared-library review (code-reviews/Auth/findings.md, Auth-007), verified at 91fb2b4. Update the finding's Resolution row when fixed.

Severity Medium
Priority P2
Category Security & secret handling
Status Open
Location ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs:329

Description

CompleteAsUser authenticates by binding as UserBindTemplate.Replace("{0}", username) (line 329–332). It then
runs a separate search, (<UserNameAttribute>=<username>) (line 345 → SearchUser, line 361), and returns the
groups and username of whatever single entry that search finds (line 349 → SucceedFromEntry). Two different
directory lookups resolve the bind name and the search filter. Nothing checks that the entry found is the account
the directory just authenticated. There is no comparison of DNs, no RFC 4532 "Who am I?", and no search keyed on
the bind identity. The XML doc and README describe the UPN mismatch as guidance only ("a mismatch can … bind a
different account than the one whose groups are read"). The validator's failure message
(LdapOptionsValidator.cs:77) offers "{0}@example.com" as its first example template. A DN-form template is also
spliced in unescaped (documented), which is another way for the bind identity and the search key to diverge.

Failure scenario

BindMode=User with UserBindTemplate="{0}@corp.com" and UserNameAttribute=sAMAccountName on AD, a combination
the validator message suggests. Mallory's account has explicit UPN jsmith@corp.com and sAMAccountName=jsmith2,
which is common after a rename or when two people share a name. A separate admin account has sAMAccountName=jsmith.
Mallory signs in as jsmith with her own password:

  1. AD resolves the UPN bind to Mallory's account and accepts it.
  2. The search (sAMAccountName=jsmith) returns the admin's entry.
  3. LdapAuthResult.Success("jsmith", "Admin Smith", ["UG_Admins"]) is returned.

That is privilege escalation with valid credentials of a different user. I reproduced this through the
FakeLdapConnection seam: the bind went to jsmith@corp.test and the result carried the other entry's groups.

Recommendation

After the bind, identify the entry by the bound identity, not the typed name:

  • UPN templates: search on userPrincipalName=<bindName>.
  • Any template: use the RFC 4532 Who-am-I extended operation (AD returns u:DOMAIN\sam) and check it against
    the found entry's sAMAccountName.
  • DN templates: require the found entry's DN to equal the bind DN, and DN-escape {0} (RFC 4514).

Fail with GroupLookupFailed on a mismatch. At minimum, reorder the validator example to lead with
EXAMPLE\{0}. Add a test where the bind name and the searched entry differ.

From the scadaproj#15 shared-library review (`code-reviews/Auth/findings.md`, **Auth-007**), verified at 91fb2b4. Update the finding's Resolution row when fixed. | | | |--|--| | Severity | Medium | | Priority | P2 | | Category | Security & secret handling | | Status | Open | | Location | `ZB.MOM.WW.Auth/src/ZB.MOM.WW.Auth.Ldap/LdapAuthService.cs:329` | **Description** `CompleteAsUser` authenticates by binding as `UserBindTemplate.Replace("{0}", username)` (line 329–332). It then runs a separate search, `(<UserNameAttribute>=<username>)` (line 345 → `SearchUser`, line 361), and returns the groups and username of whatever single entry that search finds (line 349 → `SucceedFromEntry`). Two different directory lookups resolve the bind name and the search filter. Nothing checks that the entry found is the account the directory just authenticated. There is no comparison of DNs, no RFC 4532 "Who am I?", and no search keyed on the bind identity. The XML doc and README describe the UPN mismatch as guidance only ("a mismatch can … bind a different account than the one whose groups are read"). The validator's failure message (`LdapOptionsValidator.cs:77`) offers `"{0}@example.com"` as its first example template. A DN-form template is also spliced in unescaped (documented), which is another way for the bind identity and the search key to diverge. **Failure scenario** `BindMode=User` with `UserBindTemplate="{0}@corp.com"` and `UserNameAttribute=sAMAccountName` on AD, a combination the validator message suggests. Mallory's account has explicit UPN `jsmith@corp.com` and `sAMAccountName=jsmith2`, which is common after a rename or when two people share a name. A separate admin account has `sAMAccountName=jsmith`. Mallory signs in as `jsmith` with her own password: 1. AD resolves the UPN bind to Mallory's account and accepts it. 2. The search `(sAMAccountName=jsmith)` returns the admin's entry. 3. `LdapAuthResult.Success("jsmith", "Admin Smith", ["UG_Admins"])` is returned. That is privilege escalation with valid credentials of a different user. I reproduced this through the `FakeLdapConnection` seam: the bind went to `jsmith@corp.test` and the result carried the other entry's groups. **Recommendation** After the bind, identify the entry by the bound identity, not the typed name: - **UPN templates:** search on `userPrincipalName=<bindName>`. - **Any template:** use the RFC 4532 Who-am-I extended operation (AD returns `u:DOMAIN\sam`) and check it against the found entry's `sAMAccountName`. - **DN templates:** require the found entry's DN to equal the bind DN, and DN-escape `{0}` (RFC 4514). Fail with `GroupLookupFailed` on a mismatch. At minimum, reorder the validator example to lead with `EXAMPLE\{0}`. Add a test where the bind name and the searched entry differ.
dohertj2 added the type:review-findingpriority:p2 labels 2026-11-09 14:39:22 -05:00
Author
Owner

Fixed on main d549a78 (Auth-007 → Resolved), Auth 0.4.4 unpublished. Reproduced live on a throwaway GLAuth (config committed under tests/.../Integration/glauth-userbind/): mallory logged in with her own password and got boss's Admins group. Fix: after the bind, LDAP 'Who am I?' (RFC 4532) identifies the bound account; the looked-up entry must be that account (DN or sAMAccountName match). If the server can't answer, only a DN template counts as proof (bind DN must equal the found entry's DN); otherwise GroupLookupFailed. {0} is now DN-escaped in DN templates; validator message leads with EXAMPLE{0}. 11/13 new unit tests + the live mismatch test fail without the fix; live positive control passes. Consumer impact: AD down-level/UPN templates unchanged where UPN and sAMAccountName agree; a UPN template now refuses accounts where they differ; non-AD User mode needs an exact DN template (GLAuth: cn={0},ou=,ou=users,). Only User-mode consumer is BTDB Manager (AD). Evidence: LocalDb 236/236; Auth 344 passed / 78 skipped (live SQL/LDAP gated) after rebase; code-reviews --check consistent.

Fixed on main d549a78 (Auth-007 → Resolved), Auth **0.4.4 unpublished**. Reproduced live on a throwaway GLAuth (config committed under tests/.../Integration/glauth-userbind/): mallory logged in with her own password and got boss's Admins group. Fix: after the bind, LDAP 'Who am I?' (RFC 4532) identifies the bound account; the looked-up entry must be that account (DN or sAMAccountName match). If the server can't answer, only a DN template counts as proof (bind DN must equal the found entry's DN); otherwise GroupLookupFailed. {0} is now DN-escaped in DN templates; validator message leads with EXAMPLE\{0}. 11/13 new unit tests + the live mismatch test fail without the fix; live positive control passes. **Consumer impact:** AD down-level/UPN templates unchanged where UPN and sAMAccountName agree; a UPN template now refuses accounts where they differ; non-AD User mode needs an exact DN template (GLAuth: cn={0},ou=<group>,ou=users,<base>). Only User-mode consumer is BTDB Manager (AD). Evidence: LocalDb 236/236; Auth 344 passed / 78 skipped (live SQL/LDAP gated) after rebase; code-reviews --check consistent.
Sign in to join this conversation.