DEV Community

Cover image for Code Smell 322 - Request Data in Long-Lived Variables (PHP Vulnerability)
Maxi Contieri
Maxi Contieri

Posted on

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

Your request is gone. Your pointer is not.

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 writes
  7. Test consecutive worker requests

Refactorings ⚙️

Context 💬

A server process can live for hours and handle many requests in a row.

A request may last 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 without saying so.

The request ends and the runtime frees its memory, but the variable keeps the address.

Then the next request starts.

Some code runs before initialization and reads that variable.

It trusts the address, but something else may already be using that memory.

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.

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.

This smell lives in native code, like a language runtime or an extension, where you manage memory by hand.

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.
// All names are invented, except the quoted TODO at the end.
//
// A worker serves many requests in a row.
// Its globals live as long as the worker does.
static error_state *last_error;  // points into request memory

void request_shutdown(void) {
    if (fatal_error_happened) {
        free_request_memory();
        return;  // skips the reset below
    }
    free_request_memory();
    last_error = NULL;
}

// A second way to get stale state: a late warning during
// shutdown refills the global AFTER it was cleared.
void late_diagnostic(const char *message) {
    last_error = new_error_state(message);
}

// The next request calls this before it initializes
// anything, and trusts the stale value.
void report_error(void) {
    use(last_error);
}

// 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."
Enter fullscreen mode Exit fullscreen mode

Right 👉

The first example shows selected excerpts from the actual PHP fix.

// 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 soft failure plus a
// hard exit at a higher threshold, and it touches many
// runtime components. Only resets and early init shown here.
// They illustrate Solutions 3, 4 and 5.
//
// Ref: Zend/zend_execute_API.c,
//      zend_shutdown_executor_values()
// The backtrace field below exists only in PHP 8.5.
/* 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() */
// (inside the loop that destroys each http_global)
ZVAL_UNDEF(&PG(http_globals)[i]);
// (end of 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();
Enter fullscreen mode Exit fullscreen mode

The second example is a design sketch, not PHP code.

It illustrates one possible approach to managing state ownership and cleanup.

// 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 shows Solutions 1 to 6.
//
// The request owns its state, so nothing outlives it.
void handle_request(worker *w) {
    request_ctx ctx;
    request_ctx_init(&ctx);
    // run_request() must come back here on fatal errors
    // too, so cleanup runs on every exit path.
    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.
Enter fullscreen mode Exit fullscreen mode

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 missing resets or early reads 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.

For runtime or native-extension code, you need an instrumented build with AddressSanitizer or Valgrind.

You may also need to disable the runtime's own memory allocator, so the tool sees every allocation.

You send consecutive requests through the same worker and make the first one fail on purpose.

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.

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

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 refers to request-owned memory or state.

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 🏷️

  • Security

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 shared status value, a cache, or a shortcut that avoids passing a parameter through five functions.

Using a global may seem like the smallest change that solves the problem.

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

This is my guess, not something the advisories show.

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 long-lived variable an explicit reset, and run it on every exit path before the request's memory is freed

Conclusion 🏁

A global can outlive the memory its pointer references.

That mismatch is what makes it a 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 👩‍❤️‍💋‍👨


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 PHP team recommends upgrading to a patched release.

Configuration changes can block specific attack paths, but they don't make a vulnerable installation safe.

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

PHP 8.1 and older no longer get updates.

The PHP team published source-only backports for PHP 7.4, 8.0 and 8.1, and some vendors ship their own fixes.

You can check your distribution's security advisory if you can't upgrade to one of the fixed releases.

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.

CVE-2026-109619 is the one that matches this smell, because a long-lived holder keeps request-owned state after the request ends.

CVE-2026-109618 is a related early-exit problem, and it fits the smell only partly.

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 soft failure, plus a hard exit at a higher threshold)

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 pairs the CVE numbers with the opposite mechanisms; 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.

Top comments (0)