DEV Community

Cover image for PHP forgot to forget. Node.js remembered the random.
Ayoub Hammoudi
Ayoub Hammoudi

Posted on AI-assisted

PHP forgot to forget. Node.js remembered the random.

On 21 October, PHP and Node.js published security advisories on the same day. Two unrelated bugs in two runtimes that run a large part of the web. Read side by side, they are the same bug turned inside out: one runtime kept something it had promised to throw away, the other froze something it had promised would never repeat.

PHP forgot to forget

PHP's oldest promise is that every request starts clean. Nothing survives from one request to the next. It is why most of us stopped thinking about memory the day we wrote our first <?php.

CVE-2026-109619, "RCE via stale global state" (CVSS 9.2), breaks that promise. PHP keeps global state for errors, sessions, objects and extensions, and some of it can point into memory owned by the current request. That state has to be reset before the request memory is released.

A fatal error can skip the reset. So can running out of memory, which ends the request the same way. The request heap is freed, the worker's global still points at it, and a later request that lands on the same worker can read a pointer into memory that no longer belongs to anyone.

Its sibling, CVE-2026-109618, "RCE via OOM corruption" (also 9.2), sits on the same exit path. PHP leaves an out-of-memory error with longjmp(), and some code was never written to be abandoned halfway. The fix says a lot: PHP no longer jumps out on the spot. It raises a soft out-of-memory error at the next safe point in the VM, and keeps an immediate hard _exit() for a higher threshold.

The fault was never in the happy path. It was in the moment everyone treats as over: the crash.

Node.js remembered the random

Node's bug runs the other way. A startup snapshot freezes the heap of a warmed-up process so the next start can skip the warm-up. Single executable applications can ship one too.

CVE-2026-106074 describes what happens when that heap already holds random data. crypto.randomUUID() and randomInt() keep a cache of pre-generated entropy, the snapshot saves it, and every process started from it repeats the same values.

I wanted to see it, so I tried it on the Node 22.16.0 I had installed.

// entry.js
const { randomUUID } = require('node:crypto');
randomUUID();
require('node:v8').startupSnapshot.setDeserializeMainFunction(() => {
  console.log(randomUUID());
});
Enter fullscreen mode Exit fullscreen mode
$ node --snapshot-blob snap.blob --build-snapshot entry.js
$ for i in 1 2 3; do node --snapshot-blob snap.blob; done
ef9885a2-6c01-4412-abfd-85d0ea1888aa
ef9885a2-6c01-4412-abfd-85d0ea1888aa
ef9885a2-6c01-4412-abfd-85d0ea1888aa
Enter fullscreen mode Exit fullscreen mode

There is something quietly insulting about reading the same "random" UUID three times.

Then I counted. Across two processes started from the same snapshot, the first 127 UUIDs were identical, and so were the first 1023 values from randomInt(). The 128th UUID was different.

Those numbers are not an accident. randomUUID() fills a buffer for 128 UUIDs at a time (kBatchSize = 128). One was used before the snapshot, so 127 were frozen with the heap. randomInt() reads 6 bytes per attempt from a 6 KiB cache: 1024 slots, one used, 1023 frozen.

The fix is short. While Node builds a snapshot, it registers a callback that zero-fills both caches and resets their offsets after user serialization, and again before user deserialization, because microtasks and exit handlers can refill them in between.

What I take from it

Neither bug lives in code anybody reads twice. "Every request starts clean" and "random is different every time" are not features. They are assumptions so old that nobody writes them down, and you can't test what you never wrote down.

Both become cheap to test once you say them out loud. Build the snapshot, start it twice, assert the UUIDs differ. Kill request N with a fatal error and look at what request N+1 sees.

Mostly it reminded me where to look. The expensive bugs rarely sit in the path we watch. They wait in the moments we consider finished: the crash, the shutdown, the cleanup, the boot.

Patch

  • PHP: 8.2.35, 8.3.36, 8.4.27, 8.5.12
  • Node.js: 22.23.4, 24.22.1, 26.12.1

If you ship single executable applications, rebuild them: an SEA carries its own copy of Node, so upgrading the one on the machine does not patch it. Snapshot blobs have to be rebuilt anyway, since Node refuses to load a blob made by a different binary.

Sources: the Node.js October 2026 security releases, and the php-src advisories GHSA-6wmf-4rv5-p3rp and GHSA-w8hw-w9fx-j79w.

I am Ayoub Hammoudi, a backend and AI platforms engineer in Paris. More of my work is at github.com/Ayoubhm07.

Top comments (0)