Friday, June 18, 2021

PrincipalContext::ValidateCredentials doesn't always work like you think it should (false positives!)

We've recently stumbled upon a nasty issue with PrincipalContext::ValidateCredentials, the one you could use to authenticate users against given Active Directory LDAP server. The library method is often referred to and there are tons of references to use it just like
var principalContext = new PrincipalContext(ContextType.Domain, domain);
principalContext.ValidateCredentials(username, password);
And this is where the problem is.
One of our Clients pointed us to an edge case where somehow expired passwords were accepted. We confirmed that passwords are expired for sure (some passwords were expired for over a year!). They had another service that uses the LDAP authentication and the two services were uneven: the other service was rejecting user credentials, our was happily accepting users. The other service doesn't use .NET's built in method of course.
What we have found is a complaint of someone who posted in 2016 their own issue where for some reason expired passwords were accepted over LDAPS. The post says
I am using PrincipalContext.ValidateCredentials(string, string) to validate user credentials. I am encountering behaviour that I am unable to explain and have, so far, been unable to clarify through usual searches and forum visits. Here is the behaviour, the credentials can be assumed to be correct on each call. The non-SSL behaviour is what I would expect. The SSL behaviour is not as expected in my understanding of the method.
Not using SSL/TLS

User is enabled and NOT expired - method returns TRUE
User is disabled and NOT expired - method returns FALSE
User is enabled and EXPIRED - method returns FALSE
User is disabled and EXPIRED - Method returns FALSE
Using SSL/TLS

User is enabled and NOT expired - method returns TRUE
User is disabled and NOT expired - method returns FALSE
User is enabled and EXPIRED - method returns TRUE
User is disabled and EXPIRED - Method returns FALSE
To summarise - when both machines in the negotiation (app server and domain controller) have the required certs installed, they will use SSL (port 636). In these circumstances the method returns TRUE for Expired accounts but when not using SSL (port 389) (for example the app server does not have the cert installed) the method is returning FALSE for Expired accounts. I would expect the return value to be the same for expired accounts in both scenarios, but it's possible I'm not considering something.
This looked like the case, yes, we've turned on SSL over LDAP for this particular installation. Yes, we also see that expired passwords are accepted.
Unfortunately, the post author is asked to create an entry on Connect and as we already know, Connect has been closed. No solution then.
But what ValidateCredentials has to do with LDAPS? Well, we've found another clue. Someone at StackOverflow describes how ValidateCredentials works internally:
Here's how ValidateCredentials(string, string) works: First, it tries to authenticate with the Negotiate, Signing, and Sealing context options. If this fails, it tries again with SimpleBind and SecureSocketLayer.
Ha! There goes the neighbourhood. When ValidateCredentials fails on Kerberos (it fails because the password is expired), it falls back to SimpleBind (basic auth) over SSL (which succeeds!).
Turning LDAPS off is not an option here but surely this is an option: instead of
var principalContext = new PrincipalContext(ContextType.Domain, domain);
principalContext.ValidateCredentials(username, password);
just make sure you call it
var principalContext = new PrincipalContext(ContextType.Domain, domain, ContextOptions.Negotiate | ContextOptions.Sealing | ContextOptions.Signing);
principalContext.ValidateCredentials(username, password);
This makes sure there's no SimpleBind+SSL fallback as it forces Kerberos only. And guess what? It just works correctly. Expired passwords are no longer accepted.