# T1621 - Multi-Factor Authentication Request Generation

## 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
| D3FEND Technique | Relationship | Practical SOC Action |
|---|---|---|
| Process Lineage Analysis | Detect | Monitor for Process Lineage Analysis indicators relevant to this technique. |
| Process Termination | Evict | Use Process Termination to remove the adversary's foothold once this technique is confirmed. |
| Kernel-based Process Isolation | Isolate | Apply Kernel-based Process Isolation to contain the blast radius once this technique is observed. |

## Investigation Steps
1. Review Defender for Endpoint / Defender XDR alerts and timeline for the affected host or identity.
2. Check Sentinel analytics rules and incidents correlated with this technique.
3. Review Entra ID sign-in and audit logs if the technique involves an identity or cloud resource.

## 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
| Action | Risk | Automation Safe | Approval Required |
|---|---|---|---|
| Contain the affected host or account | Medium | No | Yes |
| Collect and preserve evidence | Low | Yes | No |

## KQL
```kql
let sessionGapMin   = 5;     // max gap (minutes) between consecutive denials/timeouts to stay in the same burst
let burstThreshold  = 4;     // minimum denied/timeout MFA challenges to call it a burst
let multiIPThreshold = 2;    // distinct source IPs within a burst that bumps severity even without a success
let correlationWin  = 30m;   // how long after the last denial an approval still counts as "fatigue succeeded"
let lookback        = 1d;
let deniedPhrases   = dynamic(["denied", "declined"]);
let timeoutPhrases  = dynamic(["timeout", "timed out", "did not respond", "no response"]);
let TrustedRanges =
    _GetWatchlist("TrustedVPNRanges")
    | extend VPNRange = tostring(column_ifexists("SearchKey", column_ifexists("Subnet", "")))
    | where isnotempty(VPNRange)
    | extend VPNRange = trim(" ", VPNRange)
    | extend VPNRange = iff(VPNRange has "/", VPNRange, strcat(VPNRange, "/32"))
    | summarize by VPNRange;
// --- Per-user exclusions ---
//   homeCountries : ISO 3166 alpha-2. Suppresses the alert only when EVERY IP
//                   in the burst AND the eventual success (if any) resolve to
//                   one of these countries - covers users with known flaky
//                   connectivity who routinely fumble their own MFA prompt.
// Currently EMPTY - no users excluded:
let userExclusions = dynamic([]);
let userHomeMap =
    print arr = userExclusions
    | mv-expand entry = arr
    | where isnotempty(entry.homeCountries)
    | project HomeUpn       = tolower(tostring(entry.upn)),
              HomeCountries = todynamic(tolower(tostring(entry.homeCountries)));
let signinLogsSource =
    union isfuzzy=true SigninLogs,
    (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)[]);
let mfaSteps =
    signinLogsSource
    | where TimeGenerated >= ago(lookback)
    | where isnotempty(UserPrincipalName) and isnotempty(AuthenticationDetails)
    | extend UserPrincipalName = tolower(UserPrincipalName)
    | mv-expand Step = AuthenticationDetails
    | extend StepMethod       = tostring(Step.authenticationMethod),
             StepSucceeded    = tobool(Step.succeeded),
             StepResultDetail = tolower(tostring(Step.authenticationStepResultDetail))
    | where isnotempty(StepResultDetail)
    | evaluate ipv4_lookup(TrustedRanges, IPAddress, VPNRange, false)
    | extend IsTrustedVPNIP = isnotempty(VPNRange)
    | extend LocationDetailsDyn = todynamic(column_ifexists("LocationDetails", dynamic({})))
    | extend Location = tostring(LocationDetailsDyn.countryOrRegion)
    | extend MfaOutcome = case(
            StepSucceeded,                              "Success",
            StepResultDetail has_any (deniedPhrases),    "Denied",
            StepResultDetail has_any (timeoutPhrases),   "Timeout",
            "Other")
    | where MfaOutcome in ("Denied", "Timeout", "Success")
    | project TimeGenerated, UserPrincipalName, UserId, IPAddress, Location, IsTrustedVPNIP,
              AppDisplayName, ClientAppUsed, UserAgent, StepMethod, StepResultDetail, MfaOutcome;
// --- Sessionize denials/timeouts per user by gap, not by fixed bin ---
let denials =
    mfaSteps
    | where MfaOutcome in ("Denied", "Timeout")
    | order by UserPrincipalName asc, TimeGenerated asc
    | extend PrevUser = prev(UserPrincipalName), PrevTime = prev(TimeGenerated)
    | extend NewSession = iff(PrevUser != UserPrincipalName or isnull(PrevTime)
                               or datetime_diff("minute", TimeGenerated, PrevTime) > sessionGapMin, 1, 0)
    | extend SessionId = row_cumsum(NewSession);
let bursts =
    denials
    | summarize
            BurstStart      = min(TimeGenerated),
            BurstEnd        = max(TimeGenerated),
            DenialCount     = count(),
            DeniedCount     = countif(MfaOutcome == "Denied"),
            TimeoutCount    = countif(MfaOutcome == "Timeout"),
            DistinctIPs     = dcount(IPAddress),
            BurstIPs        = make_set(IPAddress, 32),
            BurstLocations  = make_set(Location, 16),
            BurstApps       = make_set(AppDisplayName, 16),
            BurstMethods    = make_set(StepMethod, 8),
            AnyTrustedVPNIP = max(IsTrustedVPNIP)
        by UserPrincipalName, UserId, SessionId
    | where DenialCount >= burstThreshold
    | project-away SessionId;
let successes =
    mfaSteps
    | where MfaOutcome == "Success"
    | project SuccessTime = TimeGenerated, UserPrincipalName, SuccessIP = IPAddress,
              SuccessLocation = Location, SuccessIsTrustedVPNIP = IsTrustedVPNIP,
              SuccessApp = AppDisplayName, SuccessClientApp = ClientAppUsed, SuccessUA = UserAgent;
// Earliest qualifying approval AFTER the burst, within correlationWin. Computed
// as its own scalar table (not a filtering join on bursts) so bursts with NO
// matching success are never dropped - only enriched when one exists.
let burstSuccess =
    bursts
    | join kind=inner (successes) on UserPrincipalName
    | where SuccessTime between (BurstEnd .. BurstEnd + correlationWin)
    | summarize arg_min(SuccessTime, SuccessIP, SuccessLocation, SuccessIsTrustedVPNIP, SuccessApp, SuccessClientApp, SuccessUA)
        by UserPrincipalName, BurstStart, BurstEnd;
bursts
| join kind=leftouter (burstSuccess) on UserPrincipalName, BurstStart, BurstEnd
| extend FollowedBySuccess = isnotempty(SuccessTime)
| extend SuccessFromNewIP  = FollowedBySuccess and not(set_has_element(BurstIPs, SuccessIP))
| extend Verdict = case(
        FollowedBySuccess and SuccessFromNewIP, "MFA Fatigue - Succeeded (approval from new IP)",
        FollowedBySuccess,                      "MFA Fatigue - Succeeded",
        DistinctIPs >= multiIPThreshold,        "MFA Push Spam - Attempted (multi-IP)",
        "MFA Push Spam - Attempted")
| extend Severity = case(
        FollowedBySuccess and SuccessFromNewIP, "Critical",
        FollowedBySuccess,                      "High",
        DistinctIPs >= multiIPThreshold,         "High",
        "Medium")
// --- Apply per-user home-country exclusion ---
// Drop only when the user is listed AND every burst location AND the success
// location (if any) are all home countries.
| join kind=leftouter (userHomeMap) on $left.UserPrincipalName == $right.HomeUpn
| extend IsListed          = isnotempty(HomeUpn),
         BurstLocationsLc   = todynamic(tolower(tostring(BurstLocations))),
         SuccessLocationLc  = tolower(tostring(SuccessLocation))
| extend AllBurstHome = IsListed
                      and array_length(set_difference(coalesce(BurstLocationsLc, dynamic([])),
                                                        coalesce(HomeCountries, dynamic([])))) == 0,
         SuccessHome  = not(FollowedBySuccess)
                      or (IsListed and set_has_element(HomeCountries, SuccessLocationLc))
| where not(IsListed and AllBurstHome and SuccessHome)
| project-away HomeUpn, HomeCountries, IsListed, BurstLocationsLc, SuccessLocationLc, AllBurstHome, SuccessHome
| extend AccountName = tostring(split(UserPrincipalName, "@")[0]),
         UPNSuffix   = tostring(split(UserPrincipalName, "@")[1])
| project TimeGenerated = BurstEnd, UserPrincipalName, AccountName, UPNSuffix, UserId,
          Verdict, Severity, BurstStart, BurstEnd, DenialCount, DeniedCount, TimeoutCount,
          DistinctIPs, BurstIPs, BurstLocations, BurstApps, BurstMethods, AnyTrustedVPNIP,
          FollowedBySuccess, SuccessTime, SuccessIP, SuccessFromNewIP, SuccessLocation,
          SuccessIsTrustedVPNIP, SuccessApp, SuccessClientApp, SuccessUA
| extend timestamp = TimeGenerated, AccountCustomEntity = UserPrincipalName,
         IPCustomEntity = iff(FollowedBySuccess, SuccessIP, tostring(BurstIPs[0]))
| order by Severity asc, DenialCount desc, TimeGenerated desc
```
```kql
let lookback              = 1d;
let userRingThreshold     = 3;   // distinct target users sharing one phone number to call it a "burner pool"
let actorRingThreshold    = 3;   // distinct target users touched by one actor to call it "mass registration"
let TrustedRanges =
    _GetWatchlist("TrustedVPNRanges")
    | extend VPNRange = tostring(column_ifexists("SearchKey", column_ifexists("Subnet", "")))
    | where isnotempty(VPNRange)
    | extend VPNRange = trim(" ", VPNRange)
    | extend VPNRange = iff(VPNRange has "/", VPNRange, strcat(VPNRange, "/32"))
    | summarize by VPNRange;
// --- Premium-rate calling-code prefixes (analyst-maintained watchlist) ---
// "PremiumRateNumberRanges" (Analytic Rules/Watchlists/PremiumRateNumberRanges.csv)
// is seeded from two sources:
//   1. The satellite/International Network ranges (881, 882, 883, 870) that
//      are consistently documented top IRSF destinations regardless of org.
//   2. Microsoft Entra ID's own published list of country/region codes that
//      require support-ticket opt-in for MFA SMS/voice specifically due to
//      elevated telephony/IRSF fraud risk (Microsoft Learn:
//      "Telephony Fraud Protections and Throttles" / concept-mfa-regional-opt-in).
// TUNING REQUIRED before enabling: source #2 is intentionally broad (Microsoft's
// default-block posture covers ~180 countries, including ones with large
// legitimate business populations e.g. Belgium, Netherlands, Israel, Hong Kong).
// Prune the watchlist down to countries where THIS org has no legitimate users
// or customers, or false positives will be frequent. Empty/pruned watchlist
// simply yields 0 PremiumRateMatch hits - the other three signals still apply.
let premiumPrefixSet =
    toscalar(
        _GetWatchlist("PremiumRateNumberRanges")
        | extend Prefix = trim(@"[+\s]", tostring(column_ifexists("Prefix", column_ifexists("SearchKey", ""))))
        | where isnotempty(Prefix)
        | summarize make_set(Prefix)
    );
// --- Approved shared MFA numbers (e.g. help-desk break-glass line) ---
// Add E.164 numbers here that are LEGITIMATELY shared/registered across many
// accounts or providers by design, to suppress the ring/cross-provider
// signals for just that number.
let approvedSharedNumbers = dynamic([]);
// =========================== Entra ID (Microsoft) ===========================
let entraPhoneEvents =
    AuditLogs
    | where TimeGenerated >= ago(lookback)
    | where Category == "UserManagement"
    | where OperationName has_any ("security info", "Update user", "authentication method")
    | mv-expand TargetResource = TargetResources
    | mv-expand ModifiedProp = TargetResource.modifiedProperties
    | extend PropName = tostring(ModifiedProp.displayName)
    | where PropName has "phone"
    | extend NewValueRaw = tostring(ModifiedProp.newValue)
    // newValue is JSON-encoded: a quoted string ("\"+15555550123\"") or an
    // array of strings for multi-valued properties (Business Phones).
    // Strip brackets/quotes and split on comma to cover both shapes.
    | extend NewValueClean = replace_string(trim(@"[\[\]\s]", NewValueRaw), '"', "")
    | mv-expand PhoneRaw = split(NewValueClean, ",") to typeof(string)
    | where isnotempty(PhoneRaw) and PhoneRaw !in ("null", "")
    | extend ActorUPN = tolower(coalesce(tostring(InitiatedBy.user.userPrincipalName), tostring(InitiatedBy.app.displayName)))
    | extend ActorIP  = tostring(InitiatedBy.user.ipAddress)
    | extend TargetUPN = tolower(tostring(TargetResource.userPrincipalName))
    | where isnotempty(TargetUPN)
    | project TimeGenerated, Provider = "EntraID", ActorUPN, ActorIP, TargetUPN, PhoneRaw,
              EventType = OperationName, PropName;
// ================================= Okta ====================================
// See "IMPORTANT" note above - validate table/column names for your tenant.
let oktaPhoneEvents =
    union isfuzzy=true OktaSystemLog,
    (datatable(TimeGenerated:datetime, uuid:string, eventType:string, actor:dynamic, client:dynamic, target:dynamic)[])
    | where TimeGenerated >= ago(lookback)
    | where eventType has_any ("user.mfa.factor.activate", "user.mfa.factor.update", "user.factor.activate", "user.mfa.factor.enroll")
    | mv-expand Target = target
    | extend TType = tostring(Target.type), TAlt = tostring(Target.alternateId)
    // Collapse the mv-expand back to one row per source event so we can pull
    // BOTH the enrolled phone (UserFactor) and the target user (User) out of
    // the same target[] array without a row-multiplying join.
    | summarize
            PhoneRawArr  = make_set_if(TAlt, TType == "UserFactor"),
            TargetUpnArr = make_set_if(TAlt, TType == "User")
        by TimeGenerated, EventKey = coalesce(uuid, strcat(tostring(TimeGenerated), tostring(actor.alternateId))),
           eventType, ActorUPN = tolower(tostring(actor.alternateId)), ActorIP = tostring(client.ipAddress)
    | extend PhoneRawFull = tostring(PhoneRawArr[0])
    | where isnotempty(PhoneRawFull)
    // Okta factor alternateId is often prefixed, e.g. "sms:+15555550123".
    | extend PhoneRaw = extract(@'(\+?\d[\d\-\s]{5,})', 1, PhoneRawFull)
    | extend TargetUPN = tolower(tostring(TargetUpnArr[0]))
    | where isnotempty(PhoneRaw) and isnotempty(TargetUPN)
    | project TimeGenerated, Provider = "Okta", ActorUPN, ActorIP, TargetUPN, PhoneRaw,
              EventType = eventType, PropName = "phoneFactor";
// =============================== Correlate ==================================
let allEvents =
    union isfuzzy=true entraPhoneEvents, oktaPhoneEvents
    | evaluate ipv4_lookup(TrustedRanges, ActorIP, VPNRange, false)
    | extend ActorIsTrustedVPNIP = isnotempty(VPNRange)
    | extend PhoneNormalized = replace_regex(PhoneRaw, @"[^\d+]", "")
    | where isnotempty(PhoneNormalized) and PhoneNormalized != "+"
    | extend Digits = trim_start(@"\+", PhoneNormalized)
    | extend IsPremiumRate = array_length(premiumPrefixSet) > 0 and (
            set_has_element(premiumPrefixSet, substring(Digits, 0, 1))
         or set_has_element(premiumPrefixSet, substring(Digits, 0, 2))
         or set_has_element(premiumPrefixSet, substring(Digits, 0, 3))
         or set_has_element(premiumPrefixSet, substring(Digits, 0, 4)))
    | extend IsApprovedNumber = set_has_element(approvedSharedNumbers, PhoneNormalized)
    | project-away VPNRange;
// Ring signal 1: same phone number registered across multiple providers.
let crossProviderPhones =
    allEvents
    | summarize DistinctProviders = dcount(Provider), Providers = make_set(Provider, 4) by PhoneNormalized
    | where DistinctProviders >= 2;
// Ring signal 2: same phone number registered for many distinct target users.
let sharedPhones =
    allEvents
    | summarize DistinctUsersForPhone = dcount(TargetUPN), UsersForPhone = make_set(TargetUPN, 32) by PhoneNormalized
    | where DistinctUsersForPhone >= userRingThreshold;
// Ring signal 3: same actor registering MFA phones for many distinct targets.
let massRegistrationActors =
    allEvents
    | where isnotempty(ActorUPN)
    | summarize DistinctTargetsByActor = dcount(TargetUPN), TargetsByActor = make_set(TargetUPN, 32) by ActorUPN
    | where DistinctTargetsByActor >= actorRingThreshold;
allEvents
| join kind=leftouter (crossProviderPhones) on PhoneNormalized
| join kind=leftouter (sharedPhones) on PhoneNormalized
| join kind=leftouter (massRegistrationActors) on ActorUPN
| extend DistinctProviders      = coalesce(DistinctProviders, 1),
         DistinctUsersForPhone  = coalesce(DistinctUsersForPhone, 1),
         DistinctTargetsByActor = coalesce(DistinctTargetsByActor, 1)
| extend CrossProviderReuse     = DistinctProviders >= 2,
         PhoneReusedAcrossUsers = DistinctUsersForPhone >= userRingThreshold,
         ActorMassRegistration  = DistinctTargetsByActor >= actorRingThreshold
| where not(IsApprovedNumber)
| where IsPremiumRate or CrossProviderReuse or PhoneReusedAcrossUsers or ActorMassRegistration
| extend RiskScore = toint(IsPremiumRate) * 2
                    + toint(CrossProviderReuse) * 2
                    + toint(PhoneReusedAcrossUsers) * 2
                    + toint(ActorMassRegistration) * 1
| extend Signals = set_difference(
        pack_array(
            iff(IsPremiumRate,            "PremiumRateMatch",       ""),
            iff(CrossProviderReuse,       "CrossProviderReuse",     ""),
            iff(PhoneReusedAcrossUsers,   "PhoneReusedAcrossUsers", ""),
            iff(ActorMassRegistration,    "ActorMassRegistration",  "")),
        dynamic([""]))
| extend Severity = case(
        IsPremiumRate and CrossProviderReuse, "Critical",
        RiskScore >= 4,                       "Critical",
        RiskScore >= 2,                       "High",
        "Medium")
| extend AccountName = tostring(split(TargetUPN, "@")[0]),
         UPNSuffix   = tostring(split(TargetUPN, "@")[1])
| project TimeGenerated, Provider, EventType, Severity, RiskScore, Signals,
          TargetUPN, AccountName, UPNSuffix, ActorUPN, ActorIP, ActorIsTrustedVPNIP,
          PhoneRaw, PhoneNormalized, IsPremiumRate,
          DistinctProviders, CrossProviderReuse,
          DistinctUsersForPhone, PhoneReusedAcrossUsers,
          DistinctTargetsByActor, ActorMassRegistration
| extend timestamp = TimeGenerated, AccountCustomEntity = TargetUPN, IPCustomEntity = ActorIP
| order by Severity asc, RiskScore desc, TimeGenerated desc
```
```kql
// Adjust threshold for MFA pushes to reduce noise
let PushThreshold = 10;
OktaSSO
| 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") 
| summarize IPAddress = make_set(client_ipAddress_s,100), City = make_set(client_geographicalContext_city_s,100),
          successes = countif(eventType_s == "user.authentication.auth_via_mfa"),
          denies = countif(eventType_s == "user.mfa.okta_verify.deny_push"),
          pushes = countif(eventType_s == "system.push.send_factor_verify_push") by TimeGenerated, authenticationContext_externalSessionId_s, actor_alternateId_s,actor_displayName_s, outcome_result_s 
| summarize lasttime = max(TimeGenerated), firsttime = min(TimeGenerated),
          successes = sum(successes), failures = sum(denies), pushes = sum(pushes) by  authenticationContext_externalSessionId_s, actor_alternateId_s,actor_displayName_s, outcome_result_s 
| extend seconds = lasttime - firsttime
| where pushes > PushThreshold
| extend totalattempts = successes + failures
| extend finding = case(
            failures == pushes and pushes > 1, "Authentication attempts not successful because multiple pushes denied",
            totalattempts == 0, "Multiple pushes sent and ignored",
            successes > 0 and pushes > 3, "Multiple pushes sent, eventual successful authentication!",
            "Normal authentication pattern")
| project lasttime, firsttime, seconds, actor_alternateId_s, actor_displayName_s, authenticationContext_externalSessionId_s, pushes, successes, failures, totalattempts, finding, outcome_result_s
| extend AccountName = tostring(split(actor_alternateId_s, "@")[0]), AccountUPNSuffix = tostring(split(actor_alternateId_s, "@")[1])
```

## 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.

Generated by SOC Response Atlas by Basyrix.
