Summary
A remote peer can stall a js-libp2p node's entire event loop for ~4–5 seconds per connection (and transiently allocate ~2 GB) by sending a ~15-byte crafted publicKey inside an Identify message. The malformed key reaches the DER decoder before any peer-id/signature check, so the attack is pre-auth and repeatable indefinitely by reconnecting.
Details
packages/crypto/src/keys/rsa/der.ts readLength() parses DER long-form lengths with no comparison to buf.byteLength, and readInteger() then loops over the attacker-claimed length:
function readInteger (buf, context) {
const length = readLength(buf, context) // attacker-controlled, claims up to 2^64
const end = context.offset + length
for (let i = context.offset; i < end; i++) // runs far past buf.byteLength
vals.push(buf[i]) // pushes `undefined` past the end
}
The blob reaches the parser pre-auth: Identify runs on every connection:open and calls publicKeyFromProtobuf(publicKey) (packages/protocol-identify/src/identify.ts:85) before the connection.remotePeer.equals(id) check at identify.ts:88. The MAX_RSA_JWK_SIZE guard in pkixToRSAPublicKey only bounds the total blob size, not the inner length field, so a 15-byte payload defeats it; the ECDSA path (unmarshalECDSAPublicKey) has no cap at all. The decode runs in a single synchronous tick, so no AbortSignal — including Identify's 5 s timeout — can interrupt it. Verified on js-libp2p commit a04f5e0, where @libp2p/crypto reads 5.1.21.
PoC
Full PoC attached (libp2p-crypto-der-eventloop-dos-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 libp2p-crypto-der-eventloop-dos-poc && node attack.mjs. Expected:
[*] round 1: payload sent=true, victim event-loop stall = 4s, peak RSS ~1980 MB
[*] round 2: payload sent=true, victim event-loop stall = 4-5s, peak RSS ~1980 MB
[*] round 3: payload sent=true, victim event-loop stall = 4s, peak RSS ~1980 MB
[!] Vulnerability reproduced: 3/3 connections each stalled the victim's event loop for ~4-5s — one crafted ~15-byte Identify publicKey per connection, pre-auth
The stall is measured from the victim's own 1 s heartbeat clock (frozen while the loop is blocked); the ~2 GB transient RSS peak is read from /proc/<pid>/status. The DER RangeError is swallowed by Identify's .catch(() => {}), so the node survives and every new connection re-stalls it; under a container memory limit the ~2 GB spike OOM-kills the process. A node attack.mjs control run (legitimate Identify) shows 0 s stalls.
Impact
CWE-130/CWE-400. Any node running the default Identify service is affected. A single peer can keep the node unresponsive to all of its peers (every new connection adds another multi-second stall — all timers, heartbeats, and protocol timeouts stop) and, in memory-constrained deployments, force an OOM kill. Suggested fix: in readLength, reject any long-form length whose value exceeds buf.byteLength - context.offset; in readInteger/readObjectIdentifier/readBitString/readOctetString, clamp or assert end/finalOffset against buf.byteLength before looping; add an overall DER-element-length sanity cap at decodeDer entry and a byte-length cap in unmarshalECDSAPublicKey.
libp2p-crypto-der-eventloop-dos-poc.tar.gz
Summary
A remote peer can stall a js-libp2p node's entire event loop for ~4–5 seconds per connection (and transiently allocate ~2 GB) by sending a ~15-byte crafted
publicKeyinside an Identify message. The malformed key reaches the DER decoder before any peer-id/signature check, so the attack is pre-auth and repeatable indefinitely by reconnecting.Details
packages/crypto/src/keys/rsa/der.tsreadLength()parses DER long-form lengths with no comparison tobuf.byteLength, andreadInteger()then loops over the attacker-claimed length:The blob reaches the parser pre-auth: Identify runs on every
connection:openand callspublicKeyFromProtobuf(publicKey)(packages/protocol-identify/src/identify.ts:85) before theconnection.remotePeer.equals(id)check atidentify.ts:88. TheMAX_RSA_JWK_SIZEguard inpkixToRSAPublicKeyonly bounds the total blob size, not the inner length field, so a 15-byte payload defeats it; the ECDSA path (unmarshalECDSAPublicKey) has no cap at all. The decode runs in a single synchronous tick, so noAbortSignal— including Identify's 5 s timeout — can interrupt it. Verified on js-libp2p commita04f5e0, where@libp2p/cryptoreads5.1.21.PoC
Full PoC attached (
libp2p-crypto-der-eventloop-dos-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 libp2p-crypto-der-eventloop-dos-poc && node attack.mjs. Expected:The stall is measured from the victim's own 1 s heartbeat clock (frozen while the loop is blocked); the ~2 GB transient RSS peak is read from
/proc/<pid>/status. The DERRangeErroris swallowed by Identify's.catch(() => {}), so the node survives and every new connection re-stalls it; under a container memory limit the ~2 GB spike OOM-kills the process. Anode attack.mjs controlrun (legitimate Identify) shows 0 s stalls.Impact
CWE-130/CWE-400. Any node running the default Identify service is affected. A single peer can keep the node unresponsive to all of its peers (every new connection adds another multi-second stall — all timers, heartbeats, and protocol timeouts stop) and, in memory-constrained deployments, force an OOM kill. Suggested fix: in
readLength, reject any long-form length whose value exceedsbuf.byteLength - context.offset; inreadInteger/readObjectIdentifier/readBitString/readOctetString, clamp or assertend/finalOffsetagainstbuf.byteLengthbefore looping; add an overall DER-element-length sanity cap atdecodeDerentry and a byte-length cap inunmarshalECDSAPublicKey.libp2p-crypto-der-eventloop-dos-poc.tar.gz