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.

Updated April 2026 for PHP 8.5

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.

Foreword

This document covers two related questions:

  1. 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.)?
  2. 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.

DirectiveNominal purposeActual mechanism
disable_functionsblock specific dangerous callsstring compared at function dispatch time
disable_classesblock specific dangerous classesstring compared at class instantiation time; removed in PHP 8.5
open_basedirrestrict filesystem accesspath-prefix check inside fopen wrappers
allow_url_fopenblock remote URL loadingflag check inside http/ftp wrappers
allow_url_includeblock remote includesflag check during include resolution
safe_moderemoved in PHP 5.4gone

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.

PHP-internal controls share the attacker's process address space Attacker-controlled PHP code, PHP-internal controls, runtime tables, and extension policy all live inside the same PHP worker process. Memory primitives or reachable FFI cross the false internal boundary, while kernel isolation remains outside the process. PHP-FPM worker process one address space: executor globals, ini table, function table, extension state attacker PHP webshell / POP / RCE PHP-internal controls disable_functions open_basedir, wrappers runtime tables EG / SG / handlers not a boundary same process rewrite state memory primitive / FFI bypasses dispatcher checks real boundary: UID + namespace + seccomp + LSM + network policy outside PHP
Fig. PHP's language-level controls live in the same worker process as attacker-controlled PHP code. They are useful friction, not a security boundary; the boundary has to be outside the process.

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:

TierPrimitiveAttacker needs in default FPMCost
1aFFI extension (direct call)ffi.enable=true (non-default), or a preload file that leaks an FFI handle into request scopenothing if either holds
1adl() runtime extension loadenable_dl=On (non-default in FPM) plus a writable extension dirnothing if both hold
1aRuntime-rewriting PECL (runkit7, uopz)the extension installed in productionnothing if installed
1bInterruption-class memory bugone unpatched bug in any reachable C functiondiscovery effort, possibly months of fuzzing
2Application-level RCE escalationPOP 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, or unpack returns 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) or concat_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 a zend_function struct that contains a pointer to the C-level handler (zif_exec, zif_system, zif_open, etc.).
  • EG.ini_directives - the live, parsed copy of php.ini settings.
  • EG.user_error_handler, EG.user_exception_handler - settable from PHP, useful because their addresses pin down EG’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 removed exec,system,passthru at module init, those entries are simply absent from EG.function_table. Re-insert them: duplicate a still-enabled zend_function struct, overwrite its handler pointer with the address of zif_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 the open_basedir char* in the ini directive table and zero its first byte. From the next path resolution forward, open_basedir is effectively unset.
  • Defeat allow_url_fopen / allow_url_include. These are zend_long values in the ini table. Set them to 1.
  • Defeat disable_classes. Same mechanism as functions, applied to EG.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 its MINIT. By default enable_dl=Off in FPM/CGI but On in CLI. If the operator has flipped it on for FPM and an attacker can write a .so to a directory in the extension search path, that’s a bypass.
  • Runtime-rewriting PECL extensions - runkit7 and uopz. 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=debug and xdebug.mode=develop expose runtime introspection and a remote-debugger TCP server that has been used in real attacks. Even xdebug.mode=off keeps the extension loaded in process memory; the clean answer is “not installed in production.”
  • eval() and assert() 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 bypass disable_functions but they do bypass application-level input validation. Snuffleupagus’s sp.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 $_GET parameters 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 in zend_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, and zend.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_rand is 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 by disable_functions because it’s a language construct. Snuffleupagus provides disable_eval.
  • assert() with string arguments. Worked as a hidden eval in 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+ added allowed_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\Uri and Uri\WhatWg\Url expose new C parser glue around uriparser and 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 like array_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.ini defense-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)

Highest leverage in the entire document

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 from opcache.preload files 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 an FFI handle 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 - controls dl(). Off in FPM/CGI by default, On in CLI. If enable_dl=On in your FPM config and the attacker can write a .so to a directory on the extension search path, they have loaded-extension privilege.
  • extension= directive in php.ini - not a runtime bypass, but worth flagging. Extensions loaded via extension= are loaded by FPM’s master process at startup, before privilege drop. Restrict write access to the extension directory and php.ini itself 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 - provides runkit_function_redefine, runkit_method_redefine, runkit_constant_redefine, etc. PECL. Never install on production.
  • uopz - provides uopz_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 by disable_functions. Snuffleupagus’s sp.disable_eval = "drop" closes it.
  • assert() with string argument - historically equivalent to eval. PHP 8.0 made assert(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 e modifier - 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 defines class FfiHelper { public static FFI $libc; }; request-scope code reaches FfiHelper::$libc->system(...) even though ffi.enable=preload should 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. Set opcache.jit = disable unless 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. Set opcache.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 ghostscript via system(3) calls inside the C library - bypassing PHP’s disable_functions because the call came from libMagickCore, not from PHP code. Modern Imagick has policy.xml to 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/__wakeup on attacker-controlled object graphs. Closed by default in 8.0, but filesystem functions touching phar:// URLs still parse phar files.
  • php://filter - not code execution but file disclosure. Audit include/require/file_get_contents call sites that take attacker input.
  • PHP 8.5 ext/uri. Treat new Uri\WhatWg\Url($attacker_input) and new 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)

The only layer that actually contains a PHP RCE

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, or CAP_SETUID. Docker: --cap-drop=ALL and usually add nothing back.
  • Apply a seccomp filter. Block execve, clone with 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. Use json_decode. If you must, set allowed_classes to a strict allowlist.
  • Avoid eval() and assert(string). The number of legitimate uses of eval in 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\Url and Uri\Rfc3986\Uri are better APIs than parse_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 filter extension or a typed-input library; do not roll your own.
  • Output-escape on the way out. htmlspecialchars at the template layer with ENT_QUOTES | ENT_HTML5 and an explicit UTF-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.

LayerSurvives interruption?Survives FFI?Why
L0 - ffi.enable = falseYESn/aPHP_INI_SYSTEM, cannot be re-enabled from request scope. The single highest-leverage line of php.ini.
L0 - opcache.jit = disablen/an/aRemoves one attack surface from existing.
L0 - no runkit/uopz/XDebugn/an/aSame - removes a primitive rather than defending one.
L1 - namespaces, seccomp, LSMyesyesKernel-enforced. Seccomp blocks dangerous syscalls regardless of how they were reached.
L1 - outbound network firewallyesyesNetwork stack, not the process.
L1 - read-only filesystempartialpartialCode mounts read-only; writable mounts still readable/writable.
L1 - separate UID per poolyesyesProcess privilege is the UID’s privilege.
L2 - PHP-FPM chrootmostlymostlyFFI calls to open(2) are still bound by the chroot at the kernel level.
L2 - clear_env = yesyesyesCloses LD_PRELOAD-style lateral movement.
L3 - disable_functionsNONOInterruption rewrites the table entry; FFI bypasses the dispatcher entirely.
L3 - disable_classesNONOSame class-table problem on pre-8.5; removed in 8.5 after engine UAF/double-free behavior.
L3 - open_basedirNONOWrapper check; FFI calls open(2), interruption zeros the basedir string.
L3 - allow_url_fopen / _includeNONOBoth flags get bypassed.
L3 - opcache.protect_memorypartialNOFFI-armed attacker calls mprotect(2) and reverts the protection.
L3 - Snuffleupagus restrictionsNONOLives in the same process. Falls to either primitive.
L4 - input validation, prepared statementsn/an/aBypassed by definition once attacker has RCE.

Three patterns to internalize from this table.

  1. 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.
  2. ffi.enable = false is the highest-leverage single setting in this entire document. It is the only line of php.ini that itself survives, by virtue of being checked before any FFI call happens.
  3. 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.

  1. --privileged containers - 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.
  1. 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.
  2. Bind-mounted Docker socket. -v /var/run/docker.sock:/var/run/docker.sock is full host root, period. Same applies to Kubernetes API socket, Podman socket, containerd socket, and any other orchestration control plane.
  3. Bind-mounted host paths. -v /:/host, -v /etc:/etc, -v /var/lib/docker:/dockerhostmount, -v /var/log:/log.
  4. Shared host namespaces. --pid=host, --net=host, --ipc=host, --uts=host. Each collapses one isolation layer.
  5. Running as root inside the container without user namespaces. Default Docker on most distros: container UID 0 maps directly to host UID 0.
  6. 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.

  1. Default docker run with the default seccomp profile. Docker’s default profile blocks mount, umount2, pivot_root, kexec_load, module loading, bpf, namespace clone, unshare, setns, userfaultfd, and process_vm_readv/writev. The failure mode is --security-opt seccomp=unconfined.
  2. Containers with all capabilities dropped. --cap-drop=ALL plus only the capabilities you need (PHP-FPM typically needs none).
  3. User-namespace remapping or rootless Docker. Container UID 0 maps to a host UID like 100000.
  4. Read-only root filesystem with selective writable mounts. --read-only with tmpfs for /tmp and /var/run.
  5. AppArmor or SELinux confinement. Even if the attacker has FFI and somehow regains capabilities, the LSM denies the resulting filesystem accesses.
  6. gVisor or Kata Containers. Different model entirely; container-to-host escape now requires a hypervisor or gVisor bug.
  7. Properly minimal image with no useful libraries. No bash, no gcc, no nmap, no nc, 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.
  • --privileged was 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.

  1. Set ffi.enable = false in php.ini, system-wide. Verify with php -i | grep ffi.enable. Confirm runkit7/uopz/XDebug are not loaded with php -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.
  2. 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.
  3. 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.
  4. 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, mprotect with PROT_EXEC, bpf, userfaultfd, process_vm_*, and the namespace/mount syscalls.
  5. Outbound-network egress filter. Block everything except whitelisted destinations.
  6. Read-only application code mount, separate writable mount for uploads with noexec,nosuid,nodev.
  7. Use user-namespace remapping or rootless Docker. Container UID 0 should not equal host UID 0.
  8. Disable the JIT (opcache.jit = disable) unless you have measured a concrete benefit.
  9. For PHP 8.5, disable fatal-error backtraces in production (fatal_error_backtraces = Off) and keep display_errors = Off. The new default is useful in development and dangerous when exposed.
  10. Set a system-level max_memory_limit on shared hosts so request-scope code cannot raise memory_limit to exhaust the machine. Pair it with request-size limits and disabled fatal backtraces.
  11. Do not reduce fiber.stack_size below the default unless you have measured need and tested parser-heavy code paths. Small stacks turn recursive parser bugs into easier DoS conditions.
  12. Apply the Layer 3 php.ini hardening above. Defense in depth.
  13. Deploy Snuffleupagus with a function-call allowlist tuned to your application.
  14. Disable unserialize on untrusted input application-wide; use JSON.
  15. 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.
  16. Pin and audit composer dependencies. composer audit in CI.
  17. Treat eval, assert(string), create_function-equivalents, ReflectionFunction::invoke, attribute closures, and any FFI usage as code smell in your own application.
  18. 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.
  19. 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:

  1. ext/uri is the highest-EV target. Uri\Rfc3986\Uri and Uri\WhatWg\Url are fresh C extension glue around uriparser and 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.
  2. 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.
  3. 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 as array_map, where __clone can interrupt iteration.
  4. 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.
  5. 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.
  6. disable_classes removal is an operational warning for old hosts. If a pre-8.5 deployment still has disable_classes set, remove it. It was ineffective as a boundary and was removed after confirmed engine memory-corruption behavior.
  7. 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.
  8. Fatal-error backtraces are now a production config risk. With fatal_error_backtraces=1 and display_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_CONCAT opcode 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 / uriparser and 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_size stack overflow, fixed in PHP 8.5.1.