Back to blog

MEO Outage Day

On Monday evening, a big chunk of Portugal lost the internet.

Not “my Wi-Fi is acting up” lost. Properly lost. Around 17:30 the complaints started, and by 18:20 roughly seven thousand people had filed a report on Downdetector about MEO alone. Fixed broadband, mobile data, phone calls, TV apps, all wobbling at once.

Then the arguing started. MEO said “mass attack”. Some network people on X said “no, it’s a BGP leak, look at Cloudflare Radar”. Other people said “it’s probably just their infrastructure falling over again”.

I spent an evening actually checking. Not reading takes, querying the measurement systems directly. This post is what I found, written for someone who has never heard of BGP, with every command I ran so you can reproduce it.

Spoiler: almost everyone was a little bit right and a little bit wrong.


First, what even is BGP?

Here’s the thing that surprises people: the internet has no map.

There’s no central server that knows where netflix.com lives. Instead there are about 80,000 independent networks: your ISP, Google, a university, a bank. Each one is called an Autonomous System, or AS. Each has a number: MEO’s residential network is AS3243, Cloudflare is AS13335, and so on.

These networks stay in touch by constantly gossiping to each other:

“Hey, I can reach these addresses. Send me traffic for them.”

That gossip protocol is BGP, Border Gateway Protocol. It was sketched out in 1989 on the backs of a few napkins, and it is still what holds the internet together.

The addresses they gossip about come in blocks called prefixes. A prefix looks like 194.65.0.0/16. Think of it as a postal code range, that one covers 65,536 addresses and belongs to MEO.

flowchart LR
    A["AS3243<br/>MEO Residential<br/>'I own 194.65.0.0/16'"] -->|announces| B["AS8657<br/>MEO International"]
    B -->|passes it on| C["AS1299<br/>Arelion"]
    B -->|passes it on| D["AS6939<br/>Hurricane Electric"]
    C --> E["The rest of<br/>the internet"]
    D --> E
    E -.->|"traffic flows back<br/>along the same path"| A

So when you load a website, your packets follow a chain of these announcements. Each hop is one network trusting the previous one’s claim.

And that’s the weak spot. BGP has almost no built-in way to verify that a claim is true. If a network says “I can reach those addresses”, its neighbours mostly just… believe it. That’s how route leaks and hijacks happen, and it’s why BGP incidents keep making the news.

Two more terms, then we’re done with theory:

  • Transit provider, a big network you pay to carry your traffic everywhere else. Arelion, Cogent, Hurricane Electric are examples.
  • Route leak, when a network passes along routes it shouldn’t have. Usually accidental, often embarrassing, occasionally catastrophic.

How MEO is actually wired

Before we can talk about what broke, you need to see the shape of the thing.

MEO doesn’t run one network, it runs several, each with its own AS number. Straight from RIPE’s database:

Terminal window
$ for asn in 3243 8657 42863 15525; do
curl -s "https://stat.ripe.net/data/as-overview/data.json?resource=AS$asn" \
| jq -r '.data | "AS\(.resource) \(.holder)"'
done
AS3243 MEO-RESIDENCIAL MEO - SERVICOS DE COMUNICACOES E MULTIMEDIA S.A.
AS8657 MEO-INTERNACIONAL MEO - SERVICOS DE COMUNICACOES E MULTIMEDIA S.A.
AS42863 MEO-MOVEL MEO - SERVICOS DE COMUNICACOES E MULTIMEDIA S.A.
AS15525 MEO-EMPRESAS MEO - SERVICOS DE COMUNICACOES E MULTIMEDIA S.A.

Four networks: homes, backbone, mobile, business.

Now the interesting bit. I asked RIPE who sits between each of them and the rest of the internet:

Terminal window
$ curl -s "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS3243" \
| jq -r '.data.neighbours[] | select(.type=="left") | "\(.asn) strength \(.power)"'
8657 strength 2714

That’s it. One answer. Same story for mobile and business, every one of them reaches the world through AS8657.

flowchart LR
    H["🏠 Your house"] --> R["AS3243<br/>Residential<br/>17 prefixes"]
    M["📱 Your phone"] --> MO["AS42863<br/>Mobile<br/>10 prefixes"]
    O["🏢 Your office"] --> B["AS15525<br/>Business<br/>45 prefixes"]

    R --> I["AS8657<br/>MEO INTERNATIONAL<br/>only 4 prefixes"]
    MO --> I
    B --> I

    I --> T1["Arelion<br/>AS1299"]
    I --> T2["Cogent<br/>AS174"]
    I --> T3["Hurricane<br/>AS6939"]
    I --> T4["Sparkle<br/>AS6762"]

    T1 & T2 & T3 & T4 --> NET["🌍 Internet"]

    style I fill:#0c1a1c,stroke:#42d9c0,stroke-width:3px,color:#42d9c0

Look at that middle box. AS8657 owns only four prefixes of its own:

Terminal window
$ curl -s "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS8657" \
| jq -r '.data.prefixes[].prefix'
185.96.172.0/22
217.168.112.0/21
195.8.0.0/19
2001:15d8::/32

Four. Meanwhile residential has 17, mobile has 10, business has 45, and all 72 of those reach the internet through the four-prefix network’s connections.

This confuses people, so: owning address space and carrying traffic are completely different jobs. A railway company might own four stations while running trains to a thousand destinations. AS8657 is the railway.

Which means if AS8657 has a bad day, everyone has a bad day. Hold that thought.


The timeline

Here’s the whole thing. Times are Portugal local (WEST). The attack windows are MEO’s own published figures; the measurement rows come from independent systems.

gantt
    title MEO incident, 14-15 September 2026 (WEST)
    dateFormat YYYY-MM-DD HH:mm
    axisFormat %H:%M

    section Attack waves (per MEO)
    Wave 1                  :crit, 2026-09-14 00:45, 2026-09-14 02:55
    Wave 2, the big one    :crit, 2026-09-14 17:30, 2026-09-14 19:30
    Wave 3                  :crit, 2026-09-14 22:45, 2026-09-15 01:05

    section What I measured
    Ghana route leak visible   :active, 2026-09-14 01:13, 2026-09-14 17:37
    BGP churn climbing         :2026-09-14 17:20, 2026-09-14 19:40
    IODA national outage event :crit, 2026-09-14 17:30, 2026-09-14 18:40
    Deepest packet loss        :crit, 2026-09-14 18:00, 2026-09-14 18:20

    section Public reaction
    Complaints climbing        :2026-09-14 17:30, 2026-09-14 19:00
    Peak ~7200 reports         :milestone, 2026-09-14 18:25, 0m
    MEO first statement        :milestone, 2026-09-14 22:00, 0m
    MEO + ANACOM verdict       :milestone, 2026-09-15 16:00, 0m

Three separate attack waves. Most people only felt the middle one, because it landed right at dinner time.


How I checked the outage was real

Anyone can tweet “the internet is down”. Let’s get receipts.

Step 1, ask a system that pings Portugal all day

Georgia Tech runs a project called IODA that continuously probes address blocks worldwide and records which ones answer. Free API, no key needed.

First, the incident window in Unix time:

Terminal window
$ date -u -d '2026-09-14 14:00:00' +%s
1789394400
$ date -u -d '2026-09-14 21:00:00' +%s
1789419600

Now pull raw signals for MEO residential:

Terminal window
$ curl -s "https://api.ioda.inetintel.cc.gatech.edu/v2/signals/raw/asn/3243?from=1789394400&until=1789419600&sourceParams=" \
| jq '.data[0][] | select(.datasource=="ping-slash24") | .values'
[5938, 5933, 5930, 5933, 5933, 5923, 5928, 5930, 5933, 5932, 5932, 5937,
5933, 5930, 5287, 2905, 1670, 1039, 368, 817, 524, 3957, 5467, 5648,
5834, 5917, 5932, 5939, 5936, 5941, 5939, 5946, 5944, 5942, 5947, 5944]

Read that left to right. Cruising at ~5,930 responsive blocks… then 5287 → 2905 → 1670 → 1039 → 368.

From 5,933 down to 368. A 93.8% collapse, bottoming out at 18:00 WEST.

Same query for the others:

NetworkRoleNormalWorstDrop
AS3243Residential5,933368-93.8%
AS42863Mobile60642-93.1%
AS15525Business48119-96.0%
AS8657International60-100%

And packet loss, from the same response:

Terminal window
$ curl -s "https://api.ioda.inetintel.cc.gatech.edu/v2/signals/raw/asn/3243?from=1789394400&until=1789419600&sourceParams=" \
| jq '[.data[0][] | select(.datasource=="ping-slash24-loss")
| .values[][0].agg_values.loss_pct]'
[9.53, 9.59, 9.63, 9.64, 9.58, 9.69, 9.63, 9.6, 9.51, 9.51, 9.51, 9.5,
9.47, 9.45, 19.4, 57.47, 77.26, 88.76, 97.49, 95.41, 93.91, 72.84,
26.87, 16.51, 13.93, 10.93, 9.65, 9.48]

Baseline loss ~9.5%. Peak loss 97.49%.

Ninety-seven percent of probes into MEO’s network just vanished. This was not a vibe.

Step 2, was it only MEO, or all of Portugal?

IODA also correlates outage events:

Terminal window
$ curl -s "https://api.ioda.inetintel.cc.gatech.edu/v2/outages/events?from=1789394400&until=1789419600&limit=100&relatedTo=asn%2F3243" \
| jq -r '.data[] | select(.datasource=="ping-slash24")
| "\(.location_name) start=\(.start|todate) dur=\(.duration/60)min"'
Coimbra start=2026-09-14T16:30:00Z dur=70min
Porto start=2026-09-14T16:30:00Z dur=70min
Braga start=2026-09-14T16:30:00Z dur=70min
Aveiro start=2026-09-14T16:30:00Z dur=70min
Faro start=2026-09-14T16:30:00Z dur=70min
Portugal start=2026-09-14T16:30:00Z dur=70min
Lisboa start=2026-09-14T16:40:00Z dur=60min
Setúbal start=2026-09-14T16:40:00Z dur=60min
Madeira start=2026-09-14T16:40:00Z dur=60min

A country-level outage event. Seventy minutes. Lisbon, Porto, Coimbra, Braga, Faro, even Madeira.

16:30 UTC = 17:30 WEST. Exactly when the complaints started.


Now the routing side, this is where it gets fun

RIPE runs the Routing Information Service, a fleet of collectors worldwide that record BGP messages as they happen. Also free, also no key.

First question: was there unusual BGP activity? Let’s compare the incident day against the same hours the day before.

Terminal window
$ for day in 2026-09-13 2026-09-14; do
printf "%s " "$day"
curl -s "https://stat.ripe.net/data/bgp-updates/data.json?resource=AS3243&starttime=${day}T14:00:00&endtime=${day}T21:00:00" \
| jq '.data.nr_updates'
done
2026-09-13 545
2026-09-14 72679

545 → 72,679. That’s 133 times normal.

Across all four networks:

Network13 Sep14 SepMultiplier
AS3243 Residential54572,679133×
AS8657 International35019,42856×
AS42863 Mobile46059,604130×
AS15525 Business1,377223,374162×

Something shook the routing table hard. Where exactly? There’s an endpoint that buckets it by time:

Terminal window
$ curl -s "https://stat.ripe.net/data/bgp-update-activity/data.json?resource=AS3243&starttime=2026-09-14T16:00:00&endtime=2026-09-14T18:40:00&min_sampling_period=600&hide_empty_samples=false" \
| jq -r '.data.updates[] | "\(.starttime[11:16])Z \(.announcements)"'
16:00Z 0
16:10Z 0
16:20Z 250
16:30Z 1069
16:40Z 9724
16:50Z 3824
17:00Z 30458 peak
17:10Z 10089
17:20Z 9334
17:30Z 6325
17:40Z 1
17:50Z 9
18:00Z 868

Thirty thousand announcements in ten minutes, right at 17:00 UTC (18:00 WEST), the exact moment packet loss peaked. The data plane and the control plane broke together.

The single best piece of evidence

This is my favourite bit. I took one real MEO prefix (194.65.0.0/16, where www.meo.pt lives) and pulled the global routing state at three moments: before, during, after.

Terminal window
$ for ts in 16:00 17:10 18:30; do
echo "=== $ts UTC ==="
curl -s "https://stat.ripe.net/data/bgp-state/data.json?resource=194.65.0.0%2F16&timestamp=2026-09-14T${ts}:00" \
| jq -r '.data.bgp_state[]
| select(.source_id | test("195.66.227.163|80.81.192.194|12.0.1.63|208.51.134.248"))
| "\(.source_id) \(.path|join(" "))"'
done

Output:

=== 16:00 UTC ===
01-195.66.227.163 1299 8657 3243
12-80.81.192.194 6762 8657 3243
00-12.0.1.63 7018 1299 8657 3243
00-208.51.134.248 3549 3356 1299 8657 3243
=== 17:10 UTC ===
01-195.66.227.163 1299 174 8657 3243
12-80.81.192.194 6762 174 8657 3243
00-12.0.1.63 7018 174 8657 3243
00-208.51.134.248 3549 3356 174 8657 3243
=== 18:30 UTC ===
01-195.66.227.163 1299 8657 3243
12-80.81.192.194 6762 8657 3243
00-12.0.1.63 7018 1299 8657 3243
00-208.51.134.248 3549 3356 1299 8657 3243

Do you see it?

174 appears in every single path during the incident, then vanishes again afterwards.

AS174 is Cogent. Before, the world reached MEO through a healthy spread of providers. During, almost everything funnelled through one.

The aggregate confirms it:

Time (UTC)Routes seenvia AS6939via AS174via AS1299
16:00, before3841641730
17:10, during327192070
18:30, after3851632424

Hurricane Electric went from 164 views to 19. Arelion went to zero. Cogent went from 17 to 207.

That is what a network looks like when most of its doors slam shut and everyone piles through the one that’s left.

flowchart TB
    subgraph BEFORE["16:00 UTC: healthy"]
        direction LR
        W1["World"] --> A1["Arelion"] --> M1["AS8657"]
        W1 --> H1["Hurricane"] --> M1
        W1 --> C1["Cogent"] --> M1
        W1 --> S1["Sparkle"] --> M1
    end

    subgraph DURING["17:10 UTC: under attack"]
        direction LR
        W2["World"] -.->|gone| A2["Arelion"]
        W2 -.->|mostly gone| H2["Hurricane"]
        W2 ==>|"207 of 327 views"| C2["Cogent"] ==> M2["AS8657"]
        W2 -.->|gone| S2["Sparkle"]
    end

    style C2 fill:#2c100c,stroke:#ff4b34,stroke-width:3px,color:#ff8a78
    style A2 fill:#120808,stroke:#331914,color:#5a4340
    style S2 fill:#120808,stroke:#331914,color:#5a4340

The plot twist: Ghana

While I was writing this up, someone pointed me at a tweet claiming the whole thing was a BGP leak involving Ghana Telecom.

I nearly dismissed it. I’m glad I didn’t, because it was real.

Cloudflare Radar runs an automatic route-leak detector, and it had logged event 614156:

BGP route leak: 614156
Routes obtained from: AS37468 Angola Cables (AO)
Routes leaked by: AS29614 Ghana Telecommunications (GH)
Routes leaked to: AS8657 MEO (PT)
Prefix count: 5
Peer count: 16
Origin count: 2
Detection time: 09/14/2026, 01:13
Latest message time: 09/14/2026, 17:37

So: Ghana Telecom took routes it learned from Angola Cables and advertised them to MEO. A textbook provider-customer-provider leak.

When I checked the relationships, it got worse:

Terminal window
$ curl -s "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS29614" \
| jq -r '.data.neighbours[] | select(.type=="left") | "\(.asn) strength \(.power)"' \
| sort -k3 -rn | head -4
8657 strength 3697 MEO is Ghana's STRONGEST upstream
37613 strength 2642
37468 strength 2010 ← Angola Cables
1273 strength 439

MEO is one of Ghana Telecom’s transit providers. Ghana sat between two providers and passed one’s routes to the other.

flowchart TD
    AC["AS37468<br/>Angola Cables"] -->|"1. normal: sells transit to Ghana"| GH["AS29614<br/>Ghana Telecom"]
    MEO["AS8657<br/>MEO International"] -->|"2. also sells transit to Ghana"| GH
    GH -.->|"3. ❌ LEAK<br/>passes Angola's routes to MEO"| MEO
    MEO -->|"4. MEO may now send<br/>traffic the wrong way"| GH
    GH --> AC

    style GH fill:#131a0d,stroke:#b6f24a,stroke-width:3px,color:#b6f24a
    style MEO fill:#0c1a1c,stroke:#42d9c0,stroke-width:2px,color:#42d9c0

Here’s why that’s genuinely nasty: most networks give routes learned from a customer the highest preference, because customers pay you. Ghana is MEO’s customer. So a leaked route arriving from Ghana can beat a perfectly good route from a peer on preference alone, before path length is even considered.

Which five prefixes, though?

Cloudflare tells you there were five. It does not tell you which five. I wanted to know, if they turned out to be something enormous like a CDN or a cloud region, this leak would look a lot more dangerous.

The obvious query fails:

Terminal window
# Look for "MEO ← Ghana ← Angola" in RIPEstat's per-ASN feed
$ curl -s "https://stat.ripe.net/data/bgp-updates/data.json?resource=AS29614&starttime=2026-09-14T00:00:00&endtime=2026-09-14T19:00:00" \
| jq -r '.data.updates[].attrs.path | select(.) | join(" ")' \
| grep -c "8657 29614 37468"
0

Zero. That endpoint only returns prefixes the ASN originates, and the leaked routes belonged to someone further downstream. Narrowing by Angola Cables’ customer list doesn’t help either:

Terminal window
$ curl -s "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS37468" \
| jq '[.data.neighbours[] | select(.type=="right")] | length'
2435

So the convenient APIs are a dead end. Fine, go to the raw tape.

Reading the actual BGP messages

RIPE publishes every BGP message its collectors receive as MRT dumps (RFC 6396), five-minute files, going back years. Nothing is summarised for you here; it’s the wire format, exactly as the routers spoke it.

To read it you want bgpdump, RIPE’s own C tool:

Terminal window
# Arch / Omarchy
yay -S bgpdump
# Debian / Ubuntu
sudo apt install bgpdump
# macOS
brew install bgpdump

The files are named by collector and five-minute slot:

Terminal window
$ curl -s "https://data.ris.ripe.net/rrc00/2026.09/" \
| grep -o 'updates\.20260914\.17[0-9][0-9]\.gz' | head -4
updates.20260914.1700.gz
updates.20260914.1705.gz
updates.20260914.1710.gz
updates.20260914.1715.gz

I pulled 28 of them, four collectors (rrc00 multihop, rrc01 LINX London, rrc03 AMS-IX, rrc12 DE-CIX Frankfurt) across both moments where Cloudflare said something happened. 122 MB of compressed BGP.

Then the entire search is one grep:

Terminal window
$ bgpdump -m rrc01.1730.gz | grep '29614 37468'

-m gives one pipe-delimited line per message, and because the AS path is just text in that line, “Ghana Telecom announcing Angola Cables’ routes” is literally the string 29614 37468.

Here’s real output, untouched:

BGP4MP|1789348431|A|195.66.227.160|3214|102.208.80.0/24|
3214 8657 29614 37468 174 37282 329486|IGP|195.66.227.160|0|0||NAG||
BGP4MP|1789407023|A|195.90.96.2|48185|169.239.249.0/24|
48185 8657 29614 29614 29614 29614 29614 37468 37282
327992 327992 327992 327992 327992|IGP|195.90.96.2|0|0||NAG||

Run it across all 28 files:

Terminal window
$ for g in *.gz; do bgpdump -m "$g"; done | grep '29614 37468' > leak.txt
$ wc -l < leak.txt
146
$ awk -F'|' '{print $6}' leak.txt | sort -u
102.208.80.0/24
169.239.248.0/24
169.239.249.0/24
169.239.250.0/24
169.239.251.0/24
$ awk -F'|' '{n=split($7,a," "); print a[n]}' leak.txt | sort -u
327992
329486
$ awk -F'|' '{print $4}' leak.txt | sort -u | wc -l
30

Five prefixes. Two origin ASNs. Cloudflare’s event said five prefixes and two origins. Two completely independent systems, same answer.

And the timing, pulled from field 2:

Terminal window
$ awk -F'|' '{print $6, strftime("%H:%M:%S",$2,1)}' leak.txt \
| sort -u -k1,1 -k2,2
PrefixFirst seen (UTC)Last seen (UTC)AnnouncementsRIS peers
102.208.80.0/2401:13:5117:31:382211
169.239.248.0/2417:30:2317:37:493029
169.239.249.0/2417:30:2317:37:493229
169.239.250.0/2417:30:2317:37:493229
169.239.251.0/2417:30:2317:37:493029

Cloudflare’s event window was 01:13 → 17:37. The dumps say 01:13:51 → 17:37:49. Matching to the second, from raw router messages.

That bank prefix is the interesting one: announced at 01:13 and still being re-announced at 17:31, sixteen hours later. It sat in the table all day.

So who actually got leaked?

PrefixOrigin ASWho that really isCountry
102.208.80.0/24AS329486GCB Bank PLC, Ghana’s largest indigenous bank🇬🇭 GH
169.239.248.0/24AS327992GARNET, Ghanaian Academic & Research Network🇬🇭 GH
169.239.249.0/24AS327992GARNET, NOC block🇬🇭 GH
169.239.250.0/24AS327992GARNET, point-to-point block🇬🇭 GH
169.239.251.0/24AS327992GARNET, customer point-to-point🇬🇭 GH

Straight from the registries, no interpretation:

Terminal window
$ curl -s "https://stat.ripe.net/data/whois/data.json?resource=102.208.80.0/24" | jq -r ...
inetnum : 102.208.80.0/24
netname : GCB-BANK
descr : GCB BANK PLC
country : GH
status : ASSIGNED PI
mnt-by : AFRINIC-HM-MNT
$ curl -s "https://stat.ripe.net/data/whois/data.json?resource=169.239.249.0/24" | jq -r ...
inetnum : 169.239.249.0/24
netname : GARNET-NOC
descr : for GARNET NOC
country : GH
status : ASSIGNED PA
mnt-by : GARNET-MNT

A Ghanaian bank and a Ghanaian university network. Five /24s, 1,280 IP addresses in total.

Not a CDN. Not a cloud region. Not Portugal.

The full chain, decoded

Here’s one of the real paths, lifted straight out of the dump:

1103 8657 29614 37468 174 37282 329486
│ │ │ │ │ │ └─ GCB Bank, Ghana
│ │ │ │ │ └───────── MainOne Cable, Nigeria
│ │ │ │ └──────────────── Cogent
│ │ │ └─────────────────────── Angola Cables
│ │ └─────────────────────────────── Ghana Telecom ← leak happens here
│ └────────────────────────────────────── MEO International
└───────────────────────────────────────────── SURFnet (the RIS peer that logged it)

A Ghanaian bank’s traffic went via Nigeria, via Cogent, via Angola, back through Ghana, into Portugal, then out to the world. That’s a spectacularly stupid path for packets to take, and it is exactly what a route leak looks like.

The GARNET one is even more revealing, look at the prepending:

3257 8657 29614 29614 29614 29614 29614 37468 37282 327992 327992 327992 327992 327992
└──────── ×5 ────────┘ └────────── ×5 ──────────┘

Ghana Telecom prepended itself five times, GARNET prepended itself five times. Prepending is how you make a route less attractive. Somebody was already fighting a traffic-engineering fire when this went out the door.

There’s a subtler fingerprint too. bgpdump shows the ORIGIN attribute, and it’s inconsistent across the four GARNET prefixes:

169.239.248.0/24 INCOMPLETE
169.239.249.0/24 IGP
169.239.250.0/24 IGP
169.239.251.0/24 INCOMPLETE

INCOMPLETE means the route was redistributed into BGP from somewhere else rather than originated cleanly. Four adjacent /24s from the same network shouldn’t disagree about that. It’s the signature of a config in mid-edit.

flowchart LR
    BANK["🏦 GCB Bank<br/>AS329486<br/>102.208.80.0/24"] --> MAIN["MainOne<br/>AS37282 🇳🇬"]
    GAR["🎓 GARNET<br/>AS327992<br/>169.239.248-251.0/24"] --> MAIN
    MAIN --> ANG["Angola Cables<br/>AS37468 🇦🇴"]
    ANG --> GH["Ghana Telecom<br/>AS29614 🇬🇭"]
    GH -.->|"❌ LEAK<br/>01:13 and 17:30 UTC"| MEO["MEO International<br/>AS8657 🇵🇹"]
    MEO --> W["🌍 rest of<br/>the internet"]

    style GH fill:#131a0d,stroke:#b6f24a,stroke-width:3px,color:#b6f24a
    style MEO fill:#0c1a1c,stroke:#42d9c0,stroke-width:2px,color:#42d9c0

The detail that made me sit up

Remember MEO’s three attack windows? Convert them to UTC and check where every leak event lands:

wave 1 00:45-02:55 WEST = 23:45-01:55 UTC
wave 2 17:30-19:30 WEST = 16:30-18:30 UTC
102.208.80.0/24 first 01:13:51 UTC -> INSIDE wave 1
102.208.80.0/24 last 17:31:38 UTC -> INSIDE wave 2
169.239.248-251 first 17:30:23 UTC -> INSIDE wave 2
169.239.248-251 last 17:37:49 UTC -> INSIDE wave 2

Every single leak event lands inside a declared attack window. Not near one, inside.

The bank prefix leaked during wave 1, sat quietly for sixteen hours, then got re-announced during wave 2. The GARNET prefixes appeared 23 seconds after wave 2 started.

That is not what an independent accident looks like. It’s what you’d expect when BGP sessions are flapping under load and routers are frantically re-announcing whatever they still hold.

Would RPKI have stopped it?

No, and this is the part worth understanding.

Terminal window
$ curl -s "https://stat.ripe.net/data/rpki-validation/data.json?resource=AS327992&prefix=169.239.249.0/24" \
| jq -r '.data.status'
valid
$ curl -s "https://stat.ripe.net/data/rpki-validation/data.json?resource=AS329486&prefix=102.208.80.0/24" \
| jq -r '.data.status'
unknown

The GARNET prefixes are RPKI-valid. GARNET published a signed statement saying “AS327992 may announce this”, and AS327992 is the origin in the leaked path. Everything checks out cryptographically.

Because RPKI only validates the origin, the last hop. It says nothing about the middle of the path. The origin here was completely legitimate; the crime happened four hops later, when Ghana Telecom passed routes to a provider it shouldn’t have.

That’s why RPKI alone doesn’t fix leaks, and why people are working on ASPA. It lets you also sign who your providers are, so a leak like this becomes detectable in the path rather than only at the origin.

So was Ghana the cause of the outage?

No. Now we can say it with numbers instead of hand-waving.

The leak moved 1,280 IP addresses belonging to a bank and a university in Ghana. The outage made roughly 93-96% of MEO’s entire address space unreachable across Portugal, millions of addresses, thousands of /24s, in a completely different country.

Five /24s in West Africa cannot do that. The scale is off by orders of magnitude, and the geography is wrong.

The leak was real. It was concurrent. It is now fully identified. And it was a symptom, not the disease.


How a flood breaks routing

Here’s the mechanism everyone was missing in the argument.

BGP sessions are just TCP connections. Two routers agree to chat, then send each other small keepalive messages to prove they’re still alive. Typically if you miss three in a row, around 90 seconds of silence, your neighbour declares you dead and throws away every route you ever told it about.

Now imagine someone floods that link with junk traffic:

sequenceDiagram
    participant A as MEO AS8657
    participant L as International link
    participant B as Arelion AS1299

    Note over A,B: 🟢 Normal operation
    A->>B: keepalive
    B->>A: keepalive
    Note over A,B: session healthy, routes shared

    Note over L: 🔴 DDoS traffic floods the link
    A->>L: keepalive
    L--xB: dropped in congestion
    A->>L: keepalive
    L--xB: dropped
    A->>L: keepalive
    L--xB: dropped

    Note over B: hold timer expires
    B->>B: ❌ declare AS8657 unreachable
    B->>B: withdraw all its routes
    B-->>A: "I can no longer reach MEO"

    Note over A,B: 🌍 the whole internet<br/>reroutes around the gap

That single failure ripples outward. Every network that learned MEO’s routes via Arelion now has to pick a different path. They all recompute. They tell their neighbours. And you get 72,679 announcements where you’d normally see 545.

And crucially, when the good paths disappear, whatever is left wins. Including a pre-existing leaked route from Ghana that had been sitting there quietly losing the best-path contest all along.

That’s the resolution of the whole argument:

flowchart TD
    D["💥 DDoS floods<br/>international links"] --> C["Congestion,<br/>packet loss"]
    C --> U["👥 Customers lose<br/>international sites"]
    C --> K["BGP keepalives<br/>get dropped"]
    K --> S["❌ Sessions time out"]
    S --> W["Routes withdrawn"]
    W --> R["🔄 Global reconvergence<br/>72,679 announcements"]
    R --> CG["Everything funnels<br/>through Cogent AS174"]
    R --> LK["🟡 Pre-existing Ghana leak<br/>becomes the selected path"]

    M["🛡️ Mitigation:<br/>filtering + scrubbing"] --> RE["Sessions restored,<br/>diversity returns"]
    C -.-> M

    style D fill:#2c100c,stroke:#ff4b34,color:#ff8a78
    style LK fill:#131a0d,stroke:#b6f24a,color:#b6f24a
    style M fill:#0c1a1c,stroke:#42d9c0,color:#42d9c0

A DDoS cannot make Ghana Telecom invent a bad route. That misconfiguration already existed, and the detector spotted it at 01:13, sixteen hours before the evening outage. But a DDoS absolutely can delete all the better paths, and suddenly that quiet mistake becomes the road everyone drives down.


What MEO and the regulator finally said

On Tuesday, MEO published a much more detailed statement, and it’s worth quoting properly. Via RTP/Lusa:

Os incidentes “ocorreram nos períodos, 00:45-02:55, 17:30-19:30 e 22:45-01:05, tendo como alvo específico infraestrutura da Meo, e envolveram elevados volumes de tráfego que provocaram congestionamento e degradação temporária da rede internacional”.

And on the BGP question specifically:

“Instabilidade nos encaminhamentos BGP foi consequência do ataque e não a sua causa.”

(“BGP routing instability was a consequence of the attack, not its cause.”)

ANACOM, the Portuguese regulator, backed this up. It told Expresso that a cyberattack is confirmed, and that a DDoS aimed specifically at MEO’s international interconnections is the probable cause while analysis continues. They also noted the congestion caused knock-on degradation at other operators’ interconnections, which explains why NOS and Vodafone customers saw a few hundred complaints each despite not being attacked.

The incident was reported to Portugal’s CNCS (national cybersecurity centre), as legally required.

Final scoreboard:

ClaimVerdict
Nationwide outage was real✅ Confirmed independently by IODA
Caused by targeted DDoS✅ Confirmed by MEO telemetry, probable per ANACOM
Massive BGP churn happened✅ Confirmed by RIPE RIS
BGP churn caused the outage❌ MEO says consequence, not cause
Ghana route leak existed✅ Real, Cloudflare Radar
Ghana leak caused the outage❌ 1,280 Ghanaian addresses can’t do that
Leaked prefixes identified✅ All 5, from raw RIPE MRT dumps
Customer data compromised❌ None per MEO, no independent audit

What we still don’t know

I want to be honest about the holes, because a lot of incident write-ups quietly paper over them.

  • How big was the attack? No bandwidth figure, no packet rate. 200 Gbps or 2 Tbps? Unknown.
  • What kind of attack? UDP flood? DNS reflection? Carpet bomb across many prefixes? Unknown.
  • Who did it? No attribution, no claimed responsibility.
  • What exactly was targeted? “International interconnections” is vague. Which routers? Which interfaces?
  • Did MEO actually install the Ghana path? We can see MEO propagated it onward (that’s how RIS peers recorded it), so it was in the table. What we can’t see is how much traffic, if any, followed it.
  • Why did Ghana Telecom leak? A filter change, a session reset, an automation bug? The five-times prepending suggests someone was already mid-fire-fight, but that’s inference.
  • How many customers were affected? MEO declined to say.

What would settle it: NetFlow samples, router logs with timestamps, BGP session state history, and the attack traffic profile. Standard post-incident report material. Whether MEO publishes one is another question.


Try it yourself

Everything here came from free, public, no-authentication APIs. If you want to poke at your own ISP:

Terminal window
# What's my ISP's AS number and who owns it?
curl -s "https://stat.ripe.net/data/as-overview/data.json?resource=AS3243" | jq .data
# What address blocks do they announce?
curl -s "https://stat.ripe.net/data/announced-prefixes/data.json?resource=AS3243" \
| jq -r '.data.prefixes[].prefix'
# Who do they connect to?
curl -s "https://stat.ripe.net/data/asn-neighbours/data.json?resource=AS3243" \
| jq .data.neighbour_counts
# Is a route cryptographically authorised? (RPKI)
curl -s "https://stat.ripe.net/data/rpki-validation/data.json?resource=AS3243&prefix=194.65.0.0/16" \
| jq '.data | {status, validating_roas}'

That last one returns:

{
"status": "valid",
"validating_roas": [
{ "origin": "3243", "prefix": "194.65.0.0/16", "max_length": 24, "validity": "valid" }
]
}

Which means MEO has published a signed statement saying “AS3243 is allowed to announce this”. RPKI is the partial fix for BGP’s trust problem, it can stop a stranger claiming your addresses, though it doesn’t stop leaks like Ghana’s, because the origin there was still legitimate.

And a fun one, confirm these prefixes hold actual machines:

Terminal window
$ getent ahostsv4 www.meo.pt
194.65.61.30 STREAM www.meo.pt # inside 194.65.0.0/16
$ dig +short -x 2.80.92.107
bl19-92-107.dsl.telepac.pt. # someone's actual home DSL line
$ dig +short -x 85.240.0.1
bl7-0-1.dsl.telepac.pt. # inside 85.240.0.0/13

Those bl…dsl.telepac.pt names are residential broadband lines. When the graphs above flatlined, that’s whose evening got ruined.

Going deeper: read the raw BGP yourself

If you want to do what I did in the Ghana section, it’s three commands:

Terminal window
# 1. get the tool
yay -S bgpdump # or: sudo apt install bgpdump / brew install bgpdump
# 2. grab a five-minute slice of global BGP from any collector
curl -sO https://data.ris.ripe.net/rrc01/2026.09/updates.20260914.1730.gz
# 3. hunt for any AS adjacency you care about
bgpdump -m updates.20260914.1730.gz | grep '29614 37468'

The -m output is pipe-delimited, so awk does the rest:

Terminal window
# field 2 = unix timestamp, 4 = peer IP, 6 = prefix, 7 = AS path
bgpdump -m updates.20260914.1730.gz \
| awk -F'|' '$7 ~ /29614 37468/ {print strftime("%H:%M:%S",$2,1), $6, $7}'

That’s the whole technique. Every route leak, hijack and outage since 1999 is sitting in that archive waiting to be read.


The takeaway

Three things worth remembering:

1. Shared dependencies are invisible until they break. MEO has serious diversity at the edge, Hurricane, Arelion, Cogent, Sparkle, RETN, exchange points in London, Frankfurt, Amsterdam, Madrid, Marseille. None of it helped, because residential, mobile and business all converge on AS8657 in the public routing view. Many external links, one logical failure domain.

2. Correlation during an incident is a trap. The Ghana leak was real, it was concurrent, both bursts landed inside the attack windows, and it still wasn’t the cause. If you’d stopped investigating the moment you found it, you’d have published something confidently wrong. Two things can be true at once.

And “I can’t find out” is usually a tooling problem, not a data problem. The summary APIs could not name those five prefixes, they physically don’t carry that view. bgpdump and 122 MB of raw dumps named all five, both origins, and the timestamps to the second.

3. You can check this stuff yourself. RIPE, IODA, Cloudflare Radar, PeeringDB, all free, all open, all queryable from a laptop with curl. The internet’s routing system is remarkably transparent if you know where to look. You don’t have to take anyone’s word for it, including mine.


Sources

Operator and regulator

Measurement systems I queried

Background reading on BGP


All measurements in this post were taken on 14-15 September 2026 from public APIs. Timestamps from IODA and RIPE are UTC; WEST is UTC+1. Where I’ve inferred something rather than measured it, I’ve said so. Corrections welcome, the whole point is that you can re-run the commands.