Query Details

HUNT 18 M365 Inactive User Active Permissions 90d

Query

// Hunt    : M365 - Inactive Internal Users with Active Mailbox or SharePoint Permissions (90d)
// Purpose : Surface user accounts that still hold active mailbox delegations (FullAccess /
//           SendAs) or SharePoint site-admin roles but have generated zero or very low
//           OfficeActivity in the past 90 days. Such dormant-but-privileged accounts are a
//           high-value target: they can be silently compromised and abused without triggering
//           velocity-based detections. Does NOT include guest (#EXT#) users.
// Tables  : OfficeActivity
// Period  : P90D (permissions baseline), P90D (activity check)
// Tactics : Persistence, Privilege Escalation
// MITRE   : T1078.004, T1098.002
//==========================================================================================

let LookbackDays      = 90d;
let InactivityDays    = 60;   // flag users with <InactivityDays of active ops

// --- Step 1: All permission grants in the window ---
// Mailbox delegations
let MailboxDelegates = OfficeActivity
    | where TimeGenerated > ago(LookbackDays)
    | where RecordType in ("ExchangeAdmin", "ExchangeItem")
    | where Operation in (
        "Add-MailboxPermission",
        "Set-Mailbox",
        "Add-RecipientPermission")
    | where Parameters has_any ("FullAccess", "SendAs", "SendOnBehalf")
    | mv-expand todynamic(Parameters)
    | extend ParamName  = tostring(parse_json(tostring(Parameters)).Name)
    | extend ParamValue = tostring(parse_json(tostring(Parameters)).Value)
    | where ParamName in~ ("AccessRights") and ParamValue has_any ("FullAccess", "SendAs")
    | extend
        PrivilegedUser    = tolower(OfficeObjectId),
        PermissionType    = "MailboxDelegate",
        GrantedTo         = tolower(UserId),
        GrantedAt         = TimeGenerated
    | where PrivilegedUser !has "#EXT#"
    | project PrivilegedUser, PermissionType, GrantedTo, GrantedAt;

// SharePoint site-admin grants
let SPAdminGrants = OfficeActivity
    | where TimeGenerated > ago(LookbackDays)
    | where RecordType in ("SharePoint", "SharePointFileOperation")
    | where Operation in (
        "SiteCollectionAdminAdded",
        "PermissionLevelAdded")
    | where Event_Data has_any ("Full Control", "Site Collection Administrator")
    | where UserId !has "#EXT#"
    | extend
        PrivilegedUser    = tolower(UserId),
        PermissionType    = "SharePointSiteAdmin",
        GrantedTo         = tolower(UserId),
        GrantedAt         = TimeGenerated
    | project PrivilegedUser, PermissionType, GrantedTo, GrantedAt;

let AllPrivilegedGrants = union MailboxDelegates, SPAdminGrants
    | summarize
        PermissionTypes   = make_set(PermissionType, 10),
        FirstGrant        = min(GrantedAt),
        LastGrant         = max(GrantedAt),
        GrantCount        = count()
        by PrivilegedUser;

// --- Step 2: Recent activity per internal user ---
let UserActivity = OfficeActivity
    | where TimeGenerated > ago(LookbackDays)
    | where UserId !has "#EXT#"
    | summarize
        TotalEvents       = count(),
        LastActivity      = max(TimeGenerated),
        FirstActivity     = min(TimeGenerated),
        Workloads         = make_set(RecordType, 10),
        LastOperation     = arg_max(TimeGenerated, Operation)
        by UserId;

// --- Step 3: Join – keep only privileged users with no/low recent activity ---
AllPrivilegedGrants
| join kind=leftouter UserActivity
    on $left.PrivilegedUser == $right.UserId
| extend
    DaysSinceLastActivity = iif(
        isnotnull(LastActivity),
        datetime_diff("day", now(), LastActivity),
        datetime_diff("day", now(), FirstGrant))
| where isnull(LastActivity) or DaysSinceLastActivity >= InactivityDays
| extend ActivityStatus = case(
    isnull(LastActivity),            "NeverActive",
    DaysSinceLastActivity >= 90,     "Dormant (90d+)",
    DaysSinceLastActivity >= 60,     "Inactive (60-89d)",
    "RecentlyActive")
| project
    PrivilegedUser,
    PermissionTypes,
    FirstGrant,
    LastGrant,
    GrantCount,
    TotalEvents       = coalesce(TotalEvents, 0),
    LastActivity,
    DaysSinceLastActivity,
    ActivityStatus,
    LastOperation     = tostring(LastOperation),
    WorkloadsUsed     = tostring(Workloads)
| sort by DaysSinceLastActivity desc

Explanation

This query is designed to identify internal user accounts within Microsoft 365 that have been inactive or have shown very low activity over the past 90 days, yet still possess significant permissions such as mailbox delegations or SharePoint site-admin roles. These accounts are considered high-risk because they can be exploited without triggering typical security alerts.

Here's a simplified breakdown of the query:

  1. Define Parameters:

    • The query looks back over the past 90 days.
    • It flags users who have been inactive for 60 days or more.
  2. Identify Permission Grants:

    • Mailbox Delegations: It checks for users who have been granted permissions like "FullAccess" or "SendAs" on mailboxes.
    • SharePoint Site-Admin Roles: It identifies users who have been given "Full Control" or "Site Collection Administrator" roles on SharePoint sites.
    • It excludes guest users (those with "#EXT#" in their ID).
  3. Compile All Privileged Grants:

    • Combines the mailbox and SharePoint permissions into a single list of privileged users, noting the types of permissions, when they were first and last granted, and how many times.
  4. Check User Activity:

    • It summarizes recent activity for each internal user, noting the number of events, the last time they were active, and the types of operations they performed.
  5. Join and Filter:

    • The query joins the list of privileged users with their activity data.
    • It filters to keep only those users who have been inactive or have low activity, categorizing them as "NeverActive," "Dormant (90d+)," or "Inactive (60-89d)."
  6. Output:

    • The result includes details such as the user ID, types of permissions, dates of first and last grants, number of grants, total events, last activity date, days since last activity, activity status, last operation performed, and workloads used.
    • The results are sorted by the number of days since the last activity, with the most inactive users listed first.

This query helps security teams identify potentially risky accounts that could be exploited due to their inactivity and high-level permissions.

Details

David Alonso profile picture

David Alonso

Released: March 18, 2026

Tables

OfficeActivity

Keywords

OfficeActivity

Operators

letagoinhas_anymv-expandtodynamicextendtostringparse_jsonin~hasprojectunionsummarizemake_setminmaxcountbywherearg_maxjoinkindon$iifisnotnulldatetime_diffnowisnullcasecoalescesortdesc

Actions