Detection rules › Elastic

Potential Account Takeover - Logon from New Source IP

Status
production
Severity
medium
Time window
15m
Group by
source.ip, user.id, user.name
Author
Elastic
Source
github.com/elastic/detection-rules

Identifies a user account that normally logs in with high volume from one source IP suddenly logging in from a different source IP. This pattern (one IP with many successful logons, another IP with very few) may indicate account takeover or use of stolen credentials from a new location.

MITRE ATT&CK coverage

TacticTechniques
Privilege EscalationT1078 Valid Accounts

Event coverage

Rule body elastic

[metadata]
creation_date = "2026/02/25"
integration = ["system", "windows"]
maturity = "production"
updated_date = "2026/05/04"

[rule]
author = ["Elastic"]
description = """
Identifies a user account that normally logs in with high volume from one source IP suddenly logging in from a different
source IP. This pattern (one IP with many successful logons, another IP with very few) may indicate account takeover
or use of stolen credentials from a new location.
"""
from = "now-15m"
interval = "14m"
language = "esql"
license = "Elastic License v2"
name = "Potential Account Takeover - Logon from New Source IP"
note = """## Triage and analysis

### Investigating Potential Account Takeover - Logon from New Source IP

An account that historically logs in many times from a single source IP (e.g. usual workstation or VPN) and then shows successful logons from exactly one other IP with a low count may indicate credential compromise and use from a new location (account takeover).

### Possible investigation steps

- Confirm with the account owner whether they recently logged in from the new source IP or from a new device/location.
- Check the new source IP for reputation, geography, and whether it is expected (e.g. corporate VPN range vs unknown).
- Correlate with other alerts for the same user or source IP (e.g. logon failures, password changes, MFA changes).
- Review timeline: if the "new" IP logon is very recent compared to the high-count IP, treat as higher priority.

### False positive analysis

- Legitimate use from a second device (e.g. new laptop, second office, VPN from travel) can produce exactly two IPs with one IP having few logons. Tune threshold (e.g. max_logon >= 100) or add exclusions for known VPN/remote ranges if needed.
- Service or shared accounts that are used from multiple jump hosts or scripts may show two IPs; consider excluding known service accounts.

### Response and remediation

- If takeover is confirmed: force password reset, revoke sessions, and enable or enforce MFA. Disable or lock the account until the user verifies identity.
- Investigate how credentials may have been compromised (phishing, breach, endpoint) and address the vector.
"""

setup = """## Setup

Audit Logon must be enabled to generate the events used by this rule.
Setup instructions: https://ela.st/audit-logon
"""

references = ["https://attack.mitre.org/techniques/T1078/"]
risk_score = 47
rule_id = "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d"
severity = "medium"
tags = [
    "Domain: Endpoint",
    "OS: Windows",
    "Use Case: Threat Detection",
    "Tactic: Privilege Escalation",
    "Data Source: Windows Security Event Logs",
    "Resources: Investigation Guide",
]
timestamp_override = "event.ingested"
type = "esql"

query = '''
from logs-system.security*, logs-windows.forwarded*, winlogbeat-* metadata _id, _version, _index
| where event.category == "authentication" and event.action == "logged-in" and winlog.event_id == "4624" and 
        event.outcome == "success" and winlog.logon.type in ("Network", "RemoteInteractive") and 
		source.ip is not null and source.ip != "127.0.0.1" and not to_string(source.ip) like "*::*" and not user.name like "*$"
| stats logon_count = COUNT(*), host_names = VALUES(host.name) by user.name, user.id, source.ip
| stats 
    Esql.max_logon = MAX(logon_count),
    Esql.min_logon = MIN(logon_count),
    Esql.unique_host_count = COUNT_DISTINCT(host_names),
    Esql.host_name_values = VALUES(host_names),
    Esql.source_ip_values = VALUES(source.ip),
    Esql.count_distinct_source_ip = COUNT_DISTINCT(source.ip) by user.name, user.id

// high count of logons is often associated with service account tied to a specific source.ip, if observed in use from a new source.ip it's suspicious
| where Esql.max_logon >= 1000 and (Esql.min_logon >= 1 and Esql.min_logon <= 5) and Esql.count_distinct_source_ip == 2 and Esql.unique_host_count >= 2
| eval source.ip = MV_FIRST(Esql.source_ip_values), host.name = MV_FIRST(Esql.host_name_values)
| KEEP user.name, user.id, host.name, source.ip, Esql.*
'''

[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1078"
name = "Valid Accounts"
reference = "https://attack.mitre.org/techniques/T1078/"


[rule.threat.tactic]
id = "TA0004"
name = "Privilege Escalation"
reference = "https://attack.mitre.org/tactics/TA0004/"

Stages and Predicates

Stage 1: from

from logs-system.security*, logs-windows.forwarded*, winlogbeat-* metadata _id, _version, _index

Stage 2: where

| where event.category == "authentication" and event.action == "logged-in" and winlog.event_id == "4624" and 
        event.outcome == "success" and winlog.logon.type in ("Network", "RemoteInteractive") and 
		source.ip is not null and source.ip != "127.0.0.1" and not to_string(source.ip) like "*::*" and not user.name like "*$"

Stage 3: stats

| stats logon_count = COUNT(*), host_names = VALUES(host.name) by user.name, user.id, source.ip

Stage 4: stats

| stats
    Esql.max_logon = MAX(logon_count),
    Esql.min_logon = MIN(logon_count),
    Esql.unique_host_count = COUNT_DISTINCT(host_names),
    Esql.host_name_values = VALUES(host_names),
    Esql.source_ip_values = VALUES(source.ip),
    Esql.count_distinct_source_ip = COUNT_DISTINCT(source.ip) by user.name, user.id

Stage 5: where

| where Esql.max_logon >= 1000 and (Esql.min_logon >= 1 and Esql.min_logon <= 5) and Esql.count_distinct_source_ip == 2 and Esql.unique_host_count >= 2

Stage 6: eval

| eval source.ip = MV_FIRST(Esql.source_ip_values), host.name = MV_FIRST(Esql.host_name_values)

Stage 7: keep

| KEEP user.name, user.id, host.name, source.ip, Esql.*

Exclusions

Top-level NOT(...) conjuncts: predicates this rule actively suppresses.

FieldKindExcluded values
user.nameends_with$

Indicators

Each row is a field, operator, and value that the rule matches. The corpus column counts how many other rules in the catalog look for the same combination: high numbers point to widely-used, community-vetted indicators. Blank or 1 shows that the indicator is specific to this rule.

FieldKindValues
Esql.count_distinct_source_ipeq
  • 2
Esql.max_logonge
  • 1000 corpus 2 (elastic 2)
Esql.min_logonge
  • 1 corpus 2 (elastic 2)
Esql.min_logonle
  • 5
Esql.unique_host_countge
  • 2 corpus 2 (elastic 2)
event.actioneq
  • logged-in corpus 8 (elastic 8)
event.categoryeq
  • authentication corpus 31 (elastic 31)
event.outcomeeq
  • success corpus 251 (elastic 251)
source.ipis_not_null
  • (no value, null check)
source.ipne
  • 127.0.0.1 corpus 23 (elastic 22, splunk 1)
winlog.logon.typein
  • Network corpus 40 (splunk 13, sigma 12, elastic 9, kusto 6)
  • RemoteInteractive corpus 8 (kusto 4, sigma 3, splunk 1)

Output fields

Fields the rule emits when it matches. Chronicle authors list these in the outcome block; they appear on the detection and $risk_score drives alerting. Sentinel / Defender XDR rules build them up through project / summarize / extend stages. Sentinel maps these into alert fields via entityMappings and customDetails; Defender XDR custom detections surface them as alert fields directly.

FieldSource
user.nameKEEP user.name
user.idKEEP user.id
host.nameKEEP host.name
source.ipKEEP source.ip
Esql.*KEEP Esql.*