Skip to content

quic: NULL dereference in Session::UpdateDataStats when impl_ is null #62057

@masakielastic

Description

@masakielastic

Version

Node.js v25.4.0

Platform

Debian 12 Bookworm (Chromebook Crostini)

Subsystem

quic (src/quic/session.cc)

What steps will reproduce the bug?

import { connect } from "node:quic";

connect("139.162.123.134:443", {
  sni: "nghttp2.org",
  alpn: "h3-29",
  endpoint: { address: "0.0.0.0:0" },
  handshakeTimeout: 5000,
});

setInterval(() => {}, 1000).unref?.();

Run the script with experimental QUIC enabled:

node --experimental-quic test.mjs

Observe that the process crashes with SIGSEGV shortly after printing the “connect” line (no JS exception is raised).

Optional (to confirm the root cause with gdb):

gdb --args node --experimental-quic test.mjs

In gdb, set a temporary breakpoint at node::quic::Session::UpdateDataStats() (or the resolved address) and run:

info functions UpdateDataStats
tbreak *0xaf54e0   # replace with the address shown by the previous command
run

When the breakpoint hits, inspect this->impl_ (offset +0x88 on my build):

info registers rdi
x/gx $rdi+0x88

It shows 0x0 (NULL), and execution later crashes when UpdateDataStats() dereferences it.

How often does it reproduce? Is there a required condition?

The crash is deterministic in my environment.

It reproduces every time I run:

node --experimental-quic test.mjs

There is no need for repeated attempts; the process consistently crashes shortly after initiating the QUIC connection.

The crash occurs during early connection/handshake processing, specifically when Session::Application::SendPendingData() invokes Session::UpdateDataStats() while impl_ is still nullptr.

At this point, the required condition appears to be:

UpdateDataStats() being called before Session::impl_ has been initialized.

Further testing across different Node versions may help determine whether this is a regression or long-standing issue in the experimental QUIC implementation.

What is the expected behavior? Why is that the expected behavior?

Running node --experimental-quic test.mjs should either:

  1. Establish the QUIC session (or progress the handshake) and keep running, or
  2. Fail gracefully by rejecting/raising an error at the JS API boundary (e.g., emitting an 'error'/'close' event, rejecting the connect() promise if applicable, or otherwise surfacing a normal runtime error),

but it should not crash the Node.js process.

A native SIGSEGV is an unrecoverable failure that:

  • Prevents user code from handling errors or performing cleanup,
  • Breaks basic reliability expectations for a networking API (especially during common operations like connection/handshake),
  • Is inconsistent with typical Node behavior where transport/handshake errors are surfaced as errors/events rather than terminating the process.

In this specific case, Session::UpdateDataStats() unconditionally dereferences internal state (impl_) that can be NULL in some connection states. The expected behavior is that the implementation should either (a) ensure impl_ is initialized before UpdateDataStats() is called, or (b) defensively guard against impl_ == nullptr and surface connection failure through the normal error path instead of crashing.

What do you see instead?

Running the repro script with experimental QUIC enabled results in a native crash (SIGSEGV) shortly after starting the connection. The process terminates without a JS-level exception or a normal error event.

Under gdb, the crash is in node::quic::Session::UpdateDataStats():

Thread 1 "MainThread" received signal SIGSEGV, Segmentation fault.
0x0000000000af5521 in node::quic::Session::UpdateDataStats() ()

Backtrace (representative):

#0  node::quic::Session::UpdateDataStats()
#1  node::quic::Session::Application::SendPendingData()
#2  node::quic::Endpoint::Connect(...)

Disassembly shows the faulting instruction dereferencing a NULL-derived pointer:

mov 0x28(%rbx), %rax

At a breakpoint in UpdateDataStats(), the internal pointer at this + 0x88 is NULL (likely impl_ or equivalent), which leads to the subsequent NULL dereference and crash:

(gdb) info registers rdi
rdi = <valid Session*>
(gdb) x/gx $rdi+0x88
0x...: 0x0000000000000000

So instead of a recoverable error, Node terminates with a segmentation fault while updating QUIC stats.

Additional information

Additional investigation with gdb shows that inside
Session::UpdateDataStats(), this is valid, but
*(this + 0x88) is null at function entry.

This corresponds to impl_ in src/quic/session.cc.
The function then dereferences this pointer unconditionally
(auto& stats_ = impl_->stats_;), leading to a NULL
dereference and SIGSEGV.

The call stack shows that UpdateDataStats() is invoked
from Session::Application::SendPendingData() during
early connection processing.

This suggests either:

  • impl_ is not yet initialized when UpdateDataStats() is called, or
  • impl_ has been reset before all pending send operations complete.

Adding a null guard for impl_ inside UpdateDataStats()
would prevent the crash, though the underlying lifecycle
ordering issue may still need investigation.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions