AiTM Session Stealing

AiTM frameworks such as Evilginx operate as a transparent reverse proxy that sits between the user and the genuine login endpoint. From the user’s perspective the sign in flow looks normal, but the browser is communicating with attacker controlled infrastructure that relays traffic to the real identity provider.

The attacker’s captures the vitcims password and the session issued after authentication, typically cookies or tokens. With those in hand, the attacker can replay an already authenticated session, effectively bypassing MFA for subsequent access.

HUNT

SigninLogs and proxy logs are key log sources for necessary visibility.

I will focus on Microsoft authentication and Zscaler for proxy.

AiTM frameworks essentially recreates the Microsoft Identity and authentication services hosted on the attackers domain. One indicator is a domain that serves Microsoft authentication related URIs but is not a Microsoft or known federated domain.

Depending on your environment, filter out known expected domains. If it is still noisy, introduce rarity by prioritising domains visited by a low number of distinct users.

Fun fact: the free version of Evilginx uses an 8 character URI path segment to host the phishlet by default, for example.com/abcdefgh.

CommonSecurityLog
| where DeviceVendor == 'Zscaler'
| where DeviceProduct == 'NSSWeblog'
| where RequestURL has_any ('/common/getcredentialtype?mkt%3den-', '/common/federation/oauth2claimsprovider', '/common/instrumentation/dssostatus', 
'/instrumentation/reportbssotelemetry?', '/common/sas/beginauth', '/common/sas/endauth?authMethodId', '/federation/redirecttoexternalprovider')
or RequestURL has_all ('oauth2', 'redirect_uri', 'sso_reload')
or (RequestURL contains 'ctx%3d' and RequestURL contains 'sessionid%3' and RequestContext contains 'oauth2')
| where not (DestinationHostName has_any ('.microsoftonline.', '.microsoft.', '.windows.net', '.google.com'))

Typically when an attacker completes the phish, they opt to redirect the user to an expected landing page. This is a key flow that provides annomlies for detection. We only expect Microsoft Domains or at the least, known or federated domains to be referings users to Microsoft Domains.

The usual request flows to serve the redirect are login.live.com, InsertTenantName-onmicrosoft-com.access.mcas.ms, myaccount.microsoft.com.

Add your own TenantName-onmicrosoft-com.access.mcas.ms

We can endlessly stomp out all Microsoft/expected domains.

CommonSecurityLog
let MicrosoftDomains = dynamic(['.microsoft','.microsoftonline.com','.live.com','.msftauthimages.net','.mcas.ms','.powerapps.com','.powerautomate.com',
'.microsoft365.com','.sharepoint.com','.bing.com','.msn.com','.azure.com','.office.net','.teams.cdn.office.net','.reactblade.portal.azure.net',
'.visualstudio.com','.office.net','.office.com','.b2clogin.com','.dynamics.com','.youtube.com','.google.com','.onenote.com',
'.linkedin.com','.learnondemand.net','mslearningcampus.com','.powerbi.com','.azuresynapse.net','.atlassian.net','.lighthouse-cloud.com',
'.clipchamp.com','.microsoftcrmportals.com','None']);
CommonSecurityLog
| where DeviceVendor == 'Zscaler'
| where DeviceProduct == 'NSSWeblog'
| where DestinationHostName has_any ('login.live.com', 'InsertTenantName-onmicrosoft-com.access.mcas.ms', 'myaccount.microsoft.com')
| where RequestURL !has 'oauth2'
| where not (
    RequestContext has_any ( // any msft things
        MicrosoftDomains))

Or looks for rare referers.

CommonSecurityLog
let GetRareDomain = CommonSecurityLog
| where TimeGenerated >= ago(30d)
| where DeviceVendor == 'Zscaler'
| where DeviceProduct == 'NSSWeblog'
| extend DestinationHostName = replace(@'^www\.', '', tostring(parse_url(strcat('https://', DestinationHostName)).Host))
| summarize SourceUserNameDest=dcount(SourceUserName) by DestinationHostName
| where SourceUserNameDest <=2;
CommonSecurityLog
| where TimeGenerated >= ago(1d)
| where DeviceVendor == 'Zscaler'
| where DeviceProduct == 'NSSWeblog'
| where DestinationHostName has_any ('login.live.com', 'InsertTenantName-onmicrosoft-com.access.mcas.ms', 'myaccount.microsoft.com')
| extend CleanRequestContext = replace(@'^www\.', '', tostring(parse_url(strcat('https://', RequestContext)).Host))
| where CleanRequestContext !has '.officeapps.live.com'
| where CleanRequestContext in (GetRareDomain)

If you have Terms of Use enforcement enabled, we should see that request against the AiTM domain. Depending on how rare this is in your enviroment, this can be useful indirect indicator.

CommonSecurityLog
CommonSecurityLog
| where DeviceVendor == 'Zscaler'
| where DeviceProduct == 'NSSWeblog'
| where RequestURL has '/termsofuse/consent?injectRequestBody'
| where RequestContext !has '.azure.com/' and DestinationHostName !has 'google.com'
| distinct RequestContext, RequestURL, DestinationHostName, SourceUserName

MSFT does have inbuilt detection for AiTM however it is not perfect. Microsoft Entra ID Protection also does provide good visibility into RiskySignIns however in the case where a MFA completed session is stolen, the risk is often seen as mitigated. Instead of trying to recreate MSFT backend magic. We can construct the following:

Identify Risky browser based authentications to Officehome (Common for AiTM frameworks). Join that Risky SessionID against post authentication for the same user and identify pre/post authentication variable changes.

Here, I only look for post authentications not from a known network.

When a session is stolen, the UA will be fingerprinted and most likely resued in the session replay. The region and ASN will most likely switch if the attacker does not put in the effort. We can also hope the original authentication occurred from a managed device, meaning the subsequent stolen session will not.

Introudce your own pre/post variable filters by filtering OGvalue vs post risk values.

CommonSecurityLog
let GetSusMiTm = SigninLogs
| where AppDisplayName == 'OfficeHome' and ClientAppUsed == 'Browser'
| where RiskLevelDuringSignIn != 'none' and ResultSignature == 'SUCCESS'
| extend DeviceDisplayName = tostring(DeviceDetail.displayName)
| extend isManaged = tostring(DeviceDetail.isManaged)
| extend Country = tostring(LocationDetails.countryOrRegion)
| extend City = tostring(LocationDetails.city)
| extend isManaged = iif(isempty(isManaged), 'false', isManaged)
| summarize MinTimeRisk=min(TimeGenerated) by SessionId, UserPrincipalName, OGDeviceDisplayName=DeviceDisplayName, OGisManaged=isManaged, 
OGAutonomousSystemNumber=AutonomousSystemNumber, OGUserAgent=UserAgent, OGIPAddress=IPAddress, OGCountry=Country, OGCity=City;
SigninLogs
| where ResultSignature == 'SUCCESS'
| extend DeviceDisplayName = tostring(DeviceDetail.displayName)
| extend isManaged = tostring(DeviceDetail.isManaged)
| extend Country = tostring(LocationDetails.countryOrRegion)
| extend City = tostring(LocationDetails.city)
| where not (NetworkLocationDetails has_any ('namedNetwork', 'trustedNamedLocation'))
| join kind=inner GetSusMiTm on SessionId, UserPrincipalName
| project MinTimeRisk, TimeGenerated, SessionId, UserPrincipalName, AppDisplayName, OGDeviceDisplayName, DeviceDisplayName, OGisManaged, isManaged, 
OGCountry, Country, OGCity, City, OGIPAddress, IPAddress, OGAutonomousSystemNumber, AutonomousSystemNumber, OGUserAgent, UserAgent, NetworkLocationDetails
| where TimeGenerated > MinTimeRisk 
| where IPAddress != OGIPAddress
| sort by TimeGenerated, MinTimeRisk, UserPrincipalName, SessionId desc