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:
- Their IPs get blocked, so they rotate to other ranges.
- An upstream provider receives complaints, so the host shifts traffic elsewhere.
- They use several upstreams so one takedown does not remove them from the internet.
- Their customers value short-lived infrastructure more than stable infrastructure.
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:
- Build a seed list from public reports, abuse feeds, or your own incidents.
- Calculate churn fingerprints for those ASNs.
- Calculate the same fields for nearby or related ASNs.
- Cluster the results by similarity.
- 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.