# T1078 - Valid Accounts

## SOC Recommendation
Treat as potential account compromise. Validate sign-in activity, token usage, MFA changes, device posture, privilege changes, mailbox rules, OAuth grants, and lateral movement indicators before deciding whether this is expected user behaviour or an active intrusion.

## D3FEND Mappings
| D3FEND Technique | Relationship | Practical SOC Action |
|---|---|---|
| Domain Account Monitoring | Detect | Review domain/directory sign-in activity for the account -- risky sign-ins, impossible travel, unfamiliar devices, MFA prompts. |
| Local Account Monitoring | Detect | Review local account logon activity on the affected host for use of the same credential outside the directory-managed path. |
| Account Locking | Evict | Lock or disable the account once compromise is confirmed, and revoke its active sessions and refresh tokens. |
| User Account Permissions | Isolate | Review and, if needed, restrict the account's role assignments and group memberships to limit what a compromised session can reach. |

## Investigation Steps
1. Check NoveltyReason — a new country carries more weight than a new user agent alone (browser updates and OS patches change the UA string routinely).
2. Cross-reference IsTrustedVPNIP — if true, this is likely corporate VPN egress from a new range; verify against the TrustedVPNRanges watchlist and consider adding it if legitimate.
3. Check BaselineSigninCount — a low count means the baseline itself is thin (new joiner) and false positives are more likely.
4. Contact the user directly (not by email, in case the mailbox is compromised) — are they travelling, using a new device, or on a new ISP/VPN?
5. Check HR travel records for business travel.

## Evidence to Collect
- User principal name
- Source IP
- Location
- Device ID
- User agent
- Application accessed
- Sign-in result
- MFA status
- Token activity
- Group or role changes
- Mailbox rule changes
- OAuth consent grants

## Response Actions
| Action | Risk | Automation Safe | Approval Required |
|---|---|---|---|
| Validate the sign-in with the user directly by phone | Low | No | Yes |
| If unconfirmed or suspicious: revoke active sessions and for | Medium | No | Yes |
| Check for MFA method changes or new OAuth consents in the sa | Low | Yes | No |
| Add legitimate new VPN ranges or corporate egress IPs to Tr | Low | No | Yes |
| Correlate with GEN-IA-016 (Impossible Travel) and GEN-IA- | Low | No | Yes |
| Document the outcome — confirmed travel/new device vs. confi | Low | Yes | No |
| If compromised: follow full account-takeover recovery (sessi | Medium | No | Yes |

## 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 = 1h;
let correlationWindow = 45m;
let suspiciousAccountOperations = dynamic([
    "User registered security info",
    "User changed default security info",
    "User deleted security info",
    "Admin registered security info",
    "Reset password (self-service)",
    "Reset user password",
    "Change user password",
    "Consent to application",
    "Add app role assignment to service principal"
]);
let urlClickEventsSource =
    union isfuzzy=true UrlClickEvents,
    (datatable(TimeGenerated:datetime, Url:string, AccountUpn:string, ThreatTypes:string, IsClickedThrough:string, ActionType:string, NetworkMessageId:string, IPAddress:string)[]);
let auditLogsSource =
    union isfuzzy=true AuditLogs,
    (datatable(TimeGenerated:datetime, OperationName:string, InitiatedBy:dynamic, TargetResources:dynamic, Result:string, AdditionalDetails:dynamic)[]);
let suspiciousClicks =
    urlClickEventsSource
    | where TimeGenerated >= ago(lookback)
    | extend AccountUpn = tolower(tostring(column_ifexists("AccountUpn", ""))),
             Url = tostring(column_ifexists("Url", "")),
             ThreatTypes = tostring(column_ifexists("ThreatTypes", "")),
             IsClickedThrough = tolower(tostring(column_ifexists("IsClickedThrough", "false"))),
             ActionType = tostring(column_ifexists("ActionType", "")),
             ClickIP = tostring(column_ifexists("IPAddress", ""))
    | where isnotempty(AccountUpn) and isnotempty(Url)
    | where ThreatTypes has "Phish" or IsClickedThrough == "true" or ActionType in~ ("ClickAllowed", "ClickThrough")
    | extend ParsedUrl = parse_url(Url),
             UrlHost = tolower(tostring(ParsedUrl.Host))
    | project ClickTime = TimeGenerated, AccountUpn, Url, UrlHost, ThreatTypes, ActionType, IsClickedThrough, ClickIP, NetworkMessageId;
let accountChanges =
    auditLogsSource
    | where TimeGenerated >= ago(lookback)
    | where OperationName has_any (suspiciousAccountOperations)
    | extend Actor = tolower(tostring(InitiatedBy.user.userPrincipalName)),
             TargetUser = tolower(tostring(TargetResources[0].userPrincipalName)),
             TargetDisplayName = tostring(TargetResources[0].displayName),
             ChangeDetails = tostring(AdditionalDetails)
    | extend CorrelationUser = coalesce(Actor, TargetUser)
    | where isnotempty(CorrelationUser)
    | project ChangeTime = TimeGenerated, CorrelationUser, Actor, TargetUser, TargetDisplayName, OperationName, Result, ChangeDetails;
suspiciousClicks
| join kind=inner accountChanges on $left.AccountUpn == $right.CorrelationUser
| where ChangeTime between (ClickTime .. ClickTime + correlationWindow)
| project
    TimeGenerated = ChangeTime,
    AccountUpn,
    ClickTime,
    ChangeTime,
    Url,
    UrlHost,
    ThreatTypes,
    ActionType,
    ClickIP,
    OperationName,
    Actor,
    TargetUser,
    TargetDisplayName,
    Result,
    ChangeDetails,
    NetworkMessageId
| extend AlertTitle = strcat("Phishing click followed by account change: ", AccountUpn),
         timestamp = TimeGenerated,
         AccountCustomEntity = AccountUpn,
         IPCustomEntity = ClickIP,
         URLCustomEntity = Url
| order by TimeGenerated desc
```
```kql
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 userNoisyAppsMap =
    print arr = userExclusions
    | mv-expand entry = arr
    | where isnotempty(entry.noisyApps)
    | project NoisyUpn  = tolower(tostring(entry.upn)),
              NoisyApps = todynamic(tolower(tostring(entry.noisyApps)));
//
let lookback = 1d;
let maxSpeedKmh = 900.0;
let minDistanceKm = 100.0;
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;
let signinLogsSource =
    union isfuzzy=true SigninLogs,
    (datatable(TimeGenerated:datetime, ResultType:string, IPAddress:string, LocationDetails:dynamic, DeviceDetail:dynamic, UserPrincipalName:string, AppDisplayName:string, ConditionalAccessStatus:string, UserAgent:string)[]);
signinLogsSource
| where TimeGenerated >= ago(lookback)
| where tostring(ResultType) == "0"
| where isnotempty(UserPrincipalName) and isnotempty(IPAddress)
| extend LocationDetailsDyn = todynamic(LocationDetails),
         DeviceDetailDyn = todynamic(DeviceDetail)
| extend Lat = todouble(LocationDetailsDyn.geoCoordinates.latitude),
         Lon = todouble(LocationDetailsDyn.geoCoordinates.longitude),
         City = tostring(LocationDetailsDyn.city),
         Country = tostring(LocationDetailsDyn.countryOrRegion),
         DeviceName = tostring(DeviceDetailDyn.displayName),
         DeviceId = tostring(DeviceDetailDyn.deviceId),
         Browser = tostring(DeviceDetailDyn.browser),
         OperatingSystem = tostring(DeviceDetailDyn.operatingSystem),
         UserPrincipalName = tolower(UserPrincipalName)
| evaluate ipv4_lookup(trustedRanges, IPAddress, VPNRange, false)
| extend IsTrustedVPNIP = isnotempty(VPNRange)
| where isnotempty(Lat) and isnotempty(Lon)
// Drop sign-in events for apps the user has declared noisy, so background
// token-refresh / broker traffic can't manufacture a false "impossible" pair.
| join kind=leftouter (userNoisyAppsMap) on $left.UserPrincipalName == $right.NoisyUpn
| where isempty(NoisyUpn)
     or not(set_has_element(coalesce(NoisyApps, dynamic([])), tolower(tostring(AppDisplayName))))
| project-away NoisyUpn, NoisyApps
| sort by UserPrincipalName asc, TimeGenerated asc
| serialize
| extend PrevTime = prev(TimeGenerated),
         PrevUser = prev(UserPrincipalName),
         PrevIP = prev(IPAddress),
         PrevLat = prev(Lat),
         PrevLon = prev(Lon),
         PrevCity = prev(City),
         PrevCountry = prev(Country),
         PrevDeviceName = prev(DeviceName),
         PrevDeviceId = prev(DeviceId),
         PrevAppDisplayName = prev(AppDisplayName),
         PrevConditionalAccessStatus = prev(ConditionalAccessStatus),
         PrevUserAgent = prev(UserAgent),
         PrevIsTrustedVPNIP = prev(IsTrustedVPNIP),
         PrevVPNRange = prev(VPNRange)
| where UserPrincipalName == PrevUser
| where IPAddress != PrevIP
| where isnotempty(PrevLat) and isnotempty(PrevLon)
| extend HoursBetween = abs(datetime_diff("second", TimeGenerated, PrevTime)) / 3600.0
| where HoursBetween > 0
| extend DistanceKm = 6371.0 * 2 * asin(sqrt(
    pow(sin((Lat - PrevLat) * pi() / 180.0 / 2.0), 2.0) +
    cos(PrevLat * pi() / 180.0) * cos(Lat * pi() / 180.0) *
    pow(sin((Lon - PrevLon) * pi() / 180.0 / 2.0), 2.0)
))
| extend SpeedKmh = DistanceKm / HoursBetween
| where SpeedKmh > maxSpeedKmh and DistanceKm > minDistanceKm
| project
    TimeGenerated,
    UserPrincipalName,
    FromTime = PrevTime,
    FromIP = PrevIP,
    FromCity = PrevCity,
    FromCountry = PrevCountry,
    FromDeviceName = PrevDeviceName,
    FromDeviceId = PrevDeviceId,
    FromAppDisplayName = PrevAppDisplayName,
    FromConditionalAccessStatus = PrevConditionalAccessStatus,
    FromUserAgent = PrevUserAgent,
    FromIsTrustedVPNIP = PrevIsTrustedVPNIP,
    FromVPNRange = PrevVPNRange,
    ToIP = IPAddress,
    ToCity = City,
    ToCountry = Country,
    ToDeviceName = DeviceName,
    ToDeviceId = DeviceId,
    ToAppDisplayName = AppDisplayName,
    ToConditionalAccessStatus = ConditionalAccessStatus,
    ToUserAgent = UserAgent,
    ToIsTrustedVPNIP = IsTrustedVPNIP,
    ToVPNRange = VPNRange,
    TrustedVPNInPath = (coalesce(PrevIsTrustedVPNIP, false) or coalesce(IsTrustedVPNIP, false)),
    DistanceKm = round(DistanceKm, 1),
    HoursBetween = round(HoursBetween, 3),
    SpeedKmh = round(SpeedKmh, 1)
// Suppress if BOTH the origin and destination country are on this user's home
// country allow list (e.g. rapid bounce between two declared corporate VPN
// egress countries - not genuine impossible travel).
| join kind=leftouter (userHomeMap) on $left.UserPrincipalName == $right.HomeUpn
| extend FromCountryLc = tolower(FromCountry), ToCountryLc = tolower(ToCountry)
| extend IsHomeCountryPair = isnotempty(HomeUpn)
                           and set_has_element(coalesce(HomeCountries, dynamic([])), FromCountryLc)
                           and set_has_element(coalesce(HomeCountries, dynamic([])), ToCountryLc)
| where not(IsHomeCountryPair)
| project-away HomeUpn, HomeCountries, FromCountryLc, ToCountryLc, IsHomeCountryPair
| extend AccountCustomEntity = UserPrincipalName,
         IPCustomEntity = ToIP,
         HostCustomEntity = ToDeviceName,
         timestamp = TimeGenerated
| order by SpeedKmh desc
```

## Escalation Criteria
- Privileged account involved.
- Break-glass account involved.
- Successful sign-in from unfamiliar country.
- MFA method was changed.
- OAuth grant was added.
- Mailbox forwarding rule was created.
- Access to sensitive data occurred after sign-in.
- Service principal or managed identity involved.

## False Positive Considerations
- Legitimate business travel — User confirms travel; HR booking records match
- New corporate device rollout — Device matches Intune/MDM enrolment records for that user around the same time
- Browser or OS upgrade — Only NewUserAgent fires, country and device unchanged
- New VPN egress range — IsTrustedVPNIP false but IP resolves to known corporate VPN vendor ASN

Generated by SOC Response Atlas by Basyrix.
