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.

ASN validation gate for BGP telemetryA raw BGP telemetry record enters an origin-validation gate that runs three checks in sequence: a private-ASN filter, a reserved and bogon filter drawn from the IANA special-purpose registry, and a prefix-origin consistency check against an expected-origin table. A record passing all three is emitted to the boundary mapper as a trusted event. A record failing any check is diverted to a dead-letter queue carrying the failed check and the offending ASN, and never reaches correlation.BGP telemetryraw update recordPrivate-ASN64512–65534, 4200000000+Reserved / bogon0, 23456, 65535, doc rangePrefix-originexpected-origin tableBoundarymapperDead-letter queuefailed check + offending ASN — never reaches correlation

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 None

The 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:

  1. 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.
  2. 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.
  3. 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_mismatch rejections during legitimate re-homing, so treat a rising mismatch rate as a prompt to reconcile the table, not only as an attack signal.
  4. 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.