12.13 Detection Engineering
4-6 hours · Module 12
Detection Engineering
The investigation is complete. The hardening is recommended. Detection engineering closes the loop: convert the investigation findings into permanent analytics rules that detect AiTM attacks automatically. Every rule in this subsection is deployable — tested against the incident data and ready for your Sentinel workspace.
Required role: Microsoft Sentinel Contributor (to create analytics rules).
Rule 1: Phishing URL Click Followed by Suspicious Sign-In (NRT)
This is the rule that detected Wave 1. Deploy it as-is.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| // NRT Rule: Phishing URL click → sign-in from new IP within 30 minutes
// Schedule: NRT (near-real-time)
// Severity: High
// MITRE: T1566.002 (Spearphishing Link), T1078.004 (Valid Accounts: Cloud Accounts)
let ClickWindow = 30m;
let KnownCorpIPs = _GetWatchlist('CorporateExternalIPs') | project SearchKey;
UrlClickEvents
| where ActionType == "ClickAllowed"
| where IsClickedThrough == true or UrlDomain !endswith ".com"
// Adjust: include clicks to suspicious TLDs or clicked-through warnings
| project ClickTime = TimeGenerated, AccountUpn, ClickedUrl = Url, UrlDomain
| join kind=inner (
SigninLogs
| where ResultType == "0"
| where IPAddress !in (KnownCorpIPs)
| project SigninTime = TimeGenerated, UserPrincipalName, IPAddress,
AppDisplayName, Location = tostring(LocationDetails.countryOrRegion)
) on $left.AccountUpn == $right.UserPrincipalName
| where SigninTime between (ClickTime .. (ClickTime + ClickWindow))
| project AccountUpn, ClickTime, ClickedUrl, UrlDomain,
SigninTime, IPAddress, AppDisplayName, Location,
GapMinutes = datetime_diff('minute', SigninTime, ClickTime)
|
Entity mapping: Account → AccountUpn (FullName). IP → IPAddress (Address). URL → ClickedUrl (Url).
Alert grouping: Group by Account entity. One incident per compromised user.
Blast radius: Generates alerts only when the pattern matches. No impact on users or services. Per-rule.
Compliance mapping: NIST CSF DE.AE-2 (Anomalous activity is detected). ISO 27001 A.8.16 (Monitoring activities).
Verify after deployment: Run the rule query manually against 30 days of historical data. Review results: are there false positives (legitimate sign-ins from new IPs after clicking legitimate URLs)? Tune by: expanding the KnownCorpIPs watchlist, adjusting the ClickWindow, or adding URL domain exclusions for known-safe domains.
Rule 2: Token Replay — Different IP Within Session
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Scheduled Rule: Same user, successful sign-in from 2 different IPs within 10 minutes
// Schedule: 5 minutes / Lookback: 15 minutes
// Severity: High
// MITRE: T1550.001 (Application Access Token)
SigninLogs
| where TimeGenerated > ago(15m)
| where ResultType == "0"
| summarize
IPs = make_set(IPAddress, 10),
IPCount = dcount(IPAddress),
Apps = make_set(AppDisplayName, 5),
SigninCount = count(),
FirstSignin = min(TimeGenerated),
LastSignin = max(TimeGenerated)
by UserPrincipalName
| where IPCount > 1
| extend TimeDiffMinutes = datetime_diff('minute', LastSignin, FirstSignin)
| where TimeDiffMinutes < 10
// Exclude known multi-IP patterns (VPN failover, mobile carrier NAT)
| where not(IPs has_all ("192.0.2.10", "192.0.2.15")) // Known corporate IPs
|
Entity mapping: Account → UserPrincipalName (FullName).
Custom details: IPs, IPCount, TimeDiffMinutes, Apps.
Alert title override: strcat("Token replay suspected: ", UserPrincipalName, " — ", tostring(IPCount), " IPs in ", tostring(TimeDiffMinutes), " minutes")
Rule 3: Inbox Rule Creation with External Forwarding
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Scheduled Rule: Inbox rule forwarding to external address
// Schedule: 5 minutes / Lookback: 10 minutes
// Severity: High
// MITRE: T1114.003 (Email Forwarding Rule)
CloudAppEvents
| where ActionType in ("New-InboxRule", "Set-InboxRule")
| extend RuleData = parse_json(RawEventData)
| extend Creator = tostring(RuleData.UserId)
| extend ClientIP = tostring(RuleData.ClientIP)
| extend ForwardTo = tostring(RuleData.Parameters
| mv-expand RuleData.Parameters
| where tostring(RuleData.Parameters.Name) in ("ForwardTo", "RedirectTo", "ForwardAsAttachmentTo")
| project tostring(RuleData.Parameters.Value))
| where isnotempty(ForwardTo)
| where ForwardTo !endswith "@northgateeng.com" // Replace with your domain
| extend DeleteMessage = tostring(RuleData.Parameters
| mv-expand RuleData.Parameters
| where tostring(RuleData.Parameters.Name) == "DeleteMessage"
| project tostring(RuleData.Parameters.Value))
| project TimeGenerated, Creator, ClientIP, ForwardTo, DeleteMessage
|
Entity mapping: Account → Creator (FullName). IP → ClientIP (Address).
Rule 4: MFA Method Registration from Non-Corporate IP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| // Scheduled Rule: New MFA method registered from external IP
// Schedule: 15 minutes / Lookback: 20 minutes
// Severity: Medium
// MITRE: T1098.005 (Account Manipulation)
let KnownCorpIPs = _GetWatchlist('CorporateExternalIPs') | project SearchKey;
AuditLogs
| where OperationName has_any (
"User registered security info",
"User registered all required security info")
| extend TargetUser = tostring(TargetResources[0].userPrincipalName)
| extend IPAddress = tostring(InitiatedBy.user.ipAddress)
| where IPAddress !in (KnownCorpIPs)
| where isnotempty(IPAddress)
| project TimeGenerated, TargetUser, OperationName, IPAddress
|
Entity mapping: Account → TargetUser (FullName). IP → IPAddress (Address).
Rule 5: Mass Email Read from Non-Corporate IP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| // Scheduled Rule: High-volume MailItemsAccessed from external IP
// Schedule: 1 hour / Lookback: 1 hour 15 minutes
// Severity: Medium
// MITRE: T1114.002 (Email Collection: Remote Email Collection)
CloudAppEvents
| where ActionType == "MailItemsAccessed"
| extend ClientIP = tostring(parse_json(RawEventData).ClientIPAddress)
| where not(ipv4_is_private(ClientIP))
| where ClientIP !in (_GetWatchlist('CorporateExternalIPs') | project SearchKey)
| summarize
EmailsAccessed = count(),
FirstAccess = min(TimeGenerated),
LastAccess = max(TimeGenerated)
by AccountDisplayName, AccountObjectId, ClientIP
| where EmailsAccessed > 50
| project AccountDisplayName, ClientIP, EmailsAccessed, FirstAccess, LastAccess
|
Rule 6: Internal Phishing — Compromised Account Sending Emails with External URLs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| // Scheduled Rule: Internal sender emails containing URLs to non-corporate external domains
// Schedule: 15 minutes / Lookback: 20 minutes
// Severity: High
// MITRE: T1534 (Internal Spearphishing)
let CorporateDomains = dynamic(["northgateeng.com", "microsoft.com",
"office.com", "sharepoint.com", "onmicrosoft.com"]);
EmailEvents
| where SenderFromDomain == "northgateeng.com" // Internal sender
| where RecipientEmailAddress endswith "@northgateeng.com" // Internal recipient
| join kind=inner (
EmailUrlInfo
| where UrlDomain !in (CorporateDomains)
| where UrlDomain !endswith ".microsoft.com"
) on NetworkMessageId
| summarize
RecipientCount = dcount(RecipientEmailAddress),
Recipients = make_set(RecipientEmailAddress, 10),
URLs = make_set(Url, 5)
by SenderFromAddress, Subject
| where RecipientCount > 5 // Mass internal email with external URLs
|
Rule 7: Conditional Access Policy Modification (NRT)
1
2
3
4
5
6
7
8
9
10
11
12
| // NRT Rule: CA policy disabled or modified — attacker may be weakening defences
// Severity: High
// MITRE: T1562.001 (Impair Defenses: Disable or Modify Tools)
AuditLogs
| where OperationName has_any (
"Update conditional access policy",
"Delete conditional access policy")
| where Result == "success"
| extend PolicyName = tostring(TargetResources[0].displayName)
| extend ModifiedBy = tostring(InitiatedBy.user.userPrincipalName)
| extend IPAddress = tostring(InitiatedBy.user.ipAddress)
| project TimeGenerated, ModifiedBy, PolicyName, OperationName, IPAddress
|
Rule 8: AiTM Attack Chain Sequence (Fusion-style correlation)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
| // Scheduled Rule: Complete AiTM chain — phishing click + new IP sign-in + inbox rule within 2 hours
// Schedule: 1 hour / Lookback: 3 hours
// Severity: Critical (only fires on confirmed multi-stage attack chain)
// MITRE: T1566.002, T1557, T1078.004, T1114.003
let Clickers = UrlClickEvents
| where ActionType == "ClickAllowed"
| project ClickTime = TimeGenerated, AccountUpn, UrlDomain;
let SuspiciousSignins = SigninLogs
| where ResultType == "0"
| where IPAddress !in (_GetWatchlist('CorporateExternalIPs') | project SearchKey)
| project SigninTime = TimeGenerated, UserPrincipalName, AttackerIP = IPAddress;
let InboxRules = CloudAppEvents
| where ActionType in ("New-InboxRule", "Set-InboxRule")
| extend Creator = tostring(parse_json(RawEventData).UserId)
| extend ForwardTo = tostring(parse_json(RawEventData).Parameters)
| project RuleTime = TimeGenerated, Creator, ForwardTo;
Clickers
| join kind=inner SuspiciousSignins
on $left.AccountUpn == $right.UserPrincipalName
| where SigninTime between (ClickTime .. (ClickTime + 30m))
| join kind=inner InboxRules
on $left.AccountUpn == $right.Creator
| where RuleTime between (SigninTime .. (SigninTime + 2h))
| project AccountUpn, ClickTime, UrlDomain, SigninTime,
AttackerIP, RuleTime, ForwardTo,
ChainMinutes = datetime_diff('minute', RuleTime, ClickTime)
|
This rule fires only when ALL THREE stages occur in sequence: phishing click → suspicious sign-in → inbox rule creation. It has near-zero false positives because legitimate activity almost never produces this exact sequence.
Detection rule deployment checklist
For each of the 8 rules above:
- Create the rule in Sentinel → Analytics → Create → Scheduled rule (or NRT).
- Paste the KQL. Configure schedule and lookback.
- Configure entity mapping per the specifications above.
- Add MITRE ATT&CK technique mapping.
- Set severity per the specifications above.
- Configure alert grouping (entity-based for most rules).
- Test: run the query manually against 30 days of data. Review results for false positives.
- Enable the rule.
- Monitor for 7 days. Tune thresholds and exclusions based on initial results.
Subsection artifact: 8 deployable KQL analytics rules with entity mapping, MITRE mapping, severity, schedule, and compliance mapping. These form the AiTM detection pack — the detection section of your AiTM investigation playbook and a component of the SOC Operations Kit.
Knowledge check
Check your understanding
1. Rule 8 (AiTM Attack Chain) fires only when all three stages occur in sequence. Why is this better than three separate rules?
Three separate rules each fire independently — producing three separate incidents for the same attack. An analyst must manually correlate them. Rule 8 fires only when the complete attack chain is confirmed in sequence: click → sign-in → inbox rule. It has near-zero false positives because legitimate activity rarely produces all three stages in the correct order and timing. Deploy BOTH: the individual rules (for early detection of each stage) AND the chain rule (for high-confidence multi-stage confirmation).
The chain rule replaces the individual rules
Three separate rules are better — more coverage
The chain rule is too complex to maintain
Deploy both. Individual rules detect each stage early (faster detection). The chain rule confirms the complete attack with near-zero false positives (higher confidence). Defence in depth applied to detection.