hemanshu baviskar

← writings

25 Nov 2025

mapping networks with bgp churn fingerprints

A practical intro to using BGP churn as a threat-intel signal

First, What Is BGP?

The internet is split into thousands of independently operated networks. Google runs some. Jio runs some. Amazon runs some. Small ISPs, hosting companies, universities, and enterprise networks run others.

Each of those networks is an Autonomous System, usually shortened to AS. Each AS has a number, called an ASN.

When an AS wants the rest of the internet to reach one of its IP ranges, it announces that range with BGP. A simplified version of the message looks like this:

If you want to reach 1.2.3.0/24, send the traffic through us.

That advertised IP range is called a prefix. The message advertising it is a BGP announcement.

When a network stops advertising a prefix, that is a BGP withdrawal.

So BGP is the routing system networks use to tell each other which IP ranges they can carry traffic for.

What Churn Means

Churn is repeated announcement and withdrawal activity.

Most stable networks do not announce and withdraw the same prefix constantly. A residential ISP, a cloud provider, or a university network usually advertises a prefix and keeps it up for a long time. There may be changes during maintenance, outages, peering changes, or traffic-engineering work, but the baseline is stability.

A boring pattern looks like this:

Jan 1:  Announce 103.21.0.0/20
Jun 15: Still announcing
Dec 31: Still announcing

A noisy pattern looks more like this:

Sep 15, 02:14 UTC: Announce 185.220.0.0/24
Sep 15, 02:47 UTC: Withdraw 185.220.0.0/24
Sep 15, 03:01 UTC: Announce 185.220.0.0/24
Sep 15, 03:15 UTC: Withdraw 185.220.0.0/24
Sep 15, 04:30 UTC: Announce 185.220.0.0/24

That second pattern is worth looking at. It does not prove abuse by itself, but it is rarely something I ignore.

Why Suspicious Hosts Can Be Noisy

Abuse-friendly hosting providers have different incentives from ordinary ISPs. If they are hosting malware, phishing pages, proxy nodes, or command-and-control infrastructure, they often need to move quickly.

Common reasons include:

Those operational habits can leak into BGP. Domains can change. IPs can change. Hosting accounts can change. But if the same network keeps bringing prefixes up and down in a similar way, the routing layer gives you another signal to measure.

I treat this as a fingerprint, not a verdict.

The Fingerprint I Use

For a quick first pass, I calculate four values over a 30-day window.

1. Withdrawals Per Day

How often does the ASN withdraw routes?

Very low withdrawal counts are normal. Repeated daily withdrawals are more interesting, especially when the same prefixes keep appearing and disappearing.

2. Announcement Duration

When a prefix appears, how long does it stay visible before a withdrawal?

Stable infrastructure tends to stay up for days, weeks, or months. Short-lived routes measured in minutes or hours deserve a closer look.

3. Time-of-Day Pattern

Does the activity cluster around certain hours?

Some rotation is scheduled. Some is reactive. A strong hourly pattern can point to automation, while random bursts can point to operational instability or active migration.

4. Upstream Count

How many upstream ASes does the network use?

Multihoming is normal, especially for larger providers. Still, a small host with many upstreams and heavy route churn is more interesting than a small host with one or two stable upstreams.

Together, those fields produce a basic record like this:

{
  "asn": 12345,
  "asn_name": "ExampleHost Ltd",
  "withdrawals_per_day": 9.5,
  "avg_announcement_duration_minutes": 14,
  "peak_churn_hours_utc": [2, 3, 4],
  "upstream_count": 7,
  "verdict": "high-risk routing pattern"
}

Do not read that as "this ASN is malicious." Read it as "this ASN should move up the investigation queue."

Where The Data Comes From

The useful part is that you do not need private telemetry to start.

RouteViews (archive.routeviews.org) publishes public BGP routing data collected from route collectors around the world.

RIPE RIS (ris.ripe.net) does the same through RIPE NCC's Routing Information Service.

Both projects publish MRT files, which are binary BGP data files. You can parse those directly, but for a first experiment it is easier to start with APIs.

BGPView (api.bgpview.io) gives you ASN metadata, prefixes, peers, and upstreams through a simple REST API.

RIPE Stat (stat.ripe.net) exposes BGP update data through JSON endpoints, which is enough for a small churn experiment.

Pulling Real Data

I kept this as a single Go file because that is usually how I test this kind of thing first. It pulls ASN details from BGPView and update history from RIPE Stat, then prints the few fields I care about. If I were using it every day, I would add caching and store the raw responses instead of only printing the score.

package main

import (
    "encoding/json"
    "fmt"
    "math"
    "net/http"
    "net/url"
    "os"
    "sort"
    "strconv"
    "time"
)

var hc = &http.Client{Timeout: 25 * time.Second}

type asnResp struct {
    Status string `json:"status"`
    Data   struct {
        Name    string `json:"name"`
        Country string `json:"country_code"`
    } `json:"data"`
}

type prefResp struct {
    Status string `json:"status"`
    Data   struct {
        V4 []struct{} `json:"ipv4_prefixes"`
        V6 []struct{} `json:"ipv6_prefixes"`
    } `json:"data"`
}

type upResp struct {
    Status string `json:"status"`
    Data   struct {
        V4 []struct {
            ASN int `json:"asn"`
        } `json:"ipv4_upstreams"`
    } `json:"data"`
}

type risResp struct {
    Data struct {
        Updates []struct {
            Timestamp string `json:"timestamp"`
            Type      string `json:"type"`
        } `json:"updates"`
    } `json:"data"`
}

type upd struct {
    t time.Time
    k string
}

type row struct {
    asn    int
    name   string
    cc     string
    v4     int
    v6     int
    up     int
    total  int
    wdDay  float64
    anDay  float64
    peaks  []int
    score  int
    result string
}

func main() {
    asns := []int{15169, 60068}
    if len(os.Args) > 1 {
        asns = nil
        for _, s := range os.Args[1:] {
            asn, err := strconv.Atoi(s)
            if err != nil {
                fmt.Fprintf(os.Stderr, "skip %q, not a number\n", s)
                continue
            }
            asns = append(asns, asn)
        }
    }

    for _, asn := range asns {
        r, err := checkASN(asn, 30)
        if err != nil {
            fmt.Fprintf(os.Stderr, "AS%d: %v\n", asn, err)
            continue
        }
        dump(r)
    }
}

func checkASN(asn, days int) (row, error) {
    info, err := asnInfo(asn)
    if err != nil {
        return row{}, err
    }
    v4, v6, err := prefixes(asn)
    if err != nil {
        return row{}, err
    }
    upstreams, err := upstreams(asn)
    if err != nil {
        return row{}, err
    }

    evs, err := ripeUpdates(asn, days)
    if err != nil {
        return row{}, err
    }
    if len(evs) == 0 {
        return row{}, fmt.Errorf("no bgp updates in RIPE Stat")
    }

    var an, wd int
    hours := make(map[int]int)
    for _, e := range evs {
        switch e.k {
        case "A":
            an++
        case "W":
            wd++
            hours[e.t.UTC().Hour()]++
        }
    }

    wdDay := float64(wd) / float64(days)
    anDay := float64(an) / float64(days)

    score := 0
    if wdDay > 5 {
        score += 3
    } else if wdDay > 2 {
        score++
    }
    if len(upstreams) > 5 {
        score += 2
    } else if len(upstreams) > 3 {
        score++
    }
    if anDay > 0 && wdDay > anDay*0.8 {
        score += 2
    }

    return row{
        asn:    asn,
        name:   info.Data.Name,
        cc:     info.Data.Country,
        v4:     v4,
        v6:     v6,
        up:     len(upstreams),
        total:  len(evs),
        wdDay:  round(wdDay),
        anDay:  round(anDay),
        peaks:  topHours(hours),
        score:  score,
        result: label(score),
    }, nil
}

func asnInfo(asn int) (asnResp, error) {
    var out asnResp
    if err := fetch(fmt.Sprintf("https://api.bgpview.io/asn/%d", asn), &out); err != nil {
        return out, err
    }
    if out.Status != "ok" {
        return out, fmt.Errorf("bgpview status=%q", out.Status)
    }
    return out, nil
}

func prefixes(asn int) (int, int, error) {
    var out prefResp
    if err := fetch(fmt.Sprintf("https://api.bgpview.io/asn/%d/prefixes", asn), &out); err != nil {
        return 0, 0, err
    }
    if out.Status != "ok" {
        return 0, 0, fmt.Errorf("prefix lookup status=%q", out.Status)
    }
    return len(out.Data.V4), len(out.Data.V6), nil
}

func upstreams(asn int) ([]int, error) {
    var out upResp
    if err := fetch(fmt.Sprintf("https://api.bgpview.io/asn/%d/upstreams", asn), &out); err != nil {
        return nil, err
    }
    if out.Status != "ok" {
        return nil, fmt.Errorf("upstream lookup status=%q", out.Status)
    }

    var ret []int
    for _, u := range out.Data.V4 {
        ret = append(ret, u.ASN)
    }
    return ret, nil
}

func ripeUpdates(asn, days int) ([]upd, error) {
    now := time.Now().UTC()
    start := now.AddDate(0, 0, -days)

    q := url.Values{}
    q.Set("resource", fmt.Sprintf("AS%d", asn))
    q.Set("starttime", start.Format("2006-01-02T15:04:05"))
    q.Set("endtime", now.Format("2006-01-02T15:04:05"))
    q.Set("rrcs", "0,1,5")

    var out risResp
    u := "https://stat.ripe.net/data/bgp-updates/data.json?" + q.Encode()
    if err := fetch(u, &out); err != nil {
        return nil, err
    }

    var ret []upd
    for _, x := range out.Data.Updates {
        at, err := time.Parse(time.RFC3339, x.Timestamp)
        if err != nil {
            continue
        }
        kind := x.Type
        if kind == "" {
            kind = "A"
        }
        ret = append(ret, upd{t: at, k: kind})
    }
    return ret, nil
}

func fetch(u string, out any) error {
    resp, err := hc.Get(u)
    if err != nil {
        return err
    }
    defer resp.Body.Close()

    if resp.StatusCode < 200 || resp.StatusCode >= 300 {
        return fmt.Errorf("http %d from %s", resp.StatusCode, u)
    }

    return json.NewDecoder(resp.Body).Decode(out)
}

func topHours(m map[int]int) []int {
    var hrs []int
    for h := range m {
        hrs = append(hrs, h)
    }

    sort.Slice(hrs, func(i, j int) bool {
        if m[hrs[i]] == m[hrs[j]] {
            return hrs[i] < hrs[j]
        }
        return m[hrs[i]] > m[hrs[j]]
    })

    if len(hrs) > 3 {
        hrs = hrs[:3]
    }
    return hrs
}

func label(score int) string {
    if score >= 5 {
        return "high - noisy routing"
    }
    if score >= 3 {
        return "medium - worth checking"
    }
    return "low - nothing obvious"
}

func dump(r row) {
    fmt.Printf("\nAS%d %s (%s)\n", r.asn, r.name, r.cc)
    fmt.Printf("prefixes: %d v4 / %d v6, upstreams: %d\n", r.v4, r.v6, r.up)
    fmt.Printf("events: %d, withdraw/day: %.2f, announce/day: %.2f\n", r.total, r.wdDay, r.anDay)
    fmt.Printf("peak withdraw hours UTC: %v\n", r.peaks)
    fmt.Printf("score=%d/7 => %s\n", r.score, r.result)
}

func round(v float64) float64 {
    return math.Round(v*100) / 100
}

Example Output

This is the kind of output I get from a quick run. The exact numbers will move around as the 30-day window changes, so I only use this as a shape check.

$ go run churn.go 15169 60068

AS15169 GOOGLE (US)
prefixes: 1072 v4 / 112 v6, upstreams: 2
events: 19, withdraw/day: 0.27, announce/day: 0.37
peak withdraw hours UTC: [13 14 20]
score=0/7 => low - nothing obvious

AS60068 CDN77 (GB)
prefixes: 42 v4 / 5 v6, upstreams: 7
events: 506, withdraw/day: 7.93, announce/day: 8.93
peak withdraw hours UTC: [2 4 3]
score=6/7 => high - noisy routing

The useful part is not the exact score. It is the contrast. A quiet network and a noisy network produce very different routing histories.

Finding New Infrastructure

Once you have fingerprints for known-bad or high-abuse networks, you can compare other ASNs against them.

A simple workflow looks like this:

  1. Build a seed list from public reports, abuse feeds, or your own incidents.
  2. Calculate churn fingerprints for those ASNs.
  3. Calculate the same fields for nearby or related ASNs.
  4. Cluster the results by similarity.
  5. Review the outliers with passive DNS, certificate transparency, malware telemetry, and abuse-report history.

This helps you move from single indicators to infrastructure behavior. Instead of only asking "is this IP blocked?", you can ask "does the network behind this IP behave like networks I already distrust?"

That distinction matters. IP indicators age quickly. Network behavior usually changes more slowly.

Free Tools

Tool URL Use
BGPView API api.bgpview.io ASN info, prefixes, peers, upstreams
RIPE Stat stat.ripe.net/data/bgp-updates BGP update history
RouteViews archive.routeviews.org Raw MRT archives
BGP.tools bgp.tools Manual ASN and prefix investigation
Hurricane Electric BGP Toolkit bgp.he.net Quick ASN, prefix, and peering lookup

Closing thoughts

BGP churn will not tell the whole story, but it gives me a place to look when the usual IP and domain signals are still thin. The trick is not to trust the score too much. Use it to queue better questions, then confirm with DNS, certs, abuse history, and whatever telemetry you already have. For a free public signal, that is still a pretty useful trade.