Query Details

HUNT 19 M365 Dormant User Activity Burst 90d

Query

// Hunt    : M365 - Dormant Account Sudden Activity Burst (90d lookback, 30d recent)
// Purpose : Identify user accounts that showed no M365 OfficeActivity during a
//           "silent window" (days 90–31 ago) and then suddenly generated a burst of
//           events in the last 30 days. This pattern is consistent with account
//           compromise, credential stuffing after a long gap, or re-enabled accounts
//           being immediately weaponised. The query surfaces the exact operations that
//           occurred after the silence, ranked by burst volume. Does NOT include guests.
// Tables  : OfficeActivity
// Period  : P90D (silence check) + P30D (burst detection)
// Tactics : Initial Access, Collection, Exfiltration
// MITRE   : T1078.004, T1530, T1048
//==========================================================================================

let SilenceWindowStart  = ago(90d);   // start of silence window
let SilenceWindowEnd    = ago(30d);   // end of silence window / start of watch window
let BurstWindowStart    = ago(30d);   // burst activity period
let MinBurstEvents      = 5;          // minimum events in burst window to surface account

// --- Users active BEFORE the silence window ---
let ActiveBefore = OfficeActivity
    | where TimeGenerated between (SilenceWindowStart .. SilenceWindowEnd)
    | where UserId !has "#EXT#"
    | summarize LastSeenBefore = max(TimeGenerated) by UserId;

// --- Users silent during silence window (no events at all) ---
let SilentUsers = OfficeActivity
    | where TimeGenerated between (SilenceWindowStart .. SilenceWindowEnd)
    | where UserId !has "#EXT#"
    | summarize count() by UserId
    | join kind=rightanti ActiveBefore on UserId   // those in ActiveBefore but not in silence window
    // Actually: users who HAD activity before but NOT during the silence window
    ;

// Alternative approach: find users with events older than SilenceWindowEnd
// but NO events during the silence window
let UsersWithOlderActivity = OfficeActivity
    | where TimeGenerated < SilenceWindowEnd
    | where UserId !has "#EXT#"
    | summarize LastOldEvent = max(TimeGenerated) by UserId;

let UsersActiveDuringSilence = OfficeActivity
    | where TimeGenerated between (SilenceWindowStart .. SilenceWindowEnd)
    | where UserId !has "#EXT#"
    | summarize count() by UserId;

// Users that were active BEFORE the silence window but NOT DURING it
let TrulyDormantUsers = UsersWithOlderActivity
    | join kind=leftanti UsersActiveDuringSilence on UserId
    | project UserId, LastOldEvent;

// --- Burst activity in the watch window ---
let BurstActivity = OfficeActivity
    | where TimeGenerated >= BurstWindowStart
    | where UserId !has "#EXT#"
    | extend IsExfilOp = Operation in (
        "FileDownloaded", "FileAccessed", "FileSyncDownloadedFull",
        "MailItemsAccessed", "Send", "AnonymousLinkCreated",
        "SecureLinkCreated", "SharingInvitationCreated")
    | summarize
        BurstEvents          = count(),
        FirstBurstEvent      = min(TimeGenerated),
        LastBurstEvent       = max(TimeGenerated),
        ExfilOpCount         = countif(IsExfilOp),
        DistinctOps          = dcount(Operation),
        Workloads            = make_set(RecordType, 10),
        DistinctSites        = dcount(Site_Url),
        DistinctFiles        = dcount(SourceFileName),
        SampleOps            = make_set(Operation, 15),
        SampleSites          = make_set(Site_Url, 5)
        by UserId;

// --- Join: dormant users who produced a burst ---
TrulyDormantUsers
| join kind=inner BurstActivity on UserId
| where BurstEvents >= MinBurstEvents
| extend
    SilenceDays       = datetime_diff("day", FirstBurstEvent, LastOldEvent),
    BurstDuration     = datetime_diff("hour", LastBurstEvent, FirstBurstEvent),
    ExfilRatio        = iif(BurstEvents > 0,
                            round(todouble(ExfilOpCount) / todouble(BurstEvents), 2), 0.0)
| extend RiskScore = toint(
      iif(SilenceDays >= 180, 3, iif(SilenceDays >= 90, 2, 1))
    + iif(ExfilRatio >= 0.5,  3, iif(ExfilRatio >= 0.2, 1, 0))
    + iif(BurstEvents >= 100, 2, iif(BurstEvents >= 30,  1, 0))
    + iif(DistinctSites >= 5, 1, 0))
| project
    UserId,
    LastOldEvent,
    SilenceDays,
    FirstBurstEvent,
    BurstEvents,
    BurstDuration,
    ExfilOpCount,
    ExfilRatio,
    DistinctOps,
    Workloads,
    DistinctSites,
    DistinctFiles,
    RiskScore,
    SampleOps,
    SampleSites
| sort by RiskScore desc, SilenceDays desc

Explanation

This query is designed to identify potentially compromised user accounts within Microsoft 365 by analyzing their activity patterns over a 90-day period. Here's a simplified breakdown of what the query does:

  1. Objective: The query aims to find user accounts that were inactive for a specific period (referred to as the "silent window") and then suddenly became active with a burst of events. This pattern might indicate account compromise, such as through credential stuffing or re-enabled accounts being misused.

  2. Time Windows:

    • Silent Window: The period from 90 to 31 days ago, during which the account showed no activity.
    • Burst Window: The last 30 days, during which the account suddenly became active.
  3. Process:

    • Identify Active Users Before Silence: Find users who were active before the silent window but not during it.
    • Identify Truly Dormant Users: Determine users who had activity before the silent window but none during it.
    • Detect Burst Activity: Look for users who became active again in the last 30 days with a significant number of events (at least 5).
  4. Analysis:

    • The query checks for specific operations that might indicate data exfiltration, such as file downloads or email access.
    • It calculates various metrics like the number of burst events, the duration of the burst, and the ratio of exfiltration-related operations.
  5. Risk Scoring:

    • Accounts are assigned a risk score based on factors like the length of inactivity, the proportion of exfiltration operations, the number of burst events, and the diversity of sites accessed.
    • The results are sorted by risk score to prioritize accounts that may need further investigation.
  6. Exclusions:

    • Guest accounts (identified by "#EXT#") are excluded from this analysis.

In summary, this query helps security teams identify and prioritize potentially compromised accounts by detecting unusual activity patterns following a period of inactivity.

Details

David Alonso profile picture

David Alonso

Released: March 18, 2026

Tables

OfficeActivity

Keywords

OfficeActivityUserIdOperationRecordTypeSiteUrlSourceFileName

Operators

letagobetween!hassummarizemaxjoinkind=rightantikind=leftantiprojectextendincountmindcountmake_setcountifdatetime_diffiifroundtodoubletointsort by

Actions