[Pre-RFC] Honour Linux AT_EXECVE_CHECK: W^X for PHP scripts

From: Date: Tue, 10 Nov 2026 12:03:35 +0000
Subject: [Pre-RFC] Honour Linux AT_EXECVE_CHECK: W^X for PHP scripts
Groups: php.internals 
Request: Send a blank email to internals+get-133055@lists.php.net to get a copy of this message
Dear friends, I'd like to get early feedback on a small change: making PHP honour the Linux AT_EXECVE_CHECK mechanism, so that a system-wide W^X policy can also cover PHP code, not only native binaries. Background ---------- Linux 6.14 added two related features [1]: - AT_EXECVE_CHECK, a flag for execveat(2). It asks the kernel "could this file be executed?" without executing it. The kernel applies the same checks as a real execve(): the noexec mount option, the executable bit, and LSM policies such as IPE or SELinux. - Two securebits, set per process and inherited by children: SECBIT_EXEC_RESTRICT_FILE and SECBIT_EXEC_DENY_INTERACTIVE. They tell script interpreters to enforce the result of that check. Today a system can forbid running an unknown binary, but php /tmp/payload.php or an include of an uploaded file still works: the interpreter is the loophole. The kernel side was designed for interpreters to close it. What the patch does ------------------- compile_file() checks the descriptor a script is read from, before compiling it. The check always runs, so it can be audited (for example with strace), but it is only enforced when a securebit is set: - SECBIT_EXEC_RESTRICT_FILE: the main script, include and require from a file that isn't executable throw an Error. This happens at compile time, before any of its functions or classes are bound. Streams with no file behind them, such as data:// and user stream wrappers, are refused too. - SECBIT_EXEC_DENY_INTERACTIVE: code that doesn't come from an executable file is refused. That covers php -r/-B/-R/-E, php -a, piped stdin and including php://stdin. Notes: - phar entries are checked against the archive file, and a Phar::mount()ed file is checked against itself. - php://filter over a plain file is checked against that file. - php -l still works on any file, because it doesn't execute anything. There is no INI setting. The policy belongs to whoever starts the process (systemd, a container runtime, an administrator), and is enforced the same way for every interpreter on the system. Without the securebits, PHP behaves exactly as before. The cost is one execveat() syscall per compiled file. With opcache, a cache hit compiles nothing, so it costs nothing. On other platforms and on older kernels, nothing changes. The patch is small: about 300 lines across Zend/zend_stream.c, compile_file(), main, phar and the CLI SAPI. https://github.com/mihalicyn/php-src/commits/at_execve_check_support Internals changes, listed in UPGRADING.INTERNALS: - zend_stream has a new fd callback. Code that builds ZEND_HANDLE_STREAM handles itself should set it. If it doesn't, the code is treated as not coming from a file. - There is a new PHP_STREAM_OPTION_EXEC_FD stream option, with a php_stream_get_exec_fd() helper. It lets a stream that reads code from part of a file (an archive entry) name the file to check. - There is a new ZEND_COMPILE_NO_EXEC_CHECK compiler option, for code that is compiled but not executed. Field test ---------- I ran phpBB 3.3 with SQLite under SECBIT_EXEC_RESTRICT_FILE, on PHP's built-in server. A crawl of the board and of the admin panel passes. Running chmod a-x on a file makes every page that uses it fail with "Failed to execute ...: blocked by EXEC_RESTRICT_FILE". As we know real applications pretty often generate PHP code at runtime. phpBB writes its config.php, its compiled Symfony container, its router, its BBCode renderer and its Twig templates. Under W^X, all of that has to be generated ahead of time and marked executable (and read-only) at deploy time. A warm-up script can do it. This is exactly the property the policy is meant to enforce, but frameworks will need a documented "compile everything at deploy" step. I have a self-contained demo (a script that builds the patched PHP and installs phpBB) in the same branch on GitHub. Open questions -------------- 1. opcache: a script served from the cache is not checked again. A chmod a-x takes effect only after a cache reset. My view is that processes in different security domains must not share an opcache (shared memory or opcache.file_cache) anyway, so the RFC would document this rather than add checks to opcache. Opinions welcome. 2. Compressed phars (.phar.gz, .phar.bz2) are refused under SECBIT_EXEC_RESTRICT_FILE, because phar keeps no descriptor of the original archive. This fails closed. Is that acceptable as a known limitation? 3. Under SECBIT_EXEC_RESTRICT_FILE, user stream wrappers can't provide executable code at all. I think that's correct for this policy, but it will surprise some setups. 4. Does this need an RFC vote? Without the securebits it changes no behaviour, but it is a new security feature and touches an internal ABI. I'm happy to write the RFC, and phpt tests are on the way. Kind regards, Alex [1] https://docs.kernel.org/userspace-api/check_exec.html

« previous php.internals (#133055) next »