Verdachte aanmelding in Entra ID: zo onderzoek je het met KQL
Een gebruiker meldt dat hij een MFA-prompt kreeg die hij niet zelf startte. Of er komt een alert binnen voor een aanmelding vanuit een land waar je geen medewerkers hebt. Daarna begint het eigenlijke werk, en dat vraagt om drie antwoorden: is er daadwerkelijk ingelogd, hoe is dat gelukt, en wat is er daarna gedaan?
In dit artikel loop ik langs de volgorde die ik daarvoor aanhoud. De voorbeelden zijn KQL-queries op de Entra-logs in een Log Analytics-workspace. Alle gebruikersnamen, IP-adressen en ASN’s in de voorbeelden zijn fictief. Het artikel is een vervolg op device code phishing, waar ik kort noemde dat je op die aanmeldingen moet monitoren, maar niet hoe.
Begin bij de juiste data
Voordat je een query schrijft, moet je weten welke logs je hebt, want daar gaat het vaak al mis.
- Bewaartermijn. In Entra zelf bewaar je sign-in- en auditlogs 7 dagen (Free) of 30 dagen (P1 en P2). Dat is niet met terugwerkende kracht uit te breiden: wat verlopen is, krijg je niet terug. Wil je langer terugkijken, dan moet je via Diagnostic settings de logs naar een Log Analytics-workspace sturen, en dat moet vóór het incident gebeuren.
- Twee tabellen voor aanmeldingen.
SigninLogsbevat de interactieve aanmeldingen: de gebruiker voert iets in.AADNonInteractiveUserSignInLogsbevat de aanmeldingen waarbij een client namens de gebruiker een token verlengt of ophaalt. Dat laatste is precies waar je na een tokendiefstal naar moet kijken, en het is de tabel die mensen vergeten. Zorg dat beide in je diagnostic setting staan. AuditLogslaat zien wat er in de tenant is veranderd: nieuwe MFA-methoden, apparaten, rollen, toestemmingen.TimeGeneratedis niet het tijdstip van de aanmelding. Dat is het moment waarop de regel in Log Analytics binnenkwam. GebruikCreatedDateTimevoor het echte tijdstip van de aanmelding. Alles staat in UTC, terwijl het Entra-portaal lokale tijd laat zien. Reken dus altijd even om voordat je tijden vergelijkt met wat een gebruiker vertelt.
Stap 1: bouw een tijdlijn
Eén gebruiker, beide tabellen, op volgorde van tijd:
let upn = "j.jansen@contoso.com";
let start = ago(14d);
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > start
| where UserPrincipalName =~ upn
| project CreatedDateTime, Tabel = Type, IsInteractive, ResultType,
IPAddress, AutonomousSystemNumber, Location,
AppDisplayName, ResourceDisplayName, ClientAppUsed, UserAgent,
AuthenticationRequirement, AuthenticationProtocol,
OriginalTransferMethod, SessionId
| sort by CreatedDateTime asc
Zo ziet een (fictief) fragment uit zo’n tijdlijn eruit als je het samenvat:
| Tijd (UTC) | Tabel | ResultType | IP / ASN | Wat het zegt |
|---|---|---|---|---|
| 08:02 | SigninLogs | 50126 | 203.0.113.45 / AS64500 | Verkeerd wachtwoord |
| 08:02 | SigninLogs | 50126 | 203.0.113.45 / AS64500 | Nog een keer |
| 08:04 | SigninLogs | 50076 | 203.0.113.45 / AS64500 | MFA vereist |
| 08:05 | SigninLogs | 500121 | 203.0.113.45 / AS64500 | MFA niet voltooid |
| 08:09 | SigninLogs | 0 | 203.0.113.45 / AS64500 | Geslaagd |
| 08:10 | NonInteractive | 0 | 203.0.113.45 / AS64500 | Mail en Teams via tokens |
Wat je hier leest, in volgorde:
ResultType0 is geslaagd. Alles anders is een fout of een onderbreking. Een foutcode opzoeken kan op login.microsoftonline.com/error of in de referentielijst van Microsoft.- 50126 is een ongeldig wachtwoord, 50053 een geblokkeerd account (te veel pogingen of een IP met bekende kwaadaardige activiteit), 53003 een aanmelding die door Conditional Access is geblokkeerd.
- 50074 en 50076 betekenen dat MFA nodig is. 500121 betekent dat de MFA-stap niet is geslaagd.
Dit is mijn eigen lezing en geen vaststaand feit: een MFA-vraag verschijnt pas nadat de eerste factor is gelukt. Een 50076 vanaf een vreemd IP wijst dus erop dat het wachtwoord al bekend was, ook als de aanmelding zelf mislukte. Dat verandert de ernst van het incident: het gaat dan niet meer om een mislukte poging, maar om een gelekt wachtwoord.
Een reeks 500121’s gevolgd door een geslaagde aanmelding past bij MFA-vermoeidheid, waarbij een gebruiker uiteindelijk een prompt goedkeurt om van het geklop af te zijn. Of dat hier zo is, kun je alleen vaststellen door de gebruiker te vragen wat hij heeft gezien.
Stap 2: van één aanmelding naar het hele beeld
Een enkel IP-adres zegt weinig. Vat de tijdlijn daarom samen per bron:
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(14d)
| where UserPrincipalName =~ "j.jansen@contoso.com"
| summarize Pogingen = count(),
Geslaagd = countif(ResultType == "0"),
Mislukt = countif(ResultType != "0"),
Eerste = min(CreatedDateTime),
Laatste = max(CreatedDateTime),
Apps = make_set(AppDisplayName, 10),
Foutcodes = make_set(ResultType, 10)
by IPAddress, AutonomousSystemNumber, Location
| sort by Eerste asc
Zoek naar bronnen die je niet kent, bronnen die vlak voor het eerste succes begonnen met mislukken, en een nieuwe bron die na een geslaagde aanmelding veel apps aanspreekt. Let wel op valse alarmen: een telefoon die van wifi naar 5G wisselt, een VPN of een gebruiker op reis zorgen allemaal voor nieuwe IP’s. De ASN (het netwerk van de provider) is vaak stabieler dan het IP-adres zelf. Het landveld (Location) is een indicatie, geen bewijs.
Stap 3: is dit één account of een campagne?
Kijk vanuit het IP-adres de andere kant op: hoeveel accounts proeft één bron?
let verdachte_ips =
SigninLogs
| where TimeGenerated > ago(24h)
| where ResultType in ("50126", "50053")
| summarize Accounts = dcount(UserPrincipalName), Pogingen = count() by IPAddress
| where Accounts >= 10
| project IPAddress;
SigninLogs
| where TimeGenerated > ago(24h)
| where IPAddress in (verdachte_ips)
| where ResultType in ("0", "50074", "50076", "500121")
| project CreatedDateTime, UserPrincipalName, IPAddress, ResultType, AppDisplayName
| sort by CreatedDateTime asc
De eerste helft vindt IP-adressen die bij tien of meer verschillende accounts een fout wachtwoord of een lockout veroorzaakten (de drempel is een voorbeeld en hangt af van je omgeving). De tweede helft laat zien welke van die accounts daarna wel voorbij het wachtwoord kwamen. Dat zijn de accounts die je als eerste opruimt, ook als ze zelf nog niets gemeld hebben. Zo’n wachtwoord-spray (veel accounts, een paar wachtwoorden) zie je alleen als je per IP-adres kijkt: per account zijn het maar één of twee fouten.
Stap 4: is het token elders gebruikt dan waar is ingelogd?
Dit is de stap die bij tokendiefstal het verschil maakt. Bij aanvallen waarbij een aanvaller een sessie overneemt, bijvoorbeeld via een phishingpagina die tussen gebruiker en Microsoft in zit, of via device code phishing, is de aanmelding zelf schoon: de echte gebruiker deed alles. Het verraderlijke zit in wat er daarna gebeurt: tokens die worden verlengd vanaf een ander netwerk dan waar is ingelogd.
let upn = "j.jansen@contoso.com";
let login =
SigninLogs
| where TimeGenerated > ago(14d)
| where UserPrincipalName =~ upn and ResultType == "0"
| project SessionId, LoginIP = IPAddress, LoginASN = AutonomousSystemNumber;
AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(14d)
| where UserPrincipalName =~ upn and ResultType == "0"
| join kind=inner login on SessionId
| where AutonomousSystemNumber != LoginASN
| summarize Tokenverzoeken = count(),
Apps = make_set(AppDisplayName, 10),
Eerste = min(CreatedDateTime)
by SessionId, LoginIP, LoginASN, IPAddress, AutonomousSystemNumber
Dezelfde sessie, twee verschillende netwerken: dat is een sterk signaal. Houd er rekening mee dat SessionId niet in elke regel gevuld is, en dat een telefoon die van netwerk wisselt ook zo’n patroon geeft. Test deze query dus eerst op een account waarvan je weet dat het schoon is, om te zien hoeveel ruis je krijgt.
Specifiek voor device code-aanmeldingen heeft Microsoft twee velden die je nodig hebt. AuthenticationProtocol toont de directe aanmelding met device code. OriginalTransferMethod toont of een latere aanmelding of tokenverlenging nog bij zo’n sessie hoort. Gebruik je alleen het eerste veld, dan mis je de vervolgsessies:
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where AuthenticationProtocol =~ "deviceCode"
or tolower(OriginalTransferMethod) has_any ("devicecode", "device code")
| summarize Aanmeldingen = count(), Gebruikers = dcount(UserPrincipalName)
by AppDisplayName, IPAddress, AutonomousSystemNumber
| sort by Aanmeldingen desc
De exacte waarde van OriginalTransferMethod in Log Analytics heb ik niet kunnen verifiëren, vandaar de ruime has_any. Kijk in je eigen data naar de waarden die voorkomen en pas het filter daarop aan.
Stap 5: wat deed de aanvaller na het inloggen?
Een aanmelding is niet de schade. De schade zit in wat er daarna is veranderd. Begin met de AuditLogs voor het account, zowel acties door de gebruiker als acties óp de gebruiker:
let upn = "j.jansen@contoso.com";
AuditLogs
| where TimeGenerated > ago(14d)
| where tostring(InitiatedBy.user.userPrincipalName) =~ upn
or tostring(TargetResources) has upn
| project TimeGenerated, OperationName, Category, Result,
Doel = tostring(TargetResources[0].displayName)
| sort by TimeGenerated asc
Let op de volgende dingen, in deze volgorde van belang:
- Nieuwe authenticatiemethoden. Een aanvaller die een eigen telefoon of authenticator toevoegt, komt ook na een wachtwoordreset gewoon weer binnen.
- Nieuwe of geregistreerde apparaten. Een eigen apparaat in de tenant geeft duurzamere toegang dan een los token.
- Toestemmingen voor apps en nieuwe app-registraties. Een app met rechten op mail blijft werken, ook als de gebruiker een nieuw wachtwoord heeft.
- Wijzigingen in rollen en groepen.
De precieze namen van de operaties verschillen per soort wijziging en per tenant. Begin daarom met een brede lijst en filter pas daarna, in plaats van op een veronderstelde naam te zoeken en niets te vinden.
Buiten Entra: kijk in de mailbox naar inboxregels en doorstuurinstellingen. Dat is een klassieker, omdat een aanvaller zo meelift op de mail zonder ingelogd te hoeven blijven:
Get-InboxRule -Mailbox j.jansen@contoso.com |
Select-Object Name, Enabled, ForwardTo, ForwardAsAttachmentTo, RedirectTo, DeleteMessage
Get-Mailbox j.jansen@contoso.com | Select-Object ForwardingAddress, ForwardingSmtpAddress
Stap 6: opruimen in de juiste volgorde
De volgorde telt, want een aanvaller met een token heeft het wachtwoord niet nodig.
Leg eerst vast wat je ziet. Exporteer de relevante logregels voordat je iets wijzigt. Verlopen logs en gewijzigde configuratie krijg je niet terug.
Zet de mailbox- en tenantwijzigingen van de aanvaller terug: verwijder de onbekende authenticatiemethoden, apparaten, app-toestemmingen en inboxregels die je in stap 5 hebt gevonden.
Reset het wachtwoord en trek de sessies in. Intrekken doet Microsoft Graph via
revokeSignInSessions. Dat maakt alle refresh tokens en sessiecookies van de gebruiker ongeldig:Connect-MgGraph -Scopes "User.RevokeSessions.All" Revoke-MgUserSignInSession -UserId "j.jansen@contoso.com"Volgens Microsoft kan het enkele minuten duren voordat het effect heeft. Voor zover ik weet blijven al uitgegeven access tokens tot hun vervaltijd geldig, tenzij Continuous Access Evaluation ze eerder afkapt. Reken er dus op dat er kort nog toegang kan zijn, en controleer na het intrekken of er daarna nog aanmeldingen van de bron zijn.
Controleer daarna opnieuw in de logs, een uur en een dag later, of dezelfde bron nog actief is.
Kijk breder. Is dit account het enige? Heeft het IP in stap 3 andere accounts geraakt? Is er mail gelezen of verstuurd dat onder een meldplicht kan vallen?
Valkuilen
- Alleen
SigninLogsbekijken en dus alles missen wat via tokenverlenging gebeurt. - Redeneren op
Location. Een IP-adres in een vreemd land is een aanleiding om te kijken, geen bewijs. Een verdacht IP in het land van de gebruiker is evengoed mogelijk (een VPN of hosting-provider). - Eindigen bij een wachtwoordreset. Dat helpt niet tegen een aanvaller die tokens, een eigen MFA-methode of een app-toestemming heeft.
- De logs te laat veiligstellen. Zonder export naar een workspace is je zicht beperkt tot 7 of 30 dagen, en wat een aanvaller voorheen deed is dan al weg.
- Tijdzones door elkaar halen. Log Analytics werkt in UTC, het portaal in lokale tijd.
Samengevat
Beantwoord de drie vragen in volgorde. Is er ingelogd (tijdlijn, beide tabellen)? Hoe (wachtwoord, MFA, token)? En wat is er daarna veranderd (auditlogs, mailbox)? Pas als je dat weet, ruim je op, in de volgorde: eerst zijn wijzigingen terugdraaien, dan wachtwoord en sessies. De queries hierboven zijn een uitgangspunt dat je op je eigen omgeving moet toetsen: draai ze eerst op een schoon account, zodat je weet hoe normaal verkeer eruitziet voordat je op een echt incident moet beoordelen wat afwijkt.
Bronnen
- Microsoft Learn: SigninLogs-tabel (Azure Monitor)
- Microsoft Learn: AADNonInteractiveUserSignInLogs-tabel
- Microsoft Learn: Entra data retention
- Microsoft Learn: veelgestelde vragen over Entra monitoring (TimeGenerated en CreatedDateTime)
- Microsoft Learn: Conditional Access, authentication flows (protocol tracking)
- Microsoft Learn: security operations voor gebruikersaccounts in Entra ID
- Microsoft Learn: Entra authentication and authorization error codes
- Microsoft Graph: user: revokeSignInSessions