Skip to content

Build1 publisher3 min readPublished

py-libp2p prices k-bucket slots in subnets, not node IDs

A proposed change to py-libp2p's routing table caps peers per /24 inside each bucket. Signed records never addressed eclipse, because withholding data needs no forgery.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • 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.
  • In Kademlia you find a peer or record by asking the nodes closest to the target, who point you closer until you arrive; it works because of the assumption that the peers in your routing table are a fair sample of the network.
  • An eclipse attack breaks that assumption: if an attacker gets nodes into enough routing buckets, specifically the closest-K slots for a target key, they break no crypto but answer every lookup, and can hide records, feed stale routing, or silently partition the victim from the real DHT while the victim is still online and still connected.
  • The DHT already has an integrity story: records are signed. Record signing authenticates content, so a DHT record can be verified as produced by the owning key and forged values cannot be fed to a node.
  • Eclipse attacks membership rather than content: the attacker forges nothing, ensures the only peers asked are theirs, and answers with perfectly valid, perfectly signed records but never the whole set. Withholding has no signature; silence has no signature.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

py-libp2p has a pull request, libp2p/py-libp2p#1399, that changes how a peer earns a slot in a Kademlia k-bucket: `KBucket.add_peer` now rejects a candidate when its globally-routable /24 (IPv4) or /48 (IPv6) subnet already holds `MAX_PEERS_PER_SUBNET` peers in that bucket, default 2 [11]. It closes issue #1383, and it matters because the DHT's existing integrity story, signed records, was never the control that stopped an eclipse [11][4][5].

The distinction is worth being precise about. py-libp2p speaks Kademlia, where each node keeps a routing table of other peers bucketed by how far their IDs sit from its own, and lookups walk toward a target by asking successively closer peers [1][2]. Record signing answers "did the owning key produce this value," so forged values are not available to an attacker [4]. An eclipse answers a different question: membership. According to the dev.to write-up, an attacker who occupies enough of the closest-K slots for a target key never forges anything, and instead returns perfectly valid signed records that are never the whole set [3][5]. Withholding has no signature, and neither does silence [5]. The victim stays online and stays connected [3].

So the surface is admission [6]. In vanilla Kademlia a peer gets a slot mostly by being live and having an ID that lands in range, and IDs are cheap enough to grind in bulk, so a Sybil fleet parked in one attacker-controlled subnet satisfies the rule [10]. The resource the attacker would actually have to spend, distinct network positions, was not priced at all [10]. The code comment accompanying the change makes the economic argument explicit: a rented cloud block is typically a /24 rather than a scattering of unrelated addresses [12].

The author drove the fork's real `KBucket.add_peer` at k = 20 with Sybil peers from attacker-controlled /24s, comparing `MAX_PEERS_PER_SUBNET` at 0, the pre-#1399 behaviour, against the default of 2, and describes it as a component-level measurement isolating the admission rule [8]. Before, one rented /24 owns the whole bucket; after, ten genuinely distinct networks are needed for the same result, which is what 20 slots at 2 per subnet arithmetically requires [9][13]. That is a real change in cost, and it is also the honest limit of the claim. It is a measurement of who gets admitted, not of how many lookups an attacker captures, even though the write-up frames the harness question as capture rate [8][17].

There is a separate harness: an eclipse-attack simulation living at `tests/examples/attack_simulation/eclipse_attack/`, which stands up honest `KadDHT` nodes, floods their routing tables and poisons DHT entries from malicious peers, and uses a `RealAttackMetrics` collector to run real lookups and record success rate as the attack takes hold [7]. That is the artifact that could turn the subnet number into an operational one.

Two things to watch. First, whether full bucket capture is the right bar: at 2 peers per subnet, half a bucket costs 5 subnets, so partial capture scales down proportionally [14]. Second, whether the limit is scoped to globally-routable space only, which is what the rule as written says [11], and how relays, NAT and shared egress interact with it. The post was written for DEV's Summer Bug Smash, powered by Sentry [15].

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories