Saturday, October 15, 2022

Notes About Powershell, adsi, Active Directory Module, alternative authentication, Basics of Accessing Active Directory

Two Methods for Accessing AD Info from Powershell

There are two basic methods for accessing Active Directory information from within Powershell scripts: using Active Directory Service Interfaces (ADSI), and the Powershell ActiveDirectory module.

The second method, using the ActiveDirectory module, is native to Powershell, but is not "built-in" to most Powershell installations. Therefore, if you're planning for your Powershell to run on multiple computers, you have to take actions to make sure tht module is installed. This adds complexity to your script, and possible time to the run-time of the script.

The first method is "built in" to Powershell (sort of), but not native to Powershell. It is basically an import from the .NET system that is already installed on most Windows systems. It is this immediate and reliable availability that makes me prefer the ADSI method over the ActiveDirectory module method. Otherwise I'd stick with the pure Powershell method.

ADSI is the method I'm using below. There are two basic ADSI tools used with this method: [adsi] is an accelerator (or "alias") to System.DirectoryServices.DirectoryEntry, which points to actual objects within AD, and [adsisearcher] is an accelerator to System.DirectoryServices.DirectorySearcher, which is used for searching through AD.

Using the ADSI Method for accessing information in Active Directory

On a Machine Already Bound to an AD Domain

To access Active Directory (AD) data, we must bind to the directory. If your computer is attached to a network on which resides an AD controller, and your computer is already bound to that controller's domain, and you're logged into that domain, you can simply run:

[adsi]''

at a Powershell prompt, or from a Powershell script. This command will return the distinguishedName of the domain to which the computer is bound. Your results should be something like this:

distinguishedName : {DC=acu,DC=local}
  Path              : 

adsi is an accelerator ("alias" - see here for more info) for System.DirectoryServices.DirectoryEntry. The equivalent command is:

[System.DirectoryServices.DirectoryEntry]''

I mention using adsi because you might see it elsewhere. But for clarity, I'll use the more verbose verbiage, System.DirectoryServices.DirectoryEntry.

On a Machine Not Bound to an AD Domain

If your computer is not bound to an AD controller, you'll get results like this:

PS C:\Users\westk> [adsi]""
format-default : The following exception occurred while retrieving member "distinguishedName": "The specified domain either does not exist or could not be 
contacted.
"
    + CategoryInfo          : NotSpecified: (:) [format-default], ExtendedTypeSystemException
    + FullyQualifiedErrorId : CatchFromBaseGetMember,Microsoft.PowerShell.Commands.FormatDefaultCommand

(Notice the quotes can be single or double; sometimes one will work better than the other depending on the situation.)

The computer I'm working with is not currently bound to the domain, but it is on the same network as the domain. In order to get AD info, I have to bind the computer in some way to the domain. This will require credentials for logging into the domain. If the computer were already on the domain, and I logged in as a domain user, this method would use my Windows login credentials by default. But we can specify different credentials, or just provide credentials if the computer is not already on the domain.

The System.DirectoryServices.DirectoryEntry method is picky about how credentials are presented. You have to give it the type of connection being made ("LDAP", as opposed to "WinNT", or one other method which I can't recall at the moment), and a username and a password that has domain permissions to read from the domain. For reading from this part of the domain, almost any domain user will suffice.

When giving this data to System.DirectoryServices.DirectoryEntry, the object it returns is "not compatible" with just spitting out the results to the console like it is when not giving this information; you'll need to declare the results as a new object. We'll call the new object "$root", since we're starting at the root of the Active Directory domain tree. If we wanted, we could call it "domain", or "DomainDN", or "bub". Suppose the username you're using to bind with is "johndoe", and his password is "SuperSecret". The command could thus look like this:

$root = New-Object System.DirectoryServices.DirectoryEntry("LDAP://acu.local/DC=acu,DC=local","acu.local\johndoe","SuperSecret")

Notice that the username field includes the domain name component.

When we look at the results:

PS > $root

this should produce output like this:

distinguishedName : {DC=acu,DC=local}
Path              : LDAP://acu.local/DC=acu,DC=local

We could also write the one-liner variable-assignment as several lines (ending some lines with a back-tic to signify that the line is continued), perhaps making the declaration more readable:

$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList "LDAP://acu.local/DC=acu,DC=local",
	"acu.local\johndoe",
	"SuperSecret"

Being a Little More Secure With the Password

Although you can feed the credentials directly to this command as strings, that's not a very secure way to do it in a script. So let's use variables instead, including a variable for the path within the AD tree:

$UserName = "johndoe"
$Password = "SuperSecret"
$AD_Path = "LDAP://acu.local/DC=acu,DC=local"
$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $AD_Path,
	$UserName,
	$Password

Now we'd need to do something about those credential assignments being out in the open like that.

Secure-Prompting the User

If your script is going to be interactively run by a user sitting in front of the computer, you can (securely) prompt the user for the creds, like so:

$creds = Get-Credential
$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $AD_Path,
	$($creds.UserName),
	$($creds.GetNetworkCredential().Password)

(You can also prompt with a pre-loaded username field with $creds = Get-Credential -Credential "acu.local\johndoe" . Note also that this process doesn't do anything; it just puts these two values in the $creds variable, making the previous variables, $UserName and $Passord, superflous; get rid of them from your script.)

Note that the $creds.Password must be manipulated a bit to get it in an acceptable form to be used by this command. Looking at the values of the various objects...

PS C:\Users\westk> $creds

UserName                            Password
--------                            --------
acu.local\johndoe System.Security.SecureString


PS C:\Users\westk> $creds.UserName
acu.local\johndoe

PS C:\Users\westk> $creds.Password
System.Security.SecureString

PS C:\Users\westk> $creds.GetNetworkCredential().Password
SuperSecret

you can start to understand the manipulations involved.

Reading the Creds From a File

If you don't have a user sitting in front of the computer when the script runs, you can store the username and an encrypted form of the password in a file, beforehand, and then read it from that file when it's needed. Be aware that this is still not secure, but it's more so.

Also, the most onerous caveat about this method, at least for general purpose scripts, is that the password encryption can only be decrypted by the same user account on the same computer as was used to do the encryption. You can't write/develop the script that uses this method on one computer, and then run it on another computer, or as a different user.

Saving the Credentials to an External File, Beforehand

Not in your script, but just at a Powershell prompt, enter the following:

Set-Content -Path ".\extras.zip" -Value "acu.local\johndoe"

This step creates a file named "extras.zip", over-writing any existing files of that name, in the current directory. The file contains the username. You can verify the contents of this newly-created file with:

type ".\extras.zip"

As you can see, it's not really a .zip file; it's just a plain text file. But naming it as a .zip is a minor mindgame, hoping to discourage the casual "hackers" from bothering to try and open / look into the file. It's a weak form of "security via obscurity"; probably completely useless, but I know when I'm casually looking at files with which I'm unfamiliar, I tend to look at .txt files and to leave .zip files along. But you can name your file any way you want.

Add-Content -Path ".\extras.zip" -Value $("SuperSecret") | ConvertFrom-SecureString -AsPlainText -Force)

This step adds the username to the now-already-existing "extras.zip" file. The value that gets added to the file is the password, which before getting added to the file, is piped to a converter that takes the plain text of "SuperSecret" and converts it to a SecureString.

You should now have a file named "extras.zip" with something like the following (which you can see with "type .\extras.zip":

acu.local\johndoe
01000000d08c9ddf0115d1118c7a00c04fc297eb010000001150a84662a6cd45af512286977fabcc00000000020000000000106600000001000020000000943962d675717d4a12cfe44a8a22b6a5f0ca77762c7bb61e6929515af684db5d000000000e80000000020000200000006370779b6f82983d4f417eccad5be5a80b0ae8a71d2820e736862d2b25453ce220000000c96767ea12fd28ab840fcefb49b4f86d84e97f4676e806a8d7511a86532d3c3f400000008a26d44b1d1df760436be9be186cb87a6cca6220364752e435af61188330043ac201f634ff88fd751b17aff9394c00e6ada09a2f1dd93c27702f9802e813f90d

Normally the ConvertTo-SecureString expects its input to be a standard encrypted string of plain text, like the line with numbers above. But we're starting with the plain text of "SuperSecret". If you feed it this plain unencrypted text, it complains. You can see this for yourself with this command:

"SuperSecret" | ConvertTo-SecureString

That's why we use the "-AsPlainText" argument, to override that behavior. You can see this for yourself like so:

'SuperSecret" | ConvertTo-SecureString -AsPlainText

But then you get another complaint. For security reasons, the command doesn't like to be given secret info out in the open, but you can force it to accept the input anyway, with:

'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force

and now you see that the result is a SecureString.

However, we can not write this SecureString out to a file, and then recover it later. But what we can do is reverse the process, part-way. We won't reverse it back to a plain-text "SuperSecret", but rather to an encrypted-text form of "SuperSecret", by piping the above command to the counterpart:

'SuperSecret" | ConvertTo-SecureString -AsPlainText -Force | ConvertFrom-SecureString

The "native language" of these two tools is SecureStrings on one side and encrypted text on the other, so we don't have to specify anything special; it just takes the SecureString in the previous command and converts from that into encrypted text.

Now that we have the credentials stored in an external file, we have to tell our script to read them.

Reading the Credentials from an External File

In your script, replace the "$creds = Get-Credential" line like so:

# $creds = Get-Credential
$contents = Get-Content -Path ".\extras.zip"       # Read the file's contents.
# Reconvert the encrypted password back to a SecureString, and put both username and password into a new PSCredential object named $creds.
$creds = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $contents[0], $($contents[1] | ConvertTo-SecureString)

A Whole Script

Here's a whole script, so you can see the big picture.

<#  Powershell Script To Access An Active Directory Tree
  Written By:  [your name]
  Date: [today's date]
  [any ther comments you want to add]
#>
  
$creds = Get-Credential
$AD_Path = "LDAP://acu.local/DC=acu,DC=local"
$root = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $AD_Path,
	$($creds.UserName),
	$($creds.GetNetworkCredential().Password)
Write-Output("The Distinguished (`"unique`") Name for the AD domain is: $($root.distinguishedName)")

Save that as a Powershell script file, and run it, and you should see output similar to:

The Distinguished ("unique") Name for the AD domain is: DC=acu,DC=local

A Second Whole Script, Slightly Different, for Perspective

#  Powershell Script To Access An Active Directory Tree
$Username_And_Password = [PSCustomObject]@{
	UserName = "maryjane"
	Password = "SuperTramp"
}
$TreeRoot = "LDAP://acu.local/DC=acu,DC=local"
$DirEntry = New-Object `
	-TypeName System.DirectoryServices.DirectoryEntry `
	-ArgumentList $($TreeRoot),
	$($Username_And_Password.UserName),
	$($Username_And_Password.Password)
Write-Output("The root is: $($TreeRoot).")
Write-Output("The password for $($Username_And_Password.UserName) is $($Username_And_Password.Password).")

One More Example

$AD_Node = adsi New-Object ("LDAP://DC=my_company,DC=com","my_company\accountant","time=$")
Write-Output("The domain info = $($AD_Node | Get-Member).")

which will produce output like this:

The domain info = static string ConvertDNWithBinaryToString(psobject deInstance, psobject dnWithBinaryInstance) static
 long ConvertLargeIntegerToInt64(psobject deInstance, psobject largeIntegerInstance) System.DirectoryServices.Property
ValueCollection auditingPolicy {get;set;} System.DirectoryServices.PropertyValueCollection creationTime {get;set;} Sys
tem.DirectoryServices.PropertyValueCollection dc {get;set;} System.DirectoryServices.PropertyValueCollection distingui
shedName {get;set;} System.DirectoryServices.PropertyValueCollection dSASignature {get;set;} System.DirectoryServices.
PropertyValueCollection dSCorePropagationData {get;set;} System.DirectoryServices.PropertyValueCollection forceLogoff 
{get;set;} System.DirectoryServices.PropertyValueCollection fSMORoleOwner {get;set;} System.DirectoryServices.Property
ValueCollection gPLink {get;set;} System.DirectoryServices.PropertyValueCollection instanceType {get;set;} System.Dire
ctoryServices.PropertyValueCollection isCriticalSystemObject {get;set;} System.DirectoryServices.PropertyValueCollecti
on lockoutDuration {get;set;} System.DirectoryServices.PropertyValueCollection lockOutObservationWindow {get;set;} Sys
tem.DirectoryServices.PropertyValueCollection lockoutThreshold {get;set;} System.DirectoryServices.PropertyValueCollec
tion masteredBy {get;set;} System.DirectoryServices.PropertyValueCollection maxPwdAge {get;set;} System.DirectoryServi
ces.PropertyValueCollection minPwdAge {get;set;} System.DirectoryServices.PropertyValueCollection minPwdLength {get;se
t;} System.DirectoryServices.PropertyValueCollection modifiedCount {get;set;} System.DirectoryServices.PropertyValueCo
llection modifiedCountAtLastProm {get;set;} System.DirectoryServices.PropertyValueCollection ms-DS-MachineAccountQuota
 {get;set;} System.DirectoryServices.PropertyValueCollection msDS-AllUsersTrustQuota {get;set;} System.DirectoryServic
es.PropertyValueCollection msDS-Behavior-Version {get;set;} System.DirectoryServices.PropertyValueCollection msDS-Expi
rePasswordsOnSmartCardOnlyAccounts {get;set;} System.DirectoryServices.PropertyValueCollection msDS-IsDomainFor {get;s
et;} System.DirectoryServices.PropertyValueCollection msDs-masteredBy {get;set;} System.DirectoryServices.PropertyValu
eCollection msDS-NcType {get;set;} System.DirectoryServices.PropertyValueCollection msDS-PerUserTrustQuota {get;set;} 
System.DirectoryServices.PropertyValueCollection msDS-PerUserTrustTombstonesQuota {get;set;} System.DirectoryServices.
PropertyValueCollection name {get;set;} System.DirectoryServices.PropertyValueCollection nextRid {get;set;} System.Dir
ectoryServices.PropertyValueCollection nTMixedDomain {get;set;} System.DirectoryServices.PropertyValueCollection nTSec
urityDescriptor {get;set;} System.DirectoryServices.PropertyValueCollection objectCategory {get;set;} System.Directory
Services.PropertyValueCollection objectClass {get;set;} System.DirectoryServices.PropertyValueCollection objectGUID {g
et;set;} System.DirectoryServices.PropertyValueCollection objectSid {get;set;} System.DirectoryServices.PropertyValueC
ollection otherWellKnownObjects {get;set;} System.DirectoryServices.PropertyValueCollection pwdHistoryLength {get;set;
} System.DirectoryServices.PropertyValueCollection pwdProperties {get;set;} System.DirectoryServices.PropertyValueColl
ection replUpToDateVector {get;set;} System.DirectoryServices.PropertyValueCollection repsFrom {get;set;} System.Direc
toryServices.PropertyValueCollection repsTo {get;set;} System.DirectoryServices.PropertyValueCollection rIDManagerRefe
rence {get;set;} System.DirectoryServices.PropertyValueCollection serverState {get;set;} System.DirectoryServices.Prop
ertyValueCollection subRefs {get;set;} System.DirectoryServices.PropertyValueCollection systemFlags {get;set;} System.
DirectoryServices.PropertyValueCollection uASCompat {get;set;} System.DirectoryServices.PropertyValueCollection uSNCha
nged {get;set;} System.DirectoryServices.PropertyValueCollection uSNCreated {get;set;} System.DirectoryServices.Proper
tyValueCollection wellKnownObjects {get;set;} System.DirectoryServices.PropertyValueCollection whenChanged {get;set;} 
System.DirectoryServices.PropertyValueCollection whenCreated {get;set;}.

Get The Next Level of Objects in the Domain Tree

After the last example, we now have the top lovel of our domain ("acu" in my case) in the variable $TreeRoot (or "$AD_Path", or "$root", or "AD_Node", or whatever variable name you chose to use; I'll use $Monkey in the following text, because we'll be watching where the monkey has climbed to in the tree).

Also, I have replaced the multi-line definition of the $Monkey object with the one-liner version.

Here's the script I currently have:

<# Scan_AD_Tree

    .DESCRIPTION
    This script accesses an Active Directory Tree

    .AUTHOR
    Kent West
    October 2022
#>

# These creds of a domain user will be used to bind to the domain.
# We can either prompt the user for those credentials...
# $creds = Get-Credential

# ... or we can get the creds from an external text file, assuming the file has been previously created, with:
# Set-Content -Path ".\extras.zip" -Value "acu.local\johndoe"
# Add-Content -Path ".\extras.zip" -Value $("SuperSecret") | ConvertFrom-SecureString -AsPlainText -Force)
$contents = Get-Content -Path ".\extras.zip"   # This reads the file, auto-making the "$contents" an array variable.
# Reconvert the encrypted-text password back to a SecureString, and put both username and password into a new PSCredential object named $creds.
$creds = New-Object -TypeName System.Management.Automation.PSCredential -ArgumentList $contents[0], $($contents[1] | ConvertTo-SecureString)

# This is the LDAP "path" to the root of our AD domain "tree" named "acu.local".
$Tree_Path = "LDAP://acu.local/DC=acu,DC=local"

# We'll bind our tree-climber ("$Monkey") to the tree's path, currently at the root, using the domain user's creds.
$Monkey = New-Object System.DirectoryServices.DirectoryEntry($Tree_Path,$creds.UserName,$creds.GetNetworkCredential().Password)

Write-Output("The Common Name (CN) of the domain is $($Monkey.Name)."

Explanations/Comments in the Above May Not Be Entirely Accurate

This has been a learning process for me; I'm not confident I've explained things above entirely correctly, and I'm tired of chasing this rabbit, so I'm going to stop here, with a link that I started to include, along with a piece of code that references itself for no reason I can think of. In the hidden comments of the source to this page is a lot more information that doesn't really belong here, but is related.

Source Info
$DomainDN = New-Object -Type System.DirectoryServices.DirectorySearcher
$DomainDN.SearchRoot.Path

No comments: