// Module 03 // Hunting in the Logs

The thrill
of the
hunt.

Module 02 covered how attackers move. This module covers how you find them. KQL, Sentinel, log tables, detection architecture, and hunting queries that actually fire against real attack patterns.

// Prerequisites
Understanding of AiTM, token theft, and service principal abuse
Basic familiarity with Entra ID sign-in logs helpful
Duration
~55 minutes
Level
Intermediate
Assessment
Timed / Log analysis

The Log
Landscape

Before you can hunt anything, you need to know what logs exist, what they contain by default, and critically, what is not logged unless you explicitly enable it.

The Core Problem
Most tenants are flying blind on the things that matter
Entra ID generates a large volume of logs. The sign-in logs most analysts rely on only capture interactive user sign-ins by default. Service principal sign-ins, managed identity authentications, and non-interactive user sign-ins are in separate tables that require explicit configuration to forward to your SIEM. An attacker operating exclusively through service principals generates zero entries in the table most analysts are watching.
Table
What It Contains
Default On
Why It Matters for Detection
SigninLogs
Interactive user sign-ins
Yes
AiTM detection, impossible travel, MFA anomalies
AADNonInteractiveUserSignInLogs
Refresh token redemptions
No
Detects token replay, FOCI pivots, long-lived session abuse
AADServicePrincipalSignInLogs
Service principal authentications
No
Detects anomalous SP activity, geographic anomalies on NHIs
AADManagedIdentitySignInLogs
Managed identity auth events
No
Detect unusual managed identity usage or new resource access
AuditLogs
Directory changes, consent grants, credential additions
Yes
Certificate backdoors, consent phishing, role assignments
BehaviorAnalytics
UEBA anomaly scores per entity
No (requires P2 + UEBA)
Entity-level risk scoring, correlation with sign-in events
AlertInfo / AlertEvidence
Defender XDR alert data
Unified SOC only
Cross-platform correlation in the Unified Security Operations Platform
The Unified SOC as of March 2025: Microsoft merged Sentinel, Defender XDR, and Security Copilot into a single platform. In the Unified SOC Advanced Hunting interface, the SecurityAlert table no longer exists. It was replaced by AlertInfo and AlertEvidence. If you are writing detections that reference SecurityAlert in the old Sentinel portal, they need to be rewritten for the Unified SOC. This catches a lot of teams off guard.
Critical Gap 01
Service Principal Logs Not Enabled
AADServicePrincipalSignInLogs must be explicitly added as a diagnostic setting in Entra ID and forwarded to your Log Analytics workspace. If you have not done this, every attack that runs through a compromised service principal is invisible in your SIEM. This includes the entire NordicCorp scenario from Module 02.
Critical Gap 02
UEBA Dependency on EntityAnalytics
The BehaviorAnalytics table requires EntityAnalytics to be enabled first. Attempting to enable UEBA without it returns an error: "Enabling Ueba requires EntityAnalytics to be enabled." EntityAnalytics must be configured with Microsoft Entra ID as the entity provider before UEBA can run. The Sentinel API parameter still uses the old string AzureActiveDirectory.
Critical Gap 03
Non-Interactive Sign-in Logs
Every time a refresh token is redeemed for a new access token, it appears in AADNonInteractiveUserSignInLogs, not SigninLogs. If this table is not enabled, you cannot see FOCI pivots, long-lived token abuse, or the pattern of a compromised session being actively used between interactive sign-ins. This is one of the most valuable tables for detecting post-AiTM activity.
Critical Gap 04
Microsoft Graph API Audit Logging
AzureHound calls the Microsoft Graph API as the authenticated user. These calls appear in AuditLogs only if Graph API unified audit logging is enabled. Most tenants have AuditLogs forwarding to Sentinel but have not checked whether Graph API enumeration activity is actually captured. Without this, AzureHound runs silently.
Start here

Reading
Sign-in Logs

The SigninLogs table is where most identity hunting starts. Understanding which fields matter, which combinations are high-fidelity indicators, and what AiTM and device code flow look like in raw log data.

Key Fields
What to look at and why
Not every field in SigninLogs is equally useful for detection. The following fields consistently appear in high-fidelity identity attack indicators. Know what normal looks like for each before writing detection logic against them.
Field
What It Tells You
ResultType
0 = success. Non-zero = failure. Specific codes matter: 50126 is invalid credentials, 50158 is MFA required but not performed, 70011 is invalid scope.
UserAgent
AiTM kits rotate user agents within the same CorrelationId. Sneaky 2FA uses 5 specific slightly-outdated user agent strings. Mismatches within a single authentication session are a high-confidence indicator.
CorrelationId
Groups events belonging to the same authentication session. Multiple UserAgent values within one CorrelationId where DeviceDetail.deviceId is empty is the Eye Security Sneaky 2FA detection signature.
AuthenticationRequirement
multiFactorAuthentication means MFA was required. singleFactorAuthentication means it was not. A successful sign-in with singleFactorAuthentication on a privileged account is worth investigating.
ConditionalAccessStatus
success, failure, or notApplied. notApplied means no CA policy matched. A successful sign-in with notApplied on a critical account means your CA coverage has a gap.
ClientAppUsed
The legacy authentication protocol if used. IMAP, POP3, SMTP, Exchange ActiveSync. Any sign-in via these bypasses Conditional Access entirely. These should not appear in a hardened tenant.
DeviceDetail.deviceId
Empty string means the device is not Entra-joined or registered. A successful sign-in with no device ID that triggers MFA against a policy requiring compliant devices is a CA gap or token replay indicator.
RiskLevelAggregated
Entra ID Protection risk score. none, low, medium, high. Requires P2. A high-risk successful sign-in that did not trigger a risk policy re-auth is a detection rule gap to investigate.
AiTM Signature in Sign-in Logs
What Sneaky 2FA and similar kits look like
AiTM phishing kits that rotate user agents during the proxy session leave a specific pattern in SigninLogs. Within a single CorrelationId, the UserAgent field changes between requests while DeviceDetail.deviceId remains empty. Eye Security documented this for Sneaky 2FA using a Jaccard index comparison of user agent strings to detect kit fingerprints without hardcoding specific values. The pattern persists across kit variants because it is a function of how reverse proxy architecture handles request forwarding, not kit-specific behaviour.
KQL // AiTM User Agent Rotation Detection
// Detect AiTM kits via user agent rotation within same CorrelationId // Based on Eye Security Sneaky 2FA research, 2025 let suspiciousSignins = SigninLogs | where DeviceDetail.deviceId == '' | summarize userAgents = make_set(UserAgent) by CorrelationId | where array_length(userAgents) > 2; let correlationIds = suspiciousSignins | mv-expand i = range(0, array_length(userAgents) - 2) | mv-expand j = range(i + 1, array_length(userAgents) - 1) | extend ua1 = tostring(userAgents[toint(i)]) | extend ua2 = tostring(userAgents[toint(j)]) | extend jaccardIndex = jaccard_index(to_utf8(ua1), to_utf8(ua2)) | where jaccardIndex < 0.8 | summarize by CorrelationId; SigninLogs | where ResultType == 0 | where CorrelationId in (correlationIds) | project TimeGenerated, UserPrincipalName, IPAddress, UserAgent, CorrelationId, AppDisplayName
KQL // Legacy Auth Detection
// Surface any successful sign-in using legacy authentication protocols // These bypass Conditional Access entirely. Should return zero rows in a hardened tenant. SigninLogs | where ResultType == 0 | where ClientAppUsed in ("IMAP4", "POP3", "SMTP Auth", "Exchange ActiveSync", "Other clients") | summarize Count = count() by UserPrincipalName, ClientAppUsed, IPAddress | sort by Count desc
Impossible travel detection: The SigninLogs table includes LocationDetails with countryOrRegion, state, and city fields. A sign-in from Stockholm followed by one from Singapore 25 minutes later is physically impossible. The built-in Identity Protection rule for this exists but runs on a delay. A custom KQL rule joining consecutive sign-ins by UserPrincipalName and comparing timestamps against geographic distance gives you faster and more tunable detection.

Hunting
Audit Logs

The AuditLogs table captures directory changes. Certificate additions to service principals, OAuth consent grants, role assignments, Conditional Access policy changes. This is where persistence mechanisms leave their forensic footprint.

Why AuditLogs Matter More Than Sign-in Logs for Persistence
The attacker already authenticated. Now look at what they changed.
Sign-in logs tell you when someone authenticated. Audit logs tell you what they did with that access. Certificate backdoors, consent grants, role assignments, and federated credential additions all appear in AuditLogs. An attacker who established persistence and then went quiet will have no ongoing sign-in activity to detect. But the AuditLogs entry for the certificate or credential addition remains permanently as the forensic artifact.
KQL // Certificate Backdoor Detection
// Detect new credential additions to app registrations and service principals // Tagged Solorigate / NOBELIUM in Microsoft's official Sentinel analytic rules AuditLogs | where OperationName has_any ("Add service principal", "Certificates and secrets management") | where Result =~ "success" | mv-apply TargetResource = TargetResources on ( where TargetResource.type =~ "Application" | extend targetName = tostring(TargetResource.displayName) | extend keyEvents = TargetResource.modifiedProperties) | mv-apply Property = keyEvents on ( where Property.displayName =~ "KeyDescription" | extend newCreds = parse_json(tostring(Property.newValue)) | extend oldCreds = parse_json(tostring(Property.oldValue))) | where oldCreds != "[]" // Existing app, new credential added | extend Actor = tostring(InitiatedBy.user.userPrincipalName) | extend ActorIP = tostring(InitiatedBy.user.ipAddress) | project TimeGenerated, targetName, Actor, ActorIP, OperationName
KQL // OAuth Consent Grant Detection
// Consent to application events should be rare and reviewed // Add service principal and OAuth2PermissionGrant events are also high value AuditLogs | where OperationName in ("Consent to application", "Add OAuth2PermissionGrant", "Add service principal") | where Result =~ "success" | extend Actor = tostring(InitiatedBy.user.userPrincipalName) | extend TargetApp = tostring(TargetResources[0].displayName) | extend IPAddress = tostring(InitiatedBy.user.ipAddress) | project TimeGenerated, OperationName, Actor, TargetApp, IPAddress | sort by TimeGenerated desc
KQL // Conditional Access Policy Changes
// Any CA policy modification is high-value. Attackers disable or weaken policies post-compromise. AuditLogs | where OperationName in ("Add conditional access policy", "Delete conditional access policy", "Update conditional access policy") | where Result == "success" | extend Actor = tostring(InitiatedBy.user.userPrincipalName) | extend PolicyName = tostring(TargetResources[0].displayName) | project TimeGenerated, OperationName, PolicyName, Actor
// Real World // Solorigate Forensic Pattern
The audit log entry nobody checked
In the SolarWinds supply chain attack, the persistence mechanism was a certificate credential added to a high-privilege service principal. The AuditLogs entry for this addition existed and was queryable the entire time. It was tagged with OperationName "Certificates and secrets management." Microsoft subsequently added this detection pattern as an official Sentinel analytic rule tagged Solorigate and NOBELIUM. The forensic artifact was always there. The question is whether anyone was running queries against it.

KQL for
Identity

KQL is the query language across Microsoft Sentinel and Defender XDR. These are the operators and patterns that matter most for identity hunting. Not a comprehensive language reference. The specific things you will use constantly.

Operator // Filter
where
The most used operator. Filters rows. Chain multiple where clauses rather than using and for readability. where ResultType == 0 and where ClientAppUsed has "IMAP" are both valid. The query optimizer handles them equivalently.
Operator // Aggregate
summarize
Aggregates rows. summarize count() by UserPrincipalName groups events per user. summarize make_set(IPAddress) by UserPrincipalName collects all unique IPs per user into a dynamic array. Essential for baselining and threshold-based detections.
Operator // Shape
extend
Adds a calculated column without removing existing ones. extend Country = tostring(LocationDetails.countryOrRegion) extracts a nested JSON field into a usable column. Heavily used when working with the dynamic types in SigninLogs and AuditLogs.
Operator // Join
join
Combines two result sets on a common key. join kind=inner combines only rows with matches in both. join kind=leftanti returns rows from the left set with no match on the right. Powerful for finding new countries, new IPs, and unknown entities against a baseline.
Operator // Expand
mv-expand
Expands a dynamic array into individual rows. Used heavily when working with AuditLogs TargetResources, which is an array of modified properties. Without mv-expand, you cannot filter or extend on individual properties within the array.
Operator // Time
ago() and bin()
ago(1h) returns events from the last hour. ago(7d) from the last 7 days. bin(TimeGenerated, 1h) buckets events into hourly groups for trend analysis. Combine with summarize count() by bin(TimeGenerated, 1h) to produce time-series counts for baselining.
KQL // Service Principal Geographic Anomaly
// Find service principal sign-ins from countries not seen in the prior 30 days // Requires AADServicePrincipalSignInLogs to be enabled and forwarded let lookback = 30d; let recentWindow = 1d; let knownLocations = AADServicePrincipalSignInLogs | where TimeGenerated between (ago(lookback)..ago(recentWindow)) | extend Country = tostring(LocationDetails.countryOrRegion) | distinct ServicePrincipalName, Country; AADServicePrincipalSignInLogs | where TimeGenerated > ago(recentWindow) | where ResultType == 0 | extend Country = tostring(LocationDetails.countryOrRegion) | join kind=leftanti knownLocations on ServicePrincipalName, Country | project TimeGenerated, ServicePrincipalName, Country, IPAddress, ResourceDisplayName
KQL // Correlation: Risk Event Followed by AuditLogs Change
// Correlation architecture: join two signals into one alert // Risky sign-in followed by directory change within 4 hours // Based on February 2026 Sentinel UEBA Essentials pattern let riskWindow = 4h; let riskySignins = SigninLogs | where TimeGenerated > ago(1d) | where RiskLevelAggregated in ("high", "medium") | where ResultType == 0 | project RiskTime = TimeGenerated, UserPrincipalName, RiskLevelAggregated, IPAddress; riskySignins | join kind=inner ( AuditLogs | where TimeGenerated > ago(1d) | where OperationName has_any ("Add member to role", "Certificates and secrets management", "Consent to application") | extend Actor = tostring(InitiatedBy.user.userPrincipalName) | project AuditTime = TimeGenerated, Actor, OperationName ) on $left.UserPrincipalName == $right.Actor | where AuditTime > RiskTime and AuditTime < RiskTime + riskWindow | project RiskTime, UserPrincipalName, RiskLevelAggregated, AuditTime, OperationName, IPAddress
Correlation over single-signal alerts: Three separate alerts for a brute-forced sign-in, a UEBA anomaly, and a risky sign-in event require an analyst to spend 10-15 minutes correlating manually. One correlation rule joining those signals produces a single alert with all context in one row. The analyst triages in 2-3 minutes. Treat correlation as the default detection architecture, not an optimization you add later.

Building
Detection Rules

A hunting query that stays in a notebook catches nothing. A detection rule that fires when nobody is looking is what matters. The architecture of a Sentinel scheduled analytics rule and how to tune it to actually work.

Anatomy of a Scheduled Analytics Rule
What the rule actually does
A Sentinel scheduled analytics rule runs your KQL query on a defined interval against a defined lookback window. If the query returns results, an alert is created. Alerts are grouped into incidents according to the grouping logic. The rule is useless without entity mapping, which links UserPrincipalName, IPAddress, and other fields to tracked entities that appear in the incident timeline. Skip entity mapping and your incidents have no context.
Rule Config 01
Query Period and Frequency
The lookback window should be longer than the run frequency to avoid gaps. A rule running every 5 minutes with a 5-minute lookback has a gap at the boundary. Run every 5 minutes with a 10-minute lookback. For slower-moving detections like certificate backdoors, run every hour with a 2-hour lookback. Adjust based on the latency of your log ingestion.
Rule Config 02
Threshold
Set triggerThreshold to 0 if any result should fire the alert. Set it higher for volume-based detections like failed sign-in counts. A certificate backdoor detection should fire on 1 result. An impossible travel rule might need to fire only when velocity exceeds a plausible flight speed threshold to avoid false positives on VPN usage.
Rule Config 03
Entity Mapping
Map UserPrincipalName to Account, IPAddress to IP, and relevant resource names to Host or URL entities. Without entity mapping, the incident timeline has no enrichment and UEBA cannot correlate the alert against entity risk scores. Entity mapping is mandatory for the rule to integrate with the broader detection architecture.
Rule Config 04
Alert Grouping
Group alerts by UserPrincipalName within a 24-hour window to avoid alert fatigue on the same compromised account. Group by IPAddress for spray attack detections to cluster all affected accounts under one incident. Wrong grouping creates noise. A single compromised account generating 40 separate incidents over 24 hours is not a useful detection output.
Tuning Process
Before enabling in production
Run the KQL query manually in the Logs blade against 7 days of data. Review every result. Identify false positives and build exclusions into the query using where UserPrincipalName !in (knownServiceAccounts) or similar. Start the rule in disabled state. Enable it and monitor for one week before treating it as a reliable detection. Tune thresholds based on the false positive rate in your specific environment. A rule that fires constantly is worse than no rule.
The Sentinel-As-Code approach (2026): Managing detection rules via the Sentinel portal does not scale. Sentinel-As-Code (March 2026 rebuild) lets you define KQL, entity mappings, alert grouping, and MITRE technique tags in YAML files version-controlled in Git. A CI/CD pipeline deploys them. Every rule change is tracked, reviewable, and reversible. For teams managing more than 20 custom rules, this is the only maintainable approach. Custom detection rules for Defender XDR can also be deployed via the Microsoft Graph Security API in the same pipeline.

Flashcards

Click to flip. Review Again flips back. Got It moves forward.

Card 1 of 10
// Tap to reveal answer
// Answer

Click card to flip

Read the
Log

Three log excerpts. Each one contains a compromise indicator. Identify it and explain what it confirms.

AADServicePrincipalSignInLogs // AutomationSP // 2026-04-14 03:14 UTCScenario 01
Field
Previous Value
Current Value
ServicePrincipalName
AutomationSP
AutomationSP
ResultType
0 (last 30d)
0
IPAddress
20.190.x.x (Azure DC, West Europe)
185.220.101.47 (Residential ISP, RO)
ResourceDisplayName
Azure Key Vault
Microsoft Graph
API Call Volume
2-8 calls/hr (baseline)
847 calls in 4 minutes
// What does this log confirm?
A service principal that normally calls Azure Key Vault from Azure datacenter IPs is now calling Microsoft Graph at 847 requests per minute from a Romanian residential ISP at 03:14 UTC. What are the two highest-confidence indicators and what does the combination tell you?
The combination is dispositive. A service principal does not change its source IP from a known Azure datacenter to a Romanian residential ISP. The resource change from Key Vault to Graph API with 847 calls in 4 minutes is the enumeration signature. This is AzureHound running against the tenant using the compromised service principal credentials. The residential IP is the attacker's exit node, not a VPN coincidence. This is an active incident. Disable the principal, revoke the credential, and treat the 30-day baseline period as a potential window of prior access.
AuditLogs // AppRegistration: bd-app-7731 // 2026-04-14 07:51 UTCScenario 02
Field
Value
Notes
OperationName
Add app role assignment to service principal
AppRoleAssignment.ReadWrite.All operation
Result
success
InitiatedBy (app)
AutomationSP
Service principal, not a user
Target
bd-app-7731
App registered 12 minutes ago
RoleGranted
RoleManagement.ReadWrite.All
Application permission
Prior ITSM Record
None
bd-app-7731 has no change ticket
// What does this log confirm?
AutomationSP just granted RoleManagement.ReadWrite.All to bd-app-7731, an application registered 12 minutes ago with no ITSM record. Why would this not fire a standard SIEM role assignment alert and what is the next step in the kill chain?
App role grants via AppRoleAssignment.ReadWrite.All are logged under ApplicationManagement category, not the RoleManagement category that most SIEM alert rules monitor. Standard "Add member to role" alerts do not fire for this operation. With RoleManagement.ReadWrite.All granted to bd-app-7731, the next step is a direct Global Administrator role assignment to an external account. That assignment will appear in AuditLogs under Add member to role and may finally trigger an alert — but by then the attacker already has tenant-level access.
SigninLogs // petra.lindqvist@nordiccorp.se // 2026-04-14 07:14-07:17 UTCScenario 03
TimeGenerated
Event
Detail
07:14:02
Sign-in success
MFA completed. IP: 195.67.x.x (Stockholm). CorrelationId: abc-123. UA: Mozilla/5.0 Chrome/120
07:14:03
Sign-in success
Same CorrelationId: abc-123. UA: Mozilla/5.0 Firefox/115. DeviceId: empty.
07:14:04
Sign-in success
Same CorrelationId: abc-123. UA: Mozilla/5.0 Safari/16. DeviceId: empty.
07:17:22
Sign-in success
IP: 185.220.101.47 (Tor exit node). App: Office 365. ConditionalAccessStatus: success.
// What does this log confirm?
Three user agents within the same CorrelationId with an empty DeviceId, followed by a successful sign-in from a Tor exit node 3 minutes later. Conditional Access shows success. What does this tell you and why did CA not block the Tor access?
Multiple user agents within a single CorrelationId with an empty DeviceId is the AiTM reverse proxy fingerprint, documented by Eye Security in their Sneaky 2FA and W3LL research. The proxy rotated user agents while forwarding requests. The session cookie was captured at 07:14. At 07:17 the attacker replayed it from a Tor exit node. Conditional Access shows success because it evaluated at 07:14 when Petra authenticated in Stockholm. The cookie replay does not create a new sign-in event and does not re-trigger CA evaluation. This is an active AiTM compromise. Revoke Petra's sessions immediately.

Module
Quiz

20 questions. Pass at 15. Detection logic, KQL operators, log table schemas, and hunting patterns.

Hunting in the Logs Assessment 01 / 20

out of 20

Timed
Assessment

10 questions. 10 minutes. KQL output interpretation and detection logic. No explanations until the end. Pass at 8.

// Assessment Parameters
Questions
10
Time Limit
10 minutes
Pass
8 / 10
Questions focus on KQL output interpretation, log table selection, and detection architecture decisions. No explanations mid-assessment.
10:00
Remaining