Techniques
Sample rules
AWS Bedrock Model Invocation Impossible Travel
- source: elastic
- technicques:
- T1078
- T1528
Description
Detects the same AWS principal invoking Amazon Bedrock models (InvokeModel, Converse, and their streaming variants) from geographically distant source IPs within a short window at an impossible travel speed. Stolen access keys and session credentials are frequently resold or reused for LLMjacking, and an inference call from a second continent minutes after a legitimate one is a strong indicator that the credential is being used by someone else.
Detection logic
from logs-aws.cloudtrail-*
| where
data_stream.dataset == "aws.cloudtrail" and
event.provider == "bedrock.amazonaws.com" and
event.action in ("InvokeModel", "InvokeModelWithResponseStream", "Converse", "ConverseStream") and
event.outcome == "success" and
aws.cloudtrail.user_identity.arn is not null and
source.ip is not null and
source.geo.location is not null and
source.geo.country_name is not null
| eval
Esql.identity = coalesce(
aws.cloudtrail.user_identity.session_context.session_issuer.arn,
aws.cloudtrail.user_identity.arn
),
Esql.source_geo_lat = st_y(source.geo.location),
Esql.source_geo_lon = st_x(source.geo.location)
| where Esql.source_geo_lat is not null and Esql.source_geo_lon is not null
| stats
Esql.first_lat = first(Esql.source_geo_lat, @timestamp),
Esql.first_lon = first(Esql.source_geo_lon, @timestamp),
Esql.last_lat = last(Esql.source_geo_lat, @timestamp),
Esql.last_lon = last(Esql.source_geo_lon, @timestamp),
Esql.event_count = count(*),
Esql.country_count = count_distinct(source.geo.country_name),
Esql.source_ip_values = values(source.ip),
Esql.source_geo_country_name_values = values(source.geo.country_name),
Esql.source_geo_city_name_values = values(source.geo.city_name),
Esql.source_as_organization_name_values = values(source.as.organization.name),
Esql.event_action_values = values(event.action),
Esql.user_agent_original_values = values(user_agent.original),
Esql.aws_cloudtrail_user_identity_arn_values = values(aws.cloudtrail.user_identity.arn),
Esql.aws_cloudtrail_user_identity_type_values = values(aws.cloudtrail.user_identity.type),
Esql.aws_cloudtrail_user_identity_access_key_id_values = values(aws.cloudtrail.user_identity.access_key_id),
Esql.cloud_region_values = values(cloud.region),
Esql.timestamp_first_seen = min(@timestamp),
Esql.timestamp_last_seen = max(@timestamp)
by
Esql.identity,
cloud.account.id
| where Esql.event_count >= 2 and Esql.country_count >= 2
| eval
Esql.p1 = to_geopoint(concat("POINT(", to_string(Esql.first_lon), " ", to_string(Esql.first_lat), ")")),
Esql.p2 = to_geopoint(concat("POINT(", to_string(Esql.last_lon), " ", to_string(Esql.last_lat), ")"))
| eval
Esql.distance_km = round(st_distance(Esql.p1, Esql.p2) / 1000.0, 0),
Esql.window_seconds = date_diff("second", Esql.timestamp_first_seen, Esql.timestamp_last_seen),
Esql.travel_kmh = case(Esql.window_seconds > 0, round(Esql.distance_km * 3600.0 / Esql.window_seconds, 0), null),
source.ip = mv_first(Esql.source_ip_values)
| where Esql.distance_km >= 500 and Esql.travel_kmh >= 800
| keep
Esql.identity,
cloud.account.id,
source.ip,
Esql.source_ip_values,
Esql.source_geo_country_name_values,
Esql.source_geo_city_name_values,
Esql.source_as_organization_name_values,
Esql.event_action_values,
Esql.user_agent_original_values,
Esql.aws_cloudtrail_user_identity_arn_values,
Esql.aws_cloudtrail_user_identity_type_values,
Esql.aws_cloudtrail_user_identity_access_key_id_values,
Esql.cloud_region_values,
Esql.distance_km,
Esql.travel_kmh,
Esql.window_seconds,
Esql.country_count,
Esql.event_count,
Esql.timestamp_first_seen,
Esql.timestamp_last_seen