Disabling dangerous PHP functions

Alex KondratievAlex Kondratiev

13 min read

Learn how to disable dangerous PHP functions such as exec, shell_exec and system with disable_functions, and how to make sure the setting reaches your website and not just the command line. The guide covers which php.ini PHP-FPM actually loads, which functions are worth disabling and which ones only break your app, per-site limits in a PHP-FPM pool, open_basedir, and why disable_functions is one layer of defense, not a sandbox.


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_exec

shell

1sudo systemctl reload php8.5-fpm

After 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 the conf.d directory next to it) and from the PHP-FPM pool config. A script can't change it: ini_set('disable_functions', '') returns false and 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') returns false, 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 runs

The 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 what php -i and php --ini show.
  • 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_exec

And the same server over the web:

shell

1SAPI: fpm-fcgi 8.5.4
2php.ini: /etc/php/8.5/fpm/php.ini
3exec ENABLED
4shell_exec ENABLED
5system ENABLED

To 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.ini

On 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.ini

Find 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_exec

If 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 -t

shell

1sudo systemctl reload php8.5-fpm

A 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<?php
2header('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.4
2php.ini: /etc/php/8.5/fpm/php.ini
3user: www-data
4open_basedir: (none)
5exec disabled
6shell_exec disabled
7system disabled
8passthru disabled
9proc_open disabled
10popen disabled
11curl_exec ENABLED

The 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_status and the other proc_ 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. The pcntl extension 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, because fsockopen(), stream_socket_client() and file_get_contents() with a URL do the same job. An egress firewall rule is the real fix here.
  • mail: it starts the program from sendmail_path even when exec and popen are 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, disable mail.
  • putenv: changes environment variables for programs PHP starts. Combined with mail() it's a well-known way around disable_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_setuid and the rest of posix_: 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_alter is just an alias of ini_set, so disabling one without the other is pointless. Disabling ini_set breaks WordPress and most frameworks. Lock the settings that matter with php_admin_value in the pool instead, which ini_set() can't override.
  • readfile, parse_ini_file: ordinary file reads. fopen() and file_get_contents() read the same files. Limit which files PHP can read with open_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: eval is 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 shop

shell

1sudo mkdir -p /var/lib/php/shop/tmp /var/lib/php/shop/sessions
2sudo chown -R shop:shop /var/lib/php/shop
3sudo chmod 700 /var/lib/php/shop

Then create the pool in /etc/php/8.5/fpm/pool.d/shop.conf:

shell

1[shop]
2user = shop
3group = shop
4
5listen = /run/php/php8.5-fpm-shop.sock
6listen.owner = www-data
7listen.group = www-data
8listen.mode = 0660
9
10pm = ondemand
11pm.max_children = 10
12
13php_admin_value[disable_functions] = curl_exec,curl_multi_exec
14php_admin_value[open_basedir] = /var/www/shop:/var/lib/php/shop
15php_admin_value[upload_tmp_dir] = /var/lib/php/shop/tmp
16php_admin_value[session.save_path] = /var/lib/php/shop/sessions
17php_admin_value[sys_temp_dir] = /var/lib/php/shop/tmp

Point 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-fpm
2sudo nginx -t && sudo systemctl reload nginx

The 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.4
2php.ini: /etc/php/8.5/fpm/php.ini
3user: shop
4open_basedir: /var/www/shop:/var/lib/php/shop
5exec disabled
6shell_exec disabled
7system disabled
8passthru disabled
9proc_open disabled
10popen disabled
11curl_exec disabled

How pool settings behave:

  • disable_functions in a pool adds to the list from php.ini, it doesn't replace it. So a pool can't turn a function back on: with php_admin_value[disable_functions] = (empty) in the pool, exec stayed disabled in our test. Keep the global list in php.ini to what no site needs, and add stricter lists per pool.
  • ini_get() and phpinfo() show only the pool's list. For the pool above, phpinfo() reports curl_exec,curl_multi_exec, although exec and the rest are disabled too. Check with function_exists().
  • Use php_admin_value, not php_value. Settings set with php_admin_value can't be changed with ini_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 2

Include 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() runs sendmail_path, and with putenv() 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-data connected to another site's socket, because the sockets belong to www-data. The same script in the shop pool got Permission denied. This is one more reason to never run site code as www-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 to true on a web server.
  • Bugs in PHP itself. Public exploits for old PHP versions and extensions bypass disable_functions through 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 /tmp and an upload directory where PHP doesn't execute.
  • Detection. A malware scanner such as Linux Malware Detect catches web shells that disable_functions only 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-fpm
2RUN echo "disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec" \
3 > /usr/local/etc/php/conf.d/zz-hardening.ini

In 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.

Alex Kondratiev

Alex Kondratiev

Founder of ITsyndicate. DevOps Enthusiast with 15+ years of experience in cloud, Infrastructure as Code, Kubernetes, and automation. Specialized in architecting secure, scalable, and resilient systems.

Stay online.
Stay in command.