LoFP LoFP / split-horizon dns, vpn transitions, service discovery, failover, hairpin nat, and dual-stack names that publish a public a record with a unique-local aaaa record can legitimately produce public and private answers for the same name. recursive resolvers, dns forwarders, and localhost listeners can also aggregate many endpoints under one client address. security products may sinkhole suspicious domains to loopback or private addresses with short ttls. confirm the domain, resolver placement, and client identity before adding an exception, and scope exceptions by registered domain or client rather than globally.

Techniques

Sample rules

Potential DNS Rebinding from Public to Private Address

Description

Identifies a client resolving the same public registered domain to both a public IP address and a private, loopback, link-local, unique-local IPv6, or shared address. This includes both address classes being observed at the same timestamp, and a public answer followed within five minutes by a private answer where the minimum TTL across all answer records in the private-answer events is 60 seconds or less. Either pattern is consistent with DNS rebinding that pivots browser or application trust to internal resources.

Detection logic

from logs-network_traffic.dns-*, logs-zeek.dns-*, packetbeat-*
| where
    (
      data_stream.dataset in ("network_traffic.dns", "zeek.dns") or
      event.dataset == "dns"
    ) and
    dns.question.name is not null and
    dns.question.registered_domain is not null and
    dns.resolved_ip is not null and
    TO_UPPER(dns.response_code) == "NOERROR" and
    TO_UPPER(dns.question.type) in ("A", "AAAA")
| eval
    Esql.client_ip = COALESCE(client.ip, source.ip),
    Esql.dataset = COALESCE(data_stream.dataset, event.dataset)
| where Esql.client_ip is not null
| mv_expand dns.resolved_ip
| eval Esql.is_private = CIDR_MATCH(
    dns.resolved_ip,
    "0.0.0.0/32",
    "10.0.0.0/8",
    "100.64.0.0/10",
    "127.0.0.0/8",
    "169.254.0.0/16",
    "172.16.0.0/12",
    "192.168.0.0/16",
    "::1/128",
    "fc00::/7",
    "fe80::/10"
  )
| eval
    Esql.private_time = CASE(Esql.is_private, @timestamp, null),
    Esql.public_time = CASE(not Esql.is_private, @timestamp, null),
    Esql.private_event_ttl = CASE(Esql.is_private, MV_MIN(dns.answers.ttl), null),
    Esql.private_ip = CASE(Esql.is_private, dns.resolved_ip, null),
    Esql.public_ip = CASE(not Esql.is_private, dns.resolved_ip, null)
| stats
    Esql.resolved_ip_observation_count = COUNT(*),
    Esql.resolved_ip_count = COUNT_DISTINCT(dns.resolved_ip),
    Esql.first_public_answer = MIN(Esql.public_time),
    Esql.first_private_answer = MIN(Esql.private_time),
    Esql.min_private_event_ttl = MIN(Esql.private_event_ttl),
    Esql.public_ips = MV_SLICE(VALUES(Esql.public_ip), 0, 100),
    Esql.private_ips = MV_SLICE(VALUES(Esql.private_ip), 0, 100),
    Esql.dataset_values = MV_SLICE(VALUES(Esql.dataset), 0, 10),
    Esql.observer_name_values = MV_SLICE(VALUES(observer.name), 0, 20)
  by Esql.client_ip, dns.question.name, dns.question.registered_domain
| eval
    Esql.same_timestamp = Esql.first_public_answer == Esql.first_private_answer,
    Esql.transition_seconds = DATE_DIFF("seconds", Esql.first_public_answer, Esql.first_private_answer)
| where
    Esql.first_public_answer is not null and
    Esql.first_private_answer is not null and
    (
        Esql.same_timestamp or
        (
            Esql.first_public_answer < Esql.first_private_answer and
            Esql.min_private_event_ttl is not null and
            Esql.min_private_event_ttl <= 60 and
            Esql.transition_seconds <= 300
        )
    )
| keep Esql.*, dns.*