SecureDomainJoinDiagramA few months ago I was helping an organization to automate their OS deployment by using MDT (aka the Microsoft Deployment Toolkit). MDT is an excellent set of tools to automate desktop and server deployments and if you are an admin who needs to get rid of Windows XP as soon as (technically) possible then MDT is your starting point (did I mention that it’s free, too?).

One of the caveats we had to deal with was that the deployment media generated by MDT had to be given to external contractors (this customer had lots of office branches with 5-10 PCs in each one) and understandably we didn’t want to store any sensitive info in that deployment media. Normally what we would do, in order to perform an automatic Domain Join of the workstations after the automatic OS deployment, is to populate the relevant configuration file (Control\CustomSettings.ini in this case) with the proper credentials, meaning username, user domain and user password in clear text (by populating DomainAdmin, DomainAdminDomain and DomainAdminPassword respectively, yes I know that the name of the properties can be considered a little bit misleading).

To cut a long story short we decided that storing the credentials in clear text on an INI file wasn’t going to cut it. The other alternatives like using a temporary domain user account just for the duration of the deployment was also not an option. Some of the solutions to this problem involved “security through obscurity” tricks like encoding the password to Base64 format but then this could be easily exploited by using online Base64 decoders like this one. Creating something in PowerShell was certainly an option but then we would be introducing a new dependency.

So what can we do? Visual Studio to the rescue! Create an executable that does both the encryption/decryption of the password (using “secret” keys in the code to seed the clear text) + completes the Domain Join procedure:

  1. The Admin edits a configuration file to enter the user name and the user domain for the Domain Join operation.
  2. The Admin runs the executable with the user password as a command-line argument.
  3. The executable encrypts the password and stores the encrypted text on that same configuration file.
  4. The Admin can provide the executable and the configuration file to any third-parties needed to do Domain Join operations for that particular domain.
  5. The third-parties simply need to run the executable. Only prerequisite is the proper version of the .NET Framework.

Let’s get down to the specifics now, and this means (yes) looking at source code. Our little utility will be created in C# and will be a .NET console application. The source code is below:

using System;
using System.Data;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Management;
using System.Text;
using System.IO;
using System.Security.Cryptography;
using System.Windows.Forms;
using System.Reflection;
using System.Runtime.InteropServices;

namespace SecureDomainJoin
{
    class Program
    {
        static int Main(string[] args)
        {
            // String used to encrypt/decrypt the password string
            string encodingstring = "secretencodingstring";

            // Open the Configuration File (settings.txt)
            // It must be present on the same directory as the EXE
            var MyIni = new IniFile("settings.txt");

            // If we have a command-line argument then set the password to that value in settings.txt and exit
            if (args.Length == 1)
            {
                string encryptedpasswordstring = EncryptStringSample.StringCipher.Encrypt(args[0], encodingstring);
                MyIni.Write("JoinDomainUserPassword", encryptedpasswordstring);
                Environment.Exit(0);
            }

            string JoinDomainUserName = MyIni.Read("JoinDomainUserName");
            string DomainName = MyIni.Read("JoinDomain");
            int ComputerNameSubstringStart = Convert.ToInt16(MyIni.Read("ComputerNameStart")) - 1;
            int ComputerNameSubstringLength = Convert.ToInt16(MyIni.Read("ComputerNameEnd")) - 
               Convert.ToInt16(MyIni.Read("ComputerNameStart")) + 1;
            int OUSubstringStart = Convert.ToInt16(MyIni.Read("OUNameStart")) - 1;
            int OUSubstringLength = Convert.ToInt16(MyIni.Read("OUNameEnd")) - 
               Convert.ToInt16(MyIni.Read("OUNameStart")) + 1;

            // Decrypt the user password in order to read it
            var encryptedpassword = MyIni.Read("JoinDomainUserPassword");
            string JoinDomainUserPassword = EncryptStringSample.StringCipher.Decrypt(encryptedpassword, 
               encodingstring);

            // Read OUs for that domain from the .txt text file
            string[] OUs = File.ReadAllLines(DomainName + ".txt");

            // Determine the Branch Code
            if (System.Environment.MachineName.Length < 5)
            {
                Console.WriteLine("Error: Computer Name is less than 5 characters");
                Console.ReadLine();
                Environment.Exit(-1);
            }
            string BranchCode = System.Environment.MachineName.Substring(ComputerNameSubstringStart, 
               ComputerNameSubstringLength);
            string JoinOU = null;

            foreach (string OU in OUs)
            {
                if (OU.Substring(OUSubstringStart, OUSubstringLength) == BranchCode)
                {
                    JoinOU = OU;
                    break;
                }
            }

            // if a match is not found then use the first OU in the .txt file
            if (JoinOU == null) { JoinOU = OUs[0]; }

            // Method to join the domain
            // Taken from: http://stackoverflow.com/questions/10383281/rename-computer-and-join-domain-
            // with-one-reboot-in-c-sharp 
            // Define constants used in the method.
            int JOIN_DOMAIN = 1;
            int ACCT_CREATE = 2;
            int ACCT_DELETE = 4;
            int WIN9X_UPGRADE = 16;
            int DOMAIN_JOIN_IF_JOINED = 32;
            int JOIN_UNSECURE = 64;
            int MACHINE_PASSWORD_PASSED = 128;
            int DEFERRED_SPN_SET = 256;
            int INSTALL_INVOCATION = 262144;

            // Here we will set the parameters that we like using the logical OR operator.
            // If you want to create the account if it doesn't exist you should add " | ACCT_CREATE "
            // For more information see: http://msdn.microsoft.com/en-us/library/aa392154%28VS.85%29.aspx
            int parameters = JOIN_DOMAIN | DOMAIN_JOIN_IF_JOINED | ACCT_CREATE;

            // The arguments are passed as an array of string objects in a specific order
            object[] methodArgs = { DomainName, JoinDomainUserPassword, JoinDomainUserName + "@" + DomainName, 
               JoinOU, parameters };

            // Here we construct the ManagementObject and set Options
            ManagementObject computerSystem = new ManagementObject("Win32_ComputerSystem.Name='" + 
               Environment.MachineName + "'");
            computerSystem.Scope.Options.Authentication = System.Management.AuthenticationLevel.PacketPrivacy;
            computerSystem.Scope.Options.Impersonation = ImpersonationLevel.Impersonate;
            computerSystem.Scope.Options.EnablePrivileges = true;

            // Here we invoke the method JoinDomainOrWorkgroup passing the parameters as the second parameter
            object Oresult = computerSystem.InvokeMethod("JoinDomainOrWorkgroup", methodArgs);

            // The result is returned as an object of type int, so we need to cast.
            int result = (int)Convert.ToInt32(Oresult);

            // If the result is 0 then the computer is joined.
            if (result == 0)
            {
                //MessageBox.Show("Joined Successfully!");
                return result;
            }
            else
            {
                // Here are the list of possible errors
                string strErrorDescription = " ";
                switch (result)
                {
                    case 5: strErrorDescription = "Access is denied";
                        break;
                    case 87: strErrorDescription = "The parameter is incorrect";
                        break;
                    case 110: strErrorDescription = "The system cannot open the specified object";
                        break;
                    case 1323: strErrorDescription = "Unable to update the password";
                        break;
                    case 1326: strErrorDescription = "Logon failure: unknown username or bad password";
                        break;
                    case 1355: strErrorDescription = "The specified domain either does not exist or could not 
                       be contacted";
                        break;
                    case 2224: strErrorDescription = "The account already exists";
                        break;
                    case 2691: strErrorDescription = "The machine is already joined to the domain";
                        break;
                    case 2692: strErrorDescription = "The machine is not currently joined to a domain";
                        break;
                }
                Console.WriteLine(strErrorDescription.ToString());
                Console.ReadLine();
                return result;
            }
        }
    }
    class IniFile
    {
        string Path;
        string EXE = Assembly.GetExecutingAssembly().GetName().Name;

        [DllImport("kernel32")]
        static extern long WritePrivateProfileString(string Section, string Key, string Value, string FilePath);

        [DllImport("kernel32")]
        static extern int GetPrivateProfileString(string Section, string Key, string Default, StringBuilder RetVal, 
           int Size, string FilePath);

        public IniFile(string IniPath = null)
        {
            Path = new FileInfo(IniPath ?? EXE + ".ini").FullName.ToString();
        }

        public string Read(string Key, string Section = null)
        {
            var RetVal = new StringBuilder(1024);
            GetPrivateProfileString(Section ?? EXE, Key, "", RetVal, 1024, Path);
            return RetVal.ToString();
        }

        public void Write(string Key, string Value, string Section = null)
        {
            WritePrivateProfileString(Section ?? EXE, Key, Value, Path);
        }

        public void DeleteKey(string Key, string Section = null)
        {
            Write(Key, null, Section ?? EXE);
        }

        public void DeleteSection(string Section = null)
        {
            Write(null, null, Section ?? EXE);
        }

        public bool KeyExists(string Key, string Section = null)
        {
            return Read(Key, Section).Length > 0;
        }
    }
}

// Taken from: http://stackoverflow.com/questions/10168240/encrypting-decrypting-a-string-in-c-sharp
namespace EncryptStringSample
{
    public static class StringCipher
    {
        // This constant string is used as a "salt" value for the PasswordDeriveBytes function calls.
        // This size of the IV (in bytes) must = (keysize / 8).  Default keysize is 256, so the IV must be
        // 32 bytes long.  Using a 16 character string here gives us 32 bytes when converted to a byte array.
        private const string initVector = "kodikos2kodikos3";

        // This constant is used to determine the keysize of the encryption algorithm.
        private const int keysize = 256;

        public static string Encrypt(string plainText, string passPhrase)
        {
            byte[] initVectorBytes = Encoding.UTF8.GetBytes(initVector);
            byte[] plainTextBytes = Encoding.UTF8.GetBytes(plainText);
            PasswordDeriveBytes password = new PasswordDeriveBytes(passPhrase, null);
            byte[] keyBytes = password.GetBytes(keysize / 8);
            RijndaelManaged symmetricKey = new RijndaelManaged();
            symmetricKey.Mode = CipherMode.CBC;
            ICryptoTransform encryptor = symmetricKey.CreateEncryptor(keyBytes, initVectorBytes);
            MemoryStream memoryStream = new MemoryStream();
            CryptoStream cryptoStream = new CryptoStream(memoryStream, encryptor, CryptoStreamMode.Write);
            cryptoStream.Write(plainTextBytes, 0, plainTextBytes.Length);
            cryptoStream.FlushFinalBlock();
            byte[] cipherTextBytes = memoryStream.ToArray();
            memoryStream.Close();
            cryptoStream.Close();
            return Convert.ToBase64String(cipherTextBytes);
        }

        public static string Decrypt(string cipherText, string passPhrase)
        {
            byte[] initVectorBytes = Encoding.ASCII.GetBytes(initVector);
            byte[] cipherTextBytes = Convert.FromBase64String(cipherText);
            PasswordDeriveBytes password = new PasswordDeriveBytes(passPhrase, null);
            byte[] keyBytes = password.GetBytes(keysize / 8);
            RijndaelManaged symmetricKey = new RijndaelManaged();
            symmetricKey.Mode = CipherMode.CBC;
            ICryptoTransform decryptor = symmetricKey.CreateDecryptor(keyBytes, initVectorBytes);
            MemoryStream memoryStream = new MemoryStream(cipherTextBytes);
            CryptoStream cryptoStream = new CryptoStream(memoryStream, decryptor, CryptoStreamMode.Read);
            byte[] plainTextBytes = new byte[cipherTextBytes.Length];
            int decryptedByteCount = cryptoStream.Read(plainTextBytes, 0, plainTextBytes.Length);
            memoryStream.Close();
            cryptoStream.Close();
            return Encoding.UTF8.GetString(plainTextBytes, 0, decryptedByteCount);
        }
    }
}

Specials thanks to the kind souls at StackOverflow for some of the clever tips involved (hint: If you are an IT Pro struggling to write some code to fit your needs then this site is your friend).

The two configuration files needed are the following:

settings.txt:

[SecureDomainJoin]
ComputerNameStart=3
ComputerNameEnd=5
OUNameStart=4
OUNameEnd=6
JoinDomain=contoso.local
JoinDomainUserName=contoso\joindom
JoinDomainUserPassword=NwMQ7uTKxAJ6vjcAqLneTg==

fqdn.txt:

OU=New Computers,DC=contoso,DC=local
OU=101-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
OU=107-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
OU=113-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
OU=121-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
OU=122-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local

Of course you need to modify the config files as the utility does quite a bit more than a “simple” Domain Join.

Let’s see what are the steps involved to make it work just the way you want it:

  1. First you need to compile it or just get the executable + the configuration files. Here they are:
  2. Place the executable (SecureDomainJoin.exe) and the two configuration files (settings.txt, fqdn,txt) on a separate folder.
  3. Open settings.txt and configure the FQDN of your domain in the JoinDomain property and the user name in the JoinDomainUserName property.
    For example:

    JoinDomain=contoso.local
    JoinDomainUserName=contoso\joindom
    
  4. Rename fqdn.txt to the FQDN of your domain for example rename it to: contoso.local.txt
  5. Open it and configure the names of the Organizational Units where your workstations will be joining the Domain. Place the default OU in the first line and all the other OUs in the following lines (the order is not important). For example:
    OU=New Computers,DC=contoso,DC=local
    OU=101-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
    OU=107-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
    OU=113-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
    OU=121-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
    OU=122-BRANCH,OU=Branches,OU=Workstations,DC=contoso,DC=local
    
  6. Open settings.txt and configure the properties ComputerNameStart and ComputerNameEnd with the character number where the Branch Code starts and ends. So for example if your Computer Names are in the form of PC107W001 where “107” is the code of your Branch then these numbers are “3” (the third character) and “5” (the fifth character) so we configure the attributes in this example as follows:
    ComputerNameStart=3
    ComputerNameEnd=5
  7. Using the same logic we configure the attributes OUNameStart and OUNameEnd but this time for the OU names (note: including the “OU=” part!), so for the OUs in the example above we will have:
    OUNameStart=4
    OUNameEnd=6
  8. Save and close both config files and execute SecureDomainJoin.exe from the command-prompt giving the password of the user configured in settings.txt as a parameter:
    SecureDomainJoin.exe P@ssw0rd
  9. Open settings.txt and notice that the JoinDomainUserPassword property is now populated with the encrypted password. The only way to decrypt this string is by using the encodedstring and initVector strings in the source code (so make sure you are not sharing that). Note that a really determined user can analyze the EXE file and decode the password. It is not bullet-proof security but adequate for the specific needs we have here.

You can now distribute the three files and join the domain by simply executing SecureDomainJoin.exe (either from within scripts or directly from the command-prompt). Notice that in case of failure the script will provide an output for that error and wait the user to press ENTER (handy when placed in Task Sequences for OS Deployment in MDT or ConfigMgr).

The EXE provided here only works in OSes where the proper version of .NET is installed (4.5 or 3.5). There are no other prerequisites as the Domain Join happens with a WMI call. Also note that the default behavior of the code is not to automatically reboot after the Domain Join (since we would like to control this from our scripts).

Enjoy and do provide feedback in the comments section.

Panos.