open_basedir so a hacked site can't read its neighbors. Someone else has probably said that isn't enough and every site needs its own PHP-FPM pool and Linux user. Both pieces of advice point at real controls, but they work at very different layers. This guide explains what each one restricts and gives you a few commands to check whether your setup holds up.
What open_basedir actually restricts
open_basedir is a PHP setting that lists the directories PHP's own file functions may touch. With it set to /home/sitea/:/tmp/, a call like file_get_contents('/home/siteb/public_html/wp-config.php') fails with a warning, even when the operating system would allow the read.
The important detail is who enforces it. PHP does, inside its own process. The kernel knows nothing about it. That creates three gaps.
First, it only covers code paths that respect it. If shell_exec, exec, system, passthru, proc_open, or popen are available, an attacker can run cat as a separate process. That child process is not PHP, so open_basedir doesn't apply, and it reads whatever the OS user can read.
Second, PHP has a history of reported open_basedir bypasses through symlinks, wrappers, and similar edge cases. The PHP manual itself doesn't treat it as a hard security boundary.
Third, it does nothing about the user the process runs as. If every site runs as www-data, all site files must be readable by www-data. Any bypass then exposes every site on the box.
So open_basedir is a useful filter, but it doesn't isolate sites.
What a separate pool and user change
user and group), which socket it listens on (listen), and which PHP settings it forces (php_admin_value[...]). When site A's pool runs as sitea and site B's runs as siteb, and /home/siteb is not readable by sitea, the kernel refuses the read. It doesn't matter whether the attacker uses PHP functions, a shell command, or a compiled binary dropped into /tmp.
This is the layer that holds when PHP-level checks fail. It still has gaps of its own, and support engineers often find them during cleanups after a compromise:
- Home directory permissions left at
755, so every local user can list and read them. - FPM sockets with loose permissions, so site A's user can connect to site B's pool. That lets an attacker ask site B's workers to execute a file as
siteb. - Pools listening on
127.0.0.1TCP ports. Any local user can connect to those. - Shared session directories or a shared
/tmp, where one site can read another's session files. - OPcache shared memory, which belongs to the FPM master and is shared across all of its pools. PHP has
opcache.validate_permissionandopcache.validate_rootfor this case. Check whether your version supports them.
open_basedir and a sensible disable_functions list on top as extra layers.
Checking how your pools are set up now
/etc/php-fpm.d/, and control panels use their own locations, so adjust for your setup.
BASH
# Which system user does each pool's workers run as?
ps -eo user,pid,args | grep '[p]hp-fpm: pool'
# What each pool is configured with: name, user, group, listen, admin values
grep -RnE '^(\[|user|group|listen|php_admin_value)' /etc/php/*/fpm/pool.d/
# Socket ownership and mode
ls -l /run/php/- Every pool shows the same user in
ps. Your sites aren't isolated at the OS level, whateveropen_basedirsays. - A
listen = 127.0.0.1:9xxxline. That pool is reachable by any local user. - A socket with mode
srw-rw-rw-. That socket is world-writable.
0660, and no site user in that group.
Testing whether one site can reach another
sitea, siteb, the domain, and the paths with your own.
Create a temporary file in site A's document root with a random name. It prints only READABLE or blocked, never file contents.
PHP
<?php
// Temporary isolation test. Delete immediately after use.
header('Content-Type: text/plain');
$target = '/home/siteb/public_html/wp-config.php';
if (function_exists('posix_geteuid')) {
echo 'pool user: ' . posix_getpwuid(posix_geteuid())['name'] . PHP_EOL;
}
echo 'open_basedir: ' . (ini_get('open_basedir') ?: '(not set)') . PHP_EOL;
// PHP-level read: this is what open_basedir filters
echo 'file_get_contents: ' . (@file_get_contents($target, false, null, 0, 1) === false ? 'blocked' : 'READABLE') . PHP_EOL;
// Child-process read: open_basedir does not apply here, only OS permissions do
if (function_exists('shell_exec')) {
out = shell_exec('head -c 1 ' . escapeshellarg(target) . ' 2>/dev/null');
echo 'shell_exec: ' . (strlen((string) $out) > 0 ? 'READABLE' : 'blocked') . PHP_EOL;
} else {
echo 'shell_exec: disabled' . PHP_EOL;
}BASH
# Run the in-pool test, then remove the file immediately
curl -s https://sitea.example/iso-test-7f3k.php
rm /home/sitea/public_html/iso-test-7f3k.php
# OS-level checks as site A's user (ignores all PHP settings)
sudo -u sitea head -c 1 /home/siteb/public_html/wp-config.php >/dev/null 2>&1 && echo 'file: READABLE' || echo 'file: blocked'
sudo -u sitea ls /home/siteb/ >/dev/null 2>&1 && echo 'dir: listable' || echo 'dir: blocked'
# Connecting to a Unix socket requires write permission on it
sudo -u sitea test -w /run/php/siteb.sock && echo 'socket: reachable' || echo 'socket: blocked'open_basedir value to anyone who finds it. Socket names depend on how your pools were created, so take the real name from the ls -l /run/php/ output.
Reading the results and closing the gaps
file_get_contents: blocked with shell_exec: READABLE. That means open_basedir is doing its job, but your sites aren't really separated. The OS user can read site B, and any process that isn't PHP gets straight through. Disabling the exec family of functions narrows that path. The proper fix is a separate pool and user for each site.
If the OS-level file check says READABLE even though the pools use different users, the problem is permissions. Typical fixes:
- Set home directories to
750, or710when the web server only needs to traverse them. - Set
wp-config.phpand similar secrets to600or640, owned by the site user. - Let the web server read static files through group membership or ACLs, never through world-readable bits.
reachable, set listen.owner, listen.group, and listen.mode = 0660 on that pool so that only the web server's group can connect. Then make sure no site user belongs to that group. Move any TCP listeners to Unix sockets. Restart PHP-FPM with your distro's service name, then rerun all three checks.
A clean run looks like this: each pool runs as its own user, file_get_contents and shell_exec both say blocked, and the OS-level file, directory, and socket checks all say blocked. After that, look at the shared areas the checks above don't cover: session paths, /tmp, and OPcache.
The practical rule: treat separate users and pools as the isolation, and open_basedir as an extra layer on top. Whichever you choose, don't trust the config file alone. Run the cross-site read test as the other site's user, keep the result, and repeat it whenever you add a site, change panels, or migrate servers, because those are the moments when permissions quietly go back to defaults.