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:

  1. Create the rule in Sentinel → Analytics → Create → Scheduled rule (or NRT).
  2. Paste the KQL. Configure schedule and lookback.
  3. Configure entity mapping per the specifications above.
  4. Add MITRE ATT&CK technique mapping.
  5. Set severity per the specifications above.
  6. Configure alert grouping (entity-based for most rules).
  7. Test: run the query manually against 30 days of data. Review results for false positives.
  8. Enable the rule.
  9. 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