PHP-FPM runs each worker as an OS user. Whatever that user can read or execute, the PHP code inside the worker can read or execute. On a host running more than one application, or on any host where a compromise is plausible, that is a wide blast radius. A single uploaded webshell in one pool can read /etc/passwd, walk into adjacent application directories for credentials, or shell out to enumerate the network.

Two PHP directives narrow that radius: open_basedir restricts filesystem access to a configured tree, and disable_functions removes dangerous internal functions like exec(), system(), and passthru() from the runtime. Both are per-pool, both are cheap, and both are routinely applied in a form that userland code can quietly override. This article covers what each control actually enforces, why php_admin_value in the pool .conf is the only form that holds under pressure, the version-specific behaviors that change the math, and the documented bypasses that mean you should treat these as defense-in-depth, not a security boundary.

What each directive protects against

DirectiveWhat it blocksWhat it does not block
open_basedirFile reads and writes outside the configured directory tree (/etc/passwd, other apps’ code, credentials in adjacent paths)Outbound network connections, process spawning, writes inside the allowed tree, data already loaded into memory
disable_functionsInvocation of named internal functions: exec(), system(), passthru(), shell_exec(), proc_open(), popen()Functions you forgot to list, extension-level C calls (e.g., fork() from a loaded extension), indirect execution paths

The default state is permissive. open_basedir defaults to an empty value, meaning no restriction. disable_functions defaults to an empty string, meaning every internal function is callable. A pool that has never been hardened has both unset, and a compromised script in that pool runs with the full reach of the worker user.

How per-pool enforcement works

The mechanism that makes these directives stick is php_admin_value in the pool configuration file (typically under /etc/php/*/fpm/pool.d/). Settings applied through php_admin_value and php_admin_flag cannot be overridden by ini_set() at runtime. This is the only FPM-level mechanism that produces an immutable restriction from the perspective of application code.

The changeability of the two directives differs, and that difference is the source of most misconfiguration:

  • open_basedir is PHP_INI_ALL. Without php_admin_value, it can be set in php.ini, in a .user.ini, and widened or moved at runtime via ini_set(). A restriction placed only in php.ini gives the application a way out. Setting php_admin_value[open_basedir] in the pool .conf closes that exit.
  • disable_functions is PHP_INI_SYSTEM. It cannot be set via .user.ini or ini_set() under any circumstance. It is evaluated at system startup. You can place it in php.ini globally, or scope it per pool using php_admin_value[disable_functions].

Example pool configuration:

[app-a]
user = appa
group = appa
listen = /run/php/app-a.sock

; Restrict filesystem access to this app's tree plus the session store
php_admin_value[open_basedir] = /var/www/app-a:/var/lib/php/sessions-app-a:/tmp

; Remove command-execution primitives this app does not need
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen

The exact disable list must match each application’s real needs. A CMS that shells out to imagick or ffmpeg will break if you copy a generic hardening template verbatim.

flowchart TD
  A[PHP request in worker] --> B[open_basedir gate]
  B -->|path outside tree| C[File access denied]
  B -->|path inside tree| D[File access allowed]
  A --> E[disable_functions gate]
  E -->|function disabled| F[Call fails]
  E -->|function enabled| G[Function executes]
  H[Direct FastCGI socket access] -.->|PHP_VALUE overrides open_basedir| B
  I[Loaded C extension] -.->|Direct syscall, bypasses PHP layer| G

disable_functions appends, it does not replace

The PHP-FPM configuration documentation states that defining disable_functions in the pool config does not overwrite a value already set in php.ini. It appends. If php.ini carries disable_functions = exec,system and the pool adds php_admin_value[disable_functions] = passthru, the effective list is exec,system,passthru.

This means the pool layer can only widen the global restriction, never narrow it. If you need a pool with a shorter list than php.ini, remove the directive from php.ini and set it per pool.

Version-specific behaviors that change the math

A few version transitions change how these controls behave or what an attacker can do with them.

PHP 8.0: disabled functions have no definition. Prior to PHP 8.0, disabling a function blocked invocation but the function definition remained. As of PHP 8.0, the function definition is removed entirely. Userland code can redefine a disabled function name (for example, define its own exec()), but the original internal implementation is gone. function_exists('exec') returns false when exec is disabled on PHP 8.0+. This is mostly a curiosity for attackers, but it matters if your code or a dependency gates behavior on function_exists.

PHP 8.3: open_basedir rejects .. at runtime. As of PHP 8.3, open_basedir no longer accepts paths containing the parent-directory reference (..) when set at runtime via ini_set(). This closes a classic bypass where a script would chdir() and then expand the restriction using relative segments. The manual scopes this restriction to runtime ini_set().

open_basedir is not deprecated in PHP 8.5. The PHP 8.5 deprecation RFC does not list open_basedir or disable_functions. An earlier internals discussion proposed deprecating open_basedir as a security feature, but that proposal was not accepted. Both directives remain supported.

The cost of open_basedir: realpath cache

Setting open_basedir disables PHP’s realpath cache. Every filesystem path resolution goes through a real stat() call instead of a cached lookup. On filesystem-heavy applications (frameworks with deep autoload trees, many include and require calls), this adds measurable overhead. The protection is real, but it is not free. Benchmark before and after if request latency is tight.

The documented bypasses

Neither directive is a complete security boundary. The PHP manual itself states that open_basedir is an extra safety net that is “in no way comprehensive” and should not be relied upon where security is required. The internals team has historically treated open_basedir bypass reports as non-security issues. Plan accordingly.

FastCGI parameter injection. If an attacker can speak directly to the PHP-FPM socket (a reachable TCP port, a writable Unix socket, or an SSRF that can hit FastCGI), they can send a PHP_VALUE FastCGI environment variable that sets open_basedir at request time, overriding the pool-level restriction. disable_functions cannot be overridden this way because it is INI_SYSTEM. The practical defense is to keep the FPM socket unreachable from untrusted networks and from the application itself.

Symlinks. PHP resolves symlinks when checking open_basedir. A symlink whose target resolves outside the allowed tree is denied. The historical risk is that if the application user can create symlinks inside the allowed tree, edge cases in path resolution have occasionally allowed escapes. Keep the application’s writable directories out of the code path, and do not grant the worker user write access to directories where PHP code is executed.

Extension-level execution. disable_functions operates at the PHP function-call layer. It does not prevent a loaded PHP extension from calling C-level fork(), execve(), or socket syscalls directly. If you load extensions like pcntl, disabling pcntl_fork() blocks the PHP function but not a malicious extension calling fork() in C. The extension itself must be removed (for example, phpdismod pcntl) to close that surface.

Functions you forgot to list. disable_functions is an explicit denylist. The usual targets (exec, system, passthru, shell_exec, proc_open, popen) cover direct command execution, but PHP has a long tail of indirect paths: mail() invoking a sendmail binary, putenv() poisoning LD_PRELOAD in combination with mail() or error_log(), and error_log() with a type that shells out. The list must match the application’s actual surface, not a copied template.

Auditing the current configuration

Two checks cover the configuration view and the runtime view.

# Check effective values after merging php.ini and pool configs
php-fpm8.x -tt 2>&1 | grep -E "open_basedir|disable_functions"

# Or via a PHP script served through the pool
# <?php echo 'open_basedir: ' . ini_get('open_basedir') . "\n";
#       echo 'disable_functions: ' . ini_get('disable_functions') . "\n"; ?>

The php-fpm -tt form parses the full configuration (php.ini plus pool configs) and reports what would be applied at startup, which is the authoritative view. ini_get() from a script served through the pool reports the per-pool effective value after php_admin_value has been applied.

To audit the pool files directly:

grep -E "open_basedir|disable_functions" /etc/php/*/fpm/pool.d/*.conf

Confirm that each restriction appears under php_admin_value, not php_value. The php_value form can be overridden by ini_set() and defeats the purpose.

Per-pool isolation completes the picture

open_basedir and disable_functions restrict what PHP code can do, but they do not isolate pools from each other at the OS level. Run each pool as a distinct least-privilege user, and give each pool its own socket and its own session directory inside its own open_basedir tree. A pool running as root, or multiple applications sharing a single pool user, gives a compromise in one pool a path to the others regardless of how tight the PHP directives are.

The session storage point is easy to miss. PHP’s default session.save_path is /tmp. If your open_basedir does not include /tmp, session reads and writes fail. Either include /tmp (less isolated) or set session.save_path to a directory inside the allowed tree, one per pool.

Applying changes safely

open_basedir changes take effect for newly spawned workers after a reload. disable_functions changes require the worker function table to be rebuilt.

The PHP-FPM graceful reload (SIGUSR2) re-execs the master and spawns fresh workers, which should pick up both changes. In practice, some operators report that a full restart (systemctl restart php-fpm) is needed for disable_functions changes to take effect reliably.

Either operation causes a brief window with no workers serving requests, and a full restart clears opcache. Apply during a maintenance window, or use rolling restarts across multiple FPM instances behind a load balancer. See PHP-FPM graceful reload: the brief no-worker window on SIGUSR2 for the mechanics of that gap.

Signals to watch

SignalWhy it mattersWarning sign
Application errors mentioning open_basedir restriction in effectA path the application needs is outside the allowed treeNew errors after tightening open_basedir, or after a deploy that added a new dependency path
Call to undefined function errorsA function the application legitimately needs was removed from the runtimeErrors appearing only after adding to disable_functions
Session read or write failuressession.save_path is outside open_basedirLogin failures or session losses isolated to one pool
Worker crashes after a hardening changeA disabled function or restricted path hit an extension’s initialization codeNew SIGSEGV entries in the FPM error log immediately after the config change

How Netdata helps

Netdata’s per-second metrics let you correlate a hardening change with its operational fallout without guessing at cause and effect.

  • PHP-FPM worker counts, active/idle ratio, and listen queue depth show whether a new open_basedir restriction and its realpath cache cost are pushing the pool toward saturation.
  • Worker exit rate and the FPM error log stream surface crashes triggered by a disabled function hitting extension initialization.
  • Per-process memory and CPU let you measure the realpath cache overhead of open_basedir on a filesystem-heavy application before and after the change.
  • Status page signals (accepted connections, slow requests, max children reached) confirm whether request latency or capacity shifted after the change.
  • For multi-pool hosts, per-pool breakdowns let you compare an unrestricted pool against a hardened one under the same load.