Or: why every PHP-internal security control collapses in front of a single memory-corruption primitive, and what you actually need to do about it when running PHP in 2026. This is a long one. Read it as two halves bound together - the first half explains why the controls you have been told are security boundaries are not, and the second half tells you what to deploy instead.
Originally written for PHP 8.4 (October 2024 release). Updated April 2026 to incorporate PHP 8.5 (November 2025 release) and the 8.5.0-8.5.5 feature audit - see PHP 8.5 feature audit for 8.5-specific deltas. The Layer 0/1/2/3/4 hardening structure and underlying threat model are unchanged.
This document covers two related questions:
- What can an attacker do once they have PHP code execution but are confined by PHP’s own restrictions (
disable_functions,open_basedir,allow_url_fopen=off, etc.)? - Given that those restrictions can be defeated, how should you actually harden a PHP 8.5 deployment in practice?
The historical answer to the first question - established by Stefan Esser’s BH USA 2009 / MOPS 2010 work and subsequent practical exploits including a working 2010 escape I personally built and lost - is that PHP-internal restrictions are not a security boundary. Any sufficiently expressive memory-corruption primitive lets the attacker rewrite PHP’s runtime configuration from inside the same process.
But there is a more direct and embarrassing answer too: modern PHP ships with an extension that provides exactly this bypass primitive as a documented feature, no exploit required. The FFI extension lets PHP code call arbitrary C functions in any shared library on the system. If FFI is reachable from request-scope PHP, every Layer 3 control in this guide is decorative. disable_functions doesn’t matter when system(3) is one FFI::cdef away. The interruption bug class is interesting because it produces these primitives unintentionally; FFI produces them intentionally.
The PHP threat model, made explicit
PHP ships with a set of php.ini directives that look like security boundaries but are not. Every one of them is enforced inside the same process the attacker already controls.
| Directive | Nominal purpose | Actual mechanism |
|---|---|---|
disable_functions | block specific dangerous calls | string compared at function dispatch time |
disable_classes | block specific dangerous classes | string compared at class instantiation time; removed in PHP 8.5 |
open_basedir | restrict filesystem access | path-prefix check inside fopen wrappers |
allow_url_fopen | block remote URL loading | flag check inside http/ftp wrappers |
allow_url_include | block remote includes | flag check during include resolution |
safe_mode | removed in PHP 5.4 | gone |
When PHP code runs, it shares an address space with all of these settings - the strings, the flags, the function pointer tables. Any primitive that can read or write that address space defeats the boundary. There is no privilege separation. The same is true of related extension restrictions like Snuffleupagus’s function whitelists, the various pcre.* limits, and even the SensitiveParameter attribute. They live in the same process, so they fall to the same primitive.
The bypass primitive in three tiers
A PHP-internal control like disable_functions is enforced inside the same process the attacker already controls. To get past it the attacker needs some way to reach C-level state in that process. The catalog below is more involved than just “FFI” because there are multiple documented features and PECL extensions that fit the same pattern, each with different default-availability characteristics:
| Tier | Primitive | Attacker needs in default FPM | Cost |
|---|---|---|---|
| 1a | FFI extension (direct call) | ffi.enable=true (non-default), or a preload file that leaks an FFI handle into request scope | nothing if either holds |
| 1a | dl() runtime extension load | enable_dl=On (non-default in FPM) plus a writable extension dir | nothing if both hold |
| 1a | Runtime-rewriting PECL (runkit7, uopz) | the extension installed in production | nothing if installed |
| 1b | Interruption-class memory bug | one unpatched bug in any reachable C function | discovery effort, possibly months of fuzzing |
| 2 | Application-level RCE escalation | POP chain / unrestricted upload / etc. | normal webapp pentest |
Tier 1a is conditional on misconfiguration. A correctly-configured default PHP-FPM 8.5 install ships with ffi.enable=preload and enable_dl=Off. Neither FFI nor dl() is callable from request-scope code in that configuration. If the operator has flipped either to “on” - or installed runkit7/uopz on the production system - three lines of attacker PHP code are enough to call libc directly. This is common enough in practice, especially the ffi.enable=true case, that auditing for it is the highest-leverage single check in this whole document. It is not, however, the default state of an out-of-the-box install.
Tier 1b is what most of this document is actually about. When tier 1a is closed, an attacker with PHP code execution in an FPM worker is forced to reach C-level state via memory corruption - i.e. via a userspace interruption bug or its modern equivalents (the 2024 __set UAF, the 2010 strrchr interruption, the unpublished around-2010 concat bug). The architectural analysis of what falls and what survives is unchanged.
Tier 2 is the everyday case for most real-world breaches - deserialization, file upload to a webshell, SSRF chains, SQL injection escalating to RCE. It isn’t this document’s focus, but everything in Layer 1 still applies because tier-2 attackers also benefit from container isolation.
A consolidated catalog of the tier-1a primitives - the documented features and extensions that, if reachable, give request-scope PHP code C-level capability without going through the dispatcher - is spelled out in Layer 0. The short version: default FPM installs have most of them closed already; verify, don’t assume.
What an interruption-class bug gives you
The userspace interruption bug class - the topic of every preceding document in this series - typically produces one of two primitives:
- Primitive A - information leak. A function like
strrchr,concat_function,preg_match, orunpackreturns a substring of freed or repurposed heap memory rather than the expected slice of its argument. The attacker dials the leak size by adjusting the size of the original buffer. With a few hundred well-aimed leaks the attacker has the entire layout of the PHP heap, including pointers into known PHP globals. - Primitive B - arbitrary write. A function like
usort(Esser BH USA 2009 worked example) orconcat_function(the around-2010 finding of mine) can be coaxed into writing into freed memory whose contents the attacker has refilled with crafted fake structures -zend_string,zval, or HashTable bucket headers. Once the attacker controls a structure that PHP’s own runtime will dereference for an indirect call or an indirect store, they have an arbitrary write at architectural privilege.
These two primitives compose. The leak finds the targets. The write modifies them.
The classic 2010 escape, reconstructed
I had a working version of the chain below around 2010 that defeated every PHP-internal restriction in a deliberately-hardened test configuration: disable_functions set to a paranoid blacklist, open_basedir restricting to /var/www/, safe_mode = On, allow_url_fopen = Off, allow_url_include = Off. The script was about 200 lines of PHP, of which roughly 150 were the heap walker and the reconstruction of the function-table entry. There was no Docker, no namespaces, no seccomp. Just PHP and the kernel.
Step 1 - fingerprint the build
Leak adjacent heap to find known constant strings (PHP’s own version banner, default php.ini values, opcache header magic). This identifies which specific PHP binary is running, which determines structure layouts and offsets for every subsequent step.
Step 2 - locate executor_globals
PHP’s per-request execution state lives in a thread-local struct named executor_globals (EG). Among many other things it contains:
EG.function_table- the global hash table of registered functions. Each entry maps a name to azend_functionstruct that contains a pointer to the C-level handler (zif_exec,zif_system,zif_open, etc.).EG.ini_directives- the live, parsed copy ofphp.inisettings.EG.user_error_handler,EG.user_exception_handler- settable from PHP, useful because their addresses pin downEG’s base.
In 2010 the heap layout was sufficiently predictable that executor_globals could be located with a small number of leak probes looking for telltale fingerprints. Modern PHP randomizes some of this less than you would hope.
Step 3 - locate sapi_globals
The SAPI globals (SG) struct holds settings that are SAPI-specific but security-relevant: SG.options bitfield with stream-wrapper restrictions, and the current open_basedir value reachable through the request’s per-request ini override stack.
Step 4 - modify settings to taste
Once EG and SG are located, the arbitrary write primitive lets the attacker do all of the following. Every one of these was working code, not a thought experiment.
- Defeat
disable_functions. If the directive removedexec,system,passthruat module init, those entries are simply absent fromEG.function_table. Re-insert them: duplicate a still-enabledzend_functionstruct, overwrite its handler pointer with the address ofzif_system, and insert it under whatever name you like. PHP’s dispatch hits your synthetic entry exactly as it would a real one. - Defeat
open_basedir. Find theopen_basedirchar*in the ini directive table and zero its first byte. From the next path resolution forward,open_basediris effectively unset. - Defeat
allow_url_fopen/allow_url_include. These arezend_longvalues in the ini table. Set them to 1. - Defeat
disable_classes. Same mechanism as functions, applied toEG.class_table. - Defeat error suppression, output filtering, etc. All reachable through the same paths.
Step 5 - use the restored capability
system("/bin/sh -c ..."), fopen("/etc/shadow"), dl() if compiled in, or load an attacker extension via LD_PRELOAD if the process inherited the environment. The attacker is now at process privilege, no longer at PHP-language privilege. Every PHP-internal restriction in the test configuration collapsed in front of one interruption-class memory primitive.
Esser’s published material
Anyone wanting to reconstruct an equivalent capability against modern PHP has Stefan Esser’s published work as a starting point: the BH USA 2009 talk State of the Art Post Exploitation in Hardened PHP Environments (the canonical reference; explode() as the worked leak, usort() as the worked write); POC2009 Seoul, Shocking News in PHP Exploitation; the Month of PHP Security 2010 archive with forty-plus advisories; and BH USA 2010, Utilizing Code Reuse / Return Oriented Programming in PHP Application Exploits. These remain the most thorough public material on the topic. Nothing substantively newer has been published - the bug class has been quiet in the industry not because it’s gone but because few people have looked.
The tier-1a version: same outcome, no bug needed - but only if reachable
Everything above describes the tier-1b path: find a memory-corruption primitive, walk the heap, rewrite EG.function_table. Months of work to reproduce, decades of bug-hunting to discover the primitive in the first place.
There is a much shorter path that doesn’t require any bug - but only if certain configuration conditions hold. The tier-1a primitives are documented PHP features that, when reachable, give request-scope code direct C-level capability. There are several of them; FFI is the most prominent but not the only one. Each has its own default-availability story.
FFI
The most powerful but also the most commonly defended. The default ffi.enable=preload setting permits FFI from CLI scripts and opcache.preload files but not from request-scope FPM/Apache code. If an operator has set ffi.enable=true system-wide, the bypass is three lines:
<?php
$libc = FFI::cdef("
int system(const char *cmd);
int open(const char *path, int flags);
int mprotect(void *addr, size_t len, int prot);
", "libc.so.6");
// disable_functions = "system,exec,..."?
// Doesn't matter - that check happens in PHP's dispatcher,
// which we're not going through.
$libc->system("id; cat /etc/shadow");
A more interesting variant works against the default ffi.enable=preload configuration when the operator is using preload. If the preload file declares a class with FFI handles in static properties, those handles persist into request scope:
<?php
// preload.php (run as root at FPM startup, before privilege drop)
class FfiHelper {
public static FFI $libc;
public static function init(): void {
self::$libc = FFI::cdef("int system(const char *);", "libc.so.6");
}
}
FfiHelper::init();
// Now any request-scope code can do:
FfiHelper::$libc->system("id"); // works - handle was created in preload context
PHP doesn’t re-check ffi.enable per FFI call; it checks at handle-creation time. A handle created during preload remains usable forever. Treat preload files like setuid binaries: anything they expose to autoloading is reachable from every request.
Other tier-1a primitives
dl()- runtime extension loading.dl("backdoor.so")loads the named shared library and executes itsMINIT. By defaultenable_dl=Offin FPM/CGI butOnin CLI. If the operator has flipped it on for FPM and an attacker can write a.soto a directory in the extension search path, that’s a bypass.- Runtime-rewriting PECL extensions -
runkit7anduopz. Both let PHP code redefine functions, methods, classes, and constants at runtime via documented APIs. If either is loaded in production an attacker just calls the API; no interruption bug needed. There is no place for these in production.php -m | grep -iE 'runkit|uopz'should be empty on production hosts. - XDebug in active modes -
xdebug.mode=debugandxdebug.mode=developexpose runtime introspection and a remote-debugger TCP server that has been used in real attacks. Evenxdebug.mode=offkeeps the extension loaded in process memory; the clean answer is “not installed in production.” eval()andassert()with string arguments - different shape: they execute attacker-controllable PHP code at PHP’s privilege level, which still goes through the dispatcher. They don’t bypassdisable_functionsbut they do bypass application-level input validation. Snuffleupagus’ssp.disable_eval = "drop"closes them.
The default-FPM configuration mostly closes tier-1a already. The work is verifying that every host is actually configured the default way, that nobody has flipped one of these to “on” for some forgotten reason, and that no PECL package outside the core list is silently loaded.
When tier-1a is fully closed, the attacker is forced into tier-1b (the interruption bug class), which is the entire architectural focus of this document.
What PHP 8.5 has now
Before recommending hardening, take stock of what’s actually in PHP 8.5 that didn’t exist in the 2010 era. PHP has done a lot of slow work on the standard library that I want to acknowledge before I criticize what’s left.
Things PHP removed (good)
safe_mode(removed 5.4, 2012). A permission-bit kludge that didn’t work. Good riddance.register_globals(removed 5.4). Made$_GETparameters appear as global variables. The cause of half of all PHP vulnerabilities for a decade.magic_quotes_*(removed 5.4). SQL injection “protection” that introduced as many bugs as it prevented.create_function(removed 8.0). String-eval-based closure factory.each()(removed 8.0). Caused stale-iterator UAFs.- Call-time pass-by-reference (removed 8.0). Closed half the historical interruption-bug trigger surface.
disable_classes(removed 8.5). It was never a reliable security boundary, and the removal was triggered by ASAN-confirmed engine UAF/double-free behavior inzend_disable_class()with typed properties. If you are still on pre-8.5 and have it set, remove it there too.
Things PHP added (also good)
- Strict typing and union types (7.0, 8.0). TypeError instead of silent coercion.
SensitiveParameter,zend.exception_ignore_args, andzend.exception_string_param_max_len- strip parameter values from stack traces.- Argon2id via
password_hash()(7.2). Defaults are now reasonable. - Native CSPRNG (
random_bytes,random_int, 7.0).mt_randis no longer the only option for security-sensitive callers. - Sodium in core (7.2). Modern crypto without PECL.
- Readonly properties/classes and asymmetric visibility. Application-level invariants, not memory-safety boundaries.
Things PHP didn’t remove (mixed)
disable_functions,open_basedir,allow_url_fopen,allow_url_include. Still there. Still not security boundaries.eval(). Cannot be disabled bydisable_functionsbecause it’s a language construct. Snuffleupagus providesdisable_eval.assert()with string arguments. Worked as a hiddenevalin older modes. PHP 8.0 made it not actually evaluate strings, but the function still exists and is a perennial source of bugs.unserialize()on untrusted input. Still the single most common source of PHP RCE in the wild. PHP 7+ addedallowed_classes, which helps but doesn’t eliminate the problem.
Things PHP added that are themselves attack surface
- Property hooks (8.4). Every property read/write is now potentially a userland callback. A massive new reentrancy surface for the interruption bug class.
- Lazy objects (8.4). Initializer fires on first property access. New code path through every object operation.
- Fibers (8.1). Coroutine-style suspension that captures C-runtime stack state. Fertile ground for missed-state bugs.
- OPcache JIT (8.0). Compiles PHP to native at runtime. W^X concerns; an attacker who can write to the JIT region gets arbitrary native code execution trivially.
- FFI extension (7.4). If enabled, completely bypasses every PHP-internal restriction by design. Should be off everywhere except specific trusted contexts.
- URI extension (8.5).
Uri\Rfc3986\UriandUri\WhatWg\Urlexpose new C parser glue arounduriparserand Lexbor. It is the highest-EV 8.5 fuzzing target because attacker-controlled URL strings are normal web input and several post-release parser findings already landed in 8.5.x. - Pipe operator (8.5). A new compiler path with fresh zval/refcount discipline across operator boundaries. Its shape matches the historical concat-operator interruption family.
- Clone-with and clone-as-callable (8.5).
clone($obj, ['prop' => $val])fires property writes during clone;clone(...)can now run inside higher-order C loops likearray_map. Both are new reentrancy points. - Closures in attributes (8.5). Reflection over attributes can now execute userland closures when arguments are consumed. This is not memory corruption; it is a new logic-level RCE footgun for frameworks that reflect attacker-influenced classes.
Third-party hardening, briefly
Suhosin is dead. Last release was for PHP 5.x. Do not deploy. Suhosin-NG is also effectively dead. Snuffleupagus is actively maintained, PHP 7/8 compatible, and the closest modern equivalent to what Suhosin used to provide. It is the only credible PHP-internal hardening extension in 2026. It is, however, still subject to all the caveats above - it lives in the same process and falls to the same primitive.
Hardening guide for PHP 8.5
Treat PHP-internal restrictions as defense-in-depth, not as a security boundary. A security boundary is something an attacker cannot cross from inside even with arbitrary code execution. PHP code running in a process can rewrite anything that lives in that process. The boundary therefore has to be at the OS or hypervisor level: process privilege separation, namespaces, seccomp, virtualization.
The guide below is structured in five layers:
- Layer 0 - disable PHP features that ship a bypass primitive directly
- Layer 1 - kernel-enforced isolation
- Layer 2 - PHP-FPM and SAPI configuration
- Layer 3 -
php.inidefense-in-depth - Layer 4 - application practices
Layer 0 is new in this revision and exists for a specific reason: several documented PHP features and PECL extensions ship a bypass primitive directly - no bug required. If any of them is reachable from request-scope code, nothing else in this section matters; the attacker doesn’t need to find an interruption-class bug because the language already gave them what such a bug would provide. FFI is the most prominent member of this category but not the only one. Layer 0 enumerates the full catalog and gives a single audit script to verify each host. Close it before relying on any layer below.
Layer 0 - Close the documented bypass primitives (mandatory)
These are PHP features and PECL extensions that - when reachable from request-scope code - give an attacker C-level capability without needing a memory-corruption bug. The full catalog is broader than just FFI. Most of these have safe defaults in modern PHP-FPM. The work is not setting them; it’s auditing every host to verify nothing was flipped to “on” for some forgotten reason. A single misconfigured directive on one host in a fleet undoes everything else in this document.
Category A - Documented C-level bypass features
Explicitly designed to give PHP code C-level reach. Their threat model is well-understood and their defaults are SAPI-aware.
ffi.enable- three accepted values:false- FFI never callable from any SAPI. Safe.preload(default) - FFI callable from CLI scripts and fromopcache.preloadfiles only. Request-scope FPM/Apache code cannot call FFI. Acceptable for most deployments. The catch: handles created in preload context remain valid in request scope, so if your preload file leaks anFFIhandle into a static property, request-scope code can use it. Treat preload files like setuid binaries.true- FFI callable from every SAPI including FPM. This is the bypass condition. Set only if you have a specific audited reason and only on isolated hosts.
enable_dl- controlsdl().Offin FPM/CGI by default,Onin CLI. Ifenable_dl=Onin your FPM config and the attacker can write a.soto a directory on the extension search path, they have loaded-extension privilege.extension=directive inphp.ini- not a runtime bypass, but worth flagging. Extensions loaded viaextension=are loaded by FPM’s master process at startup, before privilege drop. Restrict write access to the extension directory andphp.iniitself to root.
php -i | grep -iE '^(ffi.enable|enable_dl)\b'
# expected (default FPM): ffi.enable => preload
# enable_dl => Off
Category B - Runtime-rewriting PECL extensions
Exist for testing and debugging. Dangerous in production because they ship the same capability an interruption bug provides - runtime function/method/class rewriting - as documented APIs that any PHP script can call.
runkit7- providesrunkit_function_redefine,runkit_method_redefine,runkit_constant_redefine, etc. PECL. Never install on production.uopz- providesuopz_set_return,uopz_redefine,uopz_overload, etc. PECL. Originally a test-double helper for PHPUnit. Never install on production.xdebug- primarily a debugger, but some modes expose runtime introspection and a remote-debugger TCP server. The clean answer is “not installed in production.”
php -m | grep -iE '^(runkit|uopz|xdebug)\b'
# expected: empty
Category C - Eval-shaped language constructs
These execute attacker-controllable PHP code at PHP’s privilege level. They go through the dispatcher (so disable_functions is nominally enforced for what they call), but they bypass application-level input validation.
eval()- language construct, cannot be blocked bydisable_functions. Snuffleupagus’ssp.disable_eval = "drop"closes it.assert()with string argument - historically equivalent toeval. PHP 8.0 madeassert(string)no longer auto-evaluate, but the function still exists and legacy code may still use it dangerously.create_function()- eval-based closure creation. Removed in PHP 8.0.- PCRE
emodifier -preg_replace('/.../e', ...)was eval-on-match. Removed in PHP 7.0.
# any literal eval() or assert() with a string argument in your codebase
# is a candidate for review
grep -rE '\beval\s*\(|\bassert\s*\([^)]*\$' /path/to/app
Category D - Privilege-escalation surfaces
Not bypass primitives themselves but features that elevate the privileges of code that runs through them.
opcache.preload- runs as the user FPM was launched as at startup, before workers fork or privilege drops. Code in preloaded files has effectively-elevated privileges, and any object/handle/static-property they leave reachable from autoloaders is reachable from request-scope code at the elevated privilege. The classic exploit: preload definesclass FfiHelper { public static FFI $libc; }; request-scope code reachesFfiHelper::$libc->system(...)even thoughffi.enable=preloadshould prevent direct FFI use. If you use preload, audit the preload file with the same care as a setuid binary.- Static
extension=loaded extensions with elevated handles - similar pattern. If a loaded extension exposes objects with process-privileged handles, request-scope PHP can inherit them. opcache.jit- JIT allocates RWX memory pages for compiled code. Combined with any write primitive, an instant native-code execution target. Setopcache.jit = disableunless you’ve measured a concrete throughput gain.opcache.file_cache- empty by default in modern PHP, but if set, writes compiled bytecode to disk. If the cache directory is writable by the PHP process, an attacker with write capability can poison the cache for the next request. Setopcache.file_cache = ""unconditionally.
php -i | grep -iE 'opcache\.(jit|file_cache|preload)'
# expected: opcache.jit => disable
# opcache.file_cache => (empty)
# opcache.preload => (empty, OR a path you've audited)
Category E - Image/document/archive parsers reachable from request scope
Not direct C-level bypass, but library-level RCE primitives that operate inside the PHP process. Worth knowing about because hardening PHP itself does nothing to constrain them.
- Imagick / GD with shellout features. Imagick historically delegated to
ghostscriptviasystem(3)calls inside the C library - bypassing PHP’sdisable_functionsbecause the call came from libMagickCore, not from PHP code. Modern Imagick haspolicy.xmlto restrict delegate use; verify it’s restrictive. - Phar stream wrapper.
file_exists("phar://attacker.phar")pre-PHP 8.0 triggered automatic unserialize of phar metadata, firing__destruct/__wakeupon attacker-controlled object graphs. Closed by default in 8.0, but filesystem functions touchingphar://URLs still parse phar files. php://filter- not code execution but file disclosure. Auditinclude/require/file_get_contentscall sites that take attacker input.- PHP 8.5
ext/uri. Treatnew Uri\WhatWg\Url($attacker_input)andnew Uri\Rfc3986\Uri($attacker_input)like calls into a fresh C parser. Keep 8.5.x current, fuzz URL-handling paths, and do not assume the new parser makes SSRF or redirect validation safe by itself.
Defense for Category E: application-level input validation, restrictive Imagick policy.xml, and avoiding attacker-controlled file paths. Layer 1 isolation is still the actual containment.
Summary verification block
Drop this whole block into a deployment health-check:
echo "=== PHP Layer 0 audit ==="
php -i | grep -iE '^(ffi\.enable|enable_dl|opcache\.(jit|file_cache|preload)|fatal_error_backtraces|max_memory_limit|fiber\.stack_size)\b'
echo "--- loaded modules of concern ---"
php -m | grep -iE '^(runkit|uopz|xdebug|ffi)\b' || echo " (none loaded)"
echo "--- Snuffleupagus presence ---"
php -m | grep -i snuffleupagus || echo " (not loaded - consider deploying)"
For an FPM deployment auditing default 8.5+, the expected output is:
ffi.enable => preload
enable_dl => Off
opcache.jit => disable
opcache.file_cache => (empty)
opcache.preload => (empty, or your audited preload file)
fatal_error_backtraces => Off
max_memory_limit => (your hard ceiling, not -1 on shared hosts)
fiber.stack_size => 8M, or at least not below 1M
--- loaded modules of concern ---
FFI # FFI ext loaded; OK as long as ffi.enable=preload
# and your preload file (if any) is clean
--- Snuffleupagus presence ---
snuffleupagus
If any Category B module appears, that’s a finding. If ffi.enable is true instead of preload or false, that’s a finding. If opcache.jit is anything other than disable and you don’t have explicit performance justification, that’s a finding. Fix Layer 0 before relying on anything below it.
Layer 1 - Process and OS isolation (mandatory)
Everything below this is paint. If you are not doing Layer 1, every other layer is cosmetics.
- Run each PHP application in its own kernel namespace. Containers are the path of least resistance. Each PHP-FPM pool, each tenant, each application - separate container.
- Mount the application root read-only. Uploads to a separate tmpfs with
noexec,nosuid,nodev. Sessions and logs each on their own mount. - Run PHP-FPM as an unprivileged user with no shell, no home directory write, no group memberships beyond what is strictly required.
- Drop unnecessary capabilities. PHP-FPM does not need
CAP_SYS_ADMIN,CAP_NET_ADMIN, orCAP_SETUID. Docker:--cap-drop=ALLand usually add nothing back. - Apply a seccomp filter. Block
execve,clonewith namespace flags,ptrace,mount,pivot_root,kexec_load,bpf,userfaultfd,process_vm_readv/writev, and the obscure ones nobody uses. - Apply an LSM profile. SELinux, AppArmor, Tomoyo. Confine PHP-FPM to read its application directory, write to its session/log/upload directories, talk to its database socket, and nothing else.
- Outbound network egress filter. Most successful PHP RCEs exfiltrate or download stage 2 over outbound network. Closing this is one of the highest-ROI mitigations there is.
- Resource limits via cgroups. Memory limits make heap-spray exploitation noisier. PID limits prevent fork bombs. CPU limits prevent crypto miners.
Layer 2 - PHP-FPM and SAPI configuration
Use PHP-FPM, not mod_php. mod_php runs PHP inside Apache’s address space and shares Apache’s privileges. PHP-FPM is a separate process with separate UID and capabilities, talking to the front-end via FastCGI.
A separate FPM pool per application. Each pool gets its own UID, its own chroot, its own php.ini overrides via php_admin_value / php_admin_flag. php_admin_* cannot be overridden by application code via ini_set; php_value can.
chroot = /var/www/app
chdir = /
clear_env = yes
security.limit_extensions = .php
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
process.priority = 5
rlimit_files = 1024
rlimit_core = 0
clear_env = yes prevents environment-variable leakage into the PHP process and stops LD_PRELOAD-style attacks if the FPM master was launched with extra environment. security.limit_extensions = .php prevents serving non-PHP files as PHP.
Layer 3 - php.ini hardening
Defense-in-depth only. These cost nothing to apply, raise the cost of exploitation slightly, but do not contain an attacker with arbitrary code execution. Apply them anyway.
; ====== Output and error handling ======
display_errors = Off
display_startup_errors = Off
fatal_error_backtraces = Off
log_errors = On
error_log = /var/log/php/error.log
expose_php = Off
; Hide sensitive parameters from stack traces.
zend.exception_ignore_args = On
zend.exception_string_param_max_len = 0
; ====== Code execution restrictions ======
; Note: not a security boundary; can be defeated by memory primitive.
disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source,assert,phpinfo,dl,pcntl_exec,pcntl_fork,proc_get_status,proc_terminate,posix_kill,posix_setpgid,posix_setsid,posix_setuid,posix_setgid,posix_seteuid,posix_setegid
; Do not use disable_classes.
; Removed in PHP 8.5 after engine UAF/double-free issues; ineffective before that.
; Use application design and Snuffleupagus policy instead.
; ====== Filesystem restrictions ======
open_basedir = /var/www/app:/tmp/app-uploads
allow_url_fopen = Off
allow_url_include = Off
; ====== Resource limits ======
memory_limit = 64M
max_memory_limit = 256M
post_max_size = 8M
upload_max_filesize = 4M
max_execution_time = 30
max_input_time = 30
max_input_vars = 500
fiber.stack_size = 8M
; ====== Session security ======
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_secure = 1
session.cookie_httponly = 1
session.cookie_samesite = "Strict"
session.sid_length = 48
session.sid_bits_per_character = 6
; ====== OPcache ======
opcache.enable = 1
opcache.enable_cli = 0
opcache.validate_timestamps = 0
opcache.revalidate_freq = 0
opcache.file_cache = ""
opcache.protect_memory = 1
opcache.restrict_api = "/var/www/app"
; ====== JIT ======
opcache.jit = disable
opcache.jit_buffer_size = 0
; ====== FFI ======
ffi.enable = false
A few notes on the choices above. Do not rely on disable_classes. Older versions exposed it, but it was never a reliable security boundary and PHP 8.5 removed it after confirmed engine UAF/double-free behavior. Reflection remains dangerous when applied to attacker-controlled classes, but blocking Reflection with disable_classes is the wrong control; audit the call sites and use application-level policy.
Disabling JIT saves you from a class of bugs but loses you 5-30% throughput on CPU-bound workloads. For typical web applications, I/O dominates and the JIT gain is in the noise. Disabling FFI is non-negotiable for multi-tenant or untrusted-code contexts.
For PHP 8.5, turn off fatal-error backtraces in production. fatal_error_backtraces=1 is useful during development, but if display_errors=On slips into production an attacker can deliberately trigger OOM, recursion, or type-fatal paths and receive a full stack trace. Keep display_errors=Off, fatal_error_backtraces=Off, and zend.exception_ignore_args=On.
Treat max_memory_limit as a ceiling, not as a complete DoS fix. It prevents request-scope code from raising memory_limit arbitrarily, but a low ceiling also makes attacker-triggered OOM easier to reach. Pair it with disabled fatal backtraces and front-end request-size limits. Do not lower fiber.stack_size below the default unless you have measured need; the 8.5.1 stack-overflow fix shows that small fiber stacks interact badly with recursive parser bugs such as the URI parser class.
Layer 4 - Application-level practices
- Avoid
unserialize()on attacker-controlled input. Usejson_decode. If you must, setallowed_classesto a strict allowlist. - Avoid
eval()andassert(string). The number of legitimate uses ofevalin modern PHP is approximately zero. - Treat PHP 8.5 closure-bearing attributes as executable code. Do not call
ReflectionAttribute::getArguments()on attributes from attacker-influenced classes, autoload paths, plugin packages, or serialized class names. Before 8.5 this was mostly constant-data reflection; in 8.5 it can run arbitrary static closures. - Treat URI parsing as untrusted parser surface.
Uri\WhatWg\UrlandUri\Rfc3986\Uriare better APIs thanparse_url(), but they are still fresh C parser glue. Use them behind strict allowlists for SSRF/redirect decisions, keep PHP patched, and avoid feeding unbounded attacker strings into URI constructors. - Avoid careless persistent cURL share handles. If you use
curl_share_init_persistent, scope the persistent id per tenant/user whenever shared state can encode identity. Never persistently share cookies, and be careful with connection and TLS-session reuse for mTLS or pre-authenticated transports. - Prepared statements for all SQL. PDO with
PDO::ATTR_EMULATE_PREPARES => false. - Filter and validate input at the boundary. Use the
filterextension or a typed-input library; do not roll your own. - Output-escape on the way out.
htmlspecialcharsat the template layer withENT_QUOTES | ENT_HTML5and an explicitUTF-8. - Argon2id for passwords. Tune cost parameters to your hardware.
- Pin and audit dependencies.
composer.lock,composer audit, an SCA tool.
Snuffleupagus
If you’re running PHP 8 in any kind of multi-tenant or exposed-to-the-internet context, deploy Snuffleupagus. The high-leverage directives:
disable_function- per-function, granular, supports parameter filters.sp.disable_eval = "drop"- kills eval entirely.sp.unserialize.harden = "enable"- checks against POP-chain exploitation.sp.upload.disable,sp.session.encrypt,sp.cookie.encrypt,sp.harden_random- defense-in-depth across upload, session, cookie, and RNG paths.- Per-function call-site whitelisting - block calls to risky functions except from specific files/classes. Treat its violations log as an IDS feed.
Snuffleupagus does not rescue you from a Layer-1 failure. It does raise the bar against typical exploitation chains.
Which hardening layers survive a bypass primitive?
The practical question. If an attacker has one of the bypass primitives from §2 - either tier 1 (FFI capability) or tier 2 (a fresh interruption-class memory bug) - what hardening survives the encounter?
The two primitives reach different ranges of state. Worth showing both columns side-by-side, because some controls survive an interruption bug but fall to FFI, and a few are the other way around.
| Layer | Survives interruption? | Survives FFI? | Why |
|---|---|---|---|
L0 - ffi.enable = false | YES | n/a | PHP_INI_SYSTEM, cannot be re-enabled from request scope. The single highest-leverage line of php.ini. |
L0 - opcache.jit = disable | n/a | n/a | Removes one attack surface from existing. |
L0 - no runkit/uopz/XDebug | n/a | n/a | Same - removes a primitive rather than defending one. |
| L1 - namespaces, seccomp, LSM | yes | yes | Kernel-enforced. Seccomp blocks dangerous syscalls regardless of how they were reached. |
| L1 - outbound network firewall | yes | yes | Network stack, not the process. |
| L1 - read-only filesystem | partial | partial | Code mounts read-only; writable mounts still readable/writable. |
| L1 - separate UID per pool | yes | yes | Process privilege is the UID’s privilege. |
| L2 - PHP-FPM chroot | mostly | mostly | FFI calls to open(2) are still bound by the chroot at the kernel level. |
L2 - clear_env = yes | yes | yes | Closes LD_PRELOAD-style lateral movement. |
L3 - disable_functions | NO | NO | Interruption rewrites the table entry; FFI bypasses the dispatcher entirely. |
L3 - disable_classes | NO | NO | Same class-table problem on pre-8.5; removed in 8.5 after engine UAF/double-free behavior. |
L3 - open_basedir | NO | NO | Wrapper check; FFI calls open(2), interruption zeros the basedir string. |
L3 - allow_url_fopen / _include | NO | NO | Both flags get bypassed. |
L3 - opcache.protect_memory | partial | NO | FFI-armed attacker calls mprotect(2) and reverts the protection. |
| L3 - Snuffleupagus restrictions | NO | NO | Lives in the same process. Falls to either primitive. |
| L4 - input validation, prepared statements | n/a | n/a | Bypassed by definition once attacker has RCE. |
Three patterns to internalize from this table.
- Everything at Layer 3 falls to either primitive. PHP-internal config is decoration. It catches lazy attackers and provides audit-log signal when probed; it does not contain a determined attacker.
ffi.enable = falseis the highest-leverage single setting in this entire document. It is the only line ofphp.inithat itself survives, by virtue of being checked before any FFI call happens.- Layer 1 is what actually contains the attacker. Namespaces and seccomp work the same way against FFI as they do against an interruption bug, because they are kernel-enforced and do not care which path inside the process led to the attempted syscall.
If you can only do one thing, make that thing kernel-level isolation. If you can do two things, add
ffi.enable = false. Layer 3 is for raising the cost of unsophisticated attacks, not for containing sophisticated ones.
Can FFI escape Docker?
The natural follow-up: if PHP+FFI gives an attacker C-level capability inside the process, and the process is inside a Docker container, does that contain the attacker?
It depends entirely on how the container was launched, not on FFI itself. FFI is a mechanism, not an escape technique. Once it’s reachable inside the container, the attacker has process privilege at the contained UID with whatever capabilities, namespaces, mounts, and seccomp filters that process happens to have. From there, “escaping Docker” reduces to the well-trodden Linux-container-escape literature.
What FFI changes is the cost. Without FFI (and without an interruption-class bug), an attacker with PHP RCE has to fight PHP’s dispatcher and disable_functions to even get a shell. With FFI, they are at C-level capability in three lines of PHP. The split between “escapable” and “not escapable” is now sharp and entirely about the container’s launch configuration.
Cases where FFI does get you out
Seven failure modes seen in practice. Each one is the actual norm in production deployments, not an edge case.
--privilegedcontainers - trivial escape. All capabilities, no seccomp, no AppArmor, host devices accessible.
<?php
$libc = FFI::cdef("
int mount(const char*, const char*, const char*, unsigned long, const void*);
int mkdir(const char*, unsigned int);
", "libc.so.6");
$libc->mkdir("/host", 0755);
$libc->mount("/dev/sda1", "/host", "ext4", 0, NULL);
// /host is now the host's root filesystem.
- Dangerous capabilities granted individually.
CAP_SYS_ADMIN,CAP_SYS_PTRACE,CAP_SYS_MODULE,CAP_NET_ADMIN,CAP_DAC_READ_SEARCH; each is a different escape route. - Bind-mounted Docker socket.
-v /var/run/docker.sock:/var/run/docker.sockis full host root, period. Same applies to Kubernetes API socket, Podman socket, containerd socket, and any other orchestration control plane. - Bind-mounted host paths.
-v /:/host,-v /etc:/etc,-v /var/lib/docker:/dockerhostmount,-v /var/log:/log. - Shared host namespaces.
--pid=host,--net=host,--ipc=host,--uts=host. Each collapses one isolation layer. - Running as root inside the container without user namespaces. Default Docker on most distros: container UID 0 maps directly to host UID 0.
- Outdated kernel, container reaches a kernel CVE. FFI gives direct syscall access. If the host kernel is vulnerable and the seccomp filter does not block the relevant syscalls, FFI lets the attacker stage and trigger the exploit just like a native binary would.
Cases where FFI does NOT get you out
The more important list. It tells you what an actual hardened container looks like.
- Default
docker runwith the default seccomp profile. Docker’s default profile blocksmount,umount2,pivot_root,kexec_load, module loading,bpf, namespaceclone,unshare,setns,userfaultfd, andprocess_vm_readv/writev. The failure mode is--security-opt seccomp=unconfined. - Containers with all capabilities dropped.
--cap-drop=ALLplus only the capabilities you need (PHP-FPM typically needs none). - User-namespace remapping or rootless Docker. Container UID 0 maps to a host UID like 100000.
- Read-only root filesystem with selective writable mounts.
--read-onlywith tmpfs for/tmpand/var/run. - AppArmor or SELinux confinement. Even if the attacker has FFI and somehow regains capabilities, the LSM denies the resulting filesystem accesses.
- gVisor or Kata Containers. Different model entirely; container-to-host escape now requires a hypervisor or gVisor bug.
- Properly minimal image with no useful libraries. No
bash, nogcc, nonmap, nonc, sometimes no full libc. The attacker has to write tooling in PHP-via-FFI, which is doable but slow and noisy.
What this means in practice
Most production containers are escapable in under five minutes given PHP+FFI, because:
- The Docker socket is bind-mounted “for management.”
- Host paths are bind-mounted “for logs” or “for shared state.”
- Seccomp is unconfined “because something didn’t work once.”
- The container runs as root without user namespace remapping.
- The kernel hasn’t been patched in months.
--privilegedwas added during debugging and never removed.
Each of these is the norm. Hardened containers exist and are not hard to build, but the median deployment is not hardened, and PHP is the median deployment language for “we shoved a webapp in a container.”
The hardening guide’s posture is unchanged: kernel-enforced isolation is the boundary, PHP-internal config is decoration. FFI doesn’t change that. What FFI changes is who can exercise the container’s launch-config flaws - without FFI, an attacker had to find a tier-2 memory bug; with FFI, any PHP script with ffi.enable on can do it.
Container escape self-test
A concrete script to run inside any container hosting PHP. It audits the seven failure modes above and prints a report. Anything tagged BYPASS, EXPOSED, MOUNTED, DANGEROUS, or UNCONFINED is an actual escape primitive, not a theoretical one.
#!/bin/bash
# container-escape-self-test.sh - run inside the container
# (or as a oneshot via `docker exec`). Reports likely escape paths.
set -u
echo "=== container escape self-test ==="
echo "host: $(uname -n)"
echo "kernel: $(uname -r)"
echo
# 1. FFI reachable?
echo "[1] FFI:"
if command -v php >/dev/null; then
ffi_ext=$(php -r 'echo extension_loaded("FFI") ? "loaded" : "absent";' 2>/dev/null)
ffi_ena=$(php -r 'echo ini_get("ffi.enable") ?: "(unset)";' 2>/dev/null)
echo " ext=$ffi_ext ffi.enable=$ffi_ena"
case "$ffi_ena" in
true|1|On) echo " -> ATTACKER HAS TIER-1 BYPASS PRIMITIVE" ;;
preload) echo " -> tier-1 if any preloaded class leaks an FFI handle" ;;
false|0|Off|"") echo " -> ok, FFI not reachable from request scope" ;;
esac
else
echo " php not in PATH - running in non-php container?"
fi
echo
# 2. Privileged?
echo "[2] privileged container check:"
caps=$(grep '^CapEff:' /proc/self/status | awk '{print $2}')
echo " CapEff=$caps"
capsh --decode="$caps" 2>/dev/null | tr ',' '\n' | \
grep -E 'sys_admin|sys_module|sys_ptrace|net_admin|dac_read_search|sys_rawio' && \
echo " -> DANGEROUS CAPABILITIES PRESENT"
echo
# 3. Docker / orchestration sockets exposed?
echo "[3] orchestration socket exposure:"
for sock in /var/run/docker.sock /run/docker.sock \
/var/run/containerd/containerd.sock /run/containerd/containerd.sock \
/var/run/podman/podman.sock /run/podman/podman.sock \
/var/run/crio/crio.sock; do
if [ -S "$sock" ]; then
echo " $sock EXPOSED -> HOST ROOT"
fi
done
if [ -r /var/run/secrets/kubernetes.io/serviceaccount/token ]; then
echo " k8s service-account token mounted -> CLUSTER API ACCESS"
fi
echo
# 4. Host bind mounts
echo "[4] suspicious bind mounts:"
mount | awk '$3 == "/" && $1 ~ /^\/dev\// {print " " $0 " -> HOST ROOT MOUNTED"}'
for path in /etc /var/log /var/lib/docker /root /home; do
if [ -d "$path" ]; then
fs=$(stat -c '%T' -f "$path" 2>/dev/null || echo "?")
if [ "$fs" != "overlayfs" ] && [ "$fs" != "tmpfs" ] && [ "$fs" != "?" ]; then
echo " $path on $fs -> possibly bind-mounted from host"
fi
fi
done
echo
# 5. Shared namespaces
echo "[5] shared namespaces:"
for ns in pid net ipc uts mnt user; do
inside=$(readlink "/proc/self/ns/$ns" 2>/dev/null)
echo " $ns: $inside"
done
init_comm=$(cat /proc/1/comm 2>/dev/null || echo "?")
if [ "$init_comm" = "systemd" ] || [ "$init_comm" = "init" ]; then
echo " -> PID 1 is host init ($init_comm) - likely --pid=host"
fi
echo
# 6. UID and user namespace
echo "[6] uid + user namespace:"
echo " uid=$(id -u) gid=$(id -g)"
if [ "$(id -u)" = "0" ]; then
if grep -q ' 0 0 4294967295' /proc/self/uid_map 2>/dev/null; then
echo " -> root in container == root on host (no userns remap)"
fi
fi
echo
# 7. Seccomp + LSM
echo "[7] seccomp + LSM:"
seccomp=$(grep '^Seccomp:' /proc/self/status | awk '{print $2}')
case "$seccomp" in
0) echo " Seccomp=0 -> UNCONFINED (no syscall filter)" ;;
1) echo " Seccomp=1 -> strict mode (rare)" ;;
2) echo " Seccomp=2 -> filter active" ;;
esac
if [ -r /proc/self/attr/current ]; then
lsm=$(cat /proc/self/attr/current 2>/dev/null | tr -d '\0')
echo " LSM context: ${lsm:-none}"
fi
echo
echo "=== summary ==="
echo "Anything tagged BYPASS, EXPOSED, MOUNTED, DANGEROUS, or UNCONFINED above"
echo "is an actual escape primitive, not a theoretical one."
Re-run after every container config change; a single PR adding --privileged because “we needed it for the build” is enough to undo all the other defenses.
Hardening checklist for PHP 8.5 in 2026
Concrete, in priority order. If you can only do five things, do the first five. The first item is now ffi.enable = false, not container isolation - both matter, but FFI being on means even a correctly-isolated container is one PHP script away from process privilege within that container.
- Set
ffi.enable = falseinphp.ini, system-wide. Verify withphp -i | grep ffi.enable. Confirmrunkit7/uopz/XDebug are not loaded withphp -m. This single Layer 0 step closes the tier-1 bypass and forces any attacker to find a tier-2 memory-corruption primitive - a substantial cost increase. - Run PHP-FPM (not mod_php) in its own container/namespace per tenant or per application. Containers are not VMs, but they provide kernel-enforced isolation, which is the actual security boundary PHP-internal config cannot be.
- Audit the container launch config. Run the self-test script from §9.4 inside every PHP container. Anything flagged BYPASS, EXPOSED, MOUNTED, DANGEROUS, or UNCONFINED is an actual escape primitive.
- Drop process capabilities and apply a seccomp filter to PHP-FPM. Default Docker seccomp is too permissive - write a custom profile that additionally denies
execve,mprotectwith PROT_EXEC,bpf,userfaultfd,process_vm_*, and the namespace/mount syscalls. - Outbound-network egress filter. Block everything except whitelisted destinations.
- Read-only application code mount, separate writable mount for uploads with
noexec,nosuid,nodev. - Use user-namespace remapping or rootless Docker. Container UID 0 should not equal host UID 0.
- Disable the JIT (
opcache.jit = disable) unless you have measured a concrete benefit. - For PHP 8.5, disable fatal-error backtraces in production (
fatal_error_backtraces = Off) and keepdisplay_errors = Off. The new default is useful in development and dangerous when exposed. - Set a system-level
max_memory_limiton shared hosts so request-scope code cannot raisememory_limitto exhaust the machine. Pair it with request-size limits and disabled fatal backtraces. - Do not reduce
fiber.stack_sizebelow the default unless you have measured need and tested parser-heavy code paths. Small stacks turn recursive parser bugs into easier DoS conditions. - Apply the Layer 3
php.inihardening above. Defense in depth. - Deploy Snuffleupagus with a function-call allowlist tuned to your application.
- Disable
unserializeon untrusted input application-wide; use JSON. - Audit PHP 8.5 adoption points:
Uri\*parsing of attacker-controlled URLs, closure-bearing attributes consumed through Reflection,curl_share_init_persistent,clone($obj, [...]), and pipe-heavy request transformations. - Pin and audit composer dependencies.
composer auditin CI. - Treat
eval,assert(string),create_function-equivalents,ReflectionFunction::invoke, attribute closures, and any FFI usage as code smell in your own application. - Monitor the PHP changelog and CVE feed. Apply PHP security releases within 72 hours; for 8.5, expect the URI extension and new reentrancy surfaces to produce fixes quickly.
- Test your hardening end-to-end. If you cannot get a shell on the host from inside the container, escalate UIDs, or pivot to other containers, your boundary is working. If you can, fix it.
A note on future interruption-class bugs
The bug class is structural to PHP’s executor reentrancy model, not a sequence of unrelated bugs. PHP 8.4 added property hooks and lazy objects, and PHP 8.5 adds new parser, pipe, clone, and reflection surfaces on top of them. The standard library has been audited heavily over the past decade and most pre-2020 trigger sites are closed; the audit window for the new 8.4/8.5 surfaces is much shorter.
If a new interruption-class bug surfaces - and one will - the hardening posture above should mean the impact is bounded. Untrusted PHP code can compromise the process it runs in. Always could, always will. That process is confined by namespaces, seccomp, capability drops, and an LSM profile. The blast radius is one container, one UID, one network namespace. The attacker exfiltrates whatever that container can see, which should be approximately nothing of interest beyond the application’s own data. They cannot reach other tenants, cannot pivot to the host, cannot exfiltrate over closed network paths.
This is the actual security guarantee. Everything else is paint.
PHP 8.5 feature audit update
The architectural position is unchanged: Layer 0 removes documented bypass primitives, Layer 1 contains the process when something still goes wrong, and Layer 3 remains defense-in-depth. PHP 8.5 adds new parser and reentrancy surface, but it does not change the boundary model.
The important 8.5 delta is where I would spend audit and fuzzing time:
ext/uriis the highest-EV target.Uri\Rfc3986\UriandUri\WhatWg\Urlare fresh C extension glue arounduriparserand Lexbor. URL parsing is reachable from normal web input in webhook handlers, OAuth callbacks, link previews, image proxies, feed fetchers, redirect validators, and SSRF-prone code paths. Keep 8.5.x current and treat URI constructors as untrusted parser entry points, not as validation boundaries.- The pipe operator
|>is a new operator-boundary refcount path. The historical concat-operator interruption class lived across exactly this kind of boundary. GH-19476 already touched by-reference handling in the pipe implementation before GA. Pipe-heavy transformations over tainted input are worth targeted fuzzing. - Clone-with and clone-as-callable are new reentrancy points.
clone($obj, ['prop' => $val])performs property writes that can fire hooks during clone, in the same broad family as the__set/??=UAF fixed as CVE-2024-11235.clone(...)as a callable lets clone run inside C-level loops such asarray_map, where__clonecan interrupt iteration. - Closures in attributes change Reflection’s threat model. In 8.5, consuming attribute arguments can execute static closures. Framework code that reflects attacker-influenced classes or plugin-provided classes must treat
ReflectionAttribute::getArguments()as code execution, not just metadata reading. - Static asymmetric visibility and preload state make FFI-handle leaks cleaner to express. The underlying issue existed before: preload code can leave privileged objects or FFI handles reachable from request scope. 8.5’s static-property visibility features make that pattern easier to write without making it safer.
disable_classesremoval is an operational warning for old hosts. If a pre-8.5 deployment still hasdisable_classesset, remove it. It was ineffective as a boundary and was removed after confirmed engine memory-corruption behavior.- Persistent cURL share handles erode PHP’s shared-nothing assumption. Persistent DNS, connection, TLS-session, and especially cookie state can cross request boundaries. Scope shares per tenant/user when state can encode identity; do not persistently share cookies.
- Fatal-error backtraces are now a production config risk. With
fatal_error_backtraces=1anddisplay_errors=On, an attacker who can trigger an OOM or fatal type path gets a stack trace with paths, line numbers, and possibly arguments. Set it off in production.
For 8.5 fuzzing, the order I would use is: ext/uri round-trip parser skeletons first; pipe operator skeletons second; clone-with/property-hook skeletons third; array_map(clone(...), ...) fourth. For production hardening, the order remains: close FFI and other tier-1a primitives, isolate with Layer 1, then add the 8.5-specific Layer 3 and Layer 4 checks above.
References
- Stefan Esser. State of the Art Post Exploitation in Hardened PHP Environments. Black Hat USA 2009.
- Stefan Esser. Shocking News in PHP Exploitation. POC2009 Seoul.
- Stefan Esser. Utilizing Code Reuse / Return Oriented Programming in PHP Application Exploits. Black Hat USA 2010.
- Month of PHP Security 2010, advisories MOPS-2010-001 through MOPS-2010-064.
- CVE-2010-2191 - original Esser disclosure of PHP
ZEND_CONCAT/ZEND_ASSIGN_CONCATopcode interruption. - CVE-2010-2484 - PHP
strrchr()interruption. - bugs.php.net #79836, #81705 - concat re-discovery 2020/2021, fixed PHP 8.3.0.
- Snuffleupagus -
github.com/jvoisin/snuffleupagus. - OWASP PHP Configuration Cheat Sheet.
- PHP Manual: Runtime Configuration -
php.net/manual/en/ini.list.php. - PHP Manual: Security -
php.net/manual/en/security.php. - PHP 8.5 feature audit, local note
php-8.5-feature-audit.md. - PHP 8.5 URI extension /
uriparserand Lexbor post-release fixes including GH-20502, CVE-2025-67899, and CVE-2026-42371. - PHP GH-19476 - pipe operator returning-by-reference handling.
- PHP GH-20483 - small
fiber.stack_sizestack overflow, fixed in PHP 8.5.1.