Skip to content
Draft
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
[metadata]
creation_date = "2026/05/14"
integration = ["google_workspace"]
maturity = "production"
updated_date = "2026/05/14"

[rule]
author = ["Elastic"]
description = """
Detects successful Google Workspace sign-ins for the same user from two geographically separated locations within a
3-hour window, where the implied travel speed between the two points exceeds what is physically possible (>=800 km/h,
faster than modern commercial airliners) and the geographic separation is at least 500 km. This pattern indicates either
VPN/proxy use or an adversary signing in to a compromised account from a different location than the legitimate user.
"""
false_positives = [
"""
Users on VPN or proxy egress that geo-resolves through a region distant from the user's physical location. Mobile
clients on cellular carrier networks that peer through regional hubs may geo-resolve to a different region than the
user's physical location.
""",
]
from = "now-3h"
interval = "30m"
language = "esql"
license = "Elastic License v2"
name = "Google Workspace Impossible Travel Login"
note = """## Triage and analysis

### Investigating Google Workspace Impossible Travel Login

Google Workspace is accessible globally; legitimate users authenticate from one location at a time. Two successful sign-ins for the same user separated by a distance and time delta implying travel faster than a commercial airliner cannot be the same human being physically moving, and indicate either a VPN/proxy egress mismatch or a compromised account being accessed from a separate location by an adversary.

### Possible investigation steps

- Identify the user (`user.email`) and the geographic separation observed: `Esql.distance_km`, `Esql.travel_kmh`, `Esql.window_minutes`, and the set of distinct countries, regions, and cities (`Esql.source_geo_country_name_values`, `Esql.source_geo_region_name_values`, `Esql.source_geo_city_name_values`).
- Pull all `google_workspace.login` events for the user across the alert window. Sort by `@timestamp` and inspect each `source.ip`, `source.as.organization.name`, `source.geo.country_name`, and `user_agent.original` (when present).
- Determine which sign-ins are consistent with the user's baseline (corporate VPN egress, home ISP, mobile carrier) and which are not.
- For each non-baseline sign-in: check the ASN. Hosting-provider ASNs (Clouvider, Host Telecom, Alibaba, cheap-VPS providers) for interactive sign-ins are high-fidelity suspicious because legitimate end users do not typically egress through those networks.
- Cross-reference `logs-google_workspace.token` for `event.action: authorize` events from the same `user.email` around the same time. An OAuth grant minted from a non-baseline ASN immediately after a non-baseline sign-in is the AiTM kit signature.
- Check `logs-google_workspace.user_accounts` for `2sv_enroll`, recovery email/phone additions, or other state changes that an attacker would make to establish persistence.
- Confirm with the user whether the sign-ins are theirs (VPN, travel) or unexpected.

### False positive analysis

- Users on VPN or proxy infrastructure egressing through a distant region: validate against the user's known VPN ranges and consider excluding by ASN.
- Mobile carriers that geo-resolve outside the user's home country (cellular providers often peer through regional hubs): validate by user-agent (mobile UA fingerprint) and source ASN (carrier networks).

### Response and remediation

- If the pattern is unexpected, suspend the user immediately, then revoke OAuth tokens (`DELETE /admin/directory/v1/users/<upn>/tokens/<clientId>`), reset password, and clear recovery email/phone.
- Investigate any `google_workspace.token: authorize` events fired around the same window for tokens minted to the adversary.
- Review `google_workspace.device` for any `DEVICE_REGISTER_UNREGISTER_EVENT` with `account_state: REGISTERED` near the same window: kit-side device registrations are a persistence vector that survives password rotation if the underlying OAuth tokens were not revoked.
- Cross-check `logs-gcp.audit-*` if the tenant exposes any GCP resources to the user: look for `authenticationInfo.principalEmail` matching the user from a non-baseline `callerIp`.
"""
references = [
"https://www.elastic.co/security-labs/google-workspace-attack-surface-part-one",
"https://security.googlecloudcommunity.com/community-blog-42/detecting-impossible-travel-with-google-secops-part-1-3892",
]
risk_score = 47
rule_id = "aff74d85-5bfa-4ff1-ace2-4e3995a37cfa"
severity = "medium"
tags = [
"Domain: Cloud",
"Domain: Identity",
"Data Source: Google Workspace",
"Data Source: Google Workspace Audit Logs",
"Data Source: Google Workspace User log events",
"Use Case: Threat Detection",
"Use Case: Identity and Access Audit",
"Tactic: Initial Access",
"Tactic: Credential Access",
"Resources: Investigation Guide",
]
type = "esql"

query = '''
from logs-google_workspace.login-*
| where event.dataset == "google_workspace.login"
and event.action == "login_success"
and event.outcome == "success"
and user.email is not null
and source.geo.location is not null

| eval Esql.source_geo_lat = st_y(source.geo.location),
Esql.source_geo_lon = st_x(source.geo.location)

| stats
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_region_name_values = values(source.geo.region_name),
Esql.source_geo_city_name_values = values(source.geo.city_name),
Esql.source_as_organization_name_values = values(source.`as`.organization.name),
Esql.source_ip_values = values(source.ip),
Esql.min_lat = min(Esql.source_geo_lat),
Esql.max_lat = max(Esql.source_geo_lat),
Esql.min_lon = min(Esql.source_geo_lon),
Esql.max_lon = max(Esql.source_geo_lon),
Esql.source_geo_country_name_count_distinct = count_distinct(source.geo.country_name),
Esql.source_geo_region_name_count_distinct = count_distinct(source.geo.region_name),
Esql.timestamp_first_seen = min(@timestamp),
Esql.timestamp_last_seen = max(@timestamp),
Esql.event_count = count(*)
by user.email

| where Esql.source_geo_country_name_count_distinct >= 2
or Esql.source_geo_region_name_count_distinct >= 2

| eval Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.min_lon), " ", to_string(Esql.min_lat), ")"))
| eval Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.max_lon), " ", to_string(Esql.max_lat), ")"))
| eval Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0)
| eval Esql.window_minutes = date_diff("minute", Esql.timestamp_first_seen, Esql.timestamp_last_seen)
| eval Esql.travel_kmh = case(Esql.window_minutes > 0, round(Esql.distance_km * 60.0 / Esql.window_minutes, 0), null)

| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800

| keep user.email,
Esql.source_geo_country_name_values,
Esql.source_geo_region_name_values,
Esql.source_geo_city_name_values,
Esql.source_as_organization_name_values,
Esql.source_ip_values,
Esql.source_geo_country_name_count_distinct,
Esql.source_geo_region_name_count_distinct,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen,
Esql.window_minutes,
Esql.distance_km,
Esql.travel_kmh,
Esql.event_count
'''


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



[rule.threat.tactic]
id = "TA0001"
name = "Initial Access"
reference = "https://attack.mitre.org/tactics/TA0001/"
[[rule.threat]]
framework = "MITRE ATT&CK"
[[rule.threat.technique]]
id = "T1528"
name = "Steal Application Access Token"
reference = "https://attack.mitre.org/techniques/T1528/"

[[rule.threat.technique]]
id = "T1557"
name = "Adversary-in-the-Middle"
reference = "https://attack.mitre.org/techniques/T1557/"


[rule.threat.tactic]
id = "TA0006"
name = "Credential Access"
reference = "https://attack.mitre.org/tactics/TA0006/"

[rule.investigation_fields]
field_names = ["user.email"]

Loading