Detecting Spoofed ASN in BGP Telemetry
A single BGP update carrying a spoofed or bogon origin ASN can do more damage inside a fault-correlation platform than the routing incident it describes. When a telemetry stream reports that a customer prefix is suddenly originated by a private ASN, or by a reserved value that can never appear in the public routing table, a naive pipeline treats it as a genuine origin change, opens an incident, and — worse — folds the bogus autonomous-system number into anomaly baselines that later suppress real hijack detection. That is telemetry poisoning by way of the routing control plane, and it inflates mean time to resolution (MTTR) twice: once for the false incident it raises now, and again for the true incident it hides later. Rejecting spoofed and bogon ASNs at the ingestion edge — before they ever reach correlation — turns a silent baseline corruption into a deterministic, alertable rejection.
Schema Alignment and Taxonomy Anchor
This reference is a security-perimeter application of the routing layer described in Security Boundary Mapping, the parent stage that decides which trust zone owns an event before it is dispatched. That parent stage assumes every event it receives is already trustworthy at the field level; this page owns the narrower job of proving a BGP telemetry record’s origin ASN is legitimate before the boundary mapper is allowed to route on it. A record whose origin cannot be trusted must never reach zone resolution, because a spoofed ASN can masquerade as a jurisdiction it does not belong to.
Anchoring the check to the documented contract keeps BGP telemetry interoperable with the rest of the taxonomy. Origin validation here uses the same reject-at-the-edge posture that Validating NetFlow Events with Pydantic applies to flow records under Event Schema Design, so a validated BGP event carries the same guarantees as a validated flow or a normalized trap. The authoritative allocation status of any AS number is drawn from the IANA Autonomous System Number registry and the IANA special-purpose AS-numbers registry, which enumerate the private and reserved ranges that must never originate a public prefix.
Production Code: Origin Validation at the Edge
The validator runs three cheap, ordered checks: a private-ASN filter, a reserved-and-bogon filter, and a prefix-origin consistency check against a table of expected origins. All three are O(1) integer and set comparisons; the only awaited call is the dead-letter handoff. The record is modeled with Pydantic V2 (ConfigDict, field_validator classmethods) so a structurally malformed update is rejected before the semantic checks even run.
import asyncio
import ipaddress
import logging
from pydantic import BaseModel, ConfigDict, Field, field_validator
logger = logging.getLogger("bgp_asn_guard")
# Reserved / special-purpose AS numbers that must never originate a public prefix.
# Reference: https://www.iana.org/assignments/iana-as-numbers-special-registry/
RESERVED_ASNS = frozenset({0, 23456, 65535, 4294967295})
# Private ranges (RFC 6996) and documentation ranges (RFC 5398).
PRIVATE_16 = range(64512, 65535) # 16-bit private
PRIVATE_32 = range(4200000000, 4294967295) # 32-bit private
DOC_16 = range(64496, 64512) # 16-bit documentation
DOC_32 = range(65536, 65552) # 32-bit documentation
def _is_bogon_asn(asn: int) -> bool:
if asn in RESERVED_ASNS:
return True
if asn in PRIVATE_16 or asn in PRIVATE_32:
return True
if asn in DOC_16 or asn in DOC_32:
return True
return False
class BGPUpdate(BaseModel):
model_config = ConfigDict(strict=True, frozen=True, extra="forbid")
event_id: str
prefix: str
origin_asn: int = Field(ge=0, le=4294967295) # full 4-byte space (RFC 6793)
peer_asn: int = Field(ge=0, le=4294967295)
timestamp_ns: int = Field(gt=0)
@field_validator("prefix")
@classmethod
def _valid_prefix(cls, v: str) -> str:
# ip_network rejects a host-bit-set or malformed CIDR outright.
ipaddress.ip_network(v, strict=True)
return v
class SpoofedOriginError(ValueError):
def __init__(self, reason: str, asn: int) -> None:
super().__init__(reason)
self.reason = reason
self.asn = asn
class OriginGuard:
"""Rejects spoofed and bogon origins before boundary routing."""
def __init__(self, expected_origins: dict[str, set[int]],
dlq: asyncio.Queue) -> None:
# expected_origins maps a prefix to the set of ASNs allowed to originate it.
self._expected = expected_origins
self._dlq = dlq
async def validate(self, upd: BGPUpdate) -> BGPUpdate | None:
# 1. Bogon / private / reserved origin — impossible in the public table.
if _is_bogon_asn(upd.origin_asn):
return await self._reject(upd, "bogon_or_private_origin")
# 2. Prefix-origin consistency — a known prefix suddenly originated by
# an unexpected ASN is a hijack or a spoofed telemetry injection.
allowed = self._expected.get(upd.prefix)
if allowed is not None and upd.origin_asn not in allowed:
return await self._reject(upd, "prefix_origin_mismatch")
# 3. AS_TRANS leakage — 23456 is a placeholder, never a real origin.
if upd.origin_asn == 23456:
return await self._reject(upd, "as_trans_placeholder")
return upd # trusted — safe to hand to the boundary mapper
async def _reject(self, upd: BGPUpdate, reason: str) -> None:
payload = {"event_id": upd.event_id, "prefix": upd.prefix,
"origin_asn": upd.origin_asn, "reason": reason}
await self._dlq.put(payload)
logger.warning("spoofed ASN rejected", extra=payload)
return NoneThe private and reserved ranges are hard-coded from the IANA allocations rather than fetched at runtime, so the hot path never makes a network call. The expected_origins table is the strongest check — it encodes which ASN is allowed to originate each customer prefix, so a hijacked or spoofed origin is caught even when the ASN itself is a perfectly valid public number.
Async Ingestion Hook
Origin validation is pure CPU and bounded per record, so it drops straight into the async pipeline as a coroutine that drains a batch and forwards only trusted updates. Malformed and spoofed records divert to the dead-letter queue without stalling the consumer.
import asyncio
from pydantic import ValidationError
async def guard_worker(in_q: asyncio.Queue, out_q: asyncio.Queue,
guard: OriginGuard) -> None:
while True:
raw = await in_q.get()
try:
upd = BGPUpdate.model_validate(raw)
except ValidationError as exc:
await guard._dlq.put({"raw": raw, "errors": exc.errors(include_url=False)})
in_q.task_done()
continue
trusted = await guard.validate(upd)
if trusted is not None:
await out_q.put(trusted) # backpressure if the mapper is busy
in_q.task_done()The only awaited calls are queue operations, so a busy boundary mapper slows out_q.put, which throttles this worker without dropping telemetry. This is the same non-blocking posture the whole Security Boundary Mapping stage relies on.
Mitigation and Hardening
When an origin cannot be trusted, the system rejects deterministically rather than guessing. These are the concrete failure paths to implement:
- Dead-letter isolation with the reason code. Every rejected update is serialized with its failed check (
bogon_or_private_origin,prefix_origin_mismatch,as_trans_placeholder) and the offending ASN, then pushed to a dedicated dead-letter queue. Alert when the spoofed-origin rate exceeds 0.5% over a five-minute window — a spike is either a hijack in progress or a misconfigured telemetry exporter. - Baseline protection. A rejected record is never folded into anomaly baselines. This is the whole point of edge rejection: the same discipline that Mitigating DDoS Telemetry Poisoning uses to keep volumetric floods out of statistical baselines applies here to keep a spoofed ASN from teaching the detector that a hijack is normal.
- Expected-origin table freshness. The prefix-origin table is the strongest and most perishable check. Refresh it from a signed, out-of-band source on a fixed cadence and version it; a stale table produces false
prefix_origin_mismatchrejections during legitimate re-homing, so treat a rising mismatch rate as a prompt to reconcile the table, not only as an attack signal. - Consistency with other edge filters. Origin validation shares the ingestion edge with Filtering Illegal TCP Flag Combinations at the Ingestion Edge; both reject reconnaissance-shaped telemetry before it can corrupt correlation, and both emit structured rejects that a NOC runbook can act on without manual triage.
Operational Hardening Notes
Keep the per-record cost flat under storm load. The bogon check is a set membership test and two range checks — all O(1) — so the only structure that can grow is the expected_origins table; back it with a plain dict keyed by prefix string for constant-time lookup and cap its size by scoping it to customer and peering prefixes rather than the full global table. Validate at the consumer-batch level (500–1000 updates per poll) so the event loop yields between batches, and keep validators free of DNS or RPKI-over-the-wire calls on the hot path — pre-resolve any external origin authority into the in-memory table instead. Guard workers stay stateless and horizontally scalable, so a node under load fails over without losing telemetry, holding the same sub-2-second SLA ticket-creation budget as the rest of the taxonomy.
Frequently Asked Questions
Which ASNs should never appear as a public prefix origin?
AS 0 and AS 65535, the 16-bit private range 64512 through 65534, the 32-bit private range from 4200000000 upward, the documentation ranges, and the AS_TRANS placeholder 23456. The authoritative list of special-purpose values is the IANA special-purpose AS-numbers registry. Any of these originating a public prefix is a spoofed or misconfigured telemetry record and should be rejected at the edge.
How is a spoofed origin different from a bogon origin?
A bogon origin is an ASN that can never legitimately originate a public prefix — a private, reserved, or documentation value — and is caught by a simple range check. A spoofed origin uses a perfectly valid public ASN but claims a prefix it is not authorized to originate; catching it requires an expected-origin table that records which ASN is allowed to originate each prefix.
Why reject at ingestion instead of letting correlation handle it?
Because a spoofed ASN that reaches correlation gets folded into anomaly baselines, which teaches the detector that a hijack pattern is normal and suppresses the real incident later. Rejecting at the edge keeps poisoned origins out of the baseline entirely, turning a silent corruption into a measurable dead-letter signal that a NOC can alert on.
Does the prefix-origin check make network calls on the hot path?
No. The expected-origin table is loaded into memory from a signed, out-of-band source and refreshed on a fixed cadence, so the check is a constant-time dictionary lookup. Making an RPKI or WHOIS call per update would block the event loop and stall ingestion during a storm, so any external authority is pre-resolved into the in-memory table instead.
Related
- Up to: Security Boundary Mapping — the routing layer this edge check protects
- Filtering Illegal TCP Flag Combinations at the Ingestion Edge — a sibling edge filter for reconnaissance-shaped telemetry
- Mitigating DDoS Telemetry Poisoning — keeping volumetric floods out of anomaly baselines
- Validating NetFlow Events with Pydantic — the same reject-at-the-edge posture applied to flow records
- Event Schema Design — the canonical event contract these fields conform to