Skip to main content

Code Smell 322 - Request Data in Long-Lived Variables (PHP Vulnerability)

Your request is gone. Your pointer is not.

Updated 9 min readMarkdown
Part of seriesCode Smells
Code Smell 322 - Request Data in Long-Lived Variables (PHP Vulnerability)

TL;DR: Don't keep request-owned pointers in long-lived variables, or the next request reads freed memory.

Problems πŸ˜”

Solutions πŸ˜ƒ

  1. Scope state per request
  2. Centralize state ownership
  3. Initialize before early consumers
  4. Clear references before freeing
  5. Cover every exit path
  6. Block late republishing
  7. Test consecutive worker requests

Refactorings βš™οΈ

https://maximilianocontieri.com/refactoring-024-replace-global-variables-with-dependency-injection

Context πŸ’¬

A server process lives for hours and handles many requests in a row.

Each request lasts milliseconds.

Some variables belong to the process.

Others belong to a single request.

When you store a request's pointer in a process-wide variable, you tie two lifetimes together and tell nobody.

The request ends and the runtime frees its memory.

The variable keeps the address.

Then the next request starts.

Some code runs before initialization and reads that variable.

It trusts the address.

The memory now belongs to something else.

The normal path resets the variable.

A fatal error can interrupt an operation before it resets its state.

Timeouts, uncaught errors, and memory exhaustion can all trigger this path.

Late writes can also repopulate state after shutdown has already cleared it, for example from a warning or a diagnostic.

The next request can consume that stale state before normal initialization replaces it.

The defect depends on both sides: one operation leaves stale state behind, and another operation reads or acts on it.

Error paths get less testing than happy paths.

An attacker needs two requests handled by the same worker: one that leaves a stale value and one that reads it.

Under the right memory layout, that sequence turns a use after free into remote code execution.

Sample Code πŸ’»

Wrong 🚫

// CONCEPTUAL MODEL: NOT php-src code and NOT an exploit.
// Don't use this skeleton to try to reproduce the real
// attack vector. It exists only to show the lifetime defect.
// Only the EG(exception) and EG(last_fatal_error_backtrace)
// fields are real (Zend/zend_globals.h).
//
// A worker serves thousands of requests in a row.
// Its globals live as long as the worker does.
void request_shutdown(void) {
    free_request_memory();
    // A fatal path never reaches the next line, so the
    // globals still point at freed memory.
    reset_globals();
}

// A second way to get stale state: a late warning
// during shutdown it refills a global AFTER it was cleared.
void late_diagnostic(void) {
    record_error(EG(exception));
}

// Then it runs for the next request, before it initializes
// the executor, and trusts the stale values.
void report_error(void) {
    use(EG(exception));
    use(EG(last_fatal_error_backtrace));
}

// Real, from ext/session/session.c: a TODO the patch
// removed from php_rinit_session_globals():
// "These could be moved to MINIT and removed.
//  These should be initialized by
//  php_rshutdown_session_globals() always when
//  execution is finished."

The first gist below shows excerpts from the real commit.

// EXCERPTS FROM THE PHP FIX: commit 31c0ea7d7ff (reordered).
// This is NOT the complete fix.
// The commit also replaces
// out-of-memory bailouts with a delayed, graceful exit and
// touches many runtime components. Only resets shown here.
// It is the reset approach (Solutions 4 and 5).
//
// Ref: Zend/zend_execute_API.c,
//      zend_shutdown_executor_values()
/* may be used by zend_error_zstr_at before init_executor */
ZVAL_UNDEF(&EG(last_fatal_error_backtrace));
EG(record_errors) = false;
EG(num_errors) = 0;
EG(errors) = NULL;
EG(error_handling) = EH_NORMAL;
EG(exception) = NULL;

// Ref: main/main.c, php_request_shutdown()
/* May be accessed before php_request_startup() */
ZVAL_UNDEF(&PG(http_globals)[i]);
// (loop omitted, later in the function)
PG(modules_activated) = 0;

// Ref: ext/session/session.c,
//      php_session_rfc1867_callback(),
//      case MULTIPART_EVENT_START
/* This runs before RINIT, so we need to clear these early */
php_rinit_session_globals();

The second one is only a design sketch, not PHP code.

// DESIGN LESSON: NOT the PHP patch, only a sketch.
// It isn't production-ready: it needs real ownership
// and lifetime rules for every resource it touches.
// It is the ownership approach (Solutions 1 and 2).
// ILLUSTRATION PURPOSES
//
// The request owns its state, so nothing outlives it.
void handle_request(worker *w) {
    request_ctx ctx;
    request_ctx_init(&ctx);
    run_request(w, &ctx);
    request_ctx_cleanup(&ctx);
}

// Each state owner gets an explicit cleanup operation.
void errors_owner_cleanup(errors_owner *owner) {
    // 1. Refuse late updates. This alone isn't enough:
    //    every other path that can publish a reference
    //    must also go through the owner.
    owner->accepting_updates = false;
    // 2. Clear every reference into request memory.
    clear_references_into_request_memory(owner);
    // 3. Release what the owner owns.
    release_owned_resources(owner);
}

// Call it before request memory is released, on every
// exit path. Then initialize the owner before any early
// consumer can read it during the next request.

Detection πŸ”

[X] Semi-Automatic

You search for variables that hold a pointer to request memory but live in process scope.

You read the comments near globals.

Comments that admit a variable may be read before it's initialized, or that a reset is missing, point at the problem.

You compare every startup initialization with its shutdown reset.

A variable that appears in only one of them is suspect.

Symmetry alone isn't enough, because a late write can bring the stale value back after the reset.

You run two requests back to back in the same worker, and make the first one fail on purpose, under AddressSanitizer or Valgrind.

A static analyzer that reports use after free (CWE-416) catches some of these.

You inject allocation failures in tests, so the error paths run.

Exceptions πŸ›‘

Data that truly belongs to the process, like configuration or compiled caches, can live as long as the process.

The smell appears only when that data holds a pointer into shorter-lived memory.

A short-lived command-line script that exits after one task hides the problem, because no later request exists to read the stale value.

The smell matters most in runtimes with persistent workers, where one process serves many requests in a row.

You shouldn't rely on a process dying to clean up after you.

Tags 🏷️

  • Globals

Level πŸ”‹

[x] Intermediate

Why the Bijection Is Important πŸ—ΊοΈ

A request is a bounded interaction in the MAPPER.

When it ends, everything that belongs to it ends too.

When your code lets a piece of that interaction survive in a global, you break the bijection.

The model now holds something that no longer exists in the MAPPER.

The next interaction inherits a stale reference and trusts it, because nothing marked it as dead.

AI Generation πŸ€–

AI-generated code can introduce this smell when it uses global state to avoid passing dependencies explicitly.

You ask for a last-error value, a cache, or a shortcut that avoids passing a parameter through five functions.

A global is the smallest diff that satisfies the request.

The risk grows when the code runs in long-lived workers and stores references to shorter-lived memory.

This is a design observation, not a documented cause.

AI Detection 🧲

AI can detect it if you ask for a lifetime analysis.

Try: "List every global that stores a pointer to request-scoped memory and show where each one gets reset, including error paths."

A generic review prompt may miss lifetime problems that appear only on error paths or across consecutive requests.

Try Them! πŸ› 

Remember: AI Assistants make lots of mistakes

Suggested Prompt: Find every global that holds a pointer to request-scoped memory, give each owner an explicit cleanup operation, and run it on every exit path before the request memory is released

Without Proper Instructions With Specific Instructions
ChatGPT ChatGPT
Claude Claude
Perplexity Perplexity
Copilot Copilot
You You
Gemini Gemini
DeepSeek DeepSeek
Meta AI Meta AI
Grok Grok
Qwen Qwen

Conclusion 🏁

A global can outlive the memory its pointer references.

That mismatch is the smell.

When you see a long-lived variable pointing at short-lived memory, you should move it into request scope or reset it on every exit path.

You should also initialize it before any early consumer can read it, and block late writes from bringing the old value back.

You can't count on the error path never running, so you should test it.

Relations πŸ‘©β€β€οΈβ€πŸ’‹β€πŸ‘¨

https://maximilianocontieri.com/code-smell-32-singletons

https://maximilianocontieri.com/code-smell-60-global-classes

https://maximilianocontieri.com/code-smell-49-caches

https://maximilianocontieri.com/code-smell-209-side-effects

More Information πŸ“•

This vulnerability lives in PHP's runtime internals.

Application-level globals can create similar lifetime defects, especially in long-lived workers or native extensions, but an ordinary PHP global doesn't automatically reproduce this CVE.

The advisories recommend upgrading to a patched PHP release as the reliable mitigation for these vulnerabilities.

For CVE-2026-109618, the advisory also identifies the patch as the only complete mitigation.

The fixed releases are PHP 8.5.12, 8.4.27, 8.3.36 and 8.2.35.

You should upgrade to one of them.

The same commit fixes both CVEs, but the vulnerabilities follow different paths.

In CVE-2026-109619, persistent globals retain request-owned state, and code consumes it before normal initialization.

In CVE-2026-109618, out-of-memory bailouts interrupt execution while request state is only partly initialized.

The combined patch addresses both problems: it resets vulnerable global state and changes how PHP handles out-of-memory bailouts.

Persistent workers create the cross-request lifetime problem.

The confirmed exploitation paths depend on the SAPI, configuration and affected state. Apache mod_php has a demonstrated path, and PHP-FPM also has documented exposure paths under specific conditions.

You shouldn't assume that a different SAPI eliminates the risk.

The fix: php-src commit 31c0ea7d7ff, "Avoid state corruption via bailout" (one combined patch for both CVEs below; it also replaces out-of-memory bailouts with a delayed, graceful exit)

CVE-2026-109619 advisory: RCE via stale global state

CVE-2026-109618 advisory: RCE via OOM corruption

PHP2Shell: all you need to know (JFrog) (this write-up swaps the two CVE descriptions; the GitHub advisories above are the reference)

CWE-416: Use After Free

CWE-824: Access of Uninitialized Pointer

Quote

Simplicity is prerequisite for reliability.

Edsger W. Dijkstra

Disclaimer πŸ“˜

Code Smells are my opinion.

Credits πŸ™

Photo by Bernd Dittrich on Unsplash


This article is part of the CodeSmell Series.

https://maximilianocontieri.com/how-to-find-the-stinky-parts-of-your-code

About the author

  • I’m a senior software engineer loving clean code, and declarative designs. S.O.L.I.D. and agile methodologies fan.

No comments yet. Be the first to comment.

Code Smells

Part 1 of 50

In this series, we will see several symptoms and situations that make us doubt the quality of our developments. We will present possible solutions. Most are just clues. They are no hard rules.

Up nextCode Smell 321 - Getter PiggybackingOne broken window invites another