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 (5)
Disjoint paths harden the traversal, and the attack you open with is a terminus attack. Records for
Tlive at the closest-k, and every iterative lookup converges by XOR distance onto that same set, so partitioning the initial candidates buys independent intermediate hops while alldpaths still land in the bucket the grinder owns. S/Kademlia's guarantee is that a lookup survives when one path is adversary-free end to end. Once the closest-k slots forTare all attacker-held, none is, at anyd. Which makes the crypto puzzle awkward to drop, since it was the counter aimed squarely at cheap targeted IDs, and with it off the table (fairly, on ecosystem grounds) subnet diversity becomes the only in-scope defense that touches the attack the post opens with. Minting the ID stays free. Placing it stops being free. I would invert the contribution order for that reason.The filter defaults work against you there.
prefix_len_v6: 64capped at two peers is close to no cap, because one /56 delegation hands a single subscriber 256 distinct /64s and a /48 hands over 65,536.prefix_len_v4: 24is loose against go-libp2p's /16, and a cloud tenant's addresses tend to be scattered across /24s anyway. Cap at the delegation boundary rather than the interface.On the testbed: contamination ratio and time-to-eclipse both price the attacker's effort, and none of the four configs measure whether the honest node could tell it had been eclipsed. A
GET_VALUEeclipse never has to answer wrong. The adversarial closest-set answers "no record", and to the resolver that reads the same as a key nobody ever published. If the publisher signs a monotonic sequence number into the record and resolvers retain the highest seq seen per key, silent withholding degrades into serving a stale-but-valid record, which a client can at least flag. go-libp2p'srecord.Validatoralready carriesSelectfor this, and the IPNS validator uses it to prefer the highersequence. Worth a fifth metric: fraction of eclipsed lookups the resolver could detect as eclipsed.Interesting take on Kademlia's vulnerability to eclipse attacks even with signed records. In distributed systems I've worked on, routing table validation combined with entropy-based peer selection helped mitigate similar issues. It's a reminder that cryptographic guarantees don't always cover social or network-layer flaws.
Crypto-puzzle IDs only raise the cost of the initial land grab. If the difficulty is static an attacker grinds once and squats the bucket forever, so you need periodic re-randomization on top of the puzzle, not instead of it.
Great breakdown of why the record-signing fix doesn't fully close the attack surface. The disjoint-lookup-path approach is underrated â it requires zero protocol changes and scales linearly with the number of independent paths, which makes the attack cost O(d à k) rather than O(k).
One thing worth noting from rust-libp2p's implementation: they went a step further by randomizing the alpha concurrency source peers on each iteration, not just partitioning the initial candidate set. The S/Kademlia paper leaves this implicit, but it matters in practice â if your first-hop peers are all colluding or compromised, partitioning the candidate set doesn't help much if every path still originates from the same adversarial cluster.
The crypto-puzzle ID point is also correct that it needs ecosystem-wide buy-in, but it did get revisited in recent libp2p spec discussions with a "soft requirement" approach â nodes that present a PoW-derived ID get slightly higher bucket priority without rejecting non-PoW peers outright. Haven't seen it shipped in py-libp2p yet though.
How are you planning to simulate the eclipse condition for testing? I've found the hardest part isn't the fix itself but building a reproducible adversarial topology that doesn't accidentally create a realistic partition that could occur in the wild.
This is a useful reminder that integrity of records and integrity of neighborhood are different problems. Signing proves who wrote the record, but it does not prove the lookup path was honest. Distributed systems need threat models for routing surfaces, not only payload surfaces.