Gotowe zapytania KQL
Jak wykryć password spray w logach Entra ID
Większość zespołów, które „monitorują logowania" w Microsoft 365, monitoruje w rzeczywistości jedną tabelę — i to nie tę, w której ląduje atak. To pojedyncza, prozaiczna pomyłka odpowiada za sporą część przypadków, w których password spray trwa tygodniami, zanim ktokolwiek go zauważy.
Poniżej znajdziesz sześć zapytań KQL gotowych do wklejenia do Log Analytics lub Microsoft Sentinel, opis kodów błędów, które faktycznie coś znaczą, oraz zasadę priorytetyzacji, która odwraca intuicję większości analityków.
Zanim uruchomisz pierwsze zapytanie
Trzy rzeczy decydują o tym, czy cokolwiek znajdziesz.
Po pierwsze — retencja. Domyślnie logowania w centrum administracyjnym Entra są dostępne przez 30 dni przy licencji P1/P2 i 7 dni przy planie darmowym. Kampanie tego typu rozciągają się na tygodnie. Bez wyeksportowania logów do Log Analytics przez ustawienia diagnostyczne (Diagnostic settings) analizujesz wyłącznie ogon zdarzenia.
Po drugie — właściwa tabela. SigninLogs zawiera logowania interaktywne. Logowania przez klientów typu public — w tym Azure CLI i przepływ ROPC — trafiają do AADNonInteractiveUserSignInLogs. Zespół, który odpytuje wyłącznie SigninLogs, jest ślepy dokładnie na ten wektor, który dziś jest najczęściej wykorzystywany. Wszystkie poniższe zapytania używają union isfuzzy=true na obu tabelach.
Po trzecie — pola, które robią robotę. ResultType, AppId, AuthenticationProtocol, AutonomousSystemNumber, IPAddress, ClientAppUsed. Ostatnie dwa pozwalają odróżnić jednego niezdarnego użytkownika od zautomatyzowanej infrastruktury.
Zapytanie 1: klasyczny wzorzec — jedno źródło, wiele kont
Definicja password spray sprowadza się do jednego kształtu w danych: niska liczba prób na konto, wysoka liczba kont.
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(24h)
| where ResultType in ("50126", "50053", "50056", "50057")
| summarize
Proby = count(),
KontaCelowane = dcount(UserPrincipalName),
Konta = make_set(UserPrincipalName, 25),
Aplikacje = make_set(AppDisplayName, 10)
by IPAddress, bin(TimeGenerated, 1h)
| where KontaCelowane >= 10
| order by KontaCelowane desc
50126 to nieprawidłowa nazwa użytkownika lub hasło — podstawowy sygnał. 50053 oznacza zadziałanie smart lockout lub blokadę IP. Próg dziesięciu kont na godzinę dobierz do wielkości tenanta; w organizacji poniżej 50 użytkowników obniż go do trzech.
Zapytanie 2: ROPC, czyli przepływ omijający MFA
Resource Owner Password Credentials przesyła login i hasło bezpośrednio do punktu końcowego tokenów. Bez monitu, bez drugiego składnika. Jeśli w Twoim tenancie ten przepływ w ogóle występuje, każda jego instancja zasługuje na spojrzenie.
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where AuthenticationProtocol == "ropc"
| summarize
Proby = count(),
Uzytkownicy = dcount(UserPrincipalName),
Sukcesy = countif(ResultType == "0")
by IPAddress, AppDisplayName, AutonomousSystemNumber
| order by Proby desc
To zapytanie warto uruchomić raz, „na sucho", nawet bez podejrzenia incydentu. Zerowy wynik to informacja, że można bezpiecznie zablokować ten przepływ polityką dostępu warunkowego. Wynik niezerowy to lista aplikacji, które trzeba przemigrować, zanim zablokujesz.
Zapytanie 3: kto naprawdę używa Azure CLI
Azure CLI (AppId = 04b07795-8ddb-461a-bbee-02f9e1bf7b46) jest domyślnie dostępny dla wszystkich użytkowników tenanta — również dla tych, którzy nie wiedzą, co to wiersz poleceń.
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| where AppId == "04b07795-8ddb-461a-bbee-02f9e1bf7b46"
| summarize
Proby = count(),
PierwszeUzycie = min(TimeGenerated),
OstatnieUzycie = max(TimeGenerated),
RozneIP = dcount(IPAddress),
Sukcesy = countif(ResultType == "0")
by UserPrincipalName
| order by Proby desc
Wynik tego zapytania to gotowa lista dla polityki ograniczającej dostęp do CLI. Konto księgowej, które nagle pojawia się w logach Azure CLI, nie wymaga analizy behawioralnej — wymaga resetu hasła.
Zapytanie 4: sukces tuż po fali porażek
To najważniejsze zapytanie w całym zestawie i jednocześnie to, którego prawie nikt nie pisze. Samo wykrycie tysięcy nieudanych logowań jest mało użyteczne — one zdarzają się stale. Wartość leży w korelacji: udane logowanie z tego samego adresu, z którego chwilę wcześniej sypały się porażki na dziesiątkach innych kont.
let okno = 1h;
let porazki =
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where ResultType != "0"
| summarize NieudaneKonta = dcount(UserPrincipalName), Porazki = count()
by IPAddress, Okno = bin(TimeGenerated, okno)
| where NieudaneKonta >= 10;
let sukcesy =
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where ResultType == "0"
| project CzasSukcesu = TimeGenerated, UserPrincipalName, IPAddress,
AppDisplayName, Okno = bin(TimeGenerated, okno);
porazki
| join kind=inner sukcesy on IPAddress, Okno
| project CzasSukcesu, UserPrincipalName, IPAddress, AppDisplayName, NieudaneKonta, Porazki
| order by CzasSukcesu desc
Każdy wiersz w wyniku to potencjalne przejęte konto. Dokładnie taki kształt mają wzorce zaobserwowane w kampanii z czerwca 2026, w której ponad 80 milionów nieudanych prób przełożyło się na kilkadziesiąt skutecznych przejęć — a większość ofiar dowiedziała się o tym z zewnątrz, nie z własnych logów.
Jeśli okno godzinne daje zbyt dużo szumu, zawęź je do 15 minut (let okno = 15m;). Jeśli daje zero wyników, poszerz do 6 godzin — powolne kampanie celowo rozciągają próby w czasie.
Zapytanie 5: ciche potwierdzenie poprawnego hasła
Nie każda nieudana próba oznacza złe hasło. Część kodów błędów mówi coś dokładnie odwrotnego: hasło było poprawne, ale logowanie zatrzymało się z innego powodu. Dla atakującego to sukces — para login-hasło została zweryfikowana.
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(7d)
| where ResultType in ("50055", "50072", "50074", "50076", "50079", "50158", "53003")
| summarize
Potwierdzone = dcount(UserPrincipalName),
Konta = make_set(UserPrincipalName, 50)
by IPAddress, ResultType
| where Potwierdzone >= 3
| order by Potwierdzone desc
50055 to hasło wygasłe, 50076 i 50079 — wymagane MFA, 53003 — blokada przez dostęp warunkowy. Znaczenie poszczególnych kodów znajdziesz w dokumentacji kodów błędów platformy tożsamości Microsoft. W praktyce: konta z tej listy mają hasła, które są w rękach atakującego. Reset jest obowiązkowy, nawet jeśli logowanie nigdy się nie powiodło.
Zapytanie 6: agregacja po numerze AS
Pojedyncze adresy IP rotują. Numery systemów autonomicznych rotują znacznie wolniej i dają obraz infrastruktury, a nie jej fragmentu.
union isfuzzy=true SigninLogs, AADNonInteractiveUserSignInLogs
| where TimeGenerated > ago(30d)
| summarize
Proby = count(),
Uzytkownicy = dcount(UserPrincipalName),
Sukcesy = countif(ResultType == "0"),
RozneIP = dcount(IPAddress)
by AutonomousSystemNumber
| extend Skutecznosc = round(100.0 * Sukcesy / Proby, 3)
| where Uzytkownicy >= 20
| order by Uzytkownicy desc
Priorytetyzacja: licz skuteczność, nie próby
Tu leży odwrócenie intuicji. Naturalnym odruchem jest zająć się najpierw źródłem, które wygenerowało najwięcej prób. To zwykle błąd — największy wolumen trafia w organizacje najczęściej „spryskiwane", a więc te, które już dawno utwardziły konfigurację i mają najniższą podatność. Ruch, który generuje 200 prób i jeden sukces, jest groźniejszy niż ten, który generuje 400 tysięcy prób i zero sukcesów.
Praktyczna kolejność:
- Konta z zapytania 4 (sukces po fali) — reakcja natychmiastowa: unieważnienie sesji, reset hasła, przegląd reguł skrzynki i zarejestrowanych metod MFA.
- Konta z zapytania 5 (potwierdzone hasło) — reset w ciągu doby.
- Konta uprzywilejowane w dowolnym z wyników — niezależnie od liczby prób.
- Reszta — materiał do tuningu polityk, nie do alertu.
Co zrobić, gdy zapytanie 4 zwróci wiersz
Reset hasła bez unieważnienia tokenów nie zamyka dostępu — wydany token odświeżania pozostaje ważny. Kolejność działań: unieważnij sesje (Revoke-MgUserSignInSession lub opcja w portalu), zresetuj hasło, następnie sprawdź w tej kolejności reguły przekierowań w skrzynce, nowo dodane metody uwierzytelniania, dołączone urządzenia i zgody na aplikacje OAuth. Atakujący, który ma godzinę dostępu, zwykle zostawia sobie co najmniej jedną z tych furtek.
Najczęstsze pytania
Czy te zapytania działają bez Microsoft Sentinel? Tak, wystarczy obszar roboczy Log Analytics z podpiętymi ustawieniami diagnostycznymi Entra ID. Sentinel dodaje reguły analityczne i automatyzację, ale sama składnia KQL jest identyczna.
Nie mam Log Analytics. Co mogę zrobić od ręki? W centrum administracyjnym Entra, w widoku logowań, filtruj po statusie „Failure" i kodzie błędu, a następnie przełącz zakładkę na logowania nieinteraktywne. To znacznie mniej wygodne, ale pozwala zweryfikować podstawowy wzorzec. Opis schematu logów logowań wyjaśnia różnice między typami zdarzeń.
Jak odróżnić atak od pomyłki użytkownika? Po liczbie unikalnych kont. Użytkownik, który zapomniał hasła, generuje wiele prób na jednym koncie. Password spray generuje po jednej–dwie próby na setkach kont, często w regularnych odstępach czasowych.
Czy warto alertować na każdą próbę z nietypowego kraju? Nie. Geolokalizacja generuje ogromny szum przy niskiej wartości sygnału — atakujący korzystają z infrastruktury rozproszonej geograficznie, a pracownicy podróżują. Kombinacja „liczba unikalnych kont + skuteczność" jest znacznie mocniejszym predyktorem.
