Eine Hand drückt den roten Not-Aus-Schalter einer Industriemaschine. Ein visuelles Sinnbild für einen Schalter, der die Ausführung von Code anhält: genau das, was zend.disable_eval für eval() tut.

Statische Analyse beruht auf einem einfachen Versprechen: Das Werkzeug liest den Code, aber es führt ihn nicht aus. Dieses Versprechen ist der Grund, warum wir Analysewerkzeuge auf Code richten, den wir niemals ausführen würden: einen Pull Request von Fremden, eine Legacy-Anwendung unbekannter Herkunft, eine Abhängigkeit, die wir gerade evaluieren. Kürzlich habe ich gelernt, dass dieses Versprechen nicht immer gehalten wird.

Ein Sniff, der den Code ausführt, den er prüft

PHPCSUtils ist eine Sammlung von Hilfsfunktionen für PHP_CodeSniffer. In der Klasse AbstractArrayDeclarationSniff wurde eine Sicherheitslücke gefunden (GHSA-r6hr-vr92-vv28, CVE-2026-65954), die als hoch eingestuft ist (CVSS 8.6). Betroffen sind die Versionen 1.0.0-alpha1 bis einschließlich 1.2.2; behoben ist die Lücke in Version 1.2.3. Die Maintainer haben die Lücke selbst veröffentlicht und zügig behoben.

Um in der Methode getActualArrayKey() den effektiven Wert eines Array-Schlüssels zu bestimmen, nahm der Sniff den Quellcode des Schlüssel-Ausdrucks aus der analysierten Datei und übergab ihn an eval():

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

$content ist Text aus der Datei, die analysiert wird. Ein Array-Schlüssel wie 'system'('id') wurde deshalb nicht ausgewertet, sondern ausgeführt. Eine entsprechend präparierte Datei konnte also dazu führen, dass nicht vertrauenswürdiger Code in einem Kontext ausgeführt wird, in dem niemand damit rechnet: während er analysiert wird. Denk an einen CI-Job, der bei jedem Pull Request eine Coding-Standards-Prüfung ausführt, oder an einen Editor, der Dateien beim Öffnen snifft.

Erreichbar war die Methode über jeden Sniff, der von AbstractArrayDeclarationSniff erbt. Bekannt sind zwei solche Sniffs aus PHPCSExtra: Universal.Arrays.DuplicateArrayKey und Universal.Arrays.MixedArrayKeyTypes. Wer nicht sofort auf PHPCSUtils 1.2.3 aktualisieren kann, kann diese Sniffs im eigenen Ruleset mit <exclude> abschalten.

Dass ein statisches Analysewerkzeug eval() verwendet, hat mich überrascht. Es bricht mit der Annahme, dass statische Analyse keinen Code ausführt. Und es hat mich zu der Frage gebracht, ob sich diese Annahme erzwingen lässt, statt ihr nur zu vertrauen.

eval() ist keine Funktion

In einer Diskussion über diese Sicherheitslücke stellte Stefan Priebsch die Frage: Können wir eval() für die Ausführung eines in PHP implementierten Werkzeugs abschalten, das PHP-Code analysiert?

PHP hat bereits eine Konfigurationseinstellung, die klingt, als sollte sie genau das leisten: disable_functions. Sie tut es nicht, und sie kann es nicht. eval() ist keine Funktion, sondern ein Sprachkonstrukt. Der Compiler übersetzt es in einen eigenen Opcode, ZEND_INCLUDE_OR_EVAL; denselben Opcode, der auch include und require implementiert. disable_functions arbeitet mit Einträgen in der globalen Funktionstabelle, und eval() hat dort keinen Eintrag. Für diesen Mechanismus ist es strukturell unerreichbar.

Also habe ich untersucht, wie ein eigener Schalter aussehen könnte, und einen in einer Entwicklungsversion von PHP 8.6 implementiert.

Ein Schalter für eval()

Der Patch führt eine neue Konfigurationseinstellung namens zend.disable_eval ein. Sie besteht aus rund zehn Zeilen Engine-Code: ein Boolean in den Executor-Globals, ein INI-Eintrag und eine Prüfung an einer einzigen Engstelle. Zur Laufzeit läuft jedes eval() aus Userland-Code in jedem SAPI durch einen Zweig eines einzigen C-Helpers im Executor, und genau dort sitzt die Prüfung:

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

    // ...
}

Mit einem Skript, das nichts anderes tut, als eval() aufzurufen:

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

sieht der Effekt so aus:

❯ 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

Einige Design-Entscheidungen sind es wert, festgehalten zu werden.

Die Einstellung ist ZEND_INI_SYSTEM, wie disable_functions. Sie kann in der php.ini oder mit -d auf der Kommandozeile aktiviert werden, aber ini_set() kann sie zur Laufzeit nicht ändern; in keine der beiden Richtungen. Alles Schwächere würde genau der Code aushebeln, den die Einstellung in Schach halten soll.

Die Prüfung erfolgt zur Laufzeit, nicht beim Kompilieren. Eine Datei, die ein eval() lediglich enthält (zum Beispiel auf einem toten Codepfad), wird weiterhin kompiliert, von OPcache gecacht und ausgeführt. Nur die tatsächliche Ausführung von eval() schlägt fehl. Eine Ablehnung beim Kompilieren würde die Entscheidung in gecachte Op-Arrays einbrennen, womöglich über Konfigurationen hinweg, und sie würde Code kaputtmachen, der sein eval() nie ausführt.

Der Fehlerfall ist ein abfangbarer Error, konsistent mit der Semantik von PHP 8, in der Fehler auf Engine-Ebene geworfen werden, statt die Ausführung sofort zu beenden. Und weil die Prüfung im Pfad des Opcode-Handlers sitzt, trifft auch JIT-kompilierter Code auf sie: Der JIT inlined diesen Opcode nicht, sondern ruft denselben Handler auf.

Was weiterhin funktioniert

PHP hat einen zweiten Weg, Code aus Strings auszuwerten: die C-API der Engine, die nie durch den Opcode läuft, den die Prüfung bewacht. Diese API steckt hinter php -r, der interaktiven Shell php -a und dem ev-Kommando des Debuggers phpdbg. All das funktioniert mit zend.disable_eval=1 weiterhin.

Die Grenze verläuft genau dort, wo sie verlaufen sollte: Code, der an php -r übergeben wird, wird selbst über die ausgenommene API kompiliert, aber ein eval() innerhalb dieses Codes wird zum bewachten Opcode kompiliert und blockiert. Die Einstellung schaltet eval() in Userland-Code ab, nicht die Infrastruktur, auf die SAPIs, Debugger und Extensions angewiesen sind. Hardening-Extensions wie Suhosin und Snuffleupagus haben stattdessen die Compile-String-API gehookt. Deshalb mussten sie php -a und php -r als Sonderfälle behandeln, um sie am Leben zu erhalten.

Was es nicht leistet

Das ist eine Hardening-Maßnahme, keine Sandbox. Auch mit abgeschaltetem eval() bleibt dynamische Codeausführung durch andere Türen möglich: include einer Datei, die Angreifende beeinflussen können, FFI oder Funktionen zur Prozessausführung, sofern diese nicht separat abgeschaltet sind. eval() abzuschalten verteuert eine bestimmte, beliebte Technik. Es macht PHP nicht von allein sicher gegen feindselige Eingaben.

Wie eng diese Grenze ist, zeigt ein zweiter Fall. Der Constant Expression Evaluator von PHP-Parser, dem Parser-Fundament unter PHPStan, Psalm, Rector und PHPUnit, wertete den Pipe-Operator |> aus, indem er die rechte Seite als Callable auf die linke anwandte: ein gewöhnlicher dynamischer Aufruf, ohne eval(). Ein Ausdruck wie 'id' |> 'shell_exec' aus einer analysierten Datei rief damit die Funktion auf. Wie gut erreichbar das war, hing davon ab, wie das jeweilige Werkzeug den Evaluator einsetzt; er ist öffentliche API und wird auf beliebige Knoten des Syntaxbaums angewendet. Betroffen sind die Versionen 5.6.0 bis 5.7.0; seit 5.8.0 reicht PHP-Parser den Operator an den Fallback-Evaluator durch, damit der Aufrufer über die Umgebung entscheidet. Gemeldet hat das Volker Dusch.

zend.disable_eval hätte hier nichts verhindert, und genau das ist der Punkt: Niemand hat „führe fremden Code aus“ geschrieben, sondern „werte einen konstanten Ausdruck aus“, und ein neuer Operator hat daraus einen Aufruf gemacht.

Dazu kommt echte Reibung im Ökosystem. eval() wird auch legitim verwendet: von Generatoren für Mock-Objekte (auch dem von PHPUnit), von manchen Template-Engines bei deaktiviertem Cache und von REPL-Werkzeugen wie psysh. Eine solche Einstellung muss deshalb standardmäßig aus sein und strikt Opt-in bleiben: etwas, das für den Worker eines Code-Analyse-Dienstes aktiviert wird, der nicht vertrauenswürdigen Code anfasst; nicht etwas, das eine Distribution global einschaltet.

Das letzte Konstrukt seiner Art

PHP hatte einmal mehrere Wege, Strings in Code zu verwandeln: create_function(), preg_replace() mit dem /e-Modifikator, String-Argumente für assert(). PHP 7 und PHP 8 haben sie nacheinander entfernt. eval() ist das letzte Konstrukt seiner Art. Das bedeutet: Zum ersten Mal in der Geschichte der Sprache genügt ein einziger Schalter, um „Strings werden zu Code“ vollständig abzuschalten.

Die Technik ist, wie der Patch zeigt, der einfache Teil. Der schwierigere Teil ist der Prozess: php-src hat eine gesunde, hart erarbeitete Abneigung gegen neue INI-Einstellungen, die das Verhalten ändern, und eine Änderung wie diese müsste durch den RFC-Prozess. Das stärkste Argument dafür ist Symmetrie: disable_functions existiert bereits, wird breit genutzt, um Installationen zu härten, und deckt alles ab, außer dem einen Konstrukt, das es strukturell nicht erreichen kann.

Die Sicherheitslücke, mit der diese Erkundung begann, wurde dort behoben, wo sie behoben werden sollte: im Werkzeug. Der Fix behält das eval() und schützt es: Er erkennt Schlüssel-Ausdrücke, die ein Funktionsaufruf in Callback-Schreibweise sein könnten, und wertet sie gar nicht erst aus. Das ist eine legitime Entscheidung, und sie ändert nichts an einem grundsätzlichen Punkt: „Das Werkzeug tut das nicht“ und „das Werkzeug kann das nicht“ sind zwei verschiedene Stufen von Gewissheit. Und PHP-Parser zeigt, dass ein Werkzeug nicht einmal wissen muss, dass es Code ausführt. Dass ein statisches Analysewerkzeug den Code, den es liest, nicht ausführt, ist heute eine Frage des Vertrauens. Ich möchte, dass es eine Frage der Konfiguration ist.