Route-origin hijack hunting

Four hunts for a BGP route-origin hijack of the organisation’s own address space: a foreign origin AS for an owned prefix, a more-specific announcement of an aggregate, an RPKI-invalid route in the table, and a short-lived announcement window. A hijack is announced to the whole internet, so the strongest signal comes from the global table, not from the local routers a hijacked prefix may never cross.

Data source: a local RPKI validator (Routinator, rpki-client) for validity state, the routers’ own BGP table (show bgp), and a feed of the global table from a public route collector (RIPE RIS via the RIPEstat API, or RouteViews). The organisation’s authorised origins are the comparison baseline.

Owned prefixes seen from a foreign origin AS

Hypothesis: one of the organisation’s prefixes is being announced into the global table from an origin AS that is not its own, a multiple-origin-AS conflict.

# query RIPEstat for every origin currently announcing an owned prefix
# the owned ASN and prefix list are the local baseline
for p in 198.51.100.0/24 203.0.113.0/24; do
  curl -s "https://stat.ripe.net/data/routing-status/data.json?resource=$p" | \
    python3 -c '
import sys, json
d = json.load(sys.stdin)["data"]
own = {"64500"}                       # the organisation own ASN(s), without the AS prefix
seen = {str(o["origin"]) for o in d.get("origins", [])}
rogue = seen - own
if rogue:
    print(d["resource"], "origins:", sorted(seen), "UNEXPECTED:", sorted(rogue))
'
done

An origin that is not the organisation’s own, for a prefix only it originates, is a hijack until proven a misconfiguration by a downstream or a documented anycast arrangement.

More-specific announcements of an aggregate

Hypothesis: an attacker has announced a longer prefix, a /24 inside an announced /20, to win the route by specificity, the standard way a hijack draws traffic without displacing the legitimate aggregate.

# list every more-specific currently visible under an announced aggregate
curl -s "https://stat.ripe.net/data/related-prefixes/data.json?resource=198.51.96.0/20" | \
  python3 -c '
import sys, json
for r in json.load(sys.stdin)["data"].get("prefixes", []):
    if r.get("relationship") == "Overlap - More Specific":
        print(r["prefix"], "origin", r.get("origin_asn"))
'

A more-specific that the organisation did not announce, especially one carved to the /24 floor that most providers still accept, is the hijack’s working mechanism.

RPKI-invalid routes in the table

Hypothesis: the local routers are carrying, or selecting, a route the RPKI marks invalid.

# routes marked invalid by the validator, from the router BGP table
# (IOS-XR equivalent: 'show bgp ipv4 unicast origin-as validity invalid')
ssh rtr01 'show bgp ipv4 unicast rpki invalid' | awk '/^\*/ {print}'

With Route Origin Validation enforced, invalids are dropped or de-preferred and this hunt is expected to return nothing. A non-empty result means either ROV is not enforcing, or a route is invalid for a reason worth understanding before it is trusted.

Short-lived announcement windows

Hypothesis: an interception hijack announced an owned prefix only long enough to capture traffic, then withdrew it, which shows as an update-and-withdraw pair minutes apart rather than a standing route.

# RIPE RIS updates for an owned prefix over the last day; announce/withdraw pairs are the signal
curl -s "https://stat.ripe.net/data/bgp-updates/data.json?resource=198.51.100.0/24&starttime=$(date -u -d '1 day ago' +%FT%T)" | \
  python3 -c '
import sys, json
for u in json.load(sys.stdin)["data"]["updates"]:
    a = u.get("attrs", {})
    print(u["timestamp"], u["type"], a.get("source_id",""), a.get("path",""))
' | sort

A pair of announce and withdraw for an owned prefix, minutes apart and from a path that does not include the organisation, is the signature of a timed interception rather than a fat-finger.