Our team disables these functions on every client server we harden.
To disable dangerous PHP functions, list them in the disable_functions directive of the php.ini that your web server's PHP loads, then reload PHP. On Ubuntu with PHP-FPM that file is /etc/php/8.5/fpm/php.ini, not the one php -i shows you:
shell
1disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_execshell
1sudo systemctl reload php8.5-fpmAfter that, a call to exec() on your site fails with Call to undefined function exec().
Two things decide whether this actually protects anything. First, you need to edit the right php.ini: the command line and the web server use different files, and editing the wrong one changes nothing on the site. Second, disable_functions is a speed bump for web shells and sloppy code, not a sandbox. It works best together with a separate PHP-FPM pool per site, open_basedir and regular updates. Both are covered below.
Every config and output in this guide was tested on Ubuntu 26.04 with PHP 8.5 and nginx. Ubuntu 24.04 ships PHP 8.3: the steps are the same, just replace 8.5 with 8.3 in paths and service names.
What disable_functions does and doesn't do
disable_functions takes a comma-separated list of internal PHP functions and removes them when PHP starts. A few rules matter in practice:
- It only works from
php.ini(or a file in theconf.ddirectory next to it) and from the PHP-FPM pool config. A script can't change it:ini_set('disable_functions', '')returnsfalseand the list stays as it is. - It only affects built-in functions. Functions defined in your own code are untouched.
- Since PHP 8.0, a disabled function doesn't exist at all. Calling it throws
Error: Call to undefined function exec(),function_exists('exec')returnsfalse, and a script can declare its own function with the same name. In PHP 7 the function existed and only printed a warning. - It reads at startup. Changes take effect after you reload PHP-FPM or restart Apache.
Here is what a test script printed on PHP 8.5 with the list above in place:
shell
1ini_set: bool(false)2Error: Call to undefined function exec()3backticks -> Error: Call to undefined function shell_exec()4userland system(id)5eval() still runsThe backtick operator is the same as shell_exec(), so disabling shell_exec covers it too. The last two lines show the limits: a script redefined system() as its own harmless function, and eval() kept working because it's a language construct, not a function, so disable_functions can't touch it.
How to disable PHP functions with disable_functions
Step 1. Find the php.ini your website uses
PHP has a separate config for every way it runs (SAPI). On Ubuntu and Debian they live in different directories:
- Command line (CLI):
/etc/php/8.5/cli/php.ini. This is whatphp -iandphp --inishow. - PHP-FPM (nginx, or Apache with
proxy_fcgi):/etc/php/8.5/fpm/php.ini. - Apache
mod_php:/etc/php/8.5/apache2/php.ini.
This is the most common mistake with this setting. If you run php -i | grep php.ini and edit the file it prints, you harden the command line and leave the website as it was. Here is what that looks like on a test server. The CLI config was edited and PHP-FPM reloaded:
shell
1$ php -i | grep "^disable_functions"2disable_functions => exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec => exec,passthru,shell_exec,system,proc_open,popen,pcntl_execAnd the same server over the web:
shell
1SAPI: fpm-fcgi 8.5.42php.ini: /etc/php/8.5/fpm/php.ini3exec ENABLED4shell_exec ENABLED5system ENABLEDTo see which file PHP-FPM loads, ask the FPM binary itself:
shell
1php-fpm8.5 -i | grep "Loaded Configuration File"shell
1Loaded Configuration File => /etc/php/8.5/fpm/php.iniOn other systems, check Loaded Configuration File in phpinfo() on a temporary web page, or use the check script from step 4.
Step 2. Add the functions to disable_functions
Open the file from step 1:
shell
1sudo nano /etc/php/8.5/fpm/php.iniFind the disable_functions line and add the functions to it. On a fresh Ubuntu 24.04 or 26.04 install the value is empty, so the result looks like this:
shell
1disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_execIf the line already has functions in it (hosting panels and some distributions set their own), add yours to the end instead of replacing the value. Otherwise you silently turn the old ones back on.
The next section explains what each of these functions does and what else is worth adding.
Step 3. Reload PHP-FPM
Check the config, then reload:
shell
1sudo php-fpm8.5 -tshell
1sudo systemctl reload php8.5-fpmA reload is enough: PHP-FPM re-reads its configs and restarts its workers with the new settings, without a full stop and start of the service. If PHP runs as an Apache module, restart Apache instead with sudo systemctl restart apache2 (httpd on RHEL-based systems).
Step 4. Check the result from the web, not the command line
php -i only tells you about the CLI. Put a short script in your site's web root:
php
1<?php2header('Content-Type: text/plain');3echo 'SAPI: ', PHP_SAPI, ' ', PHP_VERSION, "\n";4echo 'php.ini: ', php_ini_loaded_file(), "\n";5echo 'user: ', posix_getpwuid(posix_geteuid())['name'], "\n";6echo 'open_basedir: ', ini_get('open_basedir') ?: '(none)', "\n";7foreach (['exec', 'shell_exec', 'system', 'passthru', 'proc_open', 'popen', 'curl_exec'] as $f) {8 echo str_pad($f, 12), function_exists($f) ? 'ENABLED' : 'disabled', "\n";9}Open it in the browser or with curl. After the change it prints:
shell
1SAPI: fpm-fcgi 8.5.42php.ini: /etc/php/8.5/fpm/php.ini3user: www-data4open_basedir: (none)5exec disabled6shell_exec disabled7system disabled8passthru disabled9proc_open disabled10popen disabled11curl_exec ENABLEDThe script checks function_exists() instead of printing disable_functions. That's on purpose: in a PHP-FPM pool, ini_get() and phpinfo() show only the pool's own list, while the functions from php.ini are disabled as well (more on that below). Delete the script when you're done.
Which PHP functions to disable
There is no official list. A good list blocks what a web shell needs and leaves alone what your application needs. Here is how the functions from popular lists break down on PHP 8.5.
Command execution: disable on every web server
These functions run programs on the server. A typical website never needs them, and they're exactly what an uploaded web shell uses to turn a PHP bug into full control of the account:
exec: runs a command and returns the last line of its output.shell_exec: runs a command through the shell and returns all output. The backtick operator uses it too.system: runs a command and prints its output.passthru: runs a command and passes raw output straight to the browser.proc_open: starts a process with pipes for input and output.proc_close,proc_get_statusand the otherproc_functions are useless without it, so there's no need to list them.popen: opens a pipe to a process.pcntl_exec: replaces the current process with another program. Thepcntlextension usually isn't loaded in PHP-FPM (on Ubuntu it's CLI only), but listing it costs nothing and covers builds where it is. PHP silently ignores names that don't exist.
Some tools do need them: image optimizers that call binaries, PDF generators, backup and deployment plugins. If a site breaks with Call to undefined function exec(), give that site its own pool instead of opening the functions for everyone (see the next section).
Disable after you check your application
These are useful to attackers, but ordinary applications use them as well. Test before you add them:
curl_exec,curl_multi_exec: outgoing HTTP requests. They're used for server-side request forgery and for downloading attack tools, but WordPress, payment SDKs and most CMS plugins need cURL. Disabling them also doesn't stop outgoing traffic, becausefsockopen(),stream_socket_client()andfile_get_contents()with a URL do the same job. An egress firewall rule is the real fix here.mail: it starts the program fromsendmail_patheven whenexecandpopenare disabled. We confirmed it on PHP 8.5: with all the command functions disabled,mail()still ran the configured program. If your app sends email over SMTP, disablemail.putenv: changes environment variables for programs PHP starts. Combined withmail()it's a well-known way arounddisable_functions. Few web apps need it.show_source,highlight_file: print the source of any PHP file the process can read, including the one with your database password.phpinfo: shows the full configuration, paths and environment. Handy for debugging, handy for attackers too. Disable it on production if nobody uses it.posix_kill,posix_setuidand the rest ofposix_: process signals and user switching. With one system user per site they have little to work with, so this one is optional.
Functions that don't belong on the list
Many older lists, including the previous version of this one, include functions that either break things or do nothing:
escapeshellarg,escapeshellcmd: these escape shell arguments and protect against command injection. Disabling them gives a web shell nothing and removes protection from code that legitimately runs commands.ini_set,ini_alter:ini_alteris just an alias ofini_set, so disabling one without the other is pointless. Disablingini_setbreaks WordPress and most frameworks. Lock the settings that matter withphp_admin_valuein the pool instead, whichini_set()can't override.readfile,parse_ini_file: ordinary file reads.fopen()andfile_get_contents()read the same files. Limit which files PHP can read withopen_basedir.dl: loads extensions at runtime, but only in the CLI, embed and command-line CGI. It doesn't exist in PHP-FPM at all.eval,assert:evalis a language construct and can't be disabled. Since PHP 8.0,assert()no longer runs strings as code, so it's no longer a risk.create_function,each: removed in PHP 8.0. Listing them does nothing.
Also note that the disable_classes directive, which used to sit next to disable_functions in php.ini, was removed in PHP 8.5.
Per-site limits in a PHP-FPM pool
On a server with several sites, give every site its own PHP-FPM pool. Each pool runs as its own system user and can have its own disable_functions and open_basedir. A compromised site then can't read or change the others.
Create a system user and directories for the site's temporary files and sessions:
shell
1sudo useradd --system --no-create-home --shell /usr/sbin/nologin shopshell
1sudo mkdir -p /var/lib/php/shop/tmp /var/lib/php/shop/sessions2sudo chown -R shop:shop /var/lib/php/shop3sudo chmod 700 /var/lib/php/shopThen create the pool in /etc/php/8.5/fpm/pool.d/shop.conf:
shell
1[shop]2user = shop3group = shop4
5listen = /run/php/php8.5-fpm-shop.sock6listen.owner = www-data7listen.group = www-data8listen.mode = 06609
10pm = ondemand11pm.max_children = 1012
13php_admin_value[disable_functions] = curl_exec,curl_multi_exec14php_admin_value[open_basedir] = /var/www/shop:/var/lib/php/shop15php_admin_value[upload_tmp_dir] = /var/lib/php/shop/tmp16php_admin_value[session.save_path] = /var/lib/php/shop/sessions17php_admin_value[sys_temp_dir] = /var/lib/php/shop/tmpPoint the site's nginx server block to the new socket:
nginx
1server {2 listen 80;3 server_name shop.example.com;4 root /var/www/shop;5 index index.php;6
7 location ~ \.php$ {8 include snippets/fastcgi-php.conf;9 fastcgi_pass unix:/run/php/php8.5-fpm-shop.sock;10 }11}Test both configs and reload:
shell
1sudo php-fpm8.5 -t && sudo systemctl reload php8.5-fpm2sudo nginx -t && sudo systemctl reload nginxThe check script from step 4 now shows the pool user, the open_basedir value, and both lists applied at once:
shell
1SAPI: fpm-fcgi 8.5.42php.ini: /etc/php/8.5/fpm/php.ini3user: shop4open_basedir: /var/www/shop:/var/lib/php/shop5exec disabled6shell_exec disabled7system disabled8passthru disabled9proc_open disabled10popen disabled11curl_exec disabledHow pool settings behave:
disable_functionsin a pool adds to the list fromphp.ini, it doesn't replace it. So a pool can't turn a function back on: withphp_admin_value[disable_functions] =(empty) in the pool,execstayed disabled in our test. Keep the global list inphp.inito what no site needs, and add stricter lists per pool.ini_get()andphpinfo()show only the pool's list. For the pool above,phpinfo()reportscurl_exec,curl_multi_exec, althoughexecand the rest are disabled too. Check withfunction_exists().- Use
php_admin_value, notphp_value. Settings set withphp_admin_valuecan't be changed withini_set().
Restrict file access with open_basedir
open_basedir limits which files PHP can open. In the pool above, the shop site can work with its own code and its temp and session directories, and nothing else. An attempt to read a file outside them fails, and the warning lands in the nginx error log:
shell
1PHP message: PHP Warning: file_get_contents(): open_basedir restriction in effect. File(/etc/hostname) is not within the allowed path(s): (/var/www/shop:/var/lib/php/shop) in /var/www/shop/index.php on line 2Include every directory the app really uses, or uploads and sessions will break. That's why the pool moves upload_tmp_dir, session.save_path and sys_temp_dir inside the allowed paths. A script can only make open_basedir stricter at runtime: ini_set('open_basedir', '/') returns false.
open_basedir and disable_functions cover each other's gaps. open_basedir only controls PHP's own file functions. Shell commands ignore it completely. With exec allowed, the same pool that couldn't read /etc/passwd with file_get_contents() printed its first line with exec('head -1 /etc/passwd'). The PHP manual also warns that open_basedir doesn't restrict Unix sockets.
Why disable_functions is not a sandbox
The PHP manual is direct about it: the directive "can be circumvented and should not be considered a sufficient security measure". It stops a lazy web shell and keeps badly written code from running commands. It won't stop a determined attacker who can already run PHP on your server. The known ways around it fall into a few groups:
- Programs that PHP starts on its own.
mail()runssendmail_path, and withputenv()an attacker can make that program load their code. - Talking to PHP-FPM directly. A script that can connect to a PHP-FPM socket can send a request with its own PHP settings. In our test, a script in the default pool running as
www-dataconnected to another site's socket, because the sockets belong towww-data. The same script in theshoppool gotPermission denied. This is one more reason to never run site code aswww-data. - FFI. The FFI extension calls C functions directly. On Ubuntu it's loaded in PHP-FPM with
ffi.enable = preload, which only allows it in preload scripts. Keep it that way and don't set it totrueon a web server. - Bugs in PHP itself. Public exploits for old PHP versions and extensions bypass
disable_functionsthrough memory corruption.
What actually limits the damage:
- One PHP-FPM pool and one system user per site. Code files owned by a different user, so the pool can read them but not change them.
- Supported PHP versions with security updates. As of November 2026, PHP 8.2 gets security fixes until December 31, 2026, PHP 8.3 until the end of 2027, 8.4 until the end of 2028 and 8.5 until the end of 2029 (supported versions). Anything older is end of life.
- Fewer places to run code from. A secured
/tmpand an upload directory where PHP doesn't execute. - Detection. A malware scanner such as Linux Malware Detect catches web shells that
disable_functionsonly slows down.
For a WordPress site, combine this with the steps from our WordPress security hardening guide.
PHP in Docker
The official php Docker image doesn't load any php.ini by default (php --ini shows Loaded Configuration File: (none)), and it reads extra settings from /usr/local/etc/php/conf.d/. Put the list there:
dockerfile
1FROM php:8.5-fpm2RUN echo "disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec" \3 > /usr/local/etc/php/conf.d/zz-hardening.iniIn this image the same conf.d directory serves both CLI and PHP-FPM, so php -r 'var_dump(function_exists("exec"));' inside the container prints bool(false). The same drop-in approach works on Ubuntu with /etc/php/8.5/fpm/conf.d/99-hardening.ini. One catch: a value in a conf.d file replaces the one from php.ini, it doesn't add to it.
Common errors
Error: Call to undefined function exec(). The function is disabled. If a site really needs it, move that site to its own pool and keep the function out of the global php.ini list. Don't reopen it for every site on the server.
open_basedir restriction in effect. File(...) is not within the allowed path(s). The app is reading a directory you didn't allow. Add the directory to open_basedir in the pool if the access is legitimate. Most often it's the temp, upload or session directory.
The change has no effect. In this order, check that you edited the file PHP-FPM loads (php-fpm8.5 -i | grep "Loaded Configuration File"), that you reloaded PHP-FPM, and that a file in conf.d doesn't set disable_functions again with a different value.
Wrapping up
Disabling dangerous PHP functions takes one line in the right php.ini. Make sure it's the PHP-FPM or Apache config, not the CLI one, block the command execution functions, and check the result from the web. Then go one step further: a pool and a system user per site, open_basedir, and PHP versions that still get security fixes. That combination is what keeps one hacked plugin from turning into a hacked server.
If you'd rather have someone review your server setup, our team does this as part of cloud security and compliance work.
