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
| Directive | What it blocks | What it does not block |
|---|---|---|
open_basedir | File 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_functions | Invocation 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_basedirisPHP_INI_ALL. Withoutphp_admin_value, it can be set in php.ini, in a.user.ini, and widened or moved at runtime viaini_set(). A restriction placed only in php.ini gives the application a way out. Settingphp_admin_value[open_basedir]in the pool.confcloses that exit.disable_functionsisPHP_INI_SYSTEM. It cannot be set via.user.iniorini_set()under any circumstance. It is evaluated at system startup. You can place it in php.ini globally, or scope it per pool usingphp_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
| Signal | Why it matters | Warning sign |
|---|---|---|
Application errors mentioning open_basedir restriction in effect | A path the application needs is outside the allowed tree | New errors after tightening open_basedir, or after a deploy that added a new dependency path |
Call to undefined function errors | A function the application legitimately needs was removed from the runtime | Errors appearing only after adding to disable_functions |
| Session read or write failures | session.save_path is outside open_basedir | Login failures or session losses isolated to one pool |
| Worker crashes after a hardening change | A disabled function or restricted path hit an extension’s initialization code | New 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_basedirrestriction 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_basediron 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.
Related guides
- PHP-FPM 502 Bad Gateway: the web server cannot reach the pool
- PHP-FPM 504 Gateway Timeout: requests accepted but never finishing in time
- PHP-FPM active processes near max_children: reading pool utilization
- PHP-FPM in containers: cgroup limits and the silent OOM kill
- PHP-FPM “child N exited on signal 11 (SIGSEGV)”: worker segfaults
- PHP-FPM “connect() failed (111: Connection refused) while connecting to upstream”
- PHP-FPM crash loop and fork storm: workers dying faster than they serve
- PHP-FPM dynamic mode scaling lag: why the pool cannot keep up with bursts
- PHP-FPM emergency restart: “failed processes threshold reached, initiating reload”
- PHP-FPM graceful reload: the brief no-worker window on SIGUSR2
- How PHP-FPM actually works in production: a mental model for operators
- PHP-FPM idle processes at zero: no burst headroom left






