{
  "technique_id": "T1621",
  "name": "Multi-Factor Authentication Request Generation",
  "priority": "high",
  "status": "draft",
  "version": "0.1.0",
  "last_reviewed": "2026-07-23",
  "generated_by": "SOC Response Atlas by Basyrix",
  "tactics": [
    "Credential Access"
  ],
  "platforms": [
    "Windows",
    "Linux",
    "macOS",
    "IaaS",
    "SaaS",
    "Office Suite",
    "Identity Provider"
  ],
  "summary": "Adversaries may attempt to bypass multi-factor authentication (MFA) mechanisms and gain access to accounts by generating MFA requests sent to users. Adversaries in possession of credentials to [Valid Accounts](https://attack.mitre.org/techniques/T1078) may be unable to complete the login process if they lack access to the 2FA or MFA mechanisms required as an additional credential and security control...",
  "soc_recommendation": "Investigate Multi-Factor Authentication Request Generation activity in the context of Credential Access: confirm scope, affected host/identity, and whether it matches expected administrative behaviour before deciding this is benign.",
  "d3fend_mappings": [
    {
      "id": "D3-PLA",
      "name": "Process Lineage Analysis",
      "relationship": "detect",
      "practical_action": "Monitor for Process Lineage Analysis indicators relevant to this technique.",
      "tooling": [
        "Defender for Endpoint"
      ]
    },
    {
      "id": "D3-PT",
      "name": "Process Termination",
      "relationship": "evict",
      "practical_action": "Use Process Termination to remove the adversary's foothold once this technique is confirmed.",
      "tooling": [
        "Defender for Endpoint"
      ]
    },
    {
      "id": "D3-KBPI",
      "name": "Kernel-based Process Isolation",
      "relationship": "isolate",
      "practical_action": "Apply Kernel-based Process Isolation to contain the blast radius once this technique is observed.",
      "tooling": [
        "Defender for Endpoint"
      ]
    }
  ],
  "investigation_steps": {
    "microsoft": [
      "Review Defender for Endpoint / Defender XDR alerts and timeline for the affected host or identity.",
      "Check Sentinel analytics rules and incidents correlated with this technique.",
      "Review Entra ID sign-in and audit logs if the technique involves an identity or cloud resource."
    ],
    "generic": [
      "Confirm whether the observed multi-factor authentication request generation activity matches expected administrative or application behaviour.",
      "Identify the host, account, or resource where the activity occurred and its business criticality.",
      "Check for related alerts before and after this activity to reconstruct the broader intrusion timeline.",
      "Real detection reference: \"MFA Fatigue / Push Bombing (MFA Spamming)\" -- Note: the Severity above is the ARM baseline (used when no success follows the burst). The query itself computes a per-row Severity/Verdict that escalates to Critical when the spam is followed by an eventual approval - see the Verdict/Severity logic below and tune the analytics rule's alert-severity mapping accordingly if you want Sentinel to reflect it. Detects \"MFA fatigue\" / push-bombing: an attacker who already holds a valid password repeatedly triggers MFA challenges for a user, hoping the user eventually taps Approve out of annoyance or confusion. The signature is a tight burst of DENIED or TIMED-OUT MFA challenges for the SAME user in quick succession - the source IP is whatever the attacker is using to replay the sign-in, not the victim's phone, so we correlate purely on UserPrincipalName rather than IP. Bursts are grouped by session-gap (row_cumsum), not a fixed tumbling bin: as long as consecutive denials/timeouts are <= sessionGapMin apart they stay in the same burst, so a spam run that starts at :58 and continues to :07 is still one incident instead of being split across two windows. Two outcomes are surfaced:   * Burst with NO subsequent approval        -> \"Attempted\" (Medium/High)   * Burst followed by an eventual MFA success within correlationWin     -> \"Succeeded\" (High/Critical) - the fatigue attack worked.     Extra weight is given when the successful sign-in's IP was never seen     in the burst itself (the user approved from their own device while the     attacker's sign-in attempts came from elsewhere - or the reverse: the     approval IS the attacker's own follow-up attempt succeeding). Scope: SigninLogs only (interactive sign-ins). Push/phone-call/passwordless MFA challenges are always interactive, so non-interactive token refreshes are out of scope for this rule by design."
    ]
  },
  "evidence_to_collect": [
    "Host or resource affected",
    "Account or identity involved",
    "Timestamp of the activity",
    "Related process, file, or network artifact",
    "Any preceding or follow-on alerts"
  ],
  "response_actions": [
    {
      "name": "Contain the affected host or account",
      "category": "Containment",
      "risk": "Medium",
      "automation_safe": false,
      "approval_required": true,
      "tool": "Defender for Endpoint / Entra ID",
      "notes": "Requires approval -- confirm malicious intent before isolating a host or disabling an account."
    },
    {
      "name": "Collect and preserve evidence",
      "category": "Investigation",
      "risk": "Low",
      "automation_safe": true,
      "approval_required": false,
      "tool": "Defender for Endpoint",
      "notes": "Safe -- read-only evidence collection."
    }
  ],
  "queries": {
    "kql": [
      {
        "name": "GEN-CA-007 — MFA Fatigue / Push Bombing (MFA Spamming)",
        "description": "Note: the Severity above is the ARM baseline (used when no success follows the burst). The query itself computes a per-row Severity/Verdict that escalates to Critical when the spam is followed by an eventual approval - see the Verdict/Severity logic below and tune the analytics rule's alert-severity mapping accordingly if you want Sentinel to reflect it. Detects \"MFA fatigue\" / push-bombing: an attacker who already holds a valid password repeatedly triggers MFA challenges for a user, hoping the user eventually taps Approve out of annoyance or confusion. The signature is a tight burst of DENIED or TIMED-OUT MFA challenges for the SAME user in quick succession - the source IP is whatever the attacker is using to replay the sign-in, not the victim's phone, so we correlate purely on UserPrincipalName rather than IP. Bursts are grouped by session-gap (row_cumsum), not a fixed tumbling bin: as long as consecutive denials/timeouts are <= sessionGapMin apart they stay in the same burst, so a spam run that starts at :58 and continues to :07 is still one incident instead of being split across two windows. Two outcomes are surfaced:   * Burst with NO subsequent approval        -> \"Attempted\" (Medium/High)   * Burst followed by an eventual MFA success within correlationWin     -> \"Succeeded\" (High/Critical) - the fatigue attack worked.     Extra weight is given when the successful sign-in's IP was never seen     in the burst itself (the user approved from their own device while the     attacker's sign-in attempts came from elsewhere - or the reverse: the     approval IS the attacker's own follow-up attempt succeeding). Scope: SigninLogs only (interactive sign-ins). Push/phone-call/passwordless MFA challenges are always interactive, so non-interactive token refreshes are out of scope for this rule by design. (Source: Bell Integration baseline detection library.)",
        "query": "let sessionGapMin   = 5;     // max gap (minutes) between consecutive denials/timeouts to stay in the same burst\nlet burstThreshold  = 4;     // minimum denied/timeout MFA challenges to call it a burst\nlet multiIPThreshold = 2;    // distinct source IPs within a burst that bumps severity even without a success\nlet correlationWin  = 30m;   // how long after the last denial an approval still counts as \"fatigue succeeded\"\nlet lookback        = 1d;\nlet deniedPhrases   = dynamic([\"denied\", \"declined\"]);\nlet timeoutPhrases  = dynamic([\"timeout\", \"timed out\", \"did not respond\", \"no response\"]);\nlet TrustedRanges =\n    _GetWatchlist(\"TrustedVPNRanges\")\n    | extend VPNRange = tostring(column_ifexists(\"SearchKey\", column_ifexists(\"Subnet\", \"\")))\n    | where isnotempty(VPNRange)\n    | extend VPNRange = trim(\" \", VPNRange)\n    | extend VPNRange = iff(VPNRange has \"/\", VPNRange, strcat(VPNRange, \"/32\"))\n    | summarize by VPNRange;\n// --- Per-user exclusions ---\n//   homeCountries : ISO 3166 alpha-2. Suppresses the alert only when EVERY IP\n//                   in the burst AND the eventual success (if any) resolve to\n//                   one of these countries - covers users with known flaky\n//                   connectivity who routinely fumble their own MFA prompt.\n// Currently EMPTY - no users excluded:\nlet userExclusions = dynamic([]);\nlet userHomeMap =\n    print arr = userExclusions\n    | mv-expand entry = arr\n    | where isnotempty(entry.homeCountries)\n    | project HomeUpn       = tolower(tostring(entry.upn)),\n              HomeCountries = todynamic(tolower(tostring(entry.homeCountries)));\nlet signinLogsSource =\n    union isfuzzy=true SigninLogs,\n    (datatable(TimeGenerated:datetime, UserPrincipalName:string, UserId:string, IPAddress:string, LocationDetails:dynamic, AppDisplayName:string, ResultType:string, ResultDescription:string, AuthenticationRequirement:string, AuthenticationDetails:dynamic, UserAgent:string, ClientAppUsed:string)[]);\nlet mfaSteps =\n    signinLogsSource\n    | where TimeGenerated >= ago(lookback)\n    | where isnotempty(UserPrincipalName) and isnotempty(AuthenticationDetails)\n    | extend UserPrincipalName = tolower(UserPrincipalName)\n    | mv-expand Step = AuthenticationDetails\n    | extend StepMethod       = tostring(Step.authenticationMethod),\n             StepSucceeded    = tobool(Step.succeeded),\n             StepResultDetail = tolower(tostring(Step.authenticationStepResultDetail))\n    | where isnotempty(StepResultDetail)\n    | evaluate ipv4_lookup(TrustedRanges, IPAddress, VPNRange, false)\n    | extend IsTrustedVPNIP = isnotempty(VPNRange)\n    | extend LocationDetailsDyn = todynamic(column_ifexists(\"LocationDetails\", dynamic({})))\n    | extend Location = tostring(LocationDetailsDyn.countryOrRegion)\n    | extend MfaOutcome = case(\n            StepSucceeded,                              \"Success\",\n            StepResultDetail has_any (deniedPhrases),    \"Denied\",\n            StepResultDetail has_any (timeoutPhrases),   \"Timeout\",\n            \"Other\")\n    | where MfaOutcome in (\"Denied\", \"Timeout\", \"Success\")\n    | project TimeGenerated, UserPrincipalName, UserId, IPAddress, Location, IsTrustedVPNIP,\n              AppDisplayName, ClientAppUsed, UserAgent, StepMethod, StepResultDetail, MfaOutcome;\n// --- Sessionize denials/timeouts per user by gap, not by fixed bin ---\nlet denials =\n    mfaSteps\n    | where MfaOutcome in (\"Denied\", \"Timeout\")\n    | order by UserPrincipalName asc, TimeGenerated asc\n    | extend PrevUser = prev(UserPrincipalName), PrevTime = prev(TimeGenerated)\n    | extend NewSession = iff(PrevUser != UserPrincipalName or isnull(PrevTime)\n                               or datetime_diff(\"minute\", TimeGenerated, PrevTime) > sessionGapMin, 1, 0)\n    | extend SessionId = row_cumsum(NewSession);\nlet bursts =\n    denials\n    | summarize\n            BurstStart      = min(TimeGenerated),\n            BurstEnd        = max(TimeGenerated),\n            DenialCount     = count(),\n            DeniedCount     = countif(MfaOutcome == \"Denied\"),\n            TimeoutCount    = countif(MfaOutcome == \"Timeout\"),\n            DistinctIPs     = dcount(IPAddress),\n            BurstIPs        = make_set(IPAddress, 32),\n            BurstLocations  = make_set(Location, 16),\n            BurstApps       = make_set(AppDisplayName, 16),\n            BurstMethods    = make_set(StepMethod, 8),\n            AnyTrustedVPNIP = max(IsTrustedVPNIP)\n        by UserPrincipalName, UserId, SessionId\n    | where DenialCount >= burstThreshold\n    | project-away SessionId;\nlet successes =\n    mfaSteps\n    | where MfaOutcome == \"Success\"\n    | project SuccessTime = TimeGenerated, UserPrincipalName, SuccessIP = IPAddress,\n              SuccessLocation = Location, SuccessIsTrustedVPNIP = IsTrustedVPNIP,\n              SuccessApp = AppDisplayName, SuccessClientApp = ClientAppUsed, SuccessUA = UserAgent;\n// Earliest qualifying approval AFTER the burst, within correlationWin. Computed\n// as its own scalar table (not a filtering join on bursts) so bursts with NO\n// matching success are never dropped - only enriched when one exists.\nlet burstSuccess =\n    bursts\n    | join kind=inner (successes) on UserPrincipalName\n    | where SuccessTime between (BurstEnd .. BurstEnd + correlationWin)\n    | summarize arg_min(SuccessTime, SuccessIP, SuccessLocation, SuccessIsTrustedVPNIP, SuccessApp, SuccessClientApp, SuccessUA)\n        by UserPrincipalName, BurstStart, BurstEnd;\nbursts\n| join kind=leftouter (burstSuccess) on UserPrincipalName, BurstStart, BurstEnd\n| extend FollowedBySuccess = isnotempty(SuccessTime)\n| extend SuccessFromNewIP  = FollowedBySuccess and not(set_has_element(BurstIPs, SuccessIP))\n| extend Verdict = case(\n        FollowedBySuccess and SuccessFromNewIP, \"MFA Fatigue - Succeeded (approval from new IP)\",\n        FollowedBySuccess,                      \"MFA Fatigue - Succeeded\",\n        DistinctIPs >= multiIPThreshold,        \"MFA Push Spam - Attempted (multi-IP)\",\n        \"MFA Push Spam - Attempted\")\n| extend Severity = case(\n        FollowedBySuccess and SuccessFromNewIP, \"Critical\",\n        FollowedBySuccess,                      \"High\",\n        DistinctIPs >= multiIPThreshold,         \"High\",\n        \"Medium\")\n// --- Apply per-user home-country exclusion ---\n// Drop only when the user is listed AND every burst location AND the success\n// location (if any) are all home countries.\n| join kind=leftouter (userHomeMap) on $left.UserPrincipalName == $right.HomeUpn\n| extend IsListed          = isnotempty(HomeUpn),\n         BurstLocationsLc   = todynamic(tolower(tostring(BurstLocations))),\n         SuccessLocationLc  = tolower(tostring(SuccessLocation))\n| extend AllBurstHome = IsListed\n                      and array_length(set_difference(coalesce(BurstLocationsLc, dynamic([])),\n                                                        coalesce(HomeCountries, dynamic([])))) == 0,\n         SuccessHome  = not(FollowedBySuccess)\n                      or (IsListed and set_has_element(HomeCountries, SuccessLocationLc))\n| where not(IsListed and AllBurstHome and SuccessHome)\n| project-away HomeUpn, HomeCountries, IsListed, BurstLocationsLc, SuccessLocationLc, AllBurstHome, SuccessHome\n| extend AccountName = tostring(split(UserPrincipalName, \"@\")[0]),\n         UPNSuffix   = tostring(split(UserPrincipalName, \"@\")[1])\n| project TimeGenerated = BurstEnd, UserPrincipalName, AccountName, UPNSuffix, UserId,\n          Verdict, Severity, BurstStart, BurstEnd, DenialCount, DeniedCount, TimeoutCount,\n          DistinctIPs, BurstIPs, BurstLocations, BurstApps, BurstMethods, AnyTrustedVPNIP,\n          FollowedBySuccess, SuccessTime, SuccessIP, SuccessFromNewIP, SuccessLocation,\n          SuccessIsTrustedVPNIP, SuccessApp, SuccessClientApp, SuccessUA\n| extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName,\n         IPCustomEntity = iff(FollowedBySuccess, SuccessIP, tostring(BurstIPs[0]))\n| order by Severity asc, DenialCount desc, TimeGenerated desc"
      },
      {
        "name": "GEN-CA-008 — MFA Fraudulent Registration - Premium Rate Number Abuse (Multi-IdP)",
        "description": "Detects \"MFA toll fraud\" / International Revenue Share Fraud (IRSF) via authentication registration: an attacker (often using a compromised or throwaway/self-service-registered account) enrolls a premium-rate phone number as an SMS/voice MFA method. Every OTP text or voice call the provider then sends to that number generates revenue for the fraudster's telco partner - the \"MFA\" system itself becomes the fraud engine, no credential theft required beyond initial account access. Correlates across BOTH Microsoft Entra ID (AuditLogs) and Okta (System Log), normalized to a common schema, because the strongest signal is the SAME phone number (or actor) being used to register MFA in MULTIPLE, otherwise-unrelated identity providers within the org - legitimate users essentially never do this; fraud rings and shared burner-number pools do. Fires when at least one of these signals is present:   * PremiumRateMatch   : registered number's calling-code prefix matches                          the analyst-maintained \"PremiumRateNumberRanges\"                          watchlist (populate from your telco/anti-fraud                          feed - left empty by default, see below).   * CrossProviderReuse : the same phone number registered as an MFA method                          in 2+ distinct providers (EntraID + Okta) within                          the window - the multi-IdP fraud-ring fingerprint.   * PhoneReusedAcrossUsers : the same phone number registered for N+                          distinct target users (a \"burner number pool\"                          shared by a fraud farm; real users don't share                          MFA phones).   * ActorMassRegistration : the same actor/session registers MFA phones                          for N+ distinct target users in the window                          (compromised admin / SSPR abuse / scripted farm). IMPORTANT - Okta section is a best-effort normalizer:   The exact custom-table and column names for Okta System Log depend on   how your tenant's Okta data connector is configured (commonly ingested   as \"OktaSystemLog\" via the Codeless Connector, or \"Okta_CL\" on older   connectors). Validate the table/column names below against what is   actually in YOUR workspace (`search *` on a sample factor-enrollment   event) before enabling this rule, and adjust the `oktaPhoneEvents` block   accordingly. The EntraID half runs safely regardless. (Source: Bell Integration baseline detection library.)",
        "query": "let lookback              = 1d;\nlet userRingThreshold     = 3;   // distinct target users sharing one phone number to call it a \"burner pool\"\nlet actorRingThreshold    = 3;   // distinct target users touched by one actor to call it \"mass registration\"\nlet TrustedRanges =\n    _GetWatchlist(\"TrustedVPNRanges\")\n    | extend VPNRange = tostring(column_ifexists(\"SearchKey\", column_ifexists(\"Subnet\", \"\")))\n    | where isnotempty(VPNRange)\n    | extend VPNRange = trim(\" \", VPNRange)\n    | extend VPNRange = iff(VPNRange has \"/\", VPNRange, strcat(VPNRange, \"/32\"))\n    | summarize by VPNRange;\n// --- Premium-rate calling-code prefixes (analyst-maintained watchlist) ---\n// \"PremiumRateNumberRanges\" (Analytic Rules/Watchlists/PremiumRateNumberRanges.csv)\n// is seeded from two sources:\n//   1. The satellite/International Network ranges (881, 882, 883, 870) that\n//      are consistently documented top IRSF destinations regardless of org.\n//   2. Microsoft Entra ID's own published list of country/region codes that\n//      require support-ticket opt-in for MFA SMS/voice specifically due to\n//      elevated telephony/IRSF fraud risk (Microsoft Learn:\n//      \"Telephony Fraud Protections and Throttles\" / concept-mfa-regional-opt-in).\n// TUNING REQUIRED before enabling: source #2 is intentionally broad (Microsoft's\n// default-block posture covers ~180 countries, including ones with large\n// legitimate business populations e.g. Belgium, Netherlands, Israel, Hong Kong).\n// Prune the watchlist down to countries where THIS org has no legitimate users\n// or customers, or false positives will be frequent. Empty/pruned watchlist\n// simply yields 0 PremiumRateMatch hits - the other three signals still apply.\nlet premiumPrefixSet =\n    toscalar(\n        _GetWatchlist(\"PremiumRateNumberRanges\")\n        | extend Prefix = trim(@\"[+\\s]\", tostring(column_ifexists(\"Prefix\", column_ifexists(\"SearchKey\", \"\"))))\n        | where isnotempty(Prefix)\n        | summarize make_set(Prefix)\n    );\n// --- Approved shared MFA numbers (e.g. help-desk break-glass line) ---\n// Add E.164 numbers here that are LEGITIMATELY shared/registered across many\n// accounts or providers by design, to suppress the ring/cross-provider\n// signals for just that number.\nlet approvedSharedNumbers = dynamic([]);\n// =========================== Entra ID (Microsoft) ===========================\nlet entraPhoneEvents =\n    AuditLogs\n    | where TimeGenerated >= ago(lookback)\n    | where Category == \"UserManagement\"\n    | where OperationName has_any (\"security info\", \"Update user\", \"authentication method\")\n    | mv-expand TargetResource = TargetResources\n    | mv-expand ModifiedProp = TargetResource.modifiedProperties\n    | extend PropName = tostring(ModifiedProp.displayName)\n    | where PropName has \"phone\"\n    | extend NewValueRaw = tostring(ModifiedProp.newValue)\n    // newValue is JSON-encoded: a quoted string (\"\\\"+15555550123\\\"\") or an\n    // array of strings for multi-valued properties (Business Phones).\n    // Strip brackets/quotes and split on comma to cover both shapes.\n    | extend NewValueClean = replace_string(trim(@\"[\\[\\]\\s]\", NewValueRaw), '\"', \"\")\n    | mv-expand PhoneRaw = split(NewValueClean, \",\") to typeof(string)\n    | where isnotempty(PhoneRaw) and PhoneRaw !in (\"null\", \"\")\n    | extend ActorUPN = tolower(coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)))\n    | extend ActorIP  = tostring(InitiatedBy.user.ipAddress)\n    | extend TargetUPN = tolower(tostring(TargetResource.userPrincipalName))\n    | where isnotempty(TargetUPN)\n    | project TimeGenerated, Provider = \"EntraID\", ActorUPN, ActorIP, TargetUPN, PhoneRaw,\n              EventType = OperationName, PropName;\n// ================================= Okta ====================================\n// See \"IMPORTANT\" note above - validate table/column names for your tenant.\nlet oktaPhoneEvents =\n    union isfuzzy=true OktaSystemLog,\n    (datatable(TimeGenerated:datetime, uuid:string, eventType:string, actor:dynamic, client:dynamic, target:dynamic)[])\n    | where TimeGenerated >= ago(lookback)\n    | where eventType has_any (\"user.mfa.factor.activate\", \"user.mfa.factor.update\", \"user.factor.activate\", \"user.mfa.factor.enroll\")\n    | mv-expand Target = target\n    | extend TType = tostring(Target.type), TAlt = tostring(Target.alternateId)\n    // Collapse the mv-expand back to one row per source event so we can pull\n    // BOTH the enrolled phone (UserFactor) and the target user (User) out of\n    // the same target[] array without a row-multiplying join.\n    | summarize\n            PhoneRawArr  = make_set_if(TAlt, TType == \"UserFactor\"),\n            TargetUpnArr = make_set_if(TAlt, TType == \"User\")\n        by TimeGenerated, EventKey = coalesce(uuid, strcat(tostring(TimeGenerated), tostring(actor.alternateId))),\n           eventType, ActorUPN = tolower(tostring(actor.alternateId)), ActorIP = tostring(client.ipAddress)\n    | extend PhoneRawFull = tostring(PhoneRawArr[0])\n    | where isnotempty(PhoneRawFull)\n    // Okta factor alternateId is often prefixed, e.g. \"sms:+15555550123\".\n    | extend PhoneRaw = extract(@'(\\+?\\d[\\d\\-\\s]{5,})', 1, PhoneRawFull)\n    | extend TargetUPN = tolower(tostring(TargetUpnArr[0]))\n    | where isnotempty(PhoneRaw) and isnotempty(TargetUPN)\n    | project TimeGenerated, Provider = \"Okta\", ActorUPN, ActorIP, TargetUPN, PhoneRaw,\n              EventType = eventType, PropName = \"phoneFactor\";\n// =============================== Correlate ==================================\nlet allEvents =\n    union isfuzzy=true entraPhoneEvents, oktaPhoneEvents\n    | evaluate ipv4_lookup(TrustedRanges, ActorIP, VPNRange, false)\n    | extend ActorIsTrustedVPNIP = isnotempty(VPNRange)\n    | extend PhoneNormalized = replace_regex(PhoneRaw, @\"[^\\d+]\", \"\")\n    | where isnotempty(PhoneNormalized) and PhoneNormalized != \"+\"\n    | extend Digits = trim_start(@\"\\+\", PhoneNormalized)\n    | extend IsPremiumRate = array_length(premiumPrefixSet) > 0 and (\n            set_has_element(premiumPrefixSet, substring(Digits, 0, 1))\n         or set_has_element(premiumPrefixSet, substring(Digits, 0, 2))\n         or set_has_element(premiumPrefixSet, substring(Digits, 0, 3))\n         or set_has_element(premiumPrefixSet, substring(Digits, 0, 4)))\n    | extend IsApprovedNumber = set_has_element(approvedSharedNumbers, PhoneNormalized)\n    | project-away VPNRange;\n// Ring signal 1: same phone number registered across multiple providers.\nlet crossProviderPhones =\n    allEvents\n    | summarize DistinctProviders = dcount(Provider), Providers = make_set(Provider, 4) by PhoneNormalized\n    | where DistinctProviders >= 2;\n// Ring signal 2: same phone number registered for many distinct target users.\nlet sharedPhones =\n    allEvents\n    | summarize DistinctUsersForPhone = dcount(TargetUPN), UsersForPhone = make_set(TargetUPN, 32) by PhoneNormalized\n    | where DistinctUsersForPhone >= userRingThreshold;\n// Ring signal 3: same actor registering MFA phones for many distinct targets.\nlet massRegistrationActors =\n    allEvents\n    | where isnotempty(ActorUPN)\n    | summarize DistinctTargetsByActor = dcount(TargetUPN), TargetsByActor = make_set(TargetUPN, 32) by ActorUPN\n    | where DistinctTargetsByActor >= actorRingThreshold;\nallEvents\n| join kind=leftouter (crossProviderPhones) on PhoneNormalized\n| join kind=leftouter (sharedPhones) on PhoneNormalized\n| join kind=leftouter (massRegistrationActors) on ActorUPN\n| extend DistinctProviders      = coalesce(DistinctProviders, 1),\n         DistinctUsersForPhone  = coalesce(DistinctUsersForPhone, 1),\n         DistinctTargetsByActor = coalesce(DistinctTargetsByActor, 1)\n| extend CrossProviderReuse     = DistinctProviders >= 2,\n         PhoneReusedAcrossUsers = DistinctUsersForPhone >= userRingThreshold,\n         ActorMassRegistration  = DistinctTargetsByActor >= actorRingThreshold\n| where not(IsApprovedNumber)\n| where IsPremiumRate or CrossProviderReuse or PhoneReusedAcrossUsers or ActorMassRegistration\n| extend RiskScore = toint(IsPremiumRate) * 2\n                    + toint(CrossProviderReuse) * 2\n                    + toint(PhoneReusedAcrossUsers) * 2\n                    + toint(ActorMassRegistration) * 1\n| extend Signals = set_difference(\n        pack_array(\n            iff(IsPremiumRate,            \"PremiumRateMatch\",       \"\"),\n            iff(CrossProviderReuse,       \"CrossProviderReuse\",     \"\"),\n            iff(PhoneReusedAcrossUsers,   \"PhoneReusedAcrossUsers\", \"\"),\n            iff(ActorMassRegistration,    \"ActorMassRegistration\",  \"\")),\n        dynamic([\"\"]))\n| extend Severity = case(\n        IsPremiumRate and CrossProviderReuse, \"Critical\",\n        RiskScore >= 4,                       \"Critical\",\n        RiskScore >= 2,                       \"High\",\n        \"Medium\")\n| extend AccountName = tostring(split(TargetUPN, \"@\")[0]),\n         UPNSuffix   = tostring(split(TargetUPN, \"@\")[1])\n| project TimeGenerated, Provider, EventType, Severity, RiskScore, Signals,\n          TargetUPN, AccountName, UPNSuffix, ActorUPN, ActorIP, ActorIsTrustedVPNIP,\n          PhoneRaw, PhoneNormalized, IsPremiumRate,\n          DistinctProviders, CrossProviderReuse,\n          DistinctUsersForPhone, PhoneReusedAcrossUsers,\n          DistinctTargetsByActor, ActorMassRegistration\n| extend timestamp = TimeGenerated, AccountCustomEntity = TargetUPN, IPCustomEntity = ActorIP\n| order by Severity asc, RiskScore desc, TimeGenerated desc"
      },
      {
        "name": "c2697b81-7fe9-4f57-ba1d-de46c6f91f9c — MFA Fatigue (OKTA)",
        "description": "MFA fatigue attack is a cybersecurity threat where attackers exploit user exhaustion from multi-factor authentication prompts to trick them into approving multi-factor authentication prompts, potentially compromising their own security. The query identifies MFA fatigue attempts in Okta by correlating repeated Okta Verify push requests, denied pushes, and eventual successful MFA authentication within the same session. Ref: https://www.okta.com/blog/identity-security/mfa-fatigue-growing-security-concern/. (Source: Microsoft's official Azure-Sentinel Detections (MIT licensed).)",
        "query": "// Adjust threshold for MFA pushes to reduce noise\nlet PushThreshold = 10;\nOktaSSO\n| where ((eventType_s ==\"user.authentication.auth_via_mfa\" and column_ifexists('debugContext_debugData_factor_s', '') == \"OKTA_VERIFY_PUSH\") or eventType_s == \"system.push.send_factor_verify_push\" or eventType_s == \"user.mfa.okta_verify.deny_push\") \n| summarize IPAddress = make_set(client_ipAddress_s,100), City = make_set(client_geographicalContext_city_s,100),\n          successes = countif(eventType_s == \"user.authentication.auth_via_mfa\"),\n          denies = countif(eventType_s == \"user.mfa.okta_verify.deny_push\"),\n          pushes = countif(eventType_s == \"system.push.send_factor_verify_push\") by TimeGenerated, authenticationContext_externalSessionId_s, actor_alternateId_s,actor_displayName_s, outcome_result_s \n| summarize lasttime = max(TimeGenerated), firsttime = min(TimeGenerated),\n          successes = sum(successes), failures = sum(denies), pushes = sum(pushes) by  authenticationContext_externalSessionId_s, actor_alternateId_s,actor_displayName_s, outcome_result_s \n| extend seconds = lasttime - firsttime\n| where pushes > PushThreshold\n| extend totalattempts = successes + failures\n| extend finding = case(\n            failures == pushes and pushes > 1, \"Authentication attempts not successful because multiple pushes denied\",\n            totalattempts == 0, \"Multiple pushes sent and ignored\",\n            successes > 0 and pushes > 3, \"Multiple pushes sent, eventual successful authentication!\",\n            \"Normal authentication pattern\")\n| project lasttime, firsttime, seconds, actor_alternateId_s, actor_displayName_s, authenticationContext_externalSessionId_s, pushes, successes, failures, totalattempts, finding, outcome_result_s\n| extend AccountName = tostring(split(actor_alternateId_s, \"@\")[0]), AccountUPNSuffix = tostring(split(actor_alternateId_s, \"@\")[1])"
      }
    ],
    "spl": [],
    "esql": [
      {
        "name": "8a0fbd26-867f-11ee-947c-f661ea17fbcd — Potential Okta MFA Bombing via Push Notifications",
        "description": "(EQL) Detects when an attacker abuses the Multi-Factor authentication mechanism by repeatedly issuing login requests until the user eventually accepts the Okta push notification. An adversary may attempt to bypass the Okta MFA policies configured for an organization to obtain unauthorized access. (Source: Elastic's official detection-rules repository (Elastic License v2).)",
        "query": "sequence by okta.actor.id with maxspan=10m\n  [ any\n    where data_stream.dataset == \"okta.system\"\n      and (\n        okta.event_type == \"user.mfa.okta_verify.deny_push\"\n        or (\n          okta.event_type == \"user.authentication.auth_via_mfa\"\n          and okta.debug_context.debug_data.factor == \"OKTA_VERIFY_PUSH\"\n          and okta.outcome.reason == \"INVALID_CREDENTIALS\"\n        )\n      )\n  ] with runs=5\n  until\n  [ any\n    where data_stream.dataset == \"okta.system\"\n      and okta.event_type in (\n        \"user.authentication.sso\",\n        \"user.authentication.auth_via_mfa\",\n        \"user.authentication.verify\",\n        \"user.session.start\"\n      )\n      and okta.outcome.result == \"SUCCESS\"\n  ]"
      },
      {
        "name": "97a8e584-fd3b-421f-9b9d-9c9d9e57e9d7 — Potentially Successful Okta MFA Bombing via Push Notifications",
        "description": "(EQL) Detects when an attacker abuses the Multi-Factor authentication mechanism by repeatedly issuing login requests until the user eventually accepts the Okta push notification. An adversary may attempt to bypass the Okta MFA policies configured for an organization to obtain unauthorized access. (Source: Elastic's official detection-rules repository (Elastic License v2).)",
        "query": "sequence by okta.actor.id with maxspan=10m\n  [ any\n    where data_stream.dataset == \"okta.system\"\n      and (\n        okta.event_type == \"user.mfa.okta_verify.deny_push\"\n        or (\n          okta.event_type == \"user.authentication.auth_via_mfa\"\n          and okta.debug_context.debug_data.factor == \"OKTA_VERIFY_PUSH\"\n          and okta.outcome.reason == \"INVALID_CREDENTIALS\"\n        )\n      )\n  ] with runs=5\n  [ any\n    where data_stream.dataset == \"okta.system\"\n      and okta.event_type in (\n        \"user.authentication.sso\",\n        \"user.authentication.auth_via_mfa\",\n        \"user.authentication.verify\",\n        \"user.session.start\"\n      )\n      and okta.outcome.result == \"SUCCESS\"\n  ]"
      }
    ]
  },
  "automation": {
    "safe": [
      "Add recommendation as Sentinel incident comment.",
      "Run enrichment queries.",
      "Create ServiceNow SecOps task."
    ],
    "approval_required": [
      "Contain or disable the affected host/account.",
      "Any change to production configuration."
    ]
  },
  "escalation_criteria": [
    "Multi-Factor Authentication Request Generation activity observed on a privileged account or critical system.",
    "Activity follows or precedes other suspicious behaviour in the same investigation.",
    "Automated triage cannot confidently rule out malicious intent."
  ],
  "false_positive_considerations": [
    "Legitimate administrative or maintenance activity matching this pattern.",
    "Approved security testing or red team exercise.",
    "Known benign software producing similar telemetry."
  ],
  "confluence": {
    "title": "T1621 - Multi-Factor Authentication Request Generation Response Guidance",
    "labels": [
      "mitre",
      "attack",
      "d3fend",
      "secops",
      "basyrix"
    ],
    "sections": [
      "summary",
      "d3fend_mappings",
      "investigation_steps",
      "response_actions",
      "queries",
      "automation",
      "escalation_criteria",
      "false_positive_considerations"
    ]
  }
}