Type to search 66 articles.

    Practical engineering guidance

    Finding NTLM before you turn it off

    NTLM is deprecated, NTLMv1 is already removed from the newest Windows releases, and nobody has an inventory. A read-only script that collects what is actually authenticating with NTLM, from the machines that know.

    Series: Security hardening

    • Active Directory
    • Security
    • Windows Server
    • PowerShell
    • Troubleshooting

    NTLM is deprecated. That word is doing a lot of work, so be precise about what it means as of today:

    • NTLMv1 is removed from Windows 11 version 24H2 and Windows Server 2025.
    • NTLMv2 and LAN Manager are deprecated — no longer developed — but still function. Microsoft states NTLMv2 will be removed from Windows Server in a future release.
    • Applications should call Negotiate, which tries Kerberos first and falls back to NTLM only when it must, rather than calling NTLM directly.

    Nothing here requires panic. It requires an inventory, because the organisations that will have a bad time are the ones that discover their NTLM dependencies at the moment a new server build stops accepting them.

    The problem: the inventory does not exist by default

    NTLM auditing is off. Until it is turned on by policy, the NTLM operational log is empty, and an empty log is indistinguishable from “we don’t use NTLM” — which is how confident, wrong answers get given.

    Three policies turn it on, at Computer Configuration → Windows Settings → Security Settings → Local Policies → Security Options. All three are audit-only: none of them blocks anything.

    Policy Where it logs What it captures
    Network security: Restrict NTLM: Audit Incoming NTLM Traffic The server receiving the connection Inbound NTLM to that machine
    Network security: Restrict NTLM: Outgoing NTLM traffic to remote servers (set to audit) The client making the connection Outbound NTLM from that machine
    Network security: Restrict NTLM: Audit NTLM authentication in this domain Domain controllers Domain NTLM authentication

    The third is the one to apply to domain controllers, and Microsoft’s guidance for the related Defender for Identity collection is explicit that the domain policy producing event 8004 should be applied to domain controllers only.

    Events land in Microsoft-Windows-NTLM/Operational, in the 8001–8004 range. I am not going to tell you what 8001, 8002 and 8003 each mean individually, because I could not verify those definitions against current Microsoft documentation — the script reports them by ID and count rather than asserting a meaning it cannot source. 8004 is the documented incoming-NTLM audit event on a domain controller.

    The second source: successful logons

    The security log’s event 4624 records every successful logon, and its detailed authentication section names the package. NtLmSsp as the logon process, with a Package Name (NTLM only) of NTLM V1 or NTLM V2, is the strongest available signal for NTLMv1 in particular.

    Two documented traps:

    Anonymous logons always report NTLM V1. There is no session key material, so the field is meaningless for ANONYMOUS LOGON. Microsoft’s guidance is to ignore the version for those events, and the script excludes them from the version counts for exactly this reason.

    Some third-party SMB clients negotiate in a way that is logged as NTLM V1 even when the exchange is not what you would call NTLMv1. Treat a small NTLMv1 count from an appliance as a lead to investigate, not a proven finding.

    The script

    Read-only. It changes no policy and blocks nothing.

    <#
    .SYNOPSIS
        Reports observed NTLM authentication from the NTLM operational and security logs.
    
    .DESCRIPTION
        Read-only. Summarises NTLM operational events (8001-8004) by ID, summarises successful
        NTLM logons from security event 4624 by workstation, account and NTLM version, and
        reports the local LAN Manager authentication level. Changes nothing.
    
        NTLM auditing must already be enabled by policy or the operational log will be empty.
        An empty log is not evidence that NTLM is unused.
    
        Run on domain controllers for domain-wide NTLM authentication, and on member servers
        for inbound NTLM to those servers. Run elevated: the security log requires it.
    
    .PARAMETER Days
        How far back to read. Default 7. The security log on a busy domain controller rolls
        quickly, so a long window may silently cover less time than requested.
    
    .PARAMETER MaxEvents
        Safety limit on 4624 events read per machine. Default 50000.
    
    .PARAMETER ReportPath
        Optional CSV path for the per-source summary.
    
    .EXAMPLE
        .\Get-NtlmUsage.ps1 -Days 7 -Verbose
    
    .EXAMPLE
        .\Get-NtlmUsage.ps1 -ReportPath C:\Reports\ntlm.csv
    
    .NOTES
        Read-only: this script makes no changes and enables no auditing.
        Output contains account names and workstation names. Treat as sensitive.
        ANONYMOUS LOGON events always report NTLM V1 and are excluded from version counts.
    #>
    
    #requires -Version 5.1
    #requires -RunAsAdministrator
    
    [CmdletBinding()]
    param(
        [ValidateRange(1, 90)]
        [int]$Days = 7,
    
        [ValidateRange(1000, 1000000)]
        [int]$MaxEvents = 50000,
    
        [ValidateScript({ Test-Path -Path (Split-Path -Path $_ -Parent) -PathType Container })]
        [string]$ReportPath
    )
    
    $ErrorActionPreference = 'Stop'
    $since = (Get-Date).AddDays(-$Days)
    
    # --- 1. Is auditing even on? -------------------------------------------------------------
    $lmCompatibility = try {
        (Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Lsa' -Name 'LmCompatibilityLevel' -ErrorAction Stop).LmCompatibilityLevel
    }
    catch { $null }
    
    $lmMeaning = switch ($lmCompatibility) {
        0 { 'Send LM and NTLM; DCs accept LM, NTLM and NTLMv2' }
        1 { 'Send LM and NTLM, NTLMv2 session security when negotiated' }
        2 { 'Send NTLM only; DCs accept LM, NTLM and NTLMv2' }
        3 { 'Send NTLMv2 only; DCs accept LM, NTLM and NTLMv2' }
        4 { 'Send NTLMv2 only; DCs refuse LM' }
        5 { 'Send NTLMv2 only; DCs refuse LM and NTLM' }
        $null { 'Not set locally; the effective value comes from policy or the OS default' }
        default { "Unexpected value: $lmCompatibility" }
    }
    
    # --- 2. NTLM operational log -------------------------------------------------------------
    $ntlmEvents = @{}
    $auditingOn = $false
    try {
        Get-WinEvent -FilterHashtable @{
            LogName   = 'Microsoft-Windows-NTLM/Operational'
            StartTime = $since
        } -ErrorAction Stop | ForEach-Object {
            $auditingOn = $true
            $ntlmEvents[$_.Id] = 1 + $(if ($ntlmEvents.ContainsKey($_.Id)) { $ntlmEvents[$_.Id] } else { 0 })
        }
    }
    catch {
        Write-Warning 'No events in Microsoft-Windows-NTLM/Operational for this window. NTLM auditing is probably not enabled by policy - an empty log is not proof that NTLM is unused.'
    }
    
    # --- 3. Successful NTLM logons from the security log --------------------------------------
    $logons = [System.Collections.Generic.List[object]]::new()
    try {
        $events = Get-WinEvent -FilterHashtable @{
            LogName   = 'Security'
            Id        = 4624
            StartTime = $since
        } -MaxEvents $MaxEvents -ErrorAction Stop
    
        Write-Verbose "Read $($events.Count) logon event(s); filtering for NTLM."
    
        foreach ($record in $events) {
            # Reading named properties from the event XML is far faster than string matching
            # the rendered message on a busy domain controller.
            $xml = [xml]$record.ToXml()
            $data = @{}
            foreach ($item in $xml.Event.EventData.Data) { $data[$item.Name] = $item.'#text' }
    
            # NTLM appears either as the authentication package itself or, more often, wrapped
            # in Negotiate after Kerberos was not available. Both count; '-' means neither.
            $package = $data['AuthenticationPackageName']
            $ntlmVersionField = $data['LmPackageName']
            $isNtlm = ($package -eq 'NTLM') -or ($ntlmVersionField -and $ntlmVersionField -ne '-')
            if (-not $isNtlm) { continue }
    
            $account = $data['TargetUserName']
            $isAnonymous = ($account -eq 'ANONYMOUS LOGON')
    
            $logons.Add([pscustomobject]@{
                    TimeCreated = $record.TimeCreated
                    Account     = $account
                    Domain      = $data['TargetDomainName']
                    Workstation = $data['WorkstationName']
                    SourceIp    = $data['IpAddress']
                    LogonType   = $data['LogonType']
                    Package     = $package
                    NtlmVersion = if ($isAnonymous) { 'ignored (anonymous)' } else { $ntlmVersionField }
                    Anonymous   = $isAnonymous
                })
        }
    }
    catch {
        Write-Warning "Could not read security event 4624: $($_.Exception.Message)"
    }
    
    $bySource = $logons |
        Group-Object Workstation, Account |
        ForEach-Object {
            $first = $_.Group[0]
            [pscustomobject]@{
                Workstation  = $first.Workstation
                Account      = $first.Account
                Domain       = $first.Domain
                SourceIp     = $first.SourceIp
                LogonCount   = $_.Count
                NtlmVersions = (($_.Group.NtlmVersion | Select-Object -Unique) -join '; ')
                LastSeenUtc  = ($_.Group.TimeCreated | Sort-Object | Select-Object -Last 1).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
            }
        } | Sort-Object LogonCount -Descending
    
    if ($ReportPath -and $bySource) {
        $bySource | Export-Csv -Path $ReportPath -NoTypeInformation -Encoding UTF8
        Write-Verbose "Wrote $ReportPath"
    }
    
    $ntlmV1 = @($logons | Where-Object { -not $_.Anonymous -and $_.NtlmVersion -eq 'NTLM V1' })
    if ($ntlmV1.Count -gt 0) {
        Write-Warning "$($ntlmV1.Count) non-anonymous logon(s) reported NTLM V1. NTLMv1 is removed in Windows Server 2025 and Windows 11 24H2: these will fail against new builds."
    }
    if (-not $auditingOn) {
        Write-Warning 'NTLM operational auditing appears to be off. Enable the Restrict NTLM audit policies before concluding anything from this report.'
    }
    
    [pscustomobject]@{
        Computer              = $env:COMPUTERNAME
        CollectedUtc          = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm')
        WindowDays            = $Days
        LmCompatibilityLevel  = $lmCompatibility
        LmCompatibilityMeaning = $lmMeaning
        NtlmAuditingObserved  = $auditingOn
        NtlmOperationalEvents = $ntlmEvents.GetEnumerator() | Sort-Object Name | ForEach-Object {
            [pscustomobject]@{ Id = $_.Name; Count = $_.Value }
        }
        NtlmLogonSources      = $bySource
        NtlmV1LogonCount      = $ntlmV1.Count
    }

    Turning the output into a decommissioning plan

    Group by Workstation and Account — which the script does — and the list resolves into a handful of categories:

    Service accounts authenticating to a name that is not registered. NTLM is frequently the symptom of a missing or duplicate service principal name: the client asked for Kerberos, could not get a ticket for that name, and fell back. Fixing the SPN removes the NTLM usage without touching the application.

    Access by IP address. Kerberos needs a name. Anything connecting to a bare address falls back to NTLM by design. The fix is a name and a matching SPN, not an NTLM exception.

    Appliances and third-party software. Vendor questions, and the reason to start early.

    Anything still reporting NTLM V1 for a real account. Highest priority: that traffic already fails against Windows Server 2025 and Windows 11 24H2.

    Once the list is short and owned, move to blocking — with the exception lists the restrict policies provide, so a known dependency continues while everything else stops. On Windows Server 2025 and Windows 11 24H2 the SMB client can also block NTLM outbound specifically, with its own server exception list, which is a narrower first step than a domain-wide restriction.

    One incompatibility worth knowing before you enforce: the RPC endpoint mapper client authentication setting is documented as incompatible with denying all incoming and outgoing NTLM. Check for it before turning the domain policies to deny.

    Verification and limits

    The NTLM operational channel name, event 8004 as the incoming NTLM audit event collected on domain controllers, the three Restrict NTLM audit policy names and their audit-only nature, the exception list policies, event 4624’s authentication package fields, the anonymous-logon and third-party SMB client caveats, the LmCompatibilityLevel values and meanings, the NTLMv1 removal in Windows Server 2025 and Windows 11 24H2 alongside NTLMv2’s deprecated-but-functional status, the SMB client NTLM blocking feature and the RPC endpoint mapper incompatibility were checked against current Microsoft documentation on 20 September 2026.

    The script was written for this article and statically analysed; it was not run against a production domain controller.

    The limits are inherent to the data. Event 4624 volume on a busy domain controller is large, and -MaxEvents will silently cover a shorter period than -Days suggests — the returned window is worth checking before drawing conclusions. Collect from every domain controller, not one. And an empty NTLM operational log means auditing is off far more often than it means NTLM is unused.

    References