A hand presses the red emergency stop button of an industrial machine. A visual metaphor for a switch that halts the execution of code: exactly what zend.disable_eval does for eval().

Static analysis rests on a simple promise: the tool reads the code, but it does not run it. That promise is why we point analysers at code we would never execute: a pull request from a stranger, a legacy application of unknown provenance, a dependency we are evaluating. Recently, I learned that this promise is not always kept.

A sniff that runs the code it sniffs

PHPCSUtils is a suite of utility functions for use with PHP_CodeSniffer. A security vulnerability (GHSA-r6hr-vr92-vv28, CVE-2026-65954), rated high (CVSS 8.6), was found in its AbstractArrayDeclarationSniff class. Versions 1.0.0-alpha1 through 1.2.2 are affected; the issue is fixed in version 1.2.3. The maintainers published the advisory themselves and fixed the issue promptly.

To determine the effective value of an array key in its getActualArrayKey() method, the sniff took the source code of the key expression from the file under analysis and passed it to eval():

$key = eval('return ' . $content . ';' . \PHP_EOL);

$content is text from the file being analysed. An array key such as 'system'('id') was therefore not evaluated, but executed. A file crafted in a certain way could thus lead to the execution of untrusted code in a context where the execution of that code is not expected at all: while it is being analysed. Think of a CI job that runs a coding standards check on every pull request, or an editor that sniffs files as you open them.

The method was reachable through any sniff that extends AbstractArrayDeclarationSniff. Two such sniffs are known, both from PHPCSExtra: Universal.Arrays.DuplicateArrayKey and Universal.Arrays.MixedArrayKeyTypes. Those who cannot upgrade to PHPCSUtils 1.2.3 right away can disable these sniffs in their own ruleset using <exclude>.

The fact that a static analysis tool uses eval() surprised me. It breaks the assumption that static analysis does not run code. It also made me wonder whether that assumption could be enforced rather than merely trusted.

eval() is not a function

In a discussion about this vulnerability, Stefan Priebsch raised the question: can we disable eval() for the execution of a tool, implemented in PHP, that analyses PHP code?

PHP already has a configuration setting that sounds like it should cover this: disable_functions. It does not, and it cannot. eval() is not a function; it is a language construct. The compiler turns it into a dedicated opcode, ZEND_INCLUDE_OR_EVAL, the same opcode that implements include and require. disable_functions works by manipulating entries in the global function table, and eval() has no such entry. It is structurally out of reach for that mechanism.

So I explored what a dedicated switch would look like, and implemented one in a development version of PHP 8.6.

A switch for eval()

The patch introduces a new configuration setting named zend.disable_eval. It is about ten lines of engine code: a boolean in the executor globals, an INI entry, and a check at a single choke point. At runtime, every userland eval() in every SAPI funnels through one branch of one C helper in the executor, and that is where the check lives:

case ZEND_EVAL: {
    if (UNEXPECTED(EG(disable_eval))) {
        zend_throw_error(
            NULL,
            "eval() is disabled by the \"zend.disable_eval\" INI directive"
        );
        break;
    }

    // ...
}

With a script that does nothing but call eval():

<?php declare(strict_types=1);
eval('print "evil";');

the effect looks like this:

❯ php evil.php
evil

❯ php -d zend.disable_eval=1 evil.php

Fatal error: Uncaught Error: eval() is disabled by the "zend.disable_eval" INI directive

A few design decisions are worth spelling out.

The setting is ZEND_INI_SYSTEM, like disable_functions. It can be enabled in php.ini or with -d on the command line, but ini_set() cannot change it at runtime in either direction. Anything weaker would be defeated by the very code the setting is meant to contain.

The check happens at runtime, not at compile time. A file that merely contains an eval() (on a dead code path, for instance) still compiles, is still cached by OPcache, and still runs. Only actually executing eval() fails. A compile-time rejection would bake the decision into cached op-arrays, potentially across configurations, and would break code that never executes its eval().

The failure mode is a catchable Error, consistent with PHP 8 semantics where engine-level failures throw rather than die. And because the check sits in the opcode handler path, JIT-compiled code hits it too: the JIT does not inline this opcode and calls back into the same handler.

What keeps working

PHP has a second way to evaluate code strings: the engine's C-level API, which never goes through the opcode that the check guards. That API is what powers php -r, the interactive shell php -a, and the ev command of the phpdbg debugger. All of these keep working with zend.disable_eval=1.

The boundary lands exactly where it should: code passed to php -r is itself compiled through the exempt API, but an eval() inside that code compiles to the guarded opcode and is blocked. The setting disables userland eval(), not the infrastructure that SAPIs, debuggers, and extensions depend on. Hardening extensions such as Suhosin and Snuffleupagus hooked the compile-string API instead, which is why they had to special-case php -a and php -r to keep them alive.

What it does not do

This is a hardening layer, not a sandbox. With eval() disabled, dynamic code execution remains possible through other doors: include of an attacker-influenced file, FFI, or process-execution functions, unless those are separately disabled. Disabling eval() raises the cost of one specific, popular technique; it does not make PHP safe against hostile input by itself.

A second case shows how narrow that boundary is. The constant expression evaluator of PHP-Parser, the parsing foundation underneath PHPStan, Psalm, Rector, and PHPUnit, evaluated the pipe operator |> by applying the right-hand side as a callable to the left-hand side: an ordinary dynamic call, with no eval() involved. An expression such as 'id' |> 'shell_exec' in a file under analysis therefore called the function. How reachable that was depended on how a given tool uses the evaluator; it is public API and gets pointed at arbitrary nodes of the syntax tree. Versions 5.6.0 through 5.7.0 are affected; since 5.8.0 PHP-Parser hands the operator to the fallback evaluator so that the caller decides about the environment. Volker Dusch reported it.

zend.disable_eval would have prevented none of this, and that is precisely the point: nobody wrote "execute untrusted code" here, they wrote "evaluate a constant expression", and a new operator turned that into a call.

There is also real ecosystem friction. Legitimate users of eval() include mock object generators (PHPUnit's among them), some template engines when their cache is disabled, and REPL tooling such as psysh. A setting like this therefore has to default to off and be strictly opt-in: something the operator of a code-analysis service enables for the worker that touches untrusted code, not something a distribution turns on globally.

The last construct of its kind

PHP used to have several ways of turning strings into code: create_function(), preg_replace() with the /e modifier, string arguments to assert(). PHP 7 and PHP 8 removed them, one after the other. eval() is the last construct of its kind, which means that, for the first time in the language's history, a single switch is enough to turn off "strings become code" entirely.

The engineering, as the patch shows, is the easy part. The harder part is process: php-src has a healthy, hard-earned aversion to new behaviour-changing INI settings, and a change like this would need to go through the RFC process. The strongest argument in its favour is symmetry: disable_functions already exists, is widely used to harden deployments, and covers everything except the one construct it structurally cannot reach.

The vulnerability that started this exploration has been fixed where it should be fixed: in the tool. The fix keeps the eval() and guards it: it detects key expressions that could be a function call in callback notation and bows out before evaluating them. That is a legitimate choice, and it does not change the underlying point: "the tool does not do that" and "the tool cannot do that" are two different levels of assurance. And PHP-Parser shows that a tool need not even know that it is running code. A static analyser's promise not to run the code it reads is, today, a matter of trust. I would like it to be a matter of configuration.