Query Details

HUNT 22 M365 Guest Dormancy Reactivation 30d

Query

// Hunt    : M365 - Guest Accounts Dormant Then Suddenly Reactivated (30d)
// Purpose : Identify external guest (#EXT#) accounts that show zero OfficeActivity
//           during a 30–90 day "dormancy window" and then generate a burst of events
//           in the most recent 7 days. This pattern signals either:
//           (a) a forgotten guest account whose credentials were compromised,
//           (b) a guest who was re-invited under the same UPN after a long gap, or
//           (c) a legitimate return that nevertheless warrants re-validation.
//           Results include a risk profile of the post-reactivation activity.
// Tables  : OfficeActivity
// Period  : P90D lookback
// Tactics : InitialAccess, Collection, Exfiltration
// MITRE   : T1078.004, T1530, T1048
// Scope   : Guest users (#EXT#) only
//==========================================================================================

let DormancyStart    = ago(90d);   // begin of dormancy check
let DormancyEnd      = ago(7d);    // end of dormancy / begin of burst window
let BurstWindowStart = ago(7d);    // burst observation window
let MinBurstEvents   = 3;          // minimum events to surface as reactivated guest

// --- Guests with NO activity during the dormancy window ---
let GuestsActiveDuringDormancy = OfficeActivity
    | where TimeGenerated between (DormancyStart .. DormancyEnd)
    | where UserId has "#EXT#"
    | summarize count() by UserId;

// --- Guests with any history BEFORE the dormancy window ---
let GuestsWithOlderHistory = OfficeActivity
    | where TimeGenerated < DormancyEnd
    | where UserId has "#EXT#"
    | summarize LastOldEvent = max(TimeGenerated) by UserId;

// Truly dormant guests = had old history, silent during dormancy window
let TrulyDormantGuests = GuestsWithOlderHistory
    | join kind=leftanti GuestsActiveDuringDormancy on UserId
    | project UserId, LastOldEvent;

// --- Burst activity in the last 7 days for those same guests ---
let RecentBurst = OfficeActivity
    | where TimeGenerated >= BurstWindowStart
    | where UserId has "#EXT#"
    | extend
        ExternalDomain = extract(@"_([^_#]+)#EXT#", 1, tolower(UserId)),
        IsExfilOp = Operation in (
            "FileDownloaded", "FileSyncDownloadedFull",
            "FileAccessed", "AnonymousLinkCreated",
            "SecureLinkCreated", "SharingInvitationCreated",
            "Send", "MailItemsAccessed"),
        IsAfterHours = hourofday(TimeGenerated) < 6 or hourofday(TimeGenerated) >= 22
    | summarize
        BurstEvents       = count(),
        FirstBurstEvent   = min(TimeGenerated),
        ExfilOpCount      = countif(IsExfilOp),
        AfterHoursCount   = countif(IsAfterHours),
        DistinctSites     = dcount(Site_Url),
        DistinctOps       = dcount(Operation),
        Workloads         = make_set(RecordType, 5),
        SampleOps         = make_set(Operation, 10),
        ExternalDomain    = any(ExternalDomain)
        by UserId;

// --- Join dormant guests with their burst activity ---
TrulyDormantGuests
| join kind=inner RecentBurst on UserId
| where BurstEvents >= MinBurstEvents
| extend
    DormancyDays    = datetime_diff("day", FirstBurstEvent, LastOldEvent),
    ExfilRatio      = round(todouble(ExfilOpCount) / todouble(BurstEvents), 2)
| extend RiskScore = toint(
      iif(DormancyDays  >= 180, 3, iif(DormancyDays  >= 90,  2, 1))
    + iif(ExfilRatio    >= 0.5, 3, iif(ExfilRatio    >= 0.2, 1, 0))
    + iif(AfterHoursCount >= 3, 2, 0)
    + iif(DistinctSites >= 5,   1, 0))
| project
    UserId,
    ExternalDomain,
    LastOldEvent,
    DormancyDays,
    FirstBurstEvent,
    BurstEvents,
    ExfilOpCount,
    ExfilRatio,
    AfterHoursCount,
    DistinctSites,
    DistinctOps,
    Workloads,
    RiskScore,
    SampleOps
| sort by RiskScore desc, DormancyDays desc

Explanation

This query is designed to identify and analyze external guest accounts in Microsoft 365 that have been inactive for a period of time and then suddenly show a burst of activity. Here's a simplified breakdown of what the query does:

  1. Objective: The query aims to find guest accounts (identified by "#EXT#") that were inactive for 30 to 90 days and then became active with multiple events in the last 7 days. This pattern could indicate potential security issues, such as compromised credentials or reactivation of an old account.

  2. Timeframes:

    • Dormancy Period: The query checks for no activity between 90 days ago and 7 days ago.
    • Burst Activity Period: It looks for a surge in activity in the last 7 days.
  3. Steps:

    • Identify Dormant Accounts: It first identifies guest accounts that had no activity during the dormancy period but had some activity before this period.
    • Detect Recent Activity: It then checks for a burst of activity in the last 7 days for these dormant accounts.
  4. Activity Analysis:

    • The query examines the type of operations performed, such as file downloads or email access, which might indicate data exfiltration.
    • It also checks if the activity occurred during unusual hours (before 6 AM or after 10 PM).
  5. Risk Assessment:

    • Each account is given a risk score based on factors like the length of dormancy, the proportion of potentially harmful operations, activity during unusual hours, and the diversity of sites accessed.
    • The results are sorted by risk score to prioritize accounts that might need further investigation.
  6. Output:

    • The query outputs details such as the user ID, the external domain, the last activity before dormancy, the number of burst events, and the calculated risk score, among other metrics.

Overall, this query helps security teams identify potentially risky guest account activities that could signal security threats or require re-validation.

Details

David Alonso profile picture

David Alonso

Released: March 18, 2026

Tables

OfficeActivity

Keywords

GuestUsersOfficeActivityExternalDomainOperationTimeGeneratedUserIdRecordTypeSiteUrl

Operators

letagobetweenhassummarizecountbymaxjoinkindleftantionprojectwhereextendextracttolowerinhourofday>=<countifdcountmake_setanyinnerdatetime_diffroundtodoubletointiif+sortdesc

Actions