/ powershell / psLDAPmonitor.ps1
psLDAPmonitor.ps1
1 # File name : psLDAPmonitor.ps1 2 # Author : Podalirius (@podalirius_) 3 # Date created : 3 Jan 2022 4 5 Param ( 6 [parameter(Mandatory=$true)][string]$dcip = $null, 7 [parameter(Mandatory=$false)][string]$Username = $null, 8 [parameter(Mandatory=$false)][string]$Password = $null, 9 [parameter(Mandatory=$false)][string]$LogFile = $null, 10 [parameter(Mandatory=$false)][int]$PageSize = 5000, 11 [parameter(Mandatory=$false)][string]$SearchBase = $null, 12 [parameter(Mandatory=$false)][int]$Delay = 1, 13 [parameter(Mandatory=$false)][switch]$LDAPS, 14 [parameter(Mandatory=$false)][switch]$Randomize, 15 [parameter(Mandatory=$false)][switch]$IgnoreUserLogons, 16 [parameter(Mandatory=$false)][switch]$Help 17 ) 18 19 If ($Help) { 20 Write-Host "[+]======================================================" 21 Write-Host "[+] Powershell LDAP live monitor v1.3 @podalirius_ " 22 Write-Host "[+]======================================================" 23 Write-Host "" 24 25 Write-Host "Required arguments:" 26 Write-Host " -dcip : LDAP host to target, most likely the domain controller." 27 Write-Host "" 28 Write-Host "Optional arguments:" 29 Write-Host " -Help : Displays this help message" 30 Write-Host " -Username : User to authenticate as." 31 Write-Host " -Password : Password for authentication." 32 Write-Host " -PageSize : Sets the LDAP page size to use in queries (default: 5000)." 33 Write-Host " -SearchBase : Sets the LDAP search base." 34 Write-Host " -LDAPS : Use LDAPS instead of LDAP." 35 Write-Host " -LogFile : Log file to save output to." 36 Write-Host " -Delay : Delay between two queries in seconds (default: 1)." 37 Write-Host " -Randomize : Randomize delay between two queries, between 1 and 5 seconds." 38 Write-Host " -IgnoreUserLogons : Ignores user logon events." 39 40 exit 0 41 } 42 43 If ($LogFile.Length -ne 0) { 44 # Init log file 45 $Stream = [System.IO.StreamWriter]::new($LogFile) 46 $Stream.Close() 47 } 48 49 if ($Delay) { 50 $DelayInSeconds = $Delay; 51 } else { 52 $DelayInSeconds = 1; 53 } 54 55 #=============================================================================== 56 57 Function Write-Logger { 58 [CmdletBinding()] 59 [OutputType([Nullable])] 60 Param 61 ( 62 [Parameter(Mandatory=$true)] $Logfile, 63 [Parameter(Mandatory=$true)] $Message 64 ) 65 Begin 66 { 67 Write-Host $Message 68 If ($LogFile.Length -ne 0) { 69 $Stream = [System.IO.StreamWriter]::new($LogFile, $true) 70 $Stream.WriteLine($Message) 71 $Stream.Close() 72 } 73 } 74 } 75 76 Function Init-LdapConnection { 77 [CmdletBinding()] 78 [OutputType([Nullable])] 79 Param 80 ( 81 [Parameter(Mandatory=$true)] $connectionString, 82 [Parameter(Mandatory=$false)] $SearchBase, 83 [Parameter(Mandatory=$false)] $Username, 84 [Parameter(Mandatory=$false)] $Password, 85 [Parameter(Mandatory=$false)] $PageSize 86 ) 87 Begin 88 { 89 $ldapSearcher = New-Object System.DirectoryServices.DirectorySearcher 90 if ($Username) { 91 if ($SearchBase.Length -ne 0) { 92 # Connect to Domain with credentials 93 $ldapSearcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry(("{0}/{1}" -f $connectionString, $SearchBase), $Username, $Password) 94 } else { 95 # Connect to Domain with current session 96 $ldapSearcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry("$connectionString", $Username, $Password) 97 } 98 } else { 99 if ($SearchBase.Length -ne 0) { 100 # Connect to Domain with credentials 101 $ldapSearcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry(("{0}/{1}" -f $connectionString, $SearchBase)) 102 } else { 103 # Connect to Domain with current session 104 $ldapSearcher.SearchRoot = New-Object System.DirectoryServices.DirectoryEntry("$connectionString") 105 } 106 } 107 $ldapSearcher.SearchScope = "Subtree" 108 if ($PageSize) { 109 $ldapSearcher.PageSize = $PageSize 110 } else { 111 Write-Verbose ("Setting PageSize to $PageSize"); 112 $ldapSearcher.PageSize = 5000 113 } 114 return $ldapSearcher; 115 } 116 } 117 118 119 Function Query-AllNamingContextsOrSearchBase { 120 [CmdletBinding()] 121 [OutputType([Nullable])] 122 Param 123 ( 124 [Parameter(Mandatory=$true)] $namingContexts, 125 [Parameter(Mandatory=$true)] $connectionString, 126 [Parameter(Mandatory=$false)] $SearchBase, 127 [Parameter(Mandatory=$false)] $Username, 128 [Parameter(Mandatory=$false)] $Password, 129 [Parameter(Mandatory=$false)] $PageSize 130 ) 131 Begin 132 { 133 if ($SearchBase.Length -ne 0) { 134 Write-Verbose "Using SearchBase: $nc" 135 $ldapSearcher = Init-LdapConnection -connectionString $connectionString -SearchBase $SearchBase -Username $Username -Password $Password -PageSize $PageSize 136 $ldapSearcher.Filter = "(objectClass=*)" 137 return $ldapSearcher.FindAll(); 138 } else { 139 $results = [ordered]@{}; 140 foreach ($nc in $namingContexts) { 141 Write-Verbose "Using namingContext as search base: $nc" 142 $ldapSearcher = Init-LdapConnection -connectionString $connectionString -SearchBase $nc -Username $Username -Password $Password -PageSize $PageSize 143 $ldapSearcher.Filter = "(objectClass=*)" 144 145 Foreach ($item in $ldapSearcher.FindAll()) { 146 if (!($results.Keys -contains $item.Path)) { 147 $results[$item.Path] = $item.Properties; 148 } else { 149 Write-Host "[debug] key already exists: $key (this shouldn't be possible)" 150 } 151 } 152 } 153 return $results; 154 } 155 } 156 } 157 158 159 Function ResultsDiff { 160 [CmdletBinding()] 161 [OutputType([Nullable])] 162 Param 163 ( 164 [Parameter(Mandatory=$true)] $ResultsBefore, 165 [Parameter(Mandatory=$true)] $ResultsAfter, 166 [Parameter(Mandatory=$true)] $connectionString, 167 [Parameter(Mandatory=$true)] $Logfile, 168 [parameter(Mandatory=$false)][switch]$IgnoreUserLogons 169 ) 170 Begin { 171 [System.Collections.ArrayList]$ignored_keys = @(); 172 If ($IgnoreUserLogons) { 173 $ignored_keys.Add("lastlogon") | Out-Null 174 $ignored_keys.Add("logoncount") | Out-Null 175 } 176 177 $dateprompt = ("[{0}] " -f (Get-Date -Format "yyyy/MM/dd hh:mm:ss")); 178 179 # Get created and deleted entries, and common_keys 180 [System.Collections.ArrayList]$commonPaths = @(); 181 Foreach ($bpath in $ResultsBefore.Keys) { 182 if (!($ResultsAfter.Keys -contains $bpath)) { 183 Write-Logger -Logfile $Logfile -Message ("{0}'{1}' was deleted." -f $dateprompt, $bpath.replace($connectionString+"/","")) 184 } else { 185 $commonPaths.Add($bpath) | Out-Null 186 } 187 } 188 Foreach ($apath in $ResultsAfter.Keys) { 189 if (!($ResultsBefore.Keys -contains $apath)) { 190 Write-Logger -Logfile $Logfile -Message ("{0}'{1}' was created." -f $dateprompt, $apath.replace($connectionString+"/","")) 191 } 192 } 193 194 # Iterate over all the common keys 195 [System.Collections.ArrayList]$attrs_diff = @(); 196 Foreach ($path in $commonPaths) { 197 $attrs_diff.Clear(); 198 199 # Convert into dictionnaries 200 $dict_direntry_before = [ordered]@{}; 201 $dict_direntry_after = [ordered]@{}; 202 203 Foreach ($propkey in $ResultsBefore[$path].Keys) { 204 if (!($ignored_keys -Contains $propkey.ToLower())) { 205 $dict_direntry_before.Add($propkey, $ResultsBefore[$path][$propkey][0]); 206 } 207 }; 208 Foreach ($propkey in $ResultsAfter[$path].Keys) { 209 if (!($ignored_keys -Contains $propkey.ToLower())) { 210 $dict_direntry_after.Add($propkey, $ResultsAfter[$path][$propkey][0]); 211 } 212 }; 213 214 # Store different values 215 Foreach ($pname in $dict_direntry_after.Keys) { 216 if (($dict_direntry_after.Keys -Contains $pname) -And ($dict_direntry_before.Keys -Contains $pname)) { 217 if (!($dict_direntry_after[$pname].ToString() -eq $dict_direntry_before[$pname].ToString())) { 218 $attrs_diff.Add(@($path, $pname, $dict_direntry_after[$pname], $dict_direntry_before[$pname])) | Out-Null; 219 } 220 } elseif (($dict_direntry_after.Keys -Contains $pname) -And !($dict_direntry_before.Keys -Contains $pname)) { 221 $attrs_diff.Add(@($path, $pname, $dict_direntry_after[$pname], $null)) | Out-Null; 222 } elseif (!($dict_direntry_after.Keys -Contains $pname) -And ($dict_direntry_before.Keys -Contains $pname)) { 223 $attrs_diff.Add(@($path, $pname, $null, $dict_direntry_before[$pname])) | Out-Null; 224 } 225 } 226 227 # Show results 228 if ($attrs_diff.Length -ge 0) { 229 Write-Logger -Logfile $Logfile -Message ("{0}{1}" -f $dateprompt, $path.replace($connectionString+"/","")) 230 231 Foreach ($t in $attrs_diff) { 232 if (($t[3] -ne $null) -And ($t[2] -ne $null)) { 233 Write-Logger -Logfile $Logfile -Message (" | Attribute {0} changed from '{1}' to '{2}'" -f $t[1], $t[3], $t[2]); 234 } elseif (($t[3] -eq $null) -And ($t[2] -ne $null)) { 235 Write-Logger -Logfile $Logfile -Message (" | Attribute {0} = '{1}' was created." -f $t[1], $t[2]); 236 } elseif (($t[3] -ne $null) -And ($t[2] -eq $null)) { 237 Write-Logger -Logfile $Logfile -Message (" | Attribute {0} = '{1}' was deleted." -f $t[1], $t[3]); 238 } 239 } 240 } 241 } 242 } 243 } 244 245 #=============================================================================== 246 247 Write-Logger -Logfile $Logfile -Message "[+]======================================================" 248 Write-Logger -Logfile $Logfile -Message "[+] Powershell LDAP live monitor v1.3 @podalirius_ " 249 Write-Logger -Logfile $Logfile -Message "[+]======================================================" 250 Write-Logger -Logfile $Logfile -Message "" 251 252 # Handle LDAPS connection 253 $connectionString = "LDAP://{0}:{1}"; 254 If ($LDAPS) { 255 $connectionString = ($connectionString -f $dcip, "636"); 256 } else { 257 $connectionString = ($connectionString -f $dcip, "389"); 258 } 259 Write-Verbose "Using connectionString: $connectionString" 260 261 # Connect to LDAP 262 try { 263 $rootDSE = New-Object System.DirectoryServices.DirectoryEntry("{0}/RootDSE" -f $connectionString); 264 $namingContexts = $rootDSE.Properties["namingContexts"]; 265 266 Write-Verbose ("Authentication successful!"); 267 268 # First query 269 $results_before = Query-AllNamingContextsOrSearchBase -connectionString $connectionString -SearchBase $SearchBase -namingContexts $namingContexts -Username $Username -Password $Password -PageSize $PageSize 270 271 Write-Logger -Logfile $Logfile -Message "[>] Listening for LDAP changes ..."; 272 Write-Logger -Logfile $Logfile -Message ""; 273 274 While ($true) { 275 # Update query 276 $results_after = Query-AllNamingContextsOrSearchBase -connectionString $connectionString -SearchBase $SearchBase -namingContexts $namingContexts -Username $Username -Password $Password -PageSize $PageSize 277 278 # Diff 279 if ($IgnoreUserLogons) { 280 ResultsDiff -ResultsBefore $results_before -ResultsAfter $results_after -connectionString $connectionString -Logfile $Logfile -IgnoreUserLogons 281 } else { 282 ResultsDiff -ResultsBefore $results_before -ResultsAfter $results_after -connectionString $connectionString -Logfile $Logfile 283 } 284 285 $results_before = $results_after; 286 if ($Randomize) { 287 $DelayInSeconds = Get-Random -Minimum 1 -Maximum 5 288 } 289 Write-Verbose ("Waiting {0} second." -f $DelayInSeconds); 290 Start-Sleep -Seconds $DelayInSeconds 291 } 292 } catch { 293 Write-Verbose $_.Exception 294 Write-Logger -Logfile $Logfile -Message ("[!] (0x{0:X8}) {1}" -f $_.Exception.HResult, $_.Exception.InnerException.Message) 295 exit -1 296 }