Adversaries may be using brute force techniques to gain unauthorized access to an IIS server by overwhelming it with failed login attempts before succeeding. SOC teams should proactively hunt for this behavior in Azure Sentinel to identify and mitigate potential credential compromise and lateral movement attempts.
KQL Query
W3CIISLog
| where scStatus in ("401","403")
| where cIP !startswith "192.168." and cIP != sIP and cIP != "::1" //and csUserName != "-"
// Handling Exchange specific items in IIS logs to remove the unique log identifier in the URI
| extend csUriQuery = iff(csUriQuery startswith "MailboxId=", tostring(split(csUriQuery, "&")[0]) , csUriQuery )
| extend csUriQuery = iff(csUriQuery startswith "X-ARR-CACHE-HIT=", strcat(tostring(split(csUriQuery, "&")[0]),tostring(split(csUriQuery, "&")[1])) , csUriQuery )
| summarize FailStartTime = min(TimeGenerated), FailEndTime = max(TimeGenerated), makeset(sPort), makeset(csUserAgent), makeset(csUserName), csUserNameCount = dcount(csUserName), ConnectionCount = count() by Computer, sSiteName, sIP, cIP, csUriQuery, csMethod, scStatus, scSubStatus, scWin32Status
| extend csUserAgentPerIPCount = arraylength(set_csUserAgent)
| extend sPortCount = arraylength(set_sPort)
| extend scStatusFull = strcat(scStatus, ".",scSubStatus)
// Map common IIS codes
| extend scStatusFull_Friendly = case(
scStatusFull == "401.0", "Access denied.",
scStatusFull == "401.1", "Logon failed.",
scStatusFull == "401.2", "Logon failed due to server configuration.",
scStatusFull == "401.3", "Unauthorized due to ACL on resource.",
scStatusFull == "401.4", "Authorization failed by filter.",
scStatusFull == "401.5", "Authorization failed by ISAPI/CGI application.",
scStatusFull == "403.0", "Forbidden.",
scStatusFull == "403.4", "SSL required.",
"See - https://support.microsoft.com/help/943891/the-http-status-code-in-iis-7-0-iis-7-5-and-iis-8-0")
// Mapping to Hex so can be mapped using website in comments above
| extend scWin32Status_Hex = tohex(tolong(scWin32Status))
// Map common win32 codes
| extend scWin32Status_Friendly = case(
scWin32Status_Hex =~ "52e", "Logon failure: Unknown user name or bad password.",
scWin32Status_Hex =~ "533", "Logon failure: Account currently disabled.",
scWin32Status_Hex =~ "2ee2", "The request has timed out.",
scWin32Status_Hex =~ "0", "The operation completed successfully.",
scWin32Status_Hex =~ "1", "Incorrect function.",
scWin32Status_Hex =~ "2", "The system cannot find the file specified.",
scWin32Status_Hex =~ "3", "The system cannot find the path specified.",
scWin32Status_Hex =~ "4", "The system cannot open the file.",
scWin32Status_Hex =~ "5", "Access is denied.",
scWin32Status_Hex =~ "8009030e", "SEC_E_NO_CREDENTIALS",
scWin32Status_Hex =~ "8009030C", "SEC_E_LOGON_DENIED",
"See - https://msdn.microsoft.com/library/cc231199.aspx")
// decode URI when available
| extend decodedUriQuery = url_decode(csUriQuery)
| where (ConnectionCount >= 1200 and csUserAgentPerIPCount > 1) or (ConnectionCount >= 1200 and sPortCount > 1)
// now join back to see if there is a successful logon after so many failures
| join (
W3CIISLog
| where scStatus startswith "20"
| where cIP !startswith "192.168." and cIP != sIP and cIP != "::1"
| extend LogonSuccessTime = TimeGenerated, Success_scStatus = scStatus
| distinct LogonSuccessTime, Computer, sSiteName, sIP, cIP, Success_scStatus
) on Computer, sSiteName, sIP, cIP
| where FailEndTime < LogonSuccessTime and not(LogonSuccessTime between (FailStartTime .. FailEndTime))
| summarize makeset(LogonSuccessTime) by FailStartTime, FailEndTime, Computer, sSiteName, sIP, cIP, tostring(set_csUserName), csUserNameCount, csUriQuery, csMethod, scStatus, scSubStatus, scWin32Status, tostring(set_sPort), tostring(set_csUserAgent), ConnectionCount, csUserAgentPerIPCount, sPortCount, scStatusFull, scStatusFull_Friendly, scWin32Status_Hex, scWin32Status_Friendly
| project FailStartTime, FailEndTime, set_LogonSuccessTime, Computer, sSiteName, sIP, cIP, set_csUserName, csUserNameCount, csUriQuery, csMethod, scStatus, scSubStatus, scWin32Status, set_sPort, set_csUserAgent, ConnectionCount, csUserAgentPerIPCount, sPortCount, scStatusFull, scStatusFull_Friendly, scWin32Status_Hex, scWin32Status_Friendly
| extend timestamp = FailStartTime, IPCustomEntity = cIP, HostCustomEntity = Computer
id: 934011da-1fe6-4507-aadb-d3914c877bcd
name: Potential IIS brute force
description: |
'Query shows 1200+ failed attempts by cIP per hour on server, then successful logon. Only includes > 1 user agent string or port. Could indicate successful probing and brute force success on IIS servers.'
description_detailed: |
'This query shows when 1200 (20 per minute) or more failed attempts by cIP per hour occur on a given server and then a successful logon by cIP.
This only includes when more than 1 user agent strings is used or more than 1 port is used.
This could be indicative of successful probing and password brute force success on your IIS servers.
Feel free to adjust the threshold as needed - ConnectionCount >= 1200
References: Status code mappings for your convenience, also inline if the mapping is not available
IIS status code mapping - https://support.microsoft.com/help/943891/the-http-status-code-in-iis-7-0-iis-7-5-and-iis-8-0
Win32 Status code mapping - https://msdn.microsoft.com/library/cc231199.aspx'
requiredDataConnectors:
- connectorId: AzureMonitor(IIS)
dataTypes:
- W3CIISLog
tactics:
- CredentialAccess
relevantTechniques:
- T1110
query: |
W3CIISLog
| where scStatus in ("401","403")
| where cIP !startswith "192.168." and cIP != sIP and cIP != "::1" //and csUserName != "-"
// Handling Exchange specific items in IIS logs to remove the unique log identifier in the URI
| extend csUriQuery = iff(csUriQuery startswith "MailboxId=", tostring(split(csUriQuery, "&")[0]) , csUriQuery )
| extend csUriQuery = iff(csUriQuery startswith "X-ARR-CACHE-HIT=", strcat(tostring(split(csUriQuery, "&")[0]),tostring(split(csUriQuery, "&")[1])) , csUriQuery )
| summarize FailStartTime = min(TimeGenerated), FailEndTime = max(TimeGenerated), makeset(sPort), makeset(csUserAgent), makeset(csUserName), csUserNameCount = dcount(csUserName), ConnectionCount = count() by Computer, sSiteName, sIP, cIP, csUriQuery, csMethod, scStatus, scSubStatus, scWin32Status
| extend csUserAgentPerIPCount = arraylength(set_csUserAgent)
| extend sPortCount = arraylength(set_sPort)
| extend scStatusFull = strcat(scStatus, ".",scSubStatus)
// Map common IIS codes
| extend scStatusFull_Friendly = case(
scStatusFull == "401.0", "Access denied.",
scStatusFull == "401.1", "Logon failed.",
scStatusFull == "401.2", "Logon failed due to server configuration.",
scStatusFull == "401.3", "Unauthorized due to ACL on resource.",
scStatusFull == "401.4", "Authorization failed by filter.",
scStatusFull == "401.5", "Authorization failed by ISAPI/CGI application.",
scStatusFull == "403.0", "Forbidden.",
scStatusFull == "403.4", "SSL required.",
"See - https://support.microsoft.com/help/943891/the-http-status-code-in-iis-7-0-iis-7-5-and-iis-8-0")
// Mapping to Hex so can be mapped using website in comments above
| extend scWin32Status_Hex = tohex(tolong(scWin32Status))
// Map common win32 codes
| extend scWin32Status_
| Sentinel Table | Notes |
|---|---|
W3CIISLog | Ensure this data connector is enabled |
Scenario: Legitimate scheduled job with multiple failed attempts
Description: A scheduled job (e.g., schtasks.exe) runs a script that connects to IIS and fails due to incorrect credentials.
Filter/Exclusion: Exclude IP addresses associated with known internal systems (e.g., 10.0.0.0/8) or use a filter like cIP == 10.0.0.1 or process_name == "schtasks.exe".
Scenario: Admin testing with multiple user agents
Description: An administrator is testing IIS authentication by using different user agents (e.g., curl, wget, or Postman) to simulate login attempts.
Filter/Exclusion: Exclude IPs from the admin team’s subnet (e.g., 192.168.1.0/24) or filter by user_agent contains "Postman" or user_agent contains "curl".
Scenario: Automated security tool scanning IIS
Description: A security tool like Nessus or OpenVAS is scanning the IIS server and generating multiple failed login attempts as part of its vulnerability assessment.
Filter/Exclusion: Exclude IPs from known security tool ranges (e.g., 192.0.2.0/24 for documentation purposes) or filter by process_name == "nessus", process_name == "openvas", or user_agent contains "Nessus".
Scenario: User agent rotation for load testing
Description: A load testing tool like JMeter or Locust is simulating multiple user agents to test IIS under stress, leading to multiple failed login attempts.
Filter/Exclusion: Exclude IPs from the internal load testing infrastructure (e.g