-
Notifications
You must be signed in to change notification settings - Fork 573
[Rule Tuning] Expand Scope of Entra ID Brute Force Sign-In Attempts #4777
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
47883d6
9d88dfe
c629d44
04cc205
8eb4be9
b591cb3
eb13b05
e9ef52b
cd0dd1c
53f57c9
f5d31a3
7292032
25e6dcb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,240 @@ | ||
[metadata] | ||
creation_date = "2024/09/06" | ||
integration = ["azure"] | ||
maturity = "production" | ||
min_stack_comments = "Elastic ES|QL values aggregation is more performant in 8.16.5 and above." | ||
min_stack_version = "8.17.0" | ||
updated_date = "2025/06/05" | ||
|
||
[rule] | ||
author = ["Elastic"] | ||
description = """ | ||
Identifies potential brute-force attacks targeting user accounts by analyzing failed sign-in patterns in Microsoft Entra | ||
ID Sign-In Logs. This detection focuses on a high volume of failed interactive or non-interactive authentication | ||
attempts within a short time window, often indicative of password spraying, credential stuffing, or password guessing. | ||
Adversaries may use these techniques to gain unauthorized access to applications integrated with Entra ID or to | ||
compromise valid user accounts. | ||
""" | ||
false_positives = [ | ||
""" | ||
Automated processes that attempt to authenticate using expired credentials or have misconfigured authentication | ||
settings may lead to false positives. | ||
""", | ||
] | ||
from = "now-60m" | ||
interval = "15m" | ||
language = "esql" | ||
license = "Elastic License v2" | ||
name = "Microsoft Entra ID Sign-In Brute Force Activity" | ||
note = """## Triage and analysis | ||
|
||
### Investigating Microsoft Entra ID Sign-In Brute Force Activity | ||
|
||
This rule detects brute-force authentication activity in Entra ID sign-in logs. It classifies failed sign-in attempts into behavior types such as password spraying, credential stuffing, or password guessing. The classification (`bf_type`) helps prioritize triage and incident response. | ||
|
||
### Possible investigation steps | ||
|
||
- Review `bf_type`: Determines the brute-force technique being used (`password_spraying`, `credential_stuffing`, or `password_guessing`). | ||
- Examine `user_id_list`: Identify if high-value accounts (e.g., administrators, service principals, federated identities) are being targeted. | ||
- Review `login_errors`: Repetitive error types like `"Invalid Grant"` or `"User Not Found"` suggest automated attacks. | ||
- Check `ip_list` and `source_orgs`: Investigate if the activity originates from suspicious infrastructure (VPNs, hosting providers, etc.). | ||
- Validate `unique_ips` and `countries`: Geographic diversity and IP volume may indicate distributed or botnet-based attacks. | ||
- Compare `total_attempts` vs `duration_seconds`: High rate of failures in a short time period implies automation. | ||
- Analyze `user_agent.original` and `device_detail_browser`: User agents like `curl`, `Python`, or generic libraries may indicate scripting tools. | ||
- Investigate `client_app_display_name` and `incoming_token_type`: Detect potential abuse of legacy or unattended login mechanisms. | ||
- Inspect `target_resource_display_name`: Understand what application or resource the attacker is trying to access. | ||
- Pivot using `session_id` and `device_detail_device_id`: Determine if a device is targeting multiple accounts. | ||
- Review `conditional_access_status`: If not enforced, ensure Conditional Access policies are scoped correctly. | ||
|
||
### False positive analysis | ||
|
||
- Legitimate automation (e.g., misconfigured scripts, sync processes) can trigger repeated failures. | ||
- Internal red team activity or penetration tests may mimic brute-force behaviors. | ||
- Certain service accounts or mobile clients may generate repetitive sign-in noise if not properly configured. | ||
|
||
### Response and remediation | ||
|
||
- Notify your identity security team for further analysis. | ||
- Investigate and lock or reset impacted accounts if compromise is suspected. | ||
- Block offending IPs or ASNs at the firewall, proxy, or using Conditional Access. | ||
- Confirm MFA and Conditional Access are enforced for all user types. | ||
- Audit targeted accounts for credential reuse across services. | ||
- Implement account lockout or throttling for failed sign-in attempts where possible. | ||
""" | ||
references = [ | ||
"https://www.microsoft.com/en-us/security/blog/2025/05/27/new-russia-affiliated-actor-void-blizzard-targets-critical-sectors-for-espionage/", | ||
"https://cloud.hacktricks.xyz/pentesting-cloud/azure-security/az-unauthenticated-enum-and-initial-entry/az-password-spraying", | ||
"https://learn.microsoft.com/en-us/security/operations/incident-response-playbook-password-spray", | ||
"https://learn.microsoft.com/en-us/purview/audit-log-detailed-properties", | ||
"https://securityscorecard.com/research/massive-botnet-targets-m365-with-stealthy-password-spraying-attacks/", | ||
"https://learn.microsoft.com/en-us/entra/identity-platform/reference-error-codes", | ||
"https://github.com/0xZDH/Omnispray", | ||
"https://github.com/0xZDH/o365spray", | ||
] | ||
risk_score = 47 | ||
rule_id = "cca64114-fb8b-11ef-86e2-f661ea17fbce" | ||
severity = "medium" | ||
tags = [ | ||
"Domain: Cloud", | ||
"Domain: Identity", | ||
"Data Source: Azure", | ||
"Data Source: Entra ID", | ||
"Data Source: Entra ID Sign-in Logs", | ||
"Use Case: Identity and Access Audit", | ||
"Use Case: Threat Detection", | ||
"Tactic: Credential Access", | ||
"Resources: Investigation Guide", | ||
] | ||
timestamp_override = "event.ingested" | ||
type = "esql" | ||
|
||
query = ''' | ||
FROM logs-azure.signinlogs* | ||
|
||
// Define a time window for grouping and maintain the original event timestamp | ||
| EVAL | ||
time_window = DATE_TRUNC(15 minutes, @timestamp), | ||
event_time = @timestamp | ||
|
||
// Filter relevant failed authentication events with specific error codes | ||
| WHERE event.dataset == "azure.signinlogs" | ||
AND event.category == "authentication" | ||
AND azure.signinlogs.category IN ("NonInteractiveUserSignInLogs", "SignInLogs") | ||
AND event.outcome == "failure" | ||
AND azure.signinlogs.properties.authentication_requirement == "singleFactorAuthentication" | ||
AND azure.signinlogs.properties.status.error_code IN ( | ||
50034, // UserAccountNotFound | ||
50126, // InvalidUsernameOrPassword | ||
50055, // PasswordExpired | ||
50056, // InvalidPassword | ||
50057, // UserDisabled | ||
50064, // CredentialValidationFailure | ||
50076, // MFARequiredButNotPassed | ||
50079, // MFARegistrationRequired | ||
50105, // EntitlementGrantsNotFound | ||
70000, // InvalidGrant | ||
70008, // ExpiredOrRevokedRefreshToken | ||
70043, // BadTokenDueToSignInFrequency | ||
80002, // OnPremisePasswordValidatorRequestTimedOut | ||
80005, // OnPremisePasswordValidatorUnpredictableWebException | ||
50144, // InvalidPasswordExpiredOnPremPassword | ||
50135, // PasswordChangeCompromisedPassword | ||
50142, // PasswordChangeRequiredConditionalAccess | ||
120000, // PasswordChangeIncorrectCurrentPassword | ||
120002, // PasswordChangeInvalidNewPasswordWeak | ||
120020 // PasswordChangeFailure | ||
) | ||
AND azure.signinlogs.properties.user_principal_name IS NOT NULL AND azure.signinlogs.properties.user_principal_name != "" | ||
AND user_agent.original != "Mozilla/5.0 (compatible; MSAL 1.0) PKeyAuth/1.0" | ||
AND source.`as`.organization.name != "MICROSOFT-CORP-MSN-AS-BLOCK" | ||
|
||
// Aggregate statistics for behavioral pattern analysis | ||
| STATS | ||
authentication_requirement = VALUES(azure.signinlogs.properties.authentication_requirement), | ||
client_app_id = VALUES(azure.signinlogs.properties.app_id), | ||
client_app_display_name = VALUES(azure.signinlogs.properties.app_display_name), | ||
target_resource_id = VALUES(azure.signinlogs.properties.resource_id), | ||
target_resource_display_name = VALUES(azure.signinlogs.properties.resource_display_name), | ||
conditional_access_status = VALUES(azure.signinlogs.properties.conditional_access_status), | ||
device_detail_browser = VALUES(azure.signinlogs.properties.device_detail.browser), | ||
device_detail_device_id = VALUES(azure.signinlogs.properties.device_detail.device_id), | ||
device_detail_operating_system = VALUES(azure.signinlogs.properties.device_detail.operating_system), | ||
incoming_token_type = VALUES(azure.signinlogs.properties.incoming_token_type), | ||
risk_state = VALUES(azure.signinlogs.properties.risk_state), | ||
session_id = VALUES(azure.signinlogs.properties.session_id), | ||
user_id = VALUES(azure.signinlogs.properties.user_id), | ||
user_principal_name = VALUES(azure.signinlogs.properties.user_principal_name), | ||
result_description = VALUES(azure.signinlogs.result_description), | ||
result_signature = VALUES(azure.signinlogs.result_signature), | ||
result_type = VALUES(azure.signinlogs.result_type), | ||
|
||
unique_users = COUNT_DISTINCT(azure.signinlogs.properties.user_id), | ||
user_id_list = VALUES(azure.signinlogs.properties.user_id), | ||
login_errors = VALUES(azure.signinlogs.result_description), | ||
unique_login_errors = COUNT_DISTINCT(azure.signinlogs.result_description), | ||
error_codes = VALUES(azure.signinlogs.properties.status.error_code), | ||
unique_error_codes = COUNT_DISTINCT(azure.signinlogs.properties.status.error_code), | ||
request_types = VALUES(azure.signinlogs.properties.incoming_token_type), | ||
app_names = VALUES(azure.signinlogs.properties.app_display_name), | ||
ip_list = VALUES(source.ip), | ||
unique_ips = COUNT_DISTINCT(source.ip), | ||
source_orgs = VALUES(source.`as`.organization.name), | ||
countries = VALUES(source.geo.country_name), | ||
unique_country_count = COUNT_DISTINCT(source.geo.country_name), | ||
unique_asn_orgs = COUNT_DISTINCT(source.`as`.organization.name), | ||
first_seen = MIN(@timestamp), | ||
last_seen = MAX(@timestamp), | ||
total_attempts = COUNT() | ||
BY time_window | ||
|
||
// Determine brute force behavior type based on statistical thresholds | ||
| EVAL | ||
duration_seconds = DATE_DIFF("seconds", first_seen, last_seen), | ||
bf_type = CASE( | ||
// Many users, relatively few distinct login errors, distributed over multiple IPs (but not too many), | ||
// and happens quickly. Often bots using leaked credentials. | ||
unique_users >= 10 AND total_attempts >= 30 AND unique_login_errors <= 3 | ||
AND unique_ips >= 5 | ||
AND duration_seconds <= 600 | ||
AND unique_users > unique_ips, | ||
"credential_stuffing", | ||
|
||
// One password against many users. Single error (e.g., "InvalidPassword"), not necessarily fast. | ||
unique_users >= 15 AND unique_login_errors == 1 AND total_attempts >= 15 AND duration_seconds <= 1800, | ||
"password_spraying", | ||
|
||
// One user targeted repeatedly (same error), OR extremely noisy pattern from many IPs. | ||
(unique_users == 1 AND unique_login_errors == 1 AND total_attempts >= 30 AND duration_seconds <= 300) | ||
OR (unique_users <= 3 AND unique_ips > 30 AND total_attempts >= 100), | ||
"password_guessing", | ||
|
||
// everything else | ||
"other" | ||
) | ||
|
||
// Only keep columns necessary for detection output/reporting | ||
| KEEP | ||
time_window, bf_type, duration_seconds, total_attempts, first_seen, last_seen, | ||
unique_users, user_id_list, login_errors, unique_login_errors, | ||
unique_error_codes, error_codes, request_types, app_names, | ||
ip_list, unique_ips, source_orgs, countries, | ||
unique_country_count, unique_asn_orgs, | ||
authentication_requirement, client_app_id, client_app_display_name, | ||
target_resource_id, target_resource_display_name, conditional_access_status, | ||
device_detail_browser, device_detail_device_id, device_detail_operating_system, | ||
incoming_token_type, risk_state, session_id, user_id, | ||
user_principal_name, result_description, result_signature, result_type | ||
|
||
// Remove anything not classified as credential attack activity | ||
| WHERE bf_type != "other" | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if we want to do any sorting/limiting at the end. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For sorting, I don't see any value added to sorting by failed login count, unique user count or alphabetically that would enable better action or prioritization. Limiting wise, I do not see any reason to limit the results since it is already aggregation-based unless we expect this to be noisy. Do you have any suggestions on limiting and why? |
||
''' | ||
|
||
|
||
[[rule.threat]] | ||
framework = "MITRE ATT&CK" | ||
[[rule.threat.technique]] | ||
id = "T1110" | ||
name = "Brute Force" | ||
reference = "https://attack.mitre.org/techniques/T1110/" | ||
[[rule.threat.technique.subtechnique]] | ||
id = "T1110.001" | ||
name = "Password Guessing" | ||
reference = "https://attack.mitre.org/techniques/T1110/001/" | ||
|
||
[[rule.threat.technique.subtechnique]] | ||
id = "T1110.003" | ||
name = "Password Spraying" | ||
reference = "https://attack.mitre.org/techniques/T1110/003/" | ||
|
||
[[rule.threat.technique.subtechnique]] | ||
id = "T1110.004" | ||
name = "Credential Stuffing" | ||
reference = "https://attack.mitre.org/techniques/T1110/004/" | ||
|
||
|
||
|
||
[rule.threat.tactic] | ||
id = "TA0006" | ||
name = "Credential Access" | ||
reference = "https://attack.mitre.org/tactics/TA0006/" | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This excludes any
other
bf_type
's from the results?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, it is meant to exclude all else. Since we are only aggregating by a time window and relying on
CASE()
to determine what type of BF - if we include other it would include signals for a bucket of failed logins which is just noise.