Code Smell 322 - Don't Keep Request-owned Pointers in Long-lived Variables

M

Maxi Contieri

Guest
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 โš™๏ธ​


https://hackernoon.com/refactoring-...bles-with-dependency-injection?embedable=true

Context ๐Ÿ’ฌ​


A server process can live for hours and handle many requests in a row, while a request may last milliseconds.

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.

The next request starts, and some code runs before initialization and reads that variable.

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

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 defect depends on both sides: one operation leaves stale state behind, and another operation reads or acts on it.

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 ๐Ÿšซ​


Code:
// 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."

Right ๐Ÿ‘‰​


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

Code:
// 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();

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

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

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 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.

Detection ๐Ÿ”​


[X] Semi-Automatic

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

You compare every startup initialization with its shutdown reset, and a variable missing from 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, sometimes with the runtime's own allocator disabled.

You send consecutive requests through the same worker, make the first one fail on purpose, and inject allocation failures 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 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] Advanced

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.

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

Without Proper Instructions ๐Ÿ“ต​

With Specific Instructions ๐Ÿ‘ฉโ€๐Ÿซ​

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 ๐Ÿ‘ฉโ€โค๏ธโ€๐Ÿ’‹โ€๐Ÿ‘จ​


https://hackernoon.com/how-to-find-the-stinky-parts-of-your-code-part-vii-8dk31x0

https://hackernoon.com/how-to-find-the-stinky-parts-of-your-code-part-xii

https://hackernoon.com/how-to-find-the-stinky-parts-of-your-code-part-x-i7r34uj

https://hackernoon.com/how-to-find-the-stinky-parts-of-your-code-part-xlii

More Information ๐Ÿ“•​


The PHP team recommends upgrading to PHP 8.5.12, 8.4.27, 8.3.36 or 8.2.35, because configuration changes don't make a vulnerable installation safe.

PHP 8.1 and older no longer get updates, but the PHP team published source-only backports for 7.4, 8.0 and 8.1, and some vendors ship their own fixes.

One commit fixes both CVEs: CVE-2026-109619 (stale global state) matches this smell, while CVE-2026-109618 (out-of-memory bailout) is a related early-exit problem that fits only partly.

Apache mod_php has a demonstrated exploitation path and PHP-FPM has documented exposure under specific conditions, so you shouldn't assume another SAPI removes 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)

https://github.com/php/php-src/security/advisories/GHSA-6wmf-4rv5-p3rp?embedable=true

https://github.com/php/php-src/security/advisories/GHSA-w8hw-w9fx-j79w?embedable=true

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)

https://cwe.mitre.org/data/definitions/416.html?embedable=true

https://cwe.mitre.org/data/definitions/824.html?embedable=true

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://hackernoon.com/how-to-find-the-stinky-parts-of-your-code-part-i-xqz3evd?embedable=true
 

Thread statistics

Created
Maxi Contieri,
Replies
0
Views
1
Back
Top