DEV Community

Cover image for One rented /24 could eclipse a Kademlia node. Now it takes ten.
Yash Kumar Saini
Yash Kumar Saini Subscriber

Posted on

One rented /24 could eclipse a Kademlia node. Now it takes ten.

Summer Bug Smash: Smash Stories 🐛🛹

This is a submission for DEV's Summer Bug Smash: Smash Stories powered by Sentry.

The setting

py-libp2p speaks Kademlia — a distributed hash table where every node keeps a routing table of other nodes, bucketed by how far their IDs sit from its own. You find a peer or a record by asking the nodes closest to the target, who point you closer, and closer, until you arrive. It's elegant, and it works because of one quiet assumption: the peers in your routing table are a fair sample of the network.

An eclipse attack breaks exactly that assumption. If an attacker can get their nodes into enough of your routing buckets — specifically the closest-K slots for a target key — they don't need to break any crypto. They just surround you. Every lookup you make gets answered by them. They can hide records, feed you stale routing, or silently partition you from the real DHT. You're still online. You're still "connected." You're just connected to a lie.

Signing authenticates the record, not the routing table

The reason this one nags at me is that the DHT already has an integrity story: records are signed. So my instinct — and I don't think I'm alone — was that the surface was mostly covered. If a malicious peer can't forge a record, how much damage can it do?

A lot, it turns out, because signing and eclipse answer different questions.

Two different questions — signing only answers one

  • Record signing authenticates content. Hand me a DHT record and I can verify the owning key produced it. I can't be fed forged values.
  • Eclipse attacks membership. The attacker never forges anything. They make sure the only peers you ever ask are theirs, then answer with perfectly valid, perfectly signed records — just never the whole set. Withholding has no signature. Silence has no signature.

So the real surface isn't the record. It's how a peer earns a slot in a k-bucket.

Building the attack to measure it

I didn't want to argue this from a whiteboard, so I built an eclipse-attack simulation inside py-libp2p — it lives at tests/examples/attack_simulation/eclipse_attack/. It spins up a network of honest KadDHT nodes, introduces malicious peers that flood the honest nodes' routing tables and poison DHT entries, and then measures what actually degrades: a RealAttackMetrics collector runs real lookups against the network and records the success rate as the attack takes hold.

# tests/examples/attack_simulation/eclipse_attack/attack_scenarios.py
async def execute(self):
    async with trio.open_nursery() as nursery:
        for mp in self.malicious_peers:
            for target in self.honest_peers:
                nursery.start_soon(mp.poison_dht_entries, target)
                nursery.start_soon(mp.flood_peer_table,
                                   self.honest_peer_tables[target])
Enter fullscreen mode Exit fullscreen mode

The point of the harness is to turn "eclipse is possible" into a number: what fraction of lookups can an attacker capture, and how does that change once the routing table stops admitting them so cheaply?

How an eclipse fills a k-bucket — diverse subnets vs one attacker subnet

The point of the harness is to turn "eclipse is possible" into a number. I drove the fork's real KBucket.add_peer (k = 20) with Sybil peers from attacker-controlled /24s, running it with MAX_PEERS_PER_SUBNET set to 0 (the pre-#1399 behaviour) versus its default of 2. This is a component-level measurement — it isolates exactly what the admission rule changes:

Attacker capability Pre-#1399 (no filter) With #1399 (max 2 / subnet)
Bucket slots captured — flood from a single /24 20 / 20 (100%) 2 / 20 (10%)
Distinct /24s needed to fully own a 20-slot bucket 1 10
Share of slots taken when the bucket already holds 10 honest, subnet-diverse peers 50% 17%

Before the fix, one rented /24 owns the whole bucket. After it, an attacker needs ten genuinely distinct networks to do the same — the cost moved from "grind cheap IDs" to "rent diverse address space," which is the entire point.

The root cause

In vanilla Kademlia, a peer earns a bucket slot mostly by being live and having an ID that lands in range. Node IDs are cheap — you can grind as many as you want. So an attacker spins up a Sybil fleet and, crucially, parks them at IP addresses in a subnet they control. Their cheap resource is node IDs; the resource they'd actually have to spend is distinct network positions — and nothing in the base admission rule was pricing that.

A rented cloud block is typically a /24, not a scattering of unrelated addresses. That's the lever: if admission is priced in distinct subnets rather than distinct IDs, surrounding a node suddenly costs real, diverse address space the attacker can't cheaply conjure.

The fix — #1399: enforce IP subnet diversity in k-buckets

libp2p/py-libp2p#1399 (closes #1383) makes KBucket.add_peer reject a new peer when its globally-routable /24 (IPv4) or /48 (IPv6) subnet already holds MAX_PEERS_PER_SUBNET (default 2) peers in that bucket:

# IP/subnet diversity limits for k-buckets (issue #1383). Within one bucket,
# at most MAX_PEERS_PER_SUBNET peers may share the same globally-routable subnet.
# /24 matches realistic attacker economics — a rented cloud block is typically
# a /24, not a /16 — and avoids bundling an ASN dataset.
MAX_PEERS_PER_SUBNET = 2

def _subnet_key(peer_info: PeerInfo) -> str | None:
    for addr in peer_info.addrs:
        if "p2p-circuit" in str(addr):      # relayed addr = relay's IP, not the peer's
            continue
        for proto, prefix_len in (("ip4", SUBNET_PREFIX_LEN_V4),
                                  ("ip6", SUBNET_PREFIX_LEN_V6)):
            ...
            if not ip.is_global:            # loopback/private/CGNAT/link-local → exempt
                continue
            return str(ip_network(f"{ip}/{prefix_len}", strict=False))
Enter fullscreen mode Exit fullscreen mode

The details are where the real engineering is:

  • Only globally-routable addresses are grouped (ip.is_global) — loopback, RFC1918/ULA private, CGNAT (100.64.0.0/10), link-local, and documentation ranges are exempt, so CI and local testnets are unaffected.
  • DNS-named and relayed (p2p-circuit) peers are exempt — a circuit address exposes the relay's IP, not the peer's, so grouping on it would wrongly bundle distinct peers behind a shared relay.
  • Multi-homed peers are grouped by their first globally-routable address (a deliberate divergence from go-libp2p, which checks every address — simpler, and it avoids false rejections of legitimately multi-homed peers).
  • Reject-only, no eviction — matching go-libp2p's TryAddPeer, which avoids opening a churn/DoS vector where an attacker forces evictions.
  • Opt-out via MAX_PEERS_PER_SUBNET <= 0, and a guard so a subnet rejection on a non-full bucket can't trigger a spurious bucket split.

No protocol or wire change — just a stricter admission rule at exactly the layer the eclipse attacks.

What I learned

The trap wasn't a bug in the code — it was a bug in the question. "Records are signed, so the DHT is safe" quietly swaps the question you need answered ("is this set of peers a fair sample?") for one you've already answered ("is this record authentic?"). Authentication and membership-diversity are orthogonal: signing is a real defense against forgery and no defense at all against being surrounded by valid liars.

The broader habit I'm keeping: when someone says "that's already handled," ask which property is handled. The most dangerous vulnerabilities live in the gap between two defenses that each look complete on their own.

Links

Top comments (0)