/ 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  }