Looking for non-secure LDAP binding is nothing new, in fact Microsoft will discourage you from using un-secured bindings (and this does make sense) and every so often then will try and enforce secure bindings, only to revert that update and simply provide a monitoring or audit mode, the desire to switch to LDAP-S needs to be a corporate decision - this background and scripting will allow you to make calculated decisions as to what is not using secure bindings.
LDAP-S
Yes, this is not a particularly new topic for a LDAP request that has a Secure certificate binding attached to it, this is their known as LDAP-S (due to the certificate) and with this update, you get a port change from TCP:389 to TCP:636
Everybody should know about the existence of LDAP-S It’s just for many organizations. It’s convenient to ignore it until Microsoft tries to turn it off, Which has happened a couple of times and the immediate hot fix is to put a mode where you’re still allowed to use LDAP but in audit mode.
Global catalogue (GC) port..
You also need to remember that domain controller is also have a global catalogue (GC) port and in this scenario, you also get an unsecured port and a secured port? - except this time it will be TCP:3268 (unsecured) and TCP:3269 (secured)
When would I use a global catalogue port?
The Global Catalog (GC) port is used for LDAP queries when you need to access forest-wide data, such as performing cross-domain searches, resolving Universal Group memberships, or supporting applications like Exchange. It improves query performance by providing a subset of essential attributes for all objects in the forest.
Service bindings with LDAP/GC
The Active Directory Domain Service (ADDS) is responsible for providing availability on both the LDAP and GC ports
ADDS and ADWS
Yes, you now have two services on domain controllers, all the LDAP/GC ports and availability all served by the ADDS service.
Whereas, on the other side of the equation, you have the ADWS (Active Directory Web Service) this Provides programmatic access to AD via Web Services protocols (e.g., PowerShell, WS-Transfer)
What is ADWS is not available?
# Requires -RunAsAdministrator
$VerbosePreference = "Continue"
Import-Module ActiveDirectory
$logPath = "LDAP_Binds.log"
$eventLogName = "Directory Service"
$daysToSearch = 7
if (!(Test-Path (Split-Path $logPath -Parent))) {
New-Item -ItemType Directory -Path (Split-Path $logPath -Parent)
}
function Write-ToLog {
param($Message)
$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$logMessage = "$timestamp - $Message"
Write-Verbose $logMessage
$logMessage | Add-Content -Path $logPath
}
function Show-Menu {
Clear-Host
Write-Host "================ LDAP Configuration and Monitoring ================"
Write-Host "1: Enable LDAP Interface Events Logging on all DCs"
Write-Host "2: Disable LDAP Interface Events Logging on all DCs"
Write-Host "3: Check Current Diagnostic Status on all DCs"
Write-Host "4: Restart NTDS Service on all DCs (with safety checks)"
Write-Host "Q: Quit"
Write-Host "=============================================================="
}
function Test-LDAPPorts {
param (
[string]$DomainController,
[switch]$Verbose
)
$ports = @{
389 = "LDAP"
636 = "LDAPS"
3268 = "Global Catalog"
3269 = "Global Catalog SSL"
}
$allPorts = $true
foreach ($port in $ports.Keys) {
$tcpClient = New-Object System.Net.Sockets.TcpClient
try {
$tcpClient.Connect($DomainController, $port)
if ($Verbose) {
Write-Host "$($ports[$port]) (Port $port): Connected" -ForegroundColor Green
}
}
catch {
$allPorts = $false
if ($Verbose) {
Write-Host "$($ports[$port]) (Port $port): Failed - $_" -ForegroundColor Red
}
}
finally {
if ($tcpClient) { $tcpClient.Close() }
}
}
return $allPorts
}
function Enable-LDAPDebug {
param ($DomainController)
Write-Host "`nEnabling LDAP Debug on: $DomainController" -ForegroundColor Cyan
try {
reg.exe add "\\$DomainController\HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics" /v "16 LDAP Interface Events" /t REG_DWORD /d 2 /f
Write-Host "Success: LDAP Interface Events logging enabled on $DomainController" -ForegroundColor Green
return $true
}
catch {
Write-Host "Failed on $DomainController : $_" -ForegroundColor Red
return $false
}
}
function Disable-LDAPDebug {
param ($DomainController)
Write-Host "`nDisabling LDAP Debug on: $DomainController" -ForegroundColor Cyan
try {
reg.exe add "\\$DomainController\HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics" /v "16 LDAP Interface Events" /t REG_DWORD /d 0 /f
Write-Host "Success: LDAP Interface Events logging disabled on $DomainController" -ForegroundColor Green
return $true
}
catch {
Write-Host "Failed on $DomainController : $_" -ForegroundColor Red
return $false
}
}
function Get-DebugStatus {
param ($DomainController)
Write-Host "`nChecking status on: $DomainController" -ForegroundColor Cyan
try {
Write-Host "Testing LDAP ports:" -ForegroundColor Yellow
Test-LDAPPorts -DomainController $DomainController -Verbose
$regValue = reg.exe query "\\$DomainController\HKLM\SYSTEM\CurrentControlSet\Services\NTDS\Diagnostics" /v "16 LDAP Interface Events"
Write-Host "`nLDAP Interface Events Status:" -ForegroundColor Yellow
Write-Host $regValue
return $true
}
catch {
Write-Host "Failed to query status on $DomainController : $_" -ForegroundColor Red
return $false
}
}
function Restart-NTDSService {
param ($DomainController)
Write-Host "`nProcessing NTDS restart for: $DomainController" -ForegroundColor Cyan
# Check if other DCs are available first
$otherDCs = Get-ADDomainController -Filter "Name -ne '$DomainController'" | Select-Object -ExpandProperty HostName
$healthyDCs = $otherDCs | Where-Object { Test-LDAPPorts -DomainController $_ }
if ($healthyDCs.Count -eq 0) {
Write-Host "No other healthy DCs available - skipping restart of $DomainController" -ForegroundColor Red
return $false
}
Write-Host "Testing initial LDAP connectivity on $DomainController" -ForegroundColor Yellow
$preTestResult = Test-LDAPPorts -DomainController $DomainController -Verbose
if (!$preTestResult) {
Write-Host "Pre-restart port test failed on $DomainController - skipping restart" -ForegroundColor Red
return $false
}
try {
Write-Host "Stopping NTDS service..." -ForegroundColor Yellow
$stopResult = sc.exe \\$DomainController stop ntds
Start-Sleep -Seconds 10
Write-Host "Starting NTDS service..." -ForegroundColor Yellow
$startResult = sc.exe \\$DomainController start ntds
Start-Sleep -Seconds 30
Write-Host "Testing post-restart LDAP connectivity" -ForegroundColor Yellow
$postTestResult = Test-LDAPPorts -DomainController $DomainController -Verbose
if ($postTestResult) {
Write-Host "NTDS restart completed successfully on $DomainController" -ForegroundColor Green
return $true
} else {
Write-Host "Post-restart port test failed on $DomainController" -ForegroundColor Red
return $false
}
}
catch {
Write-Host "Failed to restart NTDS on $DomainController : $_" -ForegroundColor Red
return $false
}
}
# Main script
try {
Write-ToLog "Script started"
$dcs = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName
do {
Show-Menu
$choice = Read-Host "`nEnter choice"
switch ($choice) {
'1' {
foreach ($dc in $dcs) {
if (Enable-LDAPDebug -DomainController $dc) {
Write-ToLog "Enabled LDAP debug on $dc"
}
}
Read-Host "`nPress Enter to continue"
}
'2' {
foreach ($dc in $dcs) {
if (Disable-LDAPDebug -DomainController $dc) {
Write-ToLog "Disabled LDAP debug on $dc"
}
}
Read-Host "`nPress Enter to continue"
}
'3' {
foreach ($dc in $dcs) {
if (Get-DebugStatus -DomainController $dc) {
Write-ToLog "Checked debug status on $dc"
}
}
Read-Host "`nPress Enter to continue"
}
'4' {
Write-Host "`nStarting NTDS service restart sequence..."
foreach ($dc in $dcs) {
if (Restart-NTDSService -DomainController $dc) {
Write-ToLog "Successfully restarted NTDS on $dc"
Write-Host "Waiting 60 seconds before next DC..." -ForegroundColor Yellow
Start-Sleep -Seconds 60
} else {
Write-ToLog "Failed to restart NTDS on $dc"
}
}
Read-Host "`nPress Enter to continue"
}
'Q' { Write-ToLog "Script ended"; return }
Default { Write-Host "Invalid choice" -ForegroundColor Red }
}
} until ($choice -eq 'Q')
}
catch {
Write-Warning "Critical Error: $_"
Write-ToLog "Critical Error: $_"
throw $_
}
When the logging flag has been enabled you will need to give that a moment to collect events then we need a script to extract those events from all the Domain Controllers, this will then in the example save those CSV's when valid data is found to a sub-folder called LDAP_Audit.
Script : InsecureLDAPBindings.ps1
# Requires -RunAsAdministrator
<#
.SYNOPSIS
Queries all domain controllers for insecure LDAP binds and exports results to CSV files.
.DESCRIPTION
Exports CSVs containing all Unsigned and Clear-text LDAP binds made to each domain controller
by extracting Event 2889 from the "Directory Services" event log.
.PARAMETER Hours
Number of hours to look back for events. Default is 24.
.PARAMETER OutputPath
Path where the CSV files will be stored. Defaults to ".\LDAP_Audit"
#>
[CmdletBinding()]
param (
[Parameter(Mandatory=$false)]
[Int]$Hours = 24,
[Parameter(Mandatory=$false)]
[String]$OutputPath = ".\LDAP_Audit"
)
# Import required module
Import-Module ActiveDirectory
# Create output directory if it doesn't exist
if (!(Test-Path $OutputPath)) {
New-Item -ItemType Directory -Path $OutputPath | Out-Null
Write-Verbose "Created output directory: $OutputPath"
}
function Get-InsecureLDAPBinds {
param (
[Parameter(Mandatory=$true)]
[String]$ComputerName
)
try {
Write-Verbose "Testing connection to $ComputerName..."
if (!(Test-Connection -ComputerName $ComputerName -Count 1 -Quiet)) {
throw "Unable to connect to $ComputerName"
}
Write-Verbose "Querying events from $ComputerName..."
$Events = Get-WinEvent -ComputerName $ComputerName -FilterHashtable @{
Logname='Directory Service'
Id=2889
StartTime=(Get-Date).AddHours("-$Hours")
} -ErrorAction Stop
# Create an Array to hold our returned values
$InsecureLDAPBinds = @()
# Process each event
foreach ($Event in $Events) {
$eventXML = [xml]$Event.ToXml()
# Extract client information
$Client = ($eventXML.event.EventData.Data[0])
$IPAddress = $Client.SubString(0,$Client.LastIndexOf(":")) # Accommodates IPV6 Addresses
$Port = $Client.SubString($Client.LastIndexOf(":")+1)
$User = $eventXML.event.EventData.Data[1]
# Determine bind type
Switch ($eventXML.event.EventData.Data[2]) {
0 {$BindType = "Unsigned"}
1 {$BindType = "Simple"}
}
# Create and populate row object
$Row = [PSCustomObject]@{
TimeCreated = $Event.TimeCreated
DomainController = $ComputerName
IPAddress = $IPAddress
Port = $Port
User = $User
BindType = $BindType
}
# Add the row to our array
$InsecureLDAPBinds += $Row
}
return $InsecureLDAPBinds
}
catch {
Write-Warning "Error processing $ComputerName : $_"
return $null
}
}
# Main script execution
try {
Write-Host "Starting insecure LDAP bind analysis..." -ForegroundColor Cyan
# Get all domain controllers
$DCs = Get-ADDomainController -Filter * | Select-Object -ExpandProperty HostName
Write-Host "Found $($DCs.Count) domain controllers" -ForegroundColor Cyan
# Process each DC
foreach ($DC in $DCs) {
Write-Host "`nProcessing DC: $DC" -ForegroundColor Green
$DCName = $DC.Split('.')[0] # Extract DC name without domain
$OutputFile = Join-Path $OutputPath "$DCName`_InsecureLDAPBinds.csv"
$Results = Get-InsecureLDAPBinds -ComputerName $DC
if ($Results) {
$Results | Export-Csv -Path $OutputFile -NoTypeInformation
Write-Host "Exported $($Results.Count) records to $OutputFile" -ForegroundColor Yellow
}
else {
Write-Host "No insecure LDAP binds found for $DC" -ForegroundColor Yellow
}
}
Write-Host "`nAnalysis complete. Results can be found in: $OutputPath" -ForegroundColor Cyan
}
catch {
Write-Error "Critical Error: $_"
throw $_
}
This will then export log files that contain all the Domain Controllers with all the "simple binds" over LDAP (not secured) which are detected in the event log as you can see below:

This will give us a rather unhelpful list that only includes the IP and the username of the bind activity:
We need the hostname to get more information like, Computer Description and the OU from Active Directory so the plan here is all the servers use RDP, so we will connect to that service and extract the name of the server from the certificate this works like this:
This will give us a rather unhelpful list that only includes the IP and the username of the bind activity:
"TimeCreated","DomainController","IPAddress","Port","User","BindType"
"15/01/2025 08:05:39","beardc1.bear.local","10.11.55.299","63352","bear\simple.bind","Simple"
"15/01/2025 08:05:39","beardc1.bear.local","10.11.55.299","63351","bear\simple.bind","Simple"
"15/01/2025 08:05:39","beardc1.bear.local","10.11.55.299","63350","bear\simple.bind","Simple"
"15/01/2025 08:05:39","beardc1.bear.local","10.11.55.299","63349","bear\simple.bind","Simple"
Reporting on the data (Devices)
You now need the "IPAddress" resolved to a hostname which can be done with DNS if your reverse zones are setup and working as they should be, if that the case we can use:
nslookup -a <ipaddress>
This should then return the hostname, if this returns the IP address then you do not have a valid reverse DNS zone to resolve that name, so that is this example cannot be used.
nslookup -a <ipaddress>
This should then return the hostname, if this returns the IP address then you do not have a valid reverse DNS zone to resolve that name, so that is this example cannot be used.
We need the hostname to get more information like, Computer Description and the OU from Active Directory so the plan here is all the servers use RDP, so we will connect to that service and extract the name of the server from the certificate this works like this:
- Checks if port 3389 (RDP) is open with a 1-second timeout
- If open, initiates an SSL connection to get the certificate
- Extracts the hostname from the certificate's Common Name (CN) field
- Properly disposes of all network resources after use
This will then output a CSV file with the following fields as below:
"IP address","Hostname","Organizational unit","Computer description"
"IP address","Hostname","Organizational unit","Computer description"
Script : OutlineDevices.ps1
# Error handling preference
$ErrorActionPreference = "Continue"
# Function to normalize IP address
function Normalize-IPAddress {
param([string]$IPAddress)
try {
# Remove any 'vip-' prefix and replace dashes with dots
$cleanIP = $IPAddress -replace 'vip-', '' -replace '-', '.'
# Validate IP format
$ipObj = [System.Net.IPAddress]::Parse($cleanIP)
return $ipObj.ToString()
}
catch {
Write-Warning "Invalid IP address format: $IPAddress"
return $null
}
}
# Function to check RDP and get hostname from certificate
function Get-RDPHostname {
param([string]$IPAddress)
try {
# First check if port 3389 is open
$tcpClient = New-Object System.Net.Sockets.TcpClient
$asyncResult = $tcpClient.BeginConnect($IPAddress, 3389, $null, $null)
$wait = $asyncResult.AsyncWaitHandle.WaitOne(1000) # 1 second timeout
if ($wait) {
try {
$tcpClient.EndConnect($asyncResult)
# Port is open, now get the certificate
$ssl = New-Object System.Net.Security.SslStream($tcpClient.GetStream(), $false, {$true})
$ssl.AuthenticateAsClient($IPAddress)
$cert = $ssl.RemoteCertificate
if ($cert) {
# Extract hostname from certificate subject
$subject = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert).Subject
if ($subject -match "CN=([^,]+)") {
return $matches[1]
}
}
}
finally {
if ($ssl) { $ssl.Dispose() }
$tcpClient.Close()
}
}
else {
Write-Verbose "RDP port not accessible on $IPAddress"
return $null
}
}
catch {
Write-Warning "Could not get certificate from $IPAddress : $_"
return $null
}
finally {
if ($tcpClient) { $tcpClient.Dispose() }
}
return $null
}
# Function to get AD computer information
function Get-ADComputerInfo {
param([string]$ComputerName)
try {
$computer = Get-ADComputer -Identity $ComputerName -Properties Description, DistinguishedName
return @{
OU = ($computer.DistinguishedName -split ',', 2)[1]
Description = if ($computer.Description) { $computer.Description } else { "No Description" }
}
}
catch {
Write-Warning "Could not retrieve AD information for computer: $ComputerName"
return @{
OU = "Not Found"
Description = "Not Found"
}
}
}
# Initialize collections
$uniqueIPs = @{}
$results = @()
# First pass: Collect and normalize all IPs
Write-Host "Phase 1: Collecting and normalizing IP addresses..."
try {
Write-Host "Searching for CSV files in LDAP_Audit folder and all subfolders..."
$csvFiles = Get-ChildItem -Path ".\LDAP_Audit" -Filter "*.csv" -Recurse
Write-Host "Found $($csvFiles.Count) CSV files:"
$csvFiles | ForEach-Object {
Write-Host " - $($_.FullName)"
}
if ($csvFiles.Count -eq 0) {
Write-Warning "No CSV files found in the LDAP_Audit directory"
exit
}
foreach ($file in $csvFiles) {
Write-Host "Reading file: $($file.FullName)"
try {
$csvData = Import-Csv -Path $file.FullName
if ($null -eq $csvData[0].IPAddress) {
Write-Warning "File $($file.Name) does not contain IPAddress column. Skipping..."
continue
}
$csvData | ForEach-Object {
$ip = $_.IPAddress
if (![string]::IsNullOrWhiteSpace($ip)) {
$normalizedIP = Normalize-IPAddress -IPAddress $ip
if ($normalizedIP) {
$uniqueIPs[$normalizedIP] = $true
}
}
}
}
catch {
Write-Warning "Error processing file $($file.Name): $_"
continue
}
}
}
catch {
Write-Error "Critical error occurred during IP collection: $_"
exit
}
# Second pass: Process unique IPs
Write-Host "`nPhase 2: Processing $($uniqueIPs.Count) unique IP addresses..."
foreach ($ip in $uniqueIPs.Keys) {
Write-Host "Processing IP: $ip"
$hostname = Get-RDPHostname -IPAddress $ip
if ($hostname) {
$computerName = $hostname.Split('.')[0]
$adInfo = Get-ADComputerInfo -ComputerName $computerName
$results += [PSCustomObject]@{
'IP address' = $ip
'Hostname' = $hostname
'Organizational unit' = $adInfo.OU
'Computer description' = $adInfo.Description
}
}
else {
$results += [PSCustomObject]@{
'IP address' = $ip
'Hostname' = "Not Found"
'Organizational unit' = "Not Found"
'Computer description' = "Not Found"
}
}
}
# Output results
if ($results.Count -gt 0) {
Write-Host "`nProcessing complete. Found $($results.Count) unique IP addresses."
$results | Format-Table 'IP address', 'Hostname', 'Organizational unit', 'Computer description' -AutoSize
# Export to CSV
$outputPath = ".\LDAP_Audit_Results_$(Get-Date -Format 'yyyyMMdd_HHmmss').csv"
$results | Export-Csv -Path $outputPath -NoTypeInformation
Write-Host "Results exported to: $outputPath"
}
else {
Write-Warning "No valid results found to display"
}
Reporting on data (Users)
If you wish to report on the users then the CSV files will have lots of users over and over again, so we need to extract the "User" field recursively from all those log files and present that in a text file that contains a list of unique usernames that are affected by these "simple bind" operations, here you can see we have 981 unique users.
This information can also give you a clue as to what that service is as well from depending on the data in that report.
Script : OutlineUser.ps1
# Set the path to your LDAP_Audit folder
$auditPath = ".\LDAP_Audit"
# Create an empty array to store unique users
$uniqueUsers = @()
# Get all CSV files recursively
$csvFiles = Get-ChildItem -Path $auditPath -Filter "*.csv" -Recurse
foreach ($file in $csvFiles) {
Write-Host "Processing file: $($file.FullName)"
try {
# Import CSV content
$csvContent = Import-Csv -Path $file.FullName
# Process each row in the CSV
foreach ($row in $csvContent) {
# Assuming there's a user-related column - adjust property name as needed
# Common names might be 'User', 'Username', 'SamAccountName', etc.
$userProperty = $row.PSObject.Properties | Where-Object {
$_.Name -match 'user|username|samaccountname|cn|displayname'
} | Select-Object -First 1
if ($userProperty) {
$username = $userProperty.Value
# Skip empty usernames and add unique ones to the array
if (![string]::IsNullOrWhiteSpace($username) -and $uniqueUsers -notcontains $username) {
$uniqueUsers += $username
}
}
}
}
catch {
Write-Warning "Error processing file $($file.Name): $_"
}
}
# Sort the users alphabetically
$uniqueUsers = $uniqueUsers | Sort-Object
# Export just the usernames to a file
$exportPath = ".\LDAP_Audit_Users_$(Get-Date -Format 'yyyyMMdd_HHmmss').txt"
$uniqueUsers | Out-File -FilePath $exportPath
Write-Host "`nTotal unique users found: $($uniqueUsers.Count)"
Write-Host "User list has been exported to: $exportPath"