Hardening the Kademlia DHT against eclipse attacks — three issues I want to work on #1386
yashksaini-coder
started this conversation in
Ideas
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
-
Hey everyone 👋
I've been spending time reading through the Kademlia DHT implementation and thinking about a class of attacks that I don't think we're currently defended against. I wrote up my findings in a blog post — Hardening Kademlia DHT: The Eclipse Attack That Record Signing Doesn't Stop — but I wanted to bring it here for a proper discussion before opening PRs, because some of these decisions genuinely need maintainer input.
The short version: an attacker can grind valid peer IDs (peer ID = multihash(pubkey), which is free to mint) until many of them land near a target key T in XOR space, then run all of them from a handful of IPs or subnets. Once those Sybil peers occupy the closest-K slots for T in honest nodes' routing tables, they can eclipse the key — either poisoning what's returned, or silently answering "no record" and starving legitimate lookups. The important thing I want to highlight is that record signing (#1338) doesn't stop this — the messages from these peers are authentically signed, the attack lives in the routing layer, not the record layer.
I filed three issues capturing specific fixes (#1383, #1384, #1385), but I want to use this discussion to explain the reasoning behind all three in one place, and to get your thoughts before I write any PRs.
1. Subnet diversity in k-buckets (#1383)
This is the most direct mitigation. Right now
KBucket.add_peeraccepts any peer regardless of where it's coming from on the network. If we cap how many peers from the same IP subnet can occupy a bucket — say, two peers per/24for IPv4 — an attacker has to actually control diverse IP space to pull off an eclipse, not just spin up many keys from one VPS.I've already implemented this locally. The logic is simple: before accepting a new peer, extract the subnet prefix from its address and check if that subnet already has
MAX_PEERS_PER_SUBNETentries. If it does, reject. Loopback and link-local addresses are exempt, so CI and local testnets aren't affected at all.The part I'm less certain about is the prefix length. I went with
/24for IPv4 because it's stricter — a/24is typically the smallest block independently routed, so staying within one/24usually means you're on one operator's infrastructure. But go-libp2p uses/16, which is more lenient. I'd love to hear whether/24feels too aggressive for how py-libp2p is actually deployed, or if/16is the safer starting point for compatibility.The other open question is whether we should just reject outright (what I did), or do something smarter like preferring longer-lived peers when a subnet is saturated — evicting the youngest existing peer in that subnet to make room for a new one, but only if the new one has been seen for longer. That's more complex, and I honestly think reject-outright is fine for a first pass, but I wanted to raise it.
2. Disjoint lookup paths / randomized candidate selection (#1384)
The current iterative lookup in
find_closest_peers_networkalways picks the ALPHA XOR-closest unqueried peers each round, deterministically. That means an attacker who places Sybil peers at the closest XOR positions gets queried on every single round — they can steer the entire path from hop one.I've already landed a small fix locally: instead of slicing strictly
[:ALPHA], we now draw ALPHA peers randomly from the closest-K window. Convergence is preserved because we're still drawing from peers we already know are close; we just stop handing the attacker a guaranteed query slot every round.The bigger thing — true disjoint paths, where you run D parallel isolated lookups with separate candidate lists and queried-peer sets, then merge at the end — is what rust-libp2p ships as
disjoint_query_paths. I want to implement this, but it's a more substantial refactor of the lookup function and I wanted to check a few things first:disjoint_paths: intonKadDHT.__init__now with a default of 1 (current behavior), so the API surface is stable before the implementation lands? Or would you rather we not add the parameter until the implementation is actually there?Also worth noting: the attack simulation tests in
tests/examples/attack_simulation/use Python-string fake IDs and never actually touchKBucketorRoutingTable. They can't validate a real fix. I think we should update those to use real routing table instances as part of this work — happy to include that in the PR if that sounds right to you.3. Monotonic sequence numbers for silent-withholding detection (#1385)
This one is deliberately a discussion, not a plan. Even with subnet diversity and disjoint paths, an attacker who legitimately occupies the closest-K set for a key can still withhold silently — just answer "no record" — and that's currently indistinguishable from a key that was never published.
The idea I want to float: if the publisher signs a monotonic sequence number into the record, and resolvers track the highest sequence number they've ever seen per key, then silent withholding degrades from "invisible" to "serving a stale record" — which is at least detectable. go-libp2p's
record.Validatoralready has aSelectmethod for exactly this; the IPNS validator uses higher-sequence-wins preference. We havelibp2p/records/ipns.pyandlibp2p/records/validator.pyas the analogous entry points.But this touches the record format, which means a proto-level change and potential interop questions with go-libp2p. I don't want to move on this without a real conversation about:
Recordwithout breaking cross-implementation compatibilityI'm happy to write a spec sketch if the direction feels worth pursuing, but I genuinely don't know enough about the interop constraints to commit to an approach without input from people who've worked on this longer than I have.
What I'm asking for
I'm not asking for anyone to review code yet. I mainly want to know:
/24or/16for IPv4? Reject-outright or prefer-longer-lived?I'm actively working on this codebase and I'd like to turn these into proper PRs, but I want to make sure I'm building the right thing before I put in the work. Thanks for reading this far.
— @yashksaini-coder
/cc @acul71 @sumanjeet0012 @asmit27rai @seetadev
Beta Was this translation helpful? Give feedback.
All reactions