Housekeeping is halfway through a room when the alarm goes. They leave. The bed is stripped, the safe stands open, and the last guest’s keycard is still live.

Downstairs, the front desk completes the checkout. Not because anyone checked out — nobody ever checked in. The paperwork runs because that is what the paperwork does when a stay ends, and as far as the desk can tell, something ended.

The room is now, officially, ready.

On 21 October 2026 the PHP project shipped a security release for two unauthenticated remote code execution bugs, reported in August under coordinated disclosure and published on patch day alongside the researchers’ write-up, PHP2Shell. The first of them, CVE-2026-109618, is the front desk.

PHP abandons a request that runs out of memory by calling longjmp() — jumping out of whatever it was doing, skipping the unwinding a normal return would perform. That is survivable on its own. What makes it reachable is a detail of how Apache runs PHP: mod_php invokes the request shutdown procedure even when request startup has failed. It tidies up after a stay that never began.

So the question becomes: what is there to tidy?

Here is the function the exhaustion lands in, from Zend/zend_hash.c at tag php-8.4.26, abridged only where marked:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
add_to_hash:
	idx = ht->nNumUsed++;
	ht->nNumOfElements++;
	p = ht->arData + idx;
	p->key = key = zend_string_init(str, len, GC_FLAGS(ht) & IS_ARRAY_PERSISTENT);
	/* … ZEND_RC_DEBUG block elided … */
	p->h = ZSTR_H(key) = h;
	HT_FLAGS(ht) &= ~HASH_FLAG_STATIC_KEYS;
	if (flag & HASH_LOOKUP) {
		ZVAL_NULL(&p->val);
	} else {
		ZVAL_COPY_VALUE(&p->val, pData);
	}
	nIndex = h | ht->nTableMask;
	Z_NEXT(p->val) = HT_HASH(ht, nIndex);
	HT_HASH(ht, nIndex) = HT_IDX_TO_HASH(idx);

Read it in order. The counters move first: the table now says it holds one more element than it did. Then the key is allocated — and zend_string_init is an allocation, which means it can fail. Everything that would make the slot real comes after it: the hash, the flags, the value, the position in the collision chain.

THE OPENING

The table counts an element that was never written.

One hash insert, interrupted by the memory limit at the worst moment.

nNumUsed4

nNumOfElements4

slot 3 · keyallocation failed

slot 3 · hashnever written

slot 3 · valuenever written

slot 3 · chainnever linked

Cleanup walks a slot the table says is live. The ordering is not the defect. Leaving through the middle of it is.

The counters move before the bucket exists, which is normal and still ships — this function is byte-for-byte identical in the patched release. What changed is that an out-of-memory condition can no longer leave through the middle of it.

An attacker who can exhaust the heap precisely there leaves through the middle. Chunked multipart bodies slip past post_max_size; nested files[a][b][c] fields across the six $_FILES hierarchies do the exhausting. The request departs. The table is left claiming an element whose key allocation never returned, whose value was never copied, and which is wired into no chain at all.

Then mod_php, obligingly, runs the cleanup — and walks the slot.

Here is where I expected to write that the bug is bumping the counters too early. It is the obvious lesson and it is wrong. I diffed that function between php-8.4.26 and the patched php-8.4.27, and it is identical — seventy-nine lines, byte for byte, across a change that touched 423 files.

Not because hash insertion was left alone. zend_hash.c is one of those 423 files: the resize invoked immediately above this label became fallible, and the sibling insert paths gained explicit out-of-memory handling. This particular function was passed over because it is never called on the fallible path. They went through the file and decided it did not need changing.

Because the ordering is not the defect. Updating a structure in several steps is normal, and it is fine for exactly as long as nothing can leave while it is in progress. What was wrong is that something could. PHP’s answer was not to make the insert atomic; it was to stop allowing an exit through the middle — a soft out-of-memory deferred to the next safe point, and, once a second and higher threshold is crossed, a hard one that calls _exit() and takes the whole process with it.

A structure never starts clean if you can leave while it is being built.

Which leaves the question this article is actually about. A process that dies on its way out hurts nobody. The reason any of this matters is that the worker was still there afterwards, waiting for the next request — and the next request was not going to be from the same person.

Why there was a room to come back to

A crash is survivable

Nothing in the last section required a persistent worker. A process that corrupts itself on the way out and then dies is a crash, and crashes are survivable. What turns it into a vulnerability is that the process stays.

Why it stays

It stays for a reason, and the reason is good. The original way to run PHP was CGI: fork a process, serve one request, exit. Perfectly clean, and clean in the strongest sense — a fresh address space every time, nothing to inherit because there is no previous occupant. It was also slow enough that essentially nobody does it any more. mod_php and PHP-FPM keep a pool of workers alive and feed them request after request, from whoever happens to be next.

The same trade, four times

This is not a PHP story. A TLS handshake is expensive, so HTTP clients hold connections open and send the next request down the same socket. Interpreter start-up is expensive, so Node can serialise a warmed-up heap into a snapshot and start from it. Giving every tenant their own build machine is expensive, so platforms run shared builders. Rotating credentials is disruptive, so they are issued once and left alive.

Two runtimes, a platform and a credential policy — four different decisions, all the same trade: we stopped starting over, because starting over was the slowest thing we did.

%%{init: {"theme": "base", "flowchart": {"curve": "basis", "rankSpacing": 34, "nodeSpacing": 26, "padding": 14, "htmlLabels": false}, "themeVariables": {"fontFamily": "Helvetica, Arial, sans-serif", "fontSize": "14px", "clusterBkg": "#141026", "clusterBorder": "#2e274e", "titleColor": "#b9aeef", "lineColor": "#50448c", "textColor": "#b9aeef", "mainBkg": "#1c1733", "nodeBorder": "#50448c"}}}%%
flowchart LR
    accTitle: Fork per request against a reused worker
    accDescr: On the cold path, every request forks a new process which serves it and exits, so no state can reach the next request. On the warm path, one worker serves every request in turn, and anything left behind by one request is still present for the next unless something resets it.

    subgraph COLD["Fork per request · nothing to inherit"]
        direction LR
        r1(["request 1"]) --> p1["fresh process"] --> x1(["exit"])
        r2(["request 2"]) --> p2["fresh process"] --> x2(["exit"])
    end

    subgraph WARM["Reused worker · everything is inherited"]
        direction LR
        q1(["request 1"]) --> w["one worker"]
        q2(["request 2"]) --> w
        q3(["request 3"]) --> w
        w --> leftover{"whatever the last one left"}
        leftover -.-> w
    end

    classDef cold fill:#1c1733,stroke:#50448c,color:#b9aeef,stroke-width:1.5px;
    classDef warm fill:#2e274e,stroke:#b9aeef,color:#f4eee2,stroke-width:1.5px;
    classDef risk fill:#8a3321,stroke:#e4703a,color:#f4eee2,stroke-width:1.5px;
    class r1,r2,p1,p2,x1,x2 cold;
    class q1,q2,q3,w warm;
    class leftover risk;
    linkStyle default stroke:#50448c,stroke-width:2px;
    style COLD fill:#141026,stroke:#2e274e,color:#b9aeef;
    style WARM fill:#141026,stroke:#2e274e,color:#b9aeef;

Nothing on the cold path can reach the next request, because there is no next request. On the warm path every arrow points at the same worker, and the only thing standing between one request and the next is the resetting someone thought to write.

The gap nobody signed off on

That leaves a gap nobody signed off on. The security model of almost every program assumes it starts clean — fresh memory, an empty socket, unused randomness, a credential that means what it meant when it was issued. The performance model of almost every runtime guarantees it does not. The invariant below is what sits in that gap:

Anything reused must be indistinguishable from new at the moment it is reused.

Note what it does not say. It does not say stop reusing; that answer is not available at any price anyone will pay, and an article that offers it has not understood the problem. It says reuse carries an obligation, and the obligation is observational. Not we called reset. Nothing downstream can tell.

Three of the October disclosures are three different ways to fail that, and the useful thing about them is that each fails on its own.

A worker can forget to clear its globals while its sockets and its randomness are perfect. A runtime can reset per-request state flawlessly and still hand over a connection with the previous exchange’s bytes already sitting in it. And a system can do both correctly, every time, while resetting to a baseline that was itself captured halfway through being used. No two of these protect you from the third.

The Key That Still Works

What did the last guest leave behind?

The card nobody cancelled

The room has been turned over properly this time. New guest, new keycard, bed made. The only thing nobody did was cancel the old card — and the door reader, which is consulted before the front desk has finished issuing the new one, still believes a stay is in progress.

CVE-2026-109619 is the second of the two PHP bugs, and the advisory states the condition in a single sentence worth reading twice:

“PHP contains various global state, such as error, session, object and extension state that is retained by a worker but points to memory owned by one request.”

Worker-scoped pointer, request-scoped memory. The request ends and its heap is released; the pointer does not care.

Two ways to leave a pointer behind

There are two ways to produce one. The first is the obvious one: a fatal error — including running out of memory — abandons an operation before it reaches its matching reset. The second is stranger and more unsettling. A warning, or any other non-fatal diagnostic, can repopulate error state after that state was already cleared during shutdown. The reset ran. It ran correctly. Then something wrote the field again, after the clearing and before the free.

There is a third producer worth naming because it is the one the demonstrated attack rests on: fatal error reporting stores backtrace state owned by the request, and the advisory notes that resetting it is always missed during shutdown.

Who reads it, and how early

A leak on its own is not an exploit. For that you need something on the next request to read it, and read it early — before the ordinary initialisation that would have replaced it. The advisory confirms two such consumers. INI diagnostics fire when a deprecated or invalid setting is touched. And Session Upload Progress reaches session handling ahead of normal session initialisation — a feature whose documented default is 1, enabled.

The chain Calif demonstrate runs through the unserialiser. Request A decodes attacker-supplied serialized data; an allocation inside the decode hits memory_limit; php_var_unserialize_destroy() never runs. The heap is freed while BG(unserialize).data still points into it — and the nesting level is still nonzero.

That asymmetry is the whole thing, and it is worth saying slowly. The pointer and the counter live in the worker. The buffer they describe lived in the request. Releasing the request’s heap takes the memory and leaves the bookkeeping behind, still sitting in the worker, still perfectly readable, now describing nothing.

That counter is the whole bug. Request B arrives as a multipart upload-progress request, before the Basic module has initialised. php_var_unserialize_init() checks the nesting level, finds it nonzero, and concludes that a nested decode is already in progress. So it does not allocate fresh state. It adopts the pointer.

From there it is arithmetic. The attacker shapes destructor blocks carrying a negative used_slots, so that

1
slot = destructor_block + 16 + 16 × used_slots

resolves backwards, to memory before the intended array, and duplicate keys in the serialized input drive ZVAL_COPY_VALUE(tmp_var, rval) into it — a chosen pointer with chosen type information, which is the end of the interesting part.

THE KEY THAT STILL WORKS

A counter nobody decremented.

One pointer, two requests, and the counter that let it cross.

nesting level1

data pointerADOPTED

request A heapfreed

A counter nobody decremented became permission to trust a freed pointer. The reset was not merely skipped. Its absence was read as a signal that no reset was needed.

CVE-2026-109619. Two producers put a worker global on request memory; three consumers run before the initialisation that would replace it. Session Upload Progress, the one used here, is enabled by default.

Strip the exploitation away and what is left is small and general. A counter that was never decremented became permission to trust a freed pointer. Nothing read the pointer and asked whether it was still valid; something read the counter and inferred that it was. The reset was not merely skipped — its absence was interpreted as a signal that no reset was needed.

What every check established

CheckProperty it establishedDid it hold?
Request shutdown  
CheckProperty it establishedDid it hold?
Request shutdownThe request heap is releasedYes
php_var_unserialize_destroy()The decoder’s state is torn downOnly on paths that reach it
Nesting levelA nested decode is in progressYes — and it was still true of a decode that no longer existed
Pointer validityThe adopted pointer refers to live memoryNothing ever asked

Every row but the last one did its job. The failure is that the last row has no owner: the counter was read as a proxy for a question nobody put to the pointer itself.

And the fix, for symmetry with the opening: the same commit that stopped the unwinding also resets this state at startup, so a worker beginning a request no longer inherits a nesting level it never incremented. Nothing about the counter’s design changed. What changed is that a fresh request now starts from a known value rather than whatever the last one left.

Note also which SAPI is exposed here, because the neat version of this is wrong. The first bug was only demonstrated end to end against mod_php, which runs shutdown after a failed startup; the advisory still calls FPM susceptible on source analysis, and FrankenPHP likely. This second one was validated against both mod_php and FPM, because it fires after php_request_startup() has succeeded and the worker survives to take another request.

So not two bugs and two SAPIs. One fix, and in both cases the question of who got hurt was decided by what happens between requests rather than during them.

The Key That Still Works, restated: a counter outliving the thing it counts does not merely go stale. It becomes an authority, and the next reader treats it as permission.

Someone Else’s Luggage

Is what is in here actually mine?

A bag already in the room

You are shown to your room and there is already a bag inside it. You assume it is the one you handed the porter.

CVE-2026-105481 is the quietest bug in this article. Medium severity, 6.5, no name, no logo, nobody wrote a thread about it. It is also the one most likely to be living in code you wrote this year.

A guard that waits for a tick

Node’s http.Agent keeps idle keep-alive sockets and hands them to whichever request needs one next. There is a guard for the obvious hazard — if unexpected bytes turn up on a socket that is supposed to be idle, destroy it rather than reuse it. The guard is correct. The advisory describes what is wrong with it:

“The guard that destroys idle keep-alive sockets when unexpected data arrives only runs when the event loop polls for I/O.”

So: a server writes a response onto a connection you are not currently using. The bytes arrive. Your process is busy doing something synchronous — parsing, hashing, a tight loop — and does not return to the event loop. The guard never fires, because the guard is not a property of the socket, it is a thing that happens during polling. Then a new request asks the Agent for a connection, gets that one, and Node parses the waiting bytes as its response.

SOMEONE ELSE'S LUGGAGE

The response arrived before the request.

A keep-alive socket, and a guard that is waiting for a tick.

socketREUSED

bufferCONSUMED

The response was real. The pairing was fiction. A cleanup conditioned on an event that reuse can outrun is not a cleanup.

CVE-2026-105481. The fix stops inferring the socket state from an event that may not have happened: it asks the operating system whether bytes are waiting, without consuming them, and discards the socket if there are.

What arrives is not an answer to anything you asked

The result is HTTP response desynchronisation: one request’s answer delivered to another request’s caller. The advisory stops there, and so will I — what that is worth to an attacker depends entirely on what sits downstream of the client, and the scored impact is on integrity rather than confidentiality. The response was real, the socket was real, and the pairing was fiction.

There is no uninitialised memory here and nothing was skipped. A reset existed and was correct, and reuse outran it. Which is the general shape worth taking away: a cleanup conditioned on an event that reuse does not wait for is not a cleanup. It is a hope with good test coverage.

What every check established

CheckProperty it establishedDid it hold?
Connection is open  
CheckProperty it establishedDid it hold?
Connection is openThe socket can carry a requestYes
Socket is in the idle poolNobody else is using itYes
Idle-data guardNo unexpected bytes are waitingOnly when the loop polled
Response belongs to this requestThese bytes answer what I askedNever checked at all

The third row is the interesting one, because it is not false — it is conditional, and nothing in the type of the socket records that condition.

The patch is the whole lesson in one change. Instead of waiting for the loop to notice, lib/_http_agent.js now asks the operating system whether anything is waiting to be read — without consuming it — and throws the socket away if there is. It stopped inferring the state of the socket from an event that may not have happened, and went and looked.

Someone Else’s Luggage, restated: a cleanup conditioned on an event that reuse does not wait for is not a cleanup. It is a hope with good test coverage.

The Pre-Cut Keys

Was this clean state ever clean?

One encoder, imaged once

The chain buys one key-card encoder, configures it, images the machine, and ships that image to every property. Every hotel in the group now issues the same sequence of “unique” card codes, in the same order, starting from the same place.

CVE-2026-106074 is rated 4.8 — the lowest score in this article, and the one I would most want an engineer to read.

Node keeps small entropy caches behind crypto.randomUUID(), crypto.randomUUIDv7() and crypto.randomInt(), because asking the operating system for sixteen bytes every time you need an identifier is wasteful. Fill a buffer, hand out slices. Entirely reasonable, until the process image itself becomes a build artifact.

Build with --build-snapshot, or ship a Single Executable Application built with useSnapshot, and the snapshot captures the heap — including those caches, including whatever was left in them at the moment the snapshot was taken. The precondition matters: it only bites if one of those functions, or something leaning on them, is called while the snapshot is being built. Every process started from that binary replays the same values: up to 127 UUIDs, and roughly a thousand randomInt() results, before the cache refills and real randomness resumes.

THE PRE-CUT KEYS

Random, photographed mid-use.

An entropy cache, a build step, and every process that follows.

cache position41

A clean state that was never clean, already in the field. Upgrading fixes the machine that makes the artifact. It cannot reach the artifact.

CVE-2026-106074. Entropy caches behind randomUUID, randomUUIDv7 and randomInt are captured into --build-snapshot artifacts and Single Executable Applications. Snapshots built with a vulnerable runtime must be rebuilt.

The case with no attacker

Stop and notice what is not happening. No memory is corrupted. No cleanup is skipped. No state is inherited from a previous request, because there is no previous request. There is no attacker in this story at all — nobody broke in, and nothing was exploited to make it true. Every line of code did exactly what it was written to do.

The failure is one level up from any of that. The clean state a process starts from was itself a used one. A function whose entire contract is different every time became identical every time, because a build step photographed a half-consumed buffer and shipped the photograph.

The patch cannot reach what you already shipped

And then the detail that makes this clause different from the other two. The fix clears the caches on both sides of the snapshot boundary. But read where that reset lives — it is wrapped in isBuildingSnapshot(), registered as serialize and deserialize callbacks. Nothing clears the cache at runtime. So an artifact built with a vulnerable runtime still carries the frozen cache, and has to be rebuilt. The advisory does not say that; the source does. Upgrading Node fixes the machine that makes the artifact. It cannot reach backwards into an artifact you already shipped. The other two bugs end when you deploy the patch; this one ends when you rebuild everything you have ever cut.

What every check established

CheckProperty it establishedDid it hold?
CSPRNG quality  
CheckProperty it establishedDid it hold?
CSPRNG qualityThe bytes are unpredictable when drawnYes
Cache refillA used buffer is replenished from the OSYes, at runtime
Snapshot integrityThe artifact faithfully reproduces the heapYes — that is the defect
Baseline freshnessThe state we start from has not been usedNobody owned this question

Note the third row. The snapshot worked perfectly. Faithfully reproducing a half-consumed buffer is exactly what it was built to do.

The question it leaves behind generalises further than Node: of any warm start — a container image, an AMI, a forked worker, a restored checkpoint — was the thing we captured mid-use?

The Pre-Cut Keys, restated: resetting to a baseline is worth nothing if the baseline was captured mid-use. Ask what the clean state is a copy of.

The Master Key

How long has this been valid, and could we tell what it opened?

A key nobody re-cut

Housekeeping carries a master key. It was cut years ago, it opens every room, and nobody has ever re-cut it. The day it goes missing you do not re-key the rooms you think it touched — you re-key the building, because there is no record of which doors it opened.

Late on 21 October 2026 — the same day PHP shipped and Node shipped — Railway published a disclosure saying an unauthorised party had reached customer environment variables, and asked users to revoke and reapply the sensitive variables they had stored on the platform.

This one is not a memory bug, and that is why it is here. Everything above happens inside a process. A credential is reusable state too, and it fails this invariant in the way state fails it when nobody ever gave it an end.

What the report says

Railway’s preliminary report is unusually specific for something published two days into an incident. Monitoring paged on-call at 13:58 UTC on 20 October for nothing more alarming than elevated latency. Within three hours the team had identified a potential privilege escalation in the build infrastructure and halted the pipelines. The attacker pulled environment variables, encrypted. Then, between 05:27 and 07:25 the next morning, they reached credentials for the CI environment — and there, the artifacts could be decrypted.

The targeting was deliberate: workspaces holding large numbers of environment variables, and in particular large numbers of AI provider keys, with the apparent object of harvesting them for distillation. Railway notified GitHub, GitLab, Anthropic, OpenAI, OpenRouter and Stripe among others. At most 1.85% of workspaces — about a hundred thousand — had environment variables potentially compromised, and the number caught by precautionary upstream revocation was larger still.

The encryption that erased its own evidence

Now the detail that decides the shape of the response. The keys were envelope encrypted, which bought time rather than immunity — the attacker had the encrypted secrets hours before they had anything that could open them, and needed the CI credentials to get there. What envelope encryption did do permanently is stop Railway reading them either.

So nothing in the logs said which keys had been decrypted. Railway reconstructed the target list from the attacker’s own payloads, which recorded the key names being searched for.

CheckProperty it establishedDid it hold?
Encryption at restThe stored bytes are not readable as writtenYes
Envelope encryptionReading them needs a separate keyYes, for several hours
Access loggingWhich variables were readYes
Decryption loggingWhich variables were understoodNo such record exists
Credential lifetimeHow much exposure any one key has accumulatedNever bounded

The last two rows are the chapter. A credential issued once and left alive is not indistinguishable from new — it has been accumulating exposure for its entire life, and nothing anywhere measures how much. When the day comes that you need to know which of your secrets an intruder understood, the honest answer is all of them, because you built no way to answer it more narrowly. “Rotate everything” is not an abundance of caution. It is what is left when a credential has no expiry and no decryption record.

They had already decided

Railway also changed the sharing, and the quote is the one people have passed around:

“We have rolled out a new generation of builder infrastructure, moving from our previous third generation isolated multi-tenant to our fourth generation single-tenant environments. Rollout was planned for next week, but was accelerated and completed due to this event.”

Read the second sentence, because it is the one that matters. They had already decided. The migration was scheduled for the following week and the breach only moved it forward — which means the cost of per-tenant builders had been priced, argued and approved before anyone broke in. What the incident changed was not the decision but its deadline.

That is the same decision PHP’s users made when they left CGI, run backwards: a shared facility is faster and cheaper, and the price is that everything passing through it is reachable from one place.

What is not known, and I am not going to guess at it. Railway has not disclosed how initial access was obtained, says the forensic investigation is continuing, and states plainly that it is “not yet able to share the specific details and scale of the exploits”. Everything above is from the published timeline, the published remedy and the published scope. The mechanism of first entry is a gap, and a gap is what it stays.

The Master Key, restated: a credential with no expiry is not reused state that someone forgot to reset. It is reused state that was never given an end, and its exposure is therefore unbounded and unmeasurable.

The price of starting warm

The counterfactuals are trivial

Every bug above has a counterfactual, and they are embarrassingly simple.

keepAlive: false has no desync: a connection used once cannot carry a previous exchange. No snapshot, no frozen entropy: a process that boots normally asks the OS for randomness like everybody else. CGI, forking per request, has no stale global state: there is no worker for a pointer to outlive its heap in.

Be careful with that last one, because it is weaker than it looks. CGI would not have prevented the half-built bucket at the top of this article. The counters would still run ahead, the allocation would still fail, and the cleanup would still walk the slot — all inside the one request. What CGI removes is not the defect but its reach: the process dies, and there is no next request to inherit anything. That first case is an abort bug that reuse made worth exploiting, which is not quite the same as a bug reuse caused.

And unavailable

So the fix is to stop reusing things, and the article could end here. Except that each of those counterfactuals is a performance decision being reversed, and the reason they were made is not laziness. PHP moved off CGI because forking per request could not serve the web. Keep-alive exists because TLS handshakes cost round trips. Snapshots exist because cold start is the dominant cost in exactly the environments people now deploy to.

What it cost PHP to fix

What it actually costs to make reuse safe is visible in PHP’s patch, and it is worth printing the number. Two CVEs, one commit, “Avoid state corruption via bailout”: 423 files changed, 11,556 insertions, 1,017 deletions, including roughly 250 new out-of-memory tests, across the Zend engine and twenty-nine extensions, by five maintainers.

The shape of that change is the lesson, and the second half of it is the part the opening did not show. Having removed the ability to leave through the middle of an operation, they then went and reset every worker global that could be left pointing at request memory: error handlers, exception state, the object store, the $_GET / $_POST / $_COOKIE zvals. That list is what “make reuse safe” actually costs — not one clever fix, but an inventory.

Node’s two are smaller and the same shape. Ask the OS rather than trust an event that may not have fired. Invalidate the cache at the artifact boundary rather than assume a process boundary that a build step erased.

None of them is clever. All of them are the consequence of someone writing down which boundary a piece of state was allowed to cross, and then checking.

Rules, and the lifetime table

Five rules

Each of these is tied to the failure that taught it, and none of them is new. That is rather the point: they are unglamorous, and skipping them is how three well-maintained organisations arrived in the same release week.

  1. Make the reset the only route to reuse. Not a call at the top of the handler that discipline keeps in place — a type that cannot be addressed until it has been through the reset. The previous article’s finalize(self) argument, applied to pools instead of verifiers.
  2. Prefer dying to unwinding. If the process is reused and the invariant is unprovable, _exit() is not a failure mode, it is the only honest reset. PHP chose this deliberately for the hard case.
  3. Do not infer state from an event that reuse can outrun. Ask the thing itself. The Node agent fix is this idea in about a dozen lines, plus a new binding to ask the socket directly.
  4. Invalidate at the artifact boundary, not the process boundary. Snapshots, images and executables move the line without telling you, and nothing in your code can see that it moved.
  5. Write the lifetime table. Every piece of reusable state: what it is, which boundary it may cross, what resets it, and what proves the reset ran.

The lifetime table

That last one is the instrument, and it is the analogue of the check-and-reliance list from the previous article. It is cheap, it needs no tooling, and it is the only one of these that survives a successful patch — because it describes obligations rather than bugs, and an obligation does not disappear when you close a ticket.

Hold the invariant up against it: anything reused must be indistinguishable from new at the moment it is reused. The table is just that sentence with the obligations written down.

StateMay crossReset byProof the reset ran
half-built hash bucketnothingcompleting the insertnothing — the counters were trusted instead
BG(unserialize).dataone nested decodephp_var_unserialize_destroy()nothing — the nesting counter was trusted instead
keep-alive socket buffernothingthe idle-socket guardnothing — the guard only ran on poll
entropy cacheone processOS refillnothing — the snapshot captured it
platform credentialone deployrotationnothing — until every one of them was rotated at once

Five rows, five empty final columns. Every case in this article is a row, and not one of them had an answer in the last column. That column is the instrument: the first two are easy to fill in and tell you nothing, and the third is where the work is.

Closer to home

Where is this already running in your stack?

Your platform documents this as a feature

Nothing in this article is exotic. Four well-resourced teams shipped it, and the reason is that reuse is the default in the infrastructure underneath almost everything — not a corner you have to seek out.

AWS describes the warm start plainly. After a Lambda finishes, the environment is frozen and kept for the next request, and on reuse, “objects declared outside of the function’s handler method remain initialized… if your Lambda function establishes a database connection, instead of reestablishing the connection, the original connection is used in subsequent invocations.” That is the optimisation, stated as such. Further down the same page AWS states the hazard in its own words, recommending you “avoid global variables for context-specific information” because handler-local scope helps “prevent global variable leaks across invocations.” Their phrase, not mine. There is even a line that belongs in this article verbatim: when a function crashes and Lambda resets the environment, the reset “does not clear the /tmp directory content before the next init phase.” The failure does not buy you a clean room.

Conventions are not resets

The one I would check first is the database pooler, because the sharing is invisible from the application. A pooled connection carries session state, and PgBouncer’s own feature matrix is blunt about what that costs in transaction mode: SET/RESET, PREPARE/DEALLOCATE, LISTEN, session-level advisory locks and PRESERVE/DELETE ROWS temporary tables are each marked Never. The documentation says transaction pooling “breaks a few session-based features of PostgreSQL” and that “you can use it only when the application cooperates by not using features that break.”

Sit with that sentence. The correctness condition is that the application cooperates. There is no enforcement, no error, no proof — the pooler cannot tell you when a SET leaked into someone else’s transaction. That is a convention, and this article is a catalogue of what happens to conventions. It is the same shape as PHP’s unwritten rule that globals get cleared, and Node’s unwritten rule that a socket is free when the response ends. The toy builds this exact defect in forty lines, along with the two honest ways out of it.

The question is not whether, it is which boundary

None of that is a defect, and none of it should be switched off. These are the same trade this article opened with, and they were all worth making. The failure is never the reuse; it is reuse across a boundary nobody wrote down.

So the useful exercise is not an audit for bugs. It is an inventory: list what your process keeps between units of work, and for each one, name the boundary it is allowed to cross.

Run the table on your own stack

The lifetime table is the instrument, and running it over your own stack takes an afternoon:

StateMay crossReset byProof the reset ran
anything module-level in a serverless handler???
a pooled DB connection’s session settings???
an HTTP client’s keep-alive sockets???
caches baked into your container image???
whatever your CI restores between runs???

Fill the first three columns from memory — they are easy, and that is the trap. The fourth is the one that matters, and in every case in this article it was empty. If you cannot name what would prove the reset happened, you do not have a reset. You have a convention.

Closer to home, restated: you are not deciding whether to reuse. That was decided for you by the runtime, the pooler and the base image. You are only deciding whether you have written down what each of them may carry.

The toy

The property, executable

One runnable file, no dependencies, and the artifact is a property test rather than a demonstration, because the property is the invariant:

For any sequence of jobs, running them through a pooled worker must produce the same results as running each on a fresh one.

Download and run it; it needs only rustc.

1
2
3
4
5
6
7
8
9
10
11
12
13
$ rustc --test pooled.rs -o pooled && ./pooled --test-threads=1

running 8 tests
test tests::accepted_jobs_agree_in_both ... ok
test tests::complete_reset_on_every_path_still_leaks ... ok
test tests::enforced_is_distinguishable_but_never_wrong ... ok
test tests::fixed_pooled_is_indistinguishable_from_fresh ... ok
test tests::leaky_pool_returns_wrong_answers_not_refusals ... ok
test tests::pooled_is_distinguishable_from_fresh ... ok
test tests::rejection_is_reported_in_both ... ok
test tests::resetting_the_session_is_indistinguishable_from_fresh ... ok

test result: ok. 8 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The pooled worker keeps a scratch buffer between jobs, and one rejection path returns before the reset. A search over short job sequences finds the smallest disagreement:

1
2
3
jobs    [0xFF], [1]
fresh   Err(Rejected), Ok(1)
pooled  Err(Rejected), Ok(256)

One is the byte you sent. Two hundred and fifty-six is your byte plus the rejected job’s, still sitting in the buffer.

Read the names of the two tests that also pass against the broken worker: accepted_jobs_agree_in_both and rejection_is_reported_in_both. Every property anyone would naturally think to test holds in the broken version — the happy path is correct, and rejection is reported correctly. The defect only appears when you ask the question the invariant actually asks, which is not did it compute the right answer but could anything tell the difference.

The fix makes the reset the only route to a usable worker: acquire() clears the buffer and hands back a handle that run consumes, so no caller can reach a job without passing through it. Then fixed_pooled_is_indistinguishable_from_fresh finds no counterexample.

The reset that runs every time, and still leaks

Everything above is the PHP shape: a reset at the end of the function, and one path that returns before reaching it. It is the easier shape, because once you see it the fix is obvious — move the reset somewhere the caller cannot skip.

The second half of the file is the harder shape, and it is the one from the pooler. Here is the whole defect:

1
2
3
4
5
/// Resets the transaction. Not the session.
pub fn acquire(&mut self) -> Ready<'_> {
    self.scratch.clear();
    Ready(self)
}

That reset is not missing. It is not conditional, there is no early return past it, and it runs on every single job. Add three more clear() calls and nothing changes, because the state that leaks was never in scratch — it is a setting a job can write, and the author did not count it as state. Jobs can now do two things: summarise bytes, or change a scale factor. On a fresh connection the scale change succeeds and then dies with the connection, which is exactly what the calling code believes happens.

The search finds the leak in two jobs:

1
2
3
jobs    SetScale(2), Sum([1])
fresh   Ok(0), Ok(1)
leaky   Ok(0), Ok(2)

Nobody got an error. The second caller asked for the sum of [1] and was told two, because the previous caller’s setting was still there. This is the SET row in PgBouncer’s matrix, and the reason it is marked Never rather than Supported with caveats.

There are two honest fixes and the file implements both, because the difference between them is the actual lesson.

Reset the session too. resetting::Pool clears scale alongside scratch and the strong property holds — indistinguishable from fresh, no counterexample. It works here only because this pool has exactly one setting and I knew about it. A real connection has prepared statements, temp tables, listeners, advisory locks, search paths, timezone. The fix is enumerate all the state, and the failure mode is that you enumerate what you remember. That is the same move that failed in all four cases in this article.

Refuse what you cannot reset. enforced::Pool has no session to leak into, because it rejects the operation that writes to one. And this is the part worth sitting with: that pool fails the strong property, deliberately. The test says so in its name.

1
2
3
jobs    SetScale(2)
fresh   Ok(0)
enforced  Err(Refused)

It is distinguishable from fresh. A job that a fresh connection accepts gets refused here. That is not a bug being papered over — it is the trade, stated out loud. So the file asserts a second, weaker property for it: no job ever returns a number that differs from fresh. It may refuse; it may never answer wrongly. That property holds for enforced and fails for leaky, and the gap between those two tests is the whole argument. leaky does not refuse anything. It answers, and the answer is wrong, and nothing anywhere says so.

PgBouncer chose the second fix and then left the enforcement to you — “only when the application cooperates”. The pool refuses nothing; the documentation asks you to. That is the convention this article keeps finding at the bottom of things.

What the test does not prove

And now the sentence this series has no right to skip. That is not a proof. The checks are bounded exhaustive searches — for each of the two parts, every sequence of up to three jobs drawn from a four-symbol alphabet, eighty-four sequences per part and a hundred and sixty-eight in total. They find nothing within that bound. A defect needing four jobs, or an input shape the alphabet does not contain, would pass them untouched. The second part is the clearer warning: its alphabet contains one kind of session state because I put one there, and a pool whose leaking state I had not thought to model would produce exactly the same clean run. Writing no counterexample and meaning none exists is precisely the distance the previous article was about, and the enumeration is a bound, not an absence.

Within that limit it is still a better test than asserting the reset was called, because it asks what the invariant claims — could anything tell — rather than whether a particular line ran. It is also, as far as I can tell, the question none of these codebases asked.

An aside: how this was found

The write-up — by Ryan Austin of Calif, on work credited to Calif, OpenAI and Wiz — describes arriving at CVE-2026-109618 by prompting a model to look specifically for ways to exceed PHP’s memory_limit, anchored on Stefan Esser’s memory-limit RCE work from 2004. Their reasoning for the anchor is worth repeating: default variant analysis “tends to move from the newest and most prominent bugs toward nearby older code”, so left alone it drifts toward the recent and the loud.

A twenty-two-year-old technique, re-aimed. Whatever else is true about machine assistance in security research, it did not invent the idea here; it covered ground that a human had mapped before most of this code was written.

What I still find hard

  • This article has no incident of my own in it. Every case above happened to somebody else, and the section above points at your stack rather than telling you about mine. That is a real weakness and I would rather name it than paper it over with a story I had to reach for. The strongest version of this piece has a warm-start bug I actually shipped; I did not have one I could stand behind well enough to print.
  • I do not know what clean costs. The whole article rests on reuse being worth having, and I have not measured it. Recycling a PHP worker after every request is safe and slow; I cannot tell you how slow on hardware you would recognise, and the argument is weaker for it.
  • Who owns a boundary that spans tools? Node’s entropy cache was correct. The snapshot builder did what snapshot builders do. The defect lived in the seam, and neither component is obviously at fault — which is the normal condition of a modern build, not an exotic one.
  • The property test cannot catch the Pre-Cut Keys. Pooled-equals-fresh finds inherited state. It cannot find a poisoned baseline, because both sides of the comparison start from the same poisoned baseline. I do not have a test for “was the thing we captured already used”, and that is the clause I would most want one for.
  • Five more bugs, four of them in that one Node release, were a different invariant entirely: a hostname parsed one way by the checker and another by the thing that used it. 0127.0.0.1 is 87.0.0.1 to Node and 127.0.0.1 to the macOS resolver. The fifth is PHP’s, patched a month earlier — in php-8.4.26, the very release I diffed against. That is its own article, and I have not written it yet.

Sources

Incident details are as published at the time of writing, and this article was fact-checked against primary sources after drafting; several claims that had come from news summaries did not survive that pass. The PHP exploitation chain follows Calif’s analysis and has no vendor-advisory equivalent — the PHP advisories never mention the unserialiser. The hash-table listing and the finding that the fix leaves that function unchanged were read from php-src at both tags rather than taken from the write-up. Railway’s initial-access vector is unpublished and nothing here speculates about it; their forensic investigation was still open and a fuller report is promised. All Rust in this article is illustrative.