Summary
A single gossipsub peer can grow a node's topics map without bound by flooding subscription RPCs, because the default configuration has no global, per-peer, or topic-length cap and peer scoring does not penalise subscription volume. The peer is never greylisted, so the growth continues toward OOM.
Details
handleReceivedSubscription (packages/gossipsub/src/gossipsub.ts:1028, line 1036) runs this.topics.set(topic, new Set()) for every distinct topic string:
private handleReceivedSubscription (from, topic, subscribe) {
let topicSet = this.topics.get(topic)
if (subscribe) {
if (topicSet == null) {
topicSet = new Set()
this.topics.set(topic, topicSet) // no global cap, no per-peer cap
}
topicSet.add(from.toString())
}
}
The only topical gate (gossipsub.ts:981, if (this.allowedTopics != null && !this.allowedTopics.has(topic)) return) is skipped when allowedTopics is null — the default. Subscriptions are unsigned RPC metadata (no signature needed); decodeRpc's per-RPC limit caps count (maxSubscriptions = 5000) but not topic-string length or aggregate size; and the peer-scoring components P1–P7 have no subscription-volume term, so the attacker's score stays at 0 (above the graylistThreshold of −80) and acceptFrom keeps returning true. Verified on js-libp2p commit a04f5e0, where @libp2p/gossipsub reads 16.0.4.
PoC
Full PoC attached (gossipsub-topics-unbounded-growth-poc.tar.gz). Build the js-libp2p monorepo at the affected version (npm install --ignore-scripts && npm run build), extract the package inside the monorepo root (sibling of packages/), then cd gossipsub-topics-unbounded-growth-poc && node attack.mjs. Expected (default config):
[*] sending 300 RPC frames x 4000 unique ~190-byte topic subscriptions (1,200,000 topics total)
[*] progress: 88% sent | victim topics=1,032,000 rss=740MB peers=1
[*] victim quiesced: topics=~1,100,000 rss=~870MB peers=1
[!] Vulnerability reproduced: one connected peer grew the victim's gossipsub topics map to ~1.1M entries (RSS +~900MB) with 300 subscription RPCs — no global, per-peer, or topic-length cap in the default config
Control run (ALLOWED_TOPICS=legit-topic, i.e. node attack.mjs control): the identical flood is rejected at the allowedTopics gate and the topics map stays at 0 entries — confirming the vulnerable condition is the default allowedTopics-unset config. The map grows monotonically with no cap until the victim's own resource pressure trips; on disconnect removePeer performs an O(N) sweep over every topic entry (a secondary CPU cost) while the ~1 GB heap stays committed.
Impact
CWE-400/CWE-770. Every node running default gossipsub is affected; the attacker needs only one connection and is never rate-limited or greylisted. Growth continues toward OOM; on disconnect the O(N) removePeer sweep adds CPU cost. Suggested fix: bound by default (not opt-in) — a global cap on topics.size, a per-peer subscription cap with a P7 behaviour penalty so acceptFrom eventually greylists, a topic-string length cap at decode time, and/or flipping allowedTopics to deny-by-default; make removePeer's topic iteration non-blocking/capped.
gossipsub-topics-unbounded-growth-poc.tar.gz
Summary
A single gossipsub peer can grow a node's
topicsmap without bound by flooding subscription RPCs, because the default configuration has no global, per-peer, or topic-length cap and peer scoring does not penalise subscription volume. The peer is never greylisted, so the growth continues toward OOM.Details
handleReceivedSubscription(packages/gossipsub/src/gossipsub.ts:1028, line 1036) runsthis.topics.set(topic, new Set())for every distinct topic string:The only topical gate (
gossipsub.ts:981,if (this.allowedTopics != null && !this.allowedTopics.has(topic)) return) is skipped whenallowedTopicsis null — the default. Subscriptions are unsigned RPC metadata (no signature needed);decodeRpc's per-RPC limit caps count (maxSubscriptions = 5000) but not topic-string length or aggregate size; and the peer-scoring components P1–P7 have no subscription-volume term, so the attacker's score stays at 0 (above thegraylistThresholdof −80) andacceptFromkeeps returning true. Verified on js-libp2p commita04f5e0, where@libp2p/gossipsubreads16.0.4.PoC
Full PoC attached (
gossipsub-topics-unbounded-growth-poc.tar.gz). Build the js-libp2p monorepo at the affected version (npm install --ignore-scripts && npm run build), extract the package inside the monorepo root (sibling ofpackages/), thencd gossipsub-topics-unbounded-growth-poc && node attack.mjs. Expected (default config):Control run (
ALLOWED_TOPICS=legit-topic, i.e.node attack.mjs control): the identical flood is rejected at theallowedTopicsgate and the topics map stays at 0 entries — confirming the vulnerable condition is the defaultallowedTopics-unset config. The map grows monotonically with no cap until the victim's own resource pressure trips; on disconnectremovePeerperforms an O(N) sweep over every topic entry (a secondary CPU cost) while the ~1 GB heap stays committed.Impact
CWE-400/CWE-770. Every node running default gossipsub is affected; the attacker needs only one connection and is never rate-limited or greylisted. Growth continues toward OOM; on disconnect the O(N)
removePeersweep adds CPU cost. Suggested fix: bound by default (not opt-in) — a global cap ontopics.size, a per-peer subscription cap with a P7 behaviour penalty soacceptFromeventually greylists, a topic-string length cap at decode time, and/or flippingallowedTopicsto deny-by-default; makeremovePeer's topic iteration non-blocking/capped.gossipsub-topics-unbounded-growth-poc.tar.gz