If you see SID, tell him

As a small addendum to my previous blog on the subject of authenticating users, and checking for administrator privileges, under Windows 2000, XP and Vista, I should add this little note. It turns out, thanks to the sort of heavily industrious testing that’s par for the course here at Red Gate, that LogonUser / SSPI has a habit under certain circumstances of accepting invalid logins. When a computer is not on a domain, but a workgroup, credentials validation seems to take a different route with respect to validating the existence of the domain to which the login belongs. Specifically, if it can find a local account matching the username and password from the (username,password,domain) tuple, then if not on a domain, the domain part is often summarily ignored and authentication says “yes, that’s fine, this user is acceptable”.

I suspect it does this for some good reason. Exactly what that reason is I couldn’t say, though I wouldn’t be surprised to find that it was something in the area of permitting users to easily access machines on a workgroup provided their usernames and passwords match.

This is all very fine and splendid until one comes to use the login for other purposes. The CreateService API, for example, doesn’t find it as amusing as the authentication APIs with regard to accepting invalid domains.

So in order to make the previous authentication code robust, we have to do some more legwork. After succeeding with the LogonUser / SSPI APIs, we need to manually verify the correctness of the domain before treating the user provided credentials as correct.

Once again this solution involved a pinch of Google and a dash of experimentation. On the way I learned a little more about SIDs, which I discussed previously. The string format of a SID, it turns out, is a very literal interpretation of the “black box” contents of the SID data structure. It’s described in some detail on MSDN, but briefly each SID is composed as follows:

S-<revision>-<identifier authority>-<first sub authority>-<second sub authority>[-<third sub authority> … ]

All SIDs to date are revision 1, so we always start “S-1-…”. The identifier authority tells us where the SID was originally issued, in broad terms. The subsequent sub authorities are also known as relative identifiers, or RIDs.

Now a user’s SID looks like the following:

S-1-5-xx-xxxxxxxxx-xxxxxxxxx-xxxxxxxxx-nnnn

S-1-5 means “SID version 1, issued by “NT Authority” (the originator of pretty much all user, computer and domain SIDs). It turns out that “xx-xxxxxxxxx-xxxxxxxxx-xxxxxxxxx” is the standard format for the RID of a computer or domain. “nnnn” is a number indicating which user we’re dealing with on that domain.

Domains and computers themselves have valid SIDs. This is understandable, given that a SID is pretty universal as the identifier used to identify something for security purposes. Can you guess what a computer or domain SID looks like?

S-1-5-xx-xxxxxxxxx-xxxxxxxxx-xxxxxxxxx

That’s right – we just lop the last sub-authority off the end.

What are the magic numbers in the “xx-xxxxxxxxx-….” portion? Well, they’re unique to the computer or domain in question. A computer’s SID is generated when Windows is installed, and stay the same for its lifetime. (This has actually led to issues in companies which literally clone machines for deployment purposes; they’d end up with identical SIDs, leading to confusion between users and other fun. SysInternals provide an handy utility to change a computer’s SID in this sort of situation.)  A domain’s SID is generated when the domain is set up. I suspect that it is the computer SID of the first (chronologically) domain controller on the domain (the primary domain controller in pre Windows 2000 terms, before domain controllers started taking joint and several liability for domain security, particularly assigning SIDs) but I haven’t got any evidence for that.  Certainly, in the good old days, the primary and backup domain controllers had to have the same computer ID, which would seem to fit.

The upshot of this discussion is that once a user is logged in it’s quite easy to determine whether the account in question is a user account or a domain account, provided one has the SIDs for the domain or computer. One simply lops off the last “-nnnn” portion of the SID, and then can compare SIDs either the nice way via the EqualSID() API or, if you’re feeling hacky, by strcmp’ing the SID strings.

When looking at this problem initially, my plan was to do just that. Initially I couldn’t find an easy way to get hold of a computer or domain SID. I then tripped over the psgetsid tool from SysInternals.

This tool is capable of converting account, machine and Windows domain names to SIDs, and SIDs to names, on local or remote computers. A quick dumpbin revealed how it does it:

C:> vcvars32.bat
Setting environment for using Microsoft Visual Studio 2005 x86 tools. 

C:> dumpbin d:datasysinternalspsgetsid.exe /imports | more
Microsoft (R) COFF/PE Dumper Version 8.00.50727.762
Copyright (C) Microsoft Corporation.  All rights reserved.

Dump of file d:datasysinternalspsgetsid.exe

File Type: EXECUTABLE IMAGE

  Section contains the following imports:

  …

    ADVAPI32.dll
                40A000 Import Address Table
                40A7C4 Import Name Table
                     0 time date stamp
                     0 Index of first forwarder reference

                  140 IsValidSid
                  116 GetSidIdentifierAuthority
                  119 GetSidSubAuthorityCount
                  118 GetSidSubAuthority
                   1D AllocateAndInitializeSid
                   AF DeleteService
                   42 ControlService
                  1AD OpenSCManagerA
                  1AF OpenServiceA
                  249 StartServiceA
                  1C3 QueryServiceStatus
                   64 CreateServiceA
                   3E CloseServiceHandle
                  149 LookupAccountSidA
                  147 LookupAccountNameA

LookupAccountSid and LookupAccountName are the important APIs here. LookupAccountSid takes a SID and returns its name. LookupAccountName takes a name and returns its SID. In both cases invalid names/SIDs are picked up.

This last property made my job even simpler. All I need to know is whether the domain part of the user supplied (username,password,domain) is in some sense valid. So all I need to do is ti call LookupAccountName to fetch the SID for that domain or computer. If it fails, then I reject the login.

To get this to work, I added the following method to my security classes (adapted, as always, from the internet via cut and paste, followed by some tidying and/or bug fixing):

        public static void GetSidForAccountOrDomain(string strAccountName,
             out string accountSid, out string strDomainName,
             out short AccountType)
        {
            int lSidSize;
            int lDomainNameSize;
            IntPtr Sid = IntPtr.Zero;
            string strServer = null;

            // First get the required buffer sizes for SID and domain name.
            if (!NativeSecurityApis.LookupAccountName(
                                strServer,
                                strAccountName,
                                Sid,
                                ref lSidSize,
                                null,
                                ref lDomainNameSize,
                                ref AccountType))
            {
                if (Marshal.GetLastWin32Error() == NativeSecurityApis.ERROR_INSUFFICIENT_BUFFER)
                {
                    // Allocate the buffers with actual sizes that are required
                    // for SID and domain name.
                    strName = new StringBuilder(lDomainNameSize);
                    Sid = Marshal.AllocHGlobal(lSidSize);
                    if (!NativeSecurityApis.LookupAccountName(
                              strServer,
                              strAccountName,
                              Sid,
                              ref lSidSize,
                              strName,
                              ref lDomainNameSize,
                              ref AccountType))
                        throw new Win32Exception(); // last error
                }
                else
                    throw new Win32Exception(); // last error
            }
            else
                throw new InvalidOperationException(“Expected LookupAccountName to fail given no buffers”); // shouldn’t get here

            strDomainName = strName.ToString();

            IntPtr pString;
            if (!NativeSecurityApis.ConvertSidToStringSid(Sid, out pString))
                throw new Win32Exception(); // last error

            accountSid = Marshal.PtrToStringAuto(pString);
            NativeSecurityApis.LocalFree(pString);

            //Console.WriteLine(“Domain Name: {0}”, strDomainName);
            //Console.WriteLine(“Account Sid: {0}”, sidText);
            Marshal.FreeHGlobal(Sid);
        }
    }

Then, a more than elementary wrapper to ensure that a given domain is valid:

        private static void VerifyDomain(IntPtr hToken, string domain)
        {
            if (string.IsNullOrEmpty(domain) || domain == “.”)
                return; // don’t attempt to verify null or local (BUILTIN.) domain

            // attempt to look up the SID of the “domain”, be it an actual domain
            // or a computer. If we succeed, then it’s valid and LogonUser() should
            // pick
            try
            {
                string domainSid;
                string canonicalDomainName;
                short accountType;
                GetSidForAccountOrDomain(domain, out domainSid, out canonicalDomainName, out accountType);
            }
            catch (Exception ex)
            {
                throw new LogonException(string.Format(“Cannot verify domain {0} is valid.”, domain), ex);
            }
        }

 Then I simply modified the IsUserAdmin() function described in my previous entry, so it now reads as follows:

        public static bool IsUserAdmin(string userName, string domain, string password)
        {
            IntPtr hToken;
            if (NativeSecurityApis.LogonUser(userName, domain, password, NativeSecurityApis.LOGON32_LOGON_INTERACTIVE, NativeSecurityApis.LOGON32_PROVIDER_DEFAULT, out hToken))
            {
                try
                {
                    VerifyDomain(hToken, domain);

                    return IsUserAdmin(hToken);
                }
                finally
                {
                    NativeSecurityApis.CloseHandle(hToken);
                }
            }
            else
            {
                try
                {
                    WindowsPrincipal principal = Win32SSPI.LogonUser(userName, domain, password);

                    IntPtr hPrincipalToken = (principal.Identity as WindowsIdentity).Token;
                    VerifyDomain(hPrincipalToken, domain);

                    return IsUserAdmin(principal);
                }
                catch (Exception ex)
                {
                    throw new LogonException(ex.Message, ex);
                }
            }
        }
 

So that was pretty straightforward. At least, relative to the prior steps I describe in my previous blog entries. So far the C# code to authenticate a user, and check that they are an administrator, is still sub 1000 lines including whitespace, comments and API import definitions. Cheap at the price?