M
Maxi Contieri
Guest
Your request is gone. Your pointer is not.
Problems
Solutions
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
Right
The first example shows selected excerpts from the actual PHP fix.
The second example is a design sketch, not PHP code.
It illustrates one possible approach to managing state ownership and cleanup.
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
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.
Remember: AI Assistants make lots of mistakes
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
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
TL;DR: Don't keep request-owned pointers in long-lived variables, or the next request reads freed memory.
Problems
- Use after free on stale request memory
- Remote code execution
- Hidden side effects
- Stale state crosses into a later request
- Stale values, like a bad cache
- Tests miss cross-request failures
Solutions
- Scope state per request
- Centralize state ownership
- Initialize before early consumers
- Clear references before freeing
- Cover every exit path
- Block late writes
- 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