Skip to main content

🪐 Backed by $16.5M to build the security workforce for the security workforce. Meet the new Cantina.

Cantina research: Two Node.js Bugs You Should Know About

Two Node.js vulnerabilities can replay SQLite writes or crash workers through oversized DNS responses. See the affected paths, fixed releases, and mitigations.

Cantina 7 min read
Node.js CVE-2026-58041 CVE-2026-58042 node:sqlite DNS vulnerability disclosure

Two bugs in Node.js crossed different trust boundaries. One lets a stale SQLite iterator run a later write again. The other aborts a Node.js worker when DNS returns more than 256 A records.

The Node.js project fixed both issues in the July 29, 2026 security release. The release tracks them as CVE-2026-58041 and CVE-2026-58042.

The code looks separate at the JavaScript layer. Underneath, both paths rely on native state or assumptions that outlive the call that created them.

These findings extend Cantina’s earlier Node.js research into three trust-boundary vulnerabilities across DNS resolution, SNI context selection, and TLS hostname verification.

Finding 1: node:sqlite iterator replay can re-execute writes

Medium DatabaseSync#createTagStore() / StatementSyncIterator • CVE-2026-58041 • Node.js advisory

SQLTagStore caches prepared statements by SQL query text. That improves performance, but calls that use the same query also share the native statement behind the cache.

The failure starts when one call creates an iterator and another call resets the cached statement. SQLTagStore calls sqlite3_reset() directly. That path does not update the generation counter that invalidates direct StatementSync iterators.

The old iterator remains valid after the second caller binds new values. A later iterator.next() can step the newly bound statement. If the statement writes to the database, the stale iterator runs that write again.

The iterator also returns { done: true } without setting its internal done_ flag. A later call can start the statement again. The replay can therefore continue instead of stopping after one extra execution.

Vulnerable application setup

The following service uses one tag store for an arm endpoint, a payment endpoint, and a fire endpoint:

const { DatabaseSync } = require('node:sqlite');

const db = new DatabaseSync(':memory:');
const sql = db.createTagStore();
let armedIter = null;

function arm(from, to, amount) {
  armedIter = sql.iterate`
    INSERT INTO transfers(from_user, to_user, amount)
    VALUES (${from}, ${to}, ${amount})
    RETURNING id
  `;
}

function pay(from, to, amount) {
  return sql.get`
    INSERT INTO transfers(from_user, to_user, amount)
    VALUES (${from}, ${to}, ${amount})
    RETURNING id
  `;
}

function fire(count) {
  const results = [];
  for (let i = 0; i < count; i++) {
    results.push(armedIter.next());
  }
  return results;
}

The attacker calls arm() and leaves the iterator untouched. A victim then calls pay() with the same tagged query. The tag store resets and rebinds the shared prepared statement with the victim’s values.

The attacker calls fire(). The stale iterator advances the statement that now contains the later bound values.

Observed result

The end-to-end test uses a transfer trigger that debits the source account and credits the destination account:

Initial balances:
victim: 1000  attacker: 0

After the victim makes one transfer:
victim: 900  attacker: 100

Stale iterator results:
{ done: false, value: { id: 2 } }
{ done: true, value: null }
{ done: false, value: { id: 3 } }

Final balances:
victim: 700  attacker: 300

The application must expose a long-lived iterator or equivalent state across requests for this attack. The Node.js bug does not create that endpoint by itself.

The replay uses the later caller’s bound parameters. The destination is therefore not always the attacker’s account. The bug can replay a victim-bound write without authorization. That can mean duplicate transfers, repeated token issuance, inventory changes, quota consumption, or repeated permission grants.

Why the cache boundary matters

The direct StatementSync path uses a reset-generation counter. When another operation resets the statement, the iterator detects the new generation and throws ERR_INVALID_STATE.

SQLTagStore bypasses that protection. It reuses the prepared statement but does not tell existing iterators that the statement changed. The API returns an iterator that looks independent, while the native implementation keeps it attached to shared cached state.

Finding 2: dns.resolveAny() can abort the process

Medium dns.resolveAny() / c-ares response parsing • CVE-2026-58042 • Node.js advisory

Node.js implements dns.resolveAny() through c-ares. The parser allocates a fixed 256-entry ares_addrttl[] buffer for A-record TTL values.

c-ares honors the caller’s limit for the TTL buffer. It can still expose more addresses through hostent->h_addr_list. Node.js counts all addresses and compares that count with the number of TTL entries.

With 257 A records, the values differ:

naddrttls = 256
a_count   = 257

The parser requires the values to match:

CHECK_EQ(static_cast<uint32_t>(naddrttls), a_count);

The failed check calls the Node.js assertion handler. The process terminates with SIGABRT. A JavaScript catch handler does not get a chance to handle the failure.

Vulnerable application setup

The application only needs to pass an attacker-controlled hostname to resolveAny():

const { Resolver } = require('node:dns').promises;

const resolver = new Resolver();
resolver.setServers(['127.0.0.1:5399']);

resolver.resolveAny('crash.attacker.example')
  .then(records => console.log(records.length))
  .catch(error => console.error(error));

The test DNS server returns 257 A records for the query. It can answer over UDP and TCP, so the test does not depend on the transport selected by the resolver.

Observed result

The child Node.js process prints the failing assertion and exits with SIGABRT:

Node v24.15.0
DNS server returning 257 A records

Assertion failed: (static_cast<uint32_t>(naddrttls)) == (a_count)

exit: null / signal: SIGABRT

An attacker can repeat the trigger when a service resolves attacker-controlled names. Each trigger terminates the worker that performs the lookup. A supervisor can restart the worker, but repeated requests can still disrupt the service.

The shared lesson: native state is part of the security boundary

These vulnerabilities do not depend on SQL injection or memory corruption.

The SQLite issue crosses a lifetime boundary. JavaScript holds an iterator while native code reuses the statement behind it.

The DNS issue crosses a data-shape boundary. c-ares returns more addresses than the fixed TTL buffer can describe, while Node.js treats the two counts as equal.

In both cases, the public API hides the state that matters for security. SQLTagStore hides a shared prepared statement behind a template tag. resolveAny() hides native DNS parsing behind a JavaScript promise. The failure appears when native state no longer matches the JavaScript assumption.

An application can reach either path through a normal database write or DNS lookup when the required input and call sequence exist.

Who needs to review their applications

Start with applications that use DatabaseSync#createTagStore() for write queries. Review code that retains an iterator beyond the current function or request. Review code that allows one user or request to create an iterator and another request to use it.

Also review services that pass user-controlled or partner-controlled hostnames to dns.resolveAny(). Pay special attention to webhook processors, URL preview services, tenant integrations, and network inventory tools.

The key questions are:

  • Can an attacker supply the hostname passed to resolveAny()?
  • Can a service keep a SQLTagStore iterator alive across requests?
  • Can two request paths use the same cached SQL query?
  • Does a replayed write trigger a balance change, token issue, quota change, or permission change?
  • Does the service restart workers after a native abort?

What to do now

Upgrade Node.js to a fixed release:

  • Node.js 22.23.2
  • Node.js 24.18.1
  • Node.js 26.5.1

For CVE-2026-58041, do not expose long-lived SQLTagStore iterators across request or user boundaries. Use the fixed release before relying on application-level controls.

For CVE-2026-58042, do not pass attacker-controlled names to resolveAny() until the runtime includes the fix. If the service must resolve external names, apply a resolver policy that limits the records and isolates resolver failures from the main worker.

After the upgrade, test both paths again. A version check alone does not confirm that the application no longer reaches the affected call sequence.

The July 29, 2026 Node.js security release fixes both issues. Upgrade first, then review the application paths that keep these native boundaries reachable.

Close the security loop on your environment

These vulnerabilities lived at the boundary between JavaScript and native code, where the public API hid the state that created the risk.

Apex, our agentic OffSec engineer, investigates like an attacker, tracing subtle logic flaws and chained exploits through code and running systems. It proves what is exploitable, generates the fix, and verifies the remediation. See how Apex works.

Book a demo and see what Apex can find in your attack surface.

References