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
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:
- Partition the initial closest-peer set into
ddisjoint subsets (configurable, defaultd = 1to preserve current behaviour) - Spawn
dparallel but isolated iterative lookups — each with its own candidate list and queried-peers tracker - No peer crosses from one path to another mid-lookup
- Merge results by XOR distance at the end
- Expose
disjoint_paths: int = 1onKadDHT.__init__so users opt in tod = 2ord = 3
Files involved:
-
libp2p/kad_dht/peer_routing.py— the lookup refactor -
libp2p/kad_dht/kad_dht.py— expose the parameter -
libp2p/kad_dht/common.py— addDISJOINT_LOOKUP_PATHS = 1
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"
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> { ... }
}
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
}
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
What this proves
- The attack is cheap. 100k keypair trials → 20 targeted IDs in ~200ms. No special hardware.
-
Disjoint paths help but multiply rather than block. With
parallelism=3and disjoint enabled, the attacker needs 3× the IDs — still microseconds per ID. - 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.
-
Combined is strongest. Attacker must simultaneously control many subnets AND eclipse all
dindependent 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
- Build and benchmark
kad-eclipse-guardwith real rust-libp2p nodes — get hard numbers. - Open a rust-libp2p discussion proposing
SubnetDiversityFilteras a companion behaviour or eventual built-in, backed by the testbed metrics. - Open a py-libp2p issue for disjoint lookup paths, referencing issue #57 — gauge maintainer appetite before writing code.
- 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)