← Alle berichten

Brute force of password spray op Windows? Zo lees je het uit event 4625 en 4624

14 oktober 2026 Security-onderzoek 7 min lezen

Een reeks mislukte aanmeldingen op een server is zelden meteen een incident, maar soms wel het eerste teken van een aanval. Het verschil zit in de vorm: één account met veel pogingen is iets anders dan veel accounts met één poging, en het echte alarm is een geslaagde aanmelding vlak na de mislukte. Dit artikel laat zien hoe je dat uit de Windows-beveiligingslog haalt, met PowerShell op één machine en met KQL als je Defender for Endpoint gebruikt.

Alle voorbeelden gebruiken fictieve gegevens (contoso.local, documentatie-IP’s uit 203.0.113.0/24).

Wat de twee events je vertellen

Event 4625 is een mislukte aanmelding, event 4624 een geslaagde. Beide worden vastgelegd op de computer waar de poging plaatsvond, dus op een domeincontroller zie je pogingen tegen het domein, en op een server of werkstation zie je pogingen tegen die machine (en dus ook tegen lokale accounts). Dat dwingt tot een keuze waar je kijkt: RDP-pogingen tegen een server zie je op die server, niet op de DC.

De velden die ertoe doen, volgens de documentatie van Microsoft:

  • Logon Type: 3 is netwerk (SMB, bijvoorbeeld), 10 is RemoteInteractive (RDP), 2 is interactief aan de console, 4 batch, 5 service. Een 4624 met type 10 vanaf een extern IP-adres is het eerste dat je wilt zien.
  • Status en Sub Status: de reden van de mislukking. Voor onderzoek zijn drie waarden het nuttigst: 0xC0000064 (gebruikersnaam bestaat niet), 0xC000006A (juiste gebruikersnaam, fout wachtwoord) en 0xC0000072 (account uitgeschakeld). In 4625-events staat de bruikbare waarde meestal in de Sub Status, controleer dus beide velden.
  • Source Network Address en Workstation Name: waar de poging vandaan kwam. Let op de kanttekening van Microsoft: deze velden zijn afhankelijk van protocol en context. Bij NTLM staat er vaak een werkstationnaam maar geen TCP/IP-gegevens, bij Kerberos-netwerklogons staat er juist vaak geen werkstationnaam.
  • Authentication Package: NTLM, Kerberos of Negotiate. Wie NTLM zoveel mogelijk heeft teruggedrongen, ziet een NTLM-aanmelding als afwijking.

Het onderscheid tussen status 0xC0000064 en 0xC000006A is direct bruikbaar. Veel pogingen met een onbekende gebruikersnaam wijzen op enumeratie of een lijst met gegokte namen. Veel pogingen met bestaande namen en een fout wachtwoord wijzen op iemand die de gebruikersnamen al kent, bijvoorbeeld uit een eerder datalek of uit je mailadressen. Microsoft noemt in dezelfde documentatie ook dat meerdere 0xC0000064-events achter elkaar een teken van een enumeratie-aanval kunnen zijn.

Drie patronen herkennen

Dit is mijn eigen indeling, niet een officiële:

PatroonHoe het eruitziet in 4625Wat het meestal is
Brute forceEén doelaccount, veel pogingen in korte tijdGericht raden op één account, of een script met een slechte instelling
Password sprayVeel accounts, een of twee pogingen per account, vanaf één bronEén veelvoorkomend wachtwoord over de hele gebruikerslijst
Verdwaald wachtwoordEén account, vaste bron, regelmatig intervalEen geplande taak, een service of een apparaat met een oud wachtwoord

Dat laatste patroon is verreweg het meest voorkomende en het is de reden dat je altijd eerst naar de bron en het interval kijkt voordat je alarm slaat. Een vast ritme van elke 10 minuten vanaf een intern IP wijst bijna altijd op een oude opgeslagen verbinding, niet op een aanvaller.

Password spraying is de variant die lockout-beleid ontloopt, omdat elk account maar één of twee keer wordt geprobeerd. Daardoor zie je het niet in lockout-meldingen, wel in 4625-aantallen per bron.

Per bron tellen met PowerShell

Dit script leest de laatste 24 uur aan 4625-events op de lokale machine en telt per bron-IP het aantal pogingen en het aantal verschillende accounts. Het is bedoeld als eerste blik, niet als detectieregel.

$start = (Get-Date).AddHours(-24)

$events = Get-WinEvent -FilterHashtable @{
    LogName   = 'Security'
    Id        = 4625
    StartTime = $start
} -ErrorAction SilentlyContinue

$rows = foreach ($e in $events) {
    $x = [xml]$e.ToXml()
    $d = @{}
    foreach ($n in $x.Event.EventData.Data) { $d[$n.Name] = $n.'#text' }
    [pscustomobject]@{
        Tijd      = $e.TimeCreated
        Account   = $d['TargetUserName']
        Bron      = $d['IpAddress']
        LogonType = $d['LogonType']
        Status    = $d['Status']
        SubStatus = $d['SubStatus']
    }
}

# Per bron: aantal pogingen en aantal verschillende accounts
$rows | Group-Object Bron | ForEach-Object {
    [pscustomobject]@{
        Bron       = $_.Name
        Pogingen   = $_.Count
        Accounts   = ($_.Group.Account | Sort-Object -Unique).Count
        Eerste     = ($_.Group.Tijd | Measure-Object -Minimum).Minimum
        Laatste    = ($_.Group.Tijd | Measure-Object -Maximum).Maximum
    }
} | Sort-Object Pogingen -Descending | Select-Object -First 15

Zo lees je de uitkomst:

Bron          Pogingen Accounts Eerste               Laatste
----          -------- -------- ------               -------
203.0.113.45       412      398 14-10-2026 02:11     14-10-2026 03:02
10.20.4.17         144        1 13-10-2026 09:40     14-10-2026 09:20

De eerste regel (fictief) is een spraypatroon: honderden pogingen, bijna evenveel accounts, binnen een uur. De tweede is een verdwaald wachtwoord: één account, een intern IP, een regelmatig interval over een hele dag.

Een paar valkuilen:

  • IpAddress is leeg (-) of ::1 bij lokale en sommige NTLM-pogingen. Een lege bron betekent niet dat er niets verdachts is.
  • Bij grote logs is Get-WinEvent met XML-parsing traag. Filter daarom altijd op Id en StartTime in de hashtable, niet erna met Where-Object.
  • De Security-log roteert. Op een drukke server staan er soms maar een paar uur in. Zonder doorsturen naar een centrale plek ben je het bewijs kwijt.

De vraag die er echt toe doet: is er daarna iemand binnengekomen?

Een spray is pas een incident als hij slaagt. Zoek dus bij de bronnen met veel mislukte pogingen naar een geslaagde aanmelding (4624) vanaf hetzelfde IP-adres, in de uren erna:

$verdacht = '203.0.113.45'

Get-WinEvent -FilterHashtable @{
    LogName   = 'Security'
    Id        = 4624
    StartTime = (Get-Date).AddHours(-24)
} | ForEach-Object {
    $x = [xml]$_.ToXml()
    $d = @{}
    foreach ($n in $x.Event.EventData.Data) { $d[$n.Name] = $n.'#text' }
    if ($d['IpAddress'] -eq $verdacht) {
        [pscustomobject]@{
            Tijd      = $_.TimeCreated
            Account   = $d['TargetUserName']
            LogonType = $d['LogonType']
            Package   = $d['AuthenticationPackageName']
            Elevated  = $d['ElevatedToken']
        }
    }
}

Een treffer hier, zeker met logon type 10 of 3 en een account dat eerder in de mislukte reeks stond, behandel je als compromittering totdat het tegendeel bewezen is. Het veld Elevated Token (Yes) zegt dat de sessie beheerrechten heeft, wat de ernst direct verhoogt. Met het Logon ID koppel je de 4624 aan vervolgevents in dezelfde sessie, zoals het toekennen van speciale rechten (4672), zoals de documentatie van Microsoft aangeeft.

Hetzelfde in Defender: KQL

Als je machines in Microsoft Defender for Endpoint zijn aangemeld, staat dit in de tabel DeviceLogonEvents (zie het schema). Dat scheelt je het verzamelen per machine. De onderstaande query zoekt bronnen met veel mislukte pogingen over meerdere accounts, en laat zien of daarna een aanmelding is geslaagd. Controleer de exacte waarden van ActionType in de schemareferentie van je eigen tenant, want die kunnen per periode verschillen.

let venster = 24h;
let spray = DeviceLogonEvents
    | where Timestamp > ago(venster)
    | where ActionType == "LogonFailed"
    | where isnotempty(RemoteIP)
    | summarize Pogingen = count(),
                Accounts = dcount(AccountName),
                Machines = dcount(DeviceName),
                Eerste = min(Timestamp),
                Laatste = max(Timestamp)
        by RemoteIP
    | where Pogingen >= 20 and Accounts >= 5;
spray
| join kind=leftouter (
    DeviceLogonEvents
    | where Timestamp > ago(venster)
    | where ActionType == "LogonSuccess"
    | project RemoteIP, SuccesTijd = Timestamp, DeviceName, AccountName, LogonType
) on RemoteIP
| where isnull(SuccesTijd) or SuccesTijd > Eerste
| project RemoteIP, Pogingen, Accounts, Machines, SuccesTijd, DeviceName, AccountName, LogonType
| order by Pogingen desc

De drempels (20 pogingen, 5 accounts) zijn een startpunt. Pas ze aan op je eigen omgeving; een bron met 3 accounts en 50 pogingen is ook interessant, alleen een ander patroon. Interne IP-adressen met een oud wachtwoord tref je met dezelfde query, en dat is juist een nuttige bijvangst: je ruimt oude verbindingen op.

Wat je ermee doet

Voorkomen is effectiever dan achteraf zoeken:

  1. Zet RDP niet open naar internet. Dit is de bron van het grootste deel van de externe pogingen die je ziet. Zet RDP achter een VPN of een gateway met MFA.
  2. Zet auditing aan. Zonder de subcategorieën Audit Logon en Audit Account Lockout bestaan 4624 en 4625 niet. Controleer het met auditpol /get /subcategory:"Logon".
  3. Stuur de logs weg van de machine. Naar een SIEM of Log Analytics, met genoeg retentie om een week terug te kijken.
  4. Alarmeer op de combinatie, niet op losse mislukte pogingen: veel mislukt vanaf één bron, gevolgd door een succes.
  5. Beperk lokale en service-accounts. Microsoft raadt aan alle 4625-events voor lokale en service-accounts te monitoren, omdat die normaal niet mogen mislukken.
  6. Ruim verdwaalde wachtwoorden op. Wat elke 10 minuten faalt, is ruis die echte signalen maskeert.

Beperkingen

Wat dit niet vangt: aanvallen tegen cloudaccounts lopen langs Entra ID en niet langs deze logs (zie Verdachte aanmelding in Entra ID). En een aanvaller die langzaam sprayt, verspreid over dagen en bronnen, valt onder jouw drempels. Daar helpt alleen een langere periode en een basislijn. De drempels in dit artikel zijn mijn eigen keuzes, geen vaste norm.

Bronnen