PHP disable_functions Is Not a Sandbox

November 11, 2026 · 8 min read · By API User

A new proof-of-concept published this week shows that PHP’s disable_functions directive can be bypassed on stock PHP 8.5.12 and 8.4.27, the current stable releases. The PoC runs a command through a forged internal closure even though exec, system, shell_exec, passthru, proc_open, popen, and pcntl_exec are all disabled.

The important correction is in the framing. This is not a new PHP vulnerability in the CVE sense. It is a new public exploit for a memory-safety bug in PHP’s unserialize path, and a demonstration that disable_functions is a hardening setting, not a security boundary. PHP’s own security policy explicitly lists both “malicious unserialize() inputs” and “disable_functions bypasses” as issues it does not classify as security vulnerabilities. No CVE has been assigned.

What the PoC actually does

The exploit, published by zerozenxlabs, targets the per-request back-reference table that unserialize() uses to resolve R: and r: tokens. The table is shared across nested unserialize() calls in the same request, including a call made from inside Serializable::unserialize(). That sharing is intentional: it lets nested calls refer back to values parsed by the outer call.

The bug is that the table stores raw pointers into an object’s property table. A nested call can register pointers to that table, then grow the same object’s properties past a resize boundary. zend_hash_do_resize() allocates a new arData buffer and frees the old one, but the shared unserialize context still holds pointers into the freed buffer. A later R: token reads through those dangling slots.

The PoC then reclaims the freed chunk with a userland string, giving it PHP-level read and write through the stale zvals. It uses that primitive to find the PHP binary in memory, resolve zif_exec, forge a zend_closure whose handler points at zif_exec, and invoke it with ordinary call syntax. disable_functions never sees a call to exec() by name, because no such call happens: the filter is a name lookup at call time, and the PoC calls a hand-built internal function instead.

$inner = 'O:8:"stdClass":5:{s:2:"p0";s:2:"v0";...}';
$outer = 'a:1:{i:0;C:1:"G":' . strlen($inner) . ':{' . $inner . '}}';
@unserialize($outer, ['allowed_classes' => ['G', 'stdClass']]);

The trigger is small. The exploit chain is not: it needs /proc/self/maps and /proc/self/exe (or PHP_BINARY) to find the PHP image, and it depends on a 64-bit build with the usual allocator behavior. It is a local post-exploitation technique, not a remote code-execution bug. An attacker must already be able to run PHP code on the target, or control an unserialize() sink in an application.

The bug: a shared context with stale pointers

PHP’s unserialize context is a per-request global. php_var_unserialize_init() creates a fresh context for the outermost parse and publishes it in BG(unserialize).data. Nested calls increment a level counter and reuse the same context, so back-references work across the nesting boundary. var_push() records each parsed value as a raw zval * in var_entries, and var_access() returns those pointers when a later R: token asks for one.

Those pointers are the problem. They point at &ht->arData[i].val, and nothing updates them when the property table is resized. The context does not own the table, does not hold a reference to it, and is not told when its arData buffer is freed. In a single, non-reentrant parse this is mostly harmless: the table does not move while the parser is using it. With a nested parse that can mutate the same object, the lifetime assumptions break.

This is one of several re-entrancy hazards in the same shared-context design, and PHP has been fixing adjacent ones. Issue #24481, opened on October 21, 2026, described a use-after-free in var_destroy() during a deferred destructor pass. It was fixed in the 8.4 branch by commit 08f4b64e0ae on October 22, 2026, and merged up. A broader design issue, #24674, covers sharing the unserialize context with callers other than the C: handler; its fix, PR #24751, is still open. The PoC’s property-table resize path is not the same as the destructor teardown in #24481, so the #24481 fix should not be read as closing this exploit.

The zerozenxlabs PoC uses the Serializable::unserialize() path, one of the re-entrant paths that the shared-context design permits. That makes the underlying context-sharing design, not just the destructor teardown, the relevant boundary. The public exploit is new; the class of bug is not. PHP has had unserialize use-after-free bugs before, including CVE-2014-8142, CVE-2015-0231, CVE-2015-2787, and CVE-2015-6832.

Why disable_functions doesn’t stop it

disable_functions is implemented as a name check when PHP resolves a function call. It is not a capability system, not a seccomp filter, and not a memory-protection boundary. A PHP extension or a forged internal function that already has a handler address can call the underlying C function without going through the name lookup. That is exactly what the PoC does: it builds a fake zend_internal_function with handler = zif_exec and calls it as a closure.

This is why the bypass works even though exec() is disabled. It is also why the setting should not be sold as a sandbox for untrusted code. The PHP manual and security policy are clear that PHP does not provide sandboxing. Shared hosting panels, plugin sandboxes, and malware-analysis harnesses that rely on disable_functions alone are relying on a speed bump.

The same logic applies to open_basedir. It is a useful guardrail against accidental path traversal, but it is not a kernel-enforced jail. A memory-corruption primitive that can call arbitrary internal functions can route around userland checks.

Is this a new PHP vulnerability?

No, not by the definition PHP uses. Three facts matter:

  • The related bug is already public and fixed in source. Issue #24481 was opened on October 21, 2026. Commit 08f4b64e0ae landed on October 22. The PoC is new; the #24481 bug report and fix are not. The PoC’s exact property-table resize path is not covered by that commit.
  • Current stable releases do not include the #24481 fix. PHP 8.5.12 and 8.4.27 were released on October 21, before the commit. The next 8.4 and 8.5 releases should include it. That fix is related, not a complete answer for this exploit: the property-table resize path does not appear to have a dedicated fix in the source tree, and the closest open change, PR #24751, does not obviously cover it. If you are running 8.5.12 or 8.4.27, you are running a build with the bug.
  • PHP does not treat this as a security vulnerability. The project’s SECURITY.md lists “malicious unserialize() inputs” and “disable_functions bypasses” among issues that are not considered security issues. No CVE is listed for this issue as of publication, and there may not be one.

That does not make the bug unimportant. It makes the label important. Calling it a “new PHP zero-day” would be wrong. Calling it a “new sandbox escape for PHP’s disable_functions” is accurate, provided the reader understands that “sandbox” is the attacker’s assumption, not a PHP guarantee.

Who is affected, and what to do

The direct exposure is any environment that runs untrusted PHP code and relies on disable_functions to contain it: shared hosting, WordPress plugin sandboxes, capture-the-flag infrastructure, malware sandboxes, and CI jobs that execute submitted PHP. The exploit does not break HTTP alone, and it does not turn a file-upload bug into remote code execution by itself. It matters after an attacker has code execution, or when an application passes attacker-controlled data to unserialize().

Practical mitigations, in order:

  1. Patch PHP. Move to a release that contains commit 08f4b64e0ae for the #24481 bug, and track #24751 for the broader context-sharing issue. Until then, treat 8.5.12 and 8.4.27 as affected. Track the PHP releases page rather than assuming the latest stable has the fix.
  2. Stop treating disable_functions as a sandbox. If you must run untrusted PHP, put it in a container or VM with a separate user, a read-only filesystem, seccomp, and no network by default. Use the kernel boundary, not a PHP ini setting.
  3. Remove unserialize() from untrusted input paths. Use JSON or another data format. If you must accept serialized data, sign it, verify the signature before parsing, and use allowed_classes to restrict object types. None of these makes a memory-safety bug impossible, but they remove the attacker’s ability to reach the parser with a crafted payload.
  4. Audit re-entrant unserialize paths. Code that calls unserialize() from __wakeup(), __unserialize(), Serializable::unserialize(), error handlers, or destructors is exactly the code path this bug needs. Avoid nested parses on data you do not control.

What we verified

We reproduced the PoC against the official php:8.5-cli image, which reports PHP 8.5.12, with the repository’s exec.ini disabling exec, passthru, shell_exec, system, proc_open, popen, pcntl_exec, and proc_nice. The harness reached [H] OK, invoked the forged closure with id; id -u, and printed BYPASS OK: 0; the 0 is the output of id -u, the root UID. The repository also claims reproduction on 8.4.27; we did not independently rerun that build.

The reproduction is a useful sanity check, not a severity score. It confirms that the code path works on a current, hardened CLI build. It does not change the classification: this is a local sandbox escape and a memory-safety bug, not a remotely exploitable PHP vulnerability.

The bottom line

PHP’s disable_functions was never a security boundary, and this PoC is a concrete reminder. The bug is real, the exploit is public, and current stable releases are affected. A fix for the related #24481 bug is already in the source tree, the broader context-sharing issue is still open, no CVE is expected, and the exploit requires code execution before it can do anything. Patch to the next PHP release, stop using disable_functions as a jail, and remove unserialize() from untrusted input paths. Those three steps address the risk without repeating the “new PHP zero-day” framing that the PHP project explicitly rejects.

Sources and References