DEV Community

Cover image for Hardening Kademlia DHT: The Eclipse Attack That Record Signing Doesn't Stop
Yash Kumar Saini
Yash Kumar Saini Subscriber

Posted on

Hardening Kademlia DHT: The Eclipse Attack That Record Signing Doesn't Stop

Hardening Kademlia DHT: The Eclipse Attack That Record Signing Doesn't Stop

A few days ago I published Dev Log #9, which covered a security fix I landed in py-libp2p: binding a signed PeerRecord to its signer's identity so an attacker can't relay another peer's record as their own.

Then this comment came in from Valentyn Kit, a systems engineer:

"Binding the record to its signer kills the forged-record path, nice. The attack that tends to show up right after in Kademlia is eclipse/Sybil, where nobody forges anything: libp2p IDs are already pubkey-derived, but nothing makes it costly to mint a batch of valid IDs that land closest to a target key and quietly own that slice of the routing table. Curious whether py-libp2p has appetite for the S/Kademlia counters (crypto-puzzle IDs + disjoint lookup paths), or if that's considered out of scope for the DHT."

This is one of those comments where someone hands you a full threat model in two sentences. I spent the next few days digging into it — reading the S/Kademlia paper, tracing through py-libp2p's routing code, and checking what rust-libp2p has already shipped. Here's what I found.


What my fix actually closed

PR #1338 closed the record-forgery path. Before the fix, an attacker could inject a signed PeerRecord into the DHT claiming to represent peer X — even without X's private key. The fix ties the envelope's signer identity to the record payload, so maybe_consume_signed_record now rejects any record where those don't match.

That's a real fix. But Valentyn is pointing at something it doesn't touch at all.


The attack that signing doesn't stop

Here's the thing: the eclipse/Sybil attack doesn't need to forge anything.

A libp2p peer ID is multihash(pubkey). Generating a keypair takes microseconds. There is nothing preventing anyone from generating 10,000 valid, spec-compliant peer IDs in a few seconds.

Now here's why that matters for Kademlia. Kademlia routes by XOR distance — every node's routing table stores the peers closest to it in XOR space. For any target key T, I can generate keypairs in bulk, compute each one's XOR distance to T, and keep the ones that land closest. With enough trials I get 20 IDs that cluster tighter around T than any honest node. I connect them to the network, let them bootstrap normally, and now every honest node's routing table has my attacker nodes occupying the "closest to T" slots.

From that point:

  • Any FIND_NODE(T) returns my nodes as the nearest
  • Any GET_VALUE(T) routes through my nodes first
  • My nodes return each other as "even closer" — it's a closed loop
  • The honest node has no way to detect this

And critically: every message is signed by a valid keypair. The fix I landed passes all my attacker messages just fine, because they're all authentic. The attack lives in the routing layer, not the record layer.


What S/Kademlia proposes

The S/Kademlia paper (Baumgart & Mies, 2007) defines two specific counters for this class of attack.

1. Crypto-puzzle IDs

Instead of just PeerID = H(pubkey), require that the ID also satisfies a proof-of-work constraint:

PeerID = H(pubkey)
H(H(pubkey), nonce) < D     # PoW puzzle
Enter fullscreen mode Exit fullscreen mode

To generate an ID that lands near a specific target in XOR space, an attacker now has to solve 2^d extra puzzles per attempt. Minting thousands of targeted IDs goes from milliseconds to hours.

The problem: this is a dead end for py-libp2p.

libp2p peer IDs are specified uniformly across all implementations — go-libp2p, rust-libp2p, js-libp2p, nim-libp2p. They're all multihash(pubkey) with no additional constraints. If py-libp2p started rejecting peers without PoW IDs it would disconnect from every other node in the ecosystem. This needs a spec-level change with buy-in from every implementation. Not something one library can ship unilaterally.

2. Disjoint lookup paths

This one is different — and it's in scope.

Instead of one iterative lookup, run d ≥ 2 independent lookups, each starting from a disjoint partition of your initial closest-peer set:

  • Path 1 starts from peers [p1, p2, ..., pk/2]
  • Path 2 starts from peers [pk/2+1, ..., pk]
  • Peers discovered on path 1 are never introduced to path 2's candidate set, and vice versa
  • Results merge only at the end

To eclipse this, an attacker now needs to dominate both paths simultaneously. With d = 2 and bucket size k = 20, that's 40 targeted IDs instead of 20. With d = 3 it's 60. Each path multiplies the cost linearly.

The important thing: this requires zero protocol changes. It uses the existing FIND_NODE wire format. Other nodes don't need to implement it. It's a pure algorithmic change to how the lookup query is structured — which means it's entirely within the scope of a single-library contribution to py-libp2p.


Where py-libp2p stands today

I went through the routing code carefully. Here's the honest state:

Component File Status
Single-path iterative lookup kad_dht/peer_routing.py Implemented — one candidate set, converges on a single path
k-bucket with liveness eviction kad_dht/routing_table.py Implemented — pings oldest peer before evicting
Sliding-window ALPHA concurrency peer_routing.py Implemented — I shipped this in PR #1274
Eclipse attack simulation tests/examples/attack_simulation/eclipse_attack/ Simulation only — fake string IDs, never touches the real routing table
Sybil attack simulation tests/examples/attack_simulation/sybil_attack/ Simulation only — IDs are Python strings like "peer_sybil_0"
IP/subnet diversity in k-buckets routing_table.py Missing
Disjoint lookup paths peer_routing.py Missing
Any actual Eclipse/Sybil defense anywhere Missing

That last row is the one that matters. The simulation code — merged under issue #57 via PR #950 — shows what an attack looks like but doesn't implement any counter-measure. The MaliciousPeer class in malicious_peer.py creates fake IDs as Python strings; it never touches a real KBucket or RoutingTable. And issue #57 itself is still open — PR #950 never formally closed it.

The gap between "we have simulations" and "we have defenses" is exactly where the contribution lives.


What I want to build

For py-libp2p: disjoint lookup paths

The target is find_closest_peers_network in peer_routing.py. Right now it maintains one shared closest_peers list and one shared queried_peers set. All ALPHA-concurrent queries feed into the same pool. If an attacker owns the closest-k slots for a target, every round of the lookup converges deeper into the eclipse.

The change I'm planning:

  1. Partition the initial closest-peer set into d disjoint subsets (configurable, default d = 1 to preserve current behaviour)
  2. Spawn d parallel but isolated iterative lookups — each with its own candidate list and queried-peers tracker
  3. No peer crosses from one path to another mid-lookup
  4. Merge results by XOR distance at the end
  5. Expose disjoint_paths: int = 1 on KadDHT.__init__ so users opt in to d = 2 or d = 3

Files involved:

This would be the first actual eclipse mitigation (not simulation) in py-libp2p.

Secondary: IP/subnet diversity in k-buckets

In KBucket.add_peer in routing_table.py, before accepting a new peer, check whether its /24 subnet already has MAX_PEERS_PER_SUBNET entries. If yes, reject rather than evict an existing peer. This forces an eclipse attacker to control IPs across many different subnets — not just many keypairs from a single VPS.

go-libp2p enforces /16 subnet diversity. rust-libp2p currently has nothing at this level (more on that below). ~30–50 lines of Python.


What rust-libp2p has — and what it's missing

I checked rust-libp2p's Kademlia implementation while researching this, because it's the most production-hardened libp2p implementation.

The good news: rust-libp2p has had disjoint lookup paths since v0.20.0 (2020). The config flag is disjoint_query_paths(true) on libp2p_kad::Config. The number of paths equals the set_parallelism() value (ALPHA).

The bad news: it's opt-in, and almost no application enables it explicitly. More importantly, rust-libp2p has no IP/subnet diversity in kbucket.rs at all. The kbucket_inserts strategy only controls whether connected or disconnected peers are preferred — not IP locality. An attacker with a /24 full of targeted IDs faces zero additional resistance.

py-libp2p rust-libp2p
Disjoint lookup paths Missing Present, opt-in, off by default
IP/subnet diversity Missing Missing
Eclipse attack testbed (real nodes) Simulation only None

The Rust project: kad-eclipse-guard

Because rust-libp2p is missing the subnet diversity piece, and because I want empirical data before writing the py-libp2p PR, I'm planning a standalone Rust crate that uses rust-libp2p as a dependency.

The idea: build a real testbed that proves the attack works, shows what each defense does to the success rate, and ships a SubnetDiversityFilter behaviour that can eventually be proposed upstream.

[package]
name = "kad-eclipse-guard"
version = "0.1.0"
edition = "2021"

[dependencies]
libp2p = { version = "0.55", features = ["kad", "tcp", "noise", "yamux", "identify", "macros"] }
tokio = { version = "1", features = ["full"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4", features = ["derive"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
ipnet = "2"
rand = "0.8"
sha2 = "0.10"
libp2p-identity = "0.2"
Enter fullscreen mode Exit fullscreen mode

What it builds

Targeted ID generator (src/attack/targeted_id_gen.rs)

Generates Ed25519 keypairs whose PeerId lands XOR-closest to a target key. This is the core of the attack proof — 100k keypair trials in ~200ms, take the 20 closest to target T, done.

pub struct TargetedIdGenerator {
    target_key: Key<PeerId>,
    best_n: usize,
}

impl TargetedIdGenerator {
    pub fn generate(&self, trials: usize) -> Vec<Keypair> { ... }
}
Enter fullscreen mode Exit fullscreen mode

Subnet diversity filter (src/hardening/subnet_diversity.rs)

A NetworkBehaviour wrapper around libp2p_kad::Behaviour that enforces per-subnet caps at the NetworkBehaviour composition layer — no changes needed to upstream kad internals.

pub struct SubnetDiversityFilter<TStore> {
    inner: libp2p_kad::Behaviour<TStore>,
    subnet_counts: HashMap<IpNet, usize>,
    max_per_subnet: usize,   // default: 2 per /24
    prefix_len_v4: u8,       // default: 24
    prefix_len_v6: u8,       // default: 64
}
Enter fullscreen mode Exit fullscreen mode

When libp2p_kad fires RoutingUpdated for a new peer, the filter checks the peer's IP subnet against subnet_counts. If the subnet is saturated, it emits RemovePeer back to the inner behaviour.

Testbed (src/testbed/network.rs)

Runs 4 configurations with real rust-libp2p nodes over TCP:

Config disjoint_query_paths SubnetDiversityFilter
Baseline false off
Disjoint only true off
Diversity only false on
Hardened true on

Metrics collected per config: routing table contamination ratio, lookup success rate, time-to-eclipse (TTFE).

CLI

USAGE:
    kad-eclipse-guard [OPTIONS]

OPTIONS:
    --honest-peers <N>      [default: 50]
    --attacker-peers <M>    [default: 30]
    --trials <T>            keypair generation trials [default: 100000]
    --config <C>            baseline|disjoint|diversity|hardened|all
    --output <FILE>         JSON metrics output
Enter fullscreen mode Exit fullscreen mode

What this proves

  1. The attack is cheap. 100k keypair trials → 20 targeted IDs in ~200ms. No special hardware.
  2. Disjoint paths help but multiply rather than block. With parallelism=3 and disjoint enabled, the attacker needs 3× the IDs — still microseconds per ID.
  3. Subnet diversity raises the cost meaningfully. Filling a bucket of 20 across /24 caps of 2 requires IPs in at least 10 different subnets — that's a real operational barrier, not a CPU barrier.
  4. Combined is strongest. Attacker must simultaneously control many subnets AND eclipse all d independent paths.

What's off the table

Idea Why
Crypto-puzzle peer IDs Requires spec change + buy-in from all libp2p implementations
Reputation / trust scoring No spec, breaks with churn, too complex to get right
Proof-of-work ID validation Would reject every non-py-libp2p peer on the network
DHT access control Contradicts libp2p's open-participation design

The contribution path

  1. Build and benchmark kad-eclipse-guard with real rust-libp2p nodes — get hard numbers.
  2. Open a rust-libp2p discussion proposing SubnetDiversityFilter as a companion behaviour or eventual built-in, backed by the testbed metrics.
  3. Open a py-libp2p issue for disjoint lookup paths, referencing issue #57 — gauge maintainer appetite before writing code.
  4. Once there's signal, open the PR. Disjoint paths first (higher impact, no protocol change), subnet diversity second (smaller, easier to review standalone).

Wrapping up

Valentyn's comment identified something real: the fix I shipped in PR #1338 closes one attack surface but leaves the routing layer undefended. Record authentication and routing table hardening are separate problems, and py-libp2p currently has zero actual defenses for the second one — only simulations that never touch the real KBucket.

The path forward is concrete:

  • Disjoint lookup paths in peer_routing.py — in scope, no protocol changes, the biggest impact
  • Subnet diversity in routing_table.py — smaller, modeled on go-libp2p's approach
  • A Rust testbed to build the empirical case first

Issue #57 has been open since 2018. It asked for attack simulation (done, via PR #950) and security assessment (not done). The actual hardening is still waiting.

Top comments (0)