PHP-FPM 进程池隔离是单机多站点最容易被跳过的一步。绝大多数「服务器被挂马后全站沦陷」的案例,根源不是漏洞本身有多强,而是所有站点跑在同一个 www 用户、同一个 pool 下:攻击者从 A 站上传一个 webshell,就能读取 B 站的数据库配置、写入 C 站的定时任务。PHP-FPM 进程池隔离把每个站点拆成独立用户、独立 socket、独立 chroot 与独立 open_basedir,让一次入侵被限制在单个站点边界内。
适用场景
- 一台服务器承载多个站点(企业官网、商城、测试环境混部),当前只配了一个
www.conf。 - 站点来源不同:部分为客户交付的第三方代码,无法审计,风险等级与自研站不同。
- 需要按站点限制 CPU/内存与执行时间,避免一个慢脚本拖死整台机器。
- 接受过等保或渗透测试,报告里出现「Web 容器权限未隔离,存在横向移动风险」。
前置条件
- PHP 8.0 及以上(本文示例基于 PHP 8.3 与
php-fpm),Nginx 或 Apache 已通过 FastCGI 对接 PHP。 - 已确认站点目录归属,且各站点程序不依赖跨目录读写(如共享上传目录、跨站 session)。
- 具备 root 权限,可以在
/etc/php-fpm.d/(RHEL 系)或/etc/php/8.3/fpm/pool.d/(Debian 系)新增 pool 文件。 - 站点已做好备份,切换 pool 属于运行态变更,需要可回滚窗口。
原理说明
PHP-FPM 是 master-worker 模型:master 进程以 root 启动、绑定 socket,随后按 pool 派生 worker。pool 是权限隔离的最小单位,每个 pool 拥有独立的 user/group、独立的监听 socket、独立的 pm 进程管理策略,以及独立的 php_admin_value 指令。Nginx 通过 fastcgi_pass 指向不同 socket,就把请求分发到不同 pool,从而实现请求级的站点隔离。
隔离有四个层次,强度递增:
- 用户隔离:不同 pool 用不同系统用户,文件权限天然阻断跨站读取。
- open_basedir 限制:即使同用户,也把 PHP 的文件操作锁在站点目录内。
- chroot 限制:把 worker 的文件系统根切到站点目录,越界路径不可见。
- 函数禁用:切断
system/exec/proc_open等命令执行通道,减少 webshell 的可用能力。
需要明确边界:disable_functions 不是安全边界,可被绕过;open_basedir 对符号链接与部分扩展存在已知绕过。真正可靠的是不同 pool 使用不同系统用户,其余三层是纵深防御的叠加。
操作步骤
1. 为每个站点建立独立系统用户与目录
# 用户不允许登录、无家目录,仅用于运行 PHP 进程
useradd -r -s /sbin/nologin -M sitea
useradd -r -s /sbin/nologin -M siteb
# 站点目录归属到各自用户,其余用户不可读
chown -R sitea:sitea /data/www/sitea
chmod 750 /data/www/sitea
find /data/www/sitea -type d -exec chmod 750 {} \;
find /data/www/sitea -type f -exec chmod 640 {} \;
# 上传目录需要写权限,但禁止执行 PHP
mkdir -p /data/www/sitea/uploads
chown -R sitea:sitea /data/www/sitea/uploads
chmod 750 /data/www/sitea/uploads
2. 新增独立 pool 配置
Debian/Ubuntu 新建 /etc/php/8.3/fpm/pool.d/sitea.conf;RHEL 系新建 /etc/php-fpm.d/sitea.conf。以下为完整可用的站点 A 配置:
[sitea]
user = sitea
group = sitea
listen = /run/php-fpm/sitea.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660
pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.min_spare_servers = 2
pm.max_spare_servers = 8
pm.max_requests = 1000
; 关键加固项
chdir = /data/www/sitea
chroot = /data/www/sitea
security.limit_extensions = .php
request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php-fpm/sitea-slow.log
; 环境变量不继承,避免泄露
clear_env = yes
; 每 pool 独立的 PHP 配置
php_admin_value[open_basedir] = /data/www/sitea/:/tmp/sitea/
php_admin_value[upload_tmp_dir] = /tmp/sitea
php_admin_value[session.save_path] = /tmp/sitea
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,curl_multi_exec,parse_ini_file,show_source
php_admin_value[expose_php] = Off
php_admin_value[display_errors] = Off
php_admin_value[allow_url_include] = Off
; 关闭 FPM 状态页的公网暴露,仅本地可查
php_admin_value[error_log] = /var/log/php-fpm/sitea-error.log
要点说明:chroot 与 open_basedir 同时配置时,open_basedir 中的路径是相对 chroot 之后的根目录,所以此处写 /data/www/sitea/ 实际指向的是 chroot 内的同名路径。若两者路径规划容易混淆,可先只启用 open_basedir,确认业务正常后再叠加 chroot。clear_env = yes 能阻断通过环境变量注入 LD_PRELOAD 之类的提权手法。
3. 准备 socket 目录、临时目录与日志目录
mkdir -p /run/php-fpm /var/log/php-fpm
chown nginx:nginx /run/php-fpm
chmod 755 /run/php-fpm
# chroot 场景下需要在站点根内准备 /tmp/sitea
mkdir -p /data/www/sitea/tmp/sitea
chown sitea:sitea /data/www/sitea/tmp/sitea
chmod 700 /data/www/sitea/tmp/sitea
chown sitea:sitea /var/log/php-fpm
php-fpm -t # 语法检查,必须先过这一步
4. 让 Nginx 指向新的 socket
server {
listen 80;
server_name sitea.example.com;
root /data/www/sitea/public;
index index.php index.html;
location ~ \.php$ {
try_files $uri =404;
fastcgi_pass unix:/run/php-fpm/sitea.sock;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $document_root;
}
location ~ ^/(uploads|files)/.*\.php$ {
deny all;
}
}
上传目录单独 deny 是必要补充:即使 security.limit_extensions 限制了扩展名,伪装成 .jpg 的 PHP 仍可能在 Nginx 解析异常时被当作脚本执行。
5. 重载并确认进程归属
systemctl reload php8.3-fpm # RHEL 系为 php-fpm
systemctl reload nginx
# 确认 worker 已按 pool 分用户运行
ps -o pid,user,args -C php-fpm | head -12
配置验证
隔离是否生效,要用「应该失败」的测试来证明。在站点 A 放一个临时探针文件:
<?php
// /data/www/sitea/public/_probe.php 验证后立即删除
echo 'uid=' . posix_getpwuid(posix_geteuid())['name'] . PHP_EOL;
echo 'basedir=' . ini_get('open_basedir') . PHP_EOL;
echo 'disable=' . ini_get('disable_functions') . PHP_EOL;
var_dump(@file_get_contents('/data/www/siteb/config.php')); // 期望 false
curl -s https://sitea.example.com/_probe.php
# 预期:uid=sitea,basedir 含 /data/www/sitea/,最后一个 var_dump 输出 bool(false)
# 命令行侧再验证一次文件权限隔离
sudo -u siteb cat /data/www/sitea/config.php
# 预期:Permission denied
慢日志验证:在站点 A 临时执行 sleep(8) 的脚本,5 秒后 /var/log/php-fpm/sitea-slow.log 应记录完整调用栈,包含脚本路径与执行函数,用于定位被植入的挖矿或扫描脚本。
常见问题
FAQ 1:切换 pool 后全站 502,日志报 Permission denied
九成是 socket 权限问题。Nginx worker 通常以 nginx 或 www-data 运行,而 socket 由 FPM master(root)创建,若 listen.owner 与实际 Nginx 用户不一致,连接会被拒绝。同时检查三点:listen.mode = 0660 是否生效、/run/php-fpm 目录是否可进入、SELinux 是否拦截。SELinux 开启时需执行 setsebool -P httpd_can_network_connect 1,并用 ausearch -m avc -ts recent 确认是否有拒绝记录。
FAQ 2:启用 open_basedir 后程序报「failed to open stream」
说明程序在读站点目录之外的路径,常见于三类:session.save_path 指向系统 /var/lib/php/sessions、upload_tmp_dir 使用全局 /tmp、缓存目录写在 /data/cache 这类共享位置。处理方式是逐项迁移到站点内目录并在 pool 中显式指定,不要用放宽 open_basedir 到 / 的方式绕过,那等于放弃隔离。排查时可临时打开 php_admin_value[error_log] 查看具体被拒路径。
FAQ 3:禁用 exec 系列函数后 Composer、图片处理或备份插件失效
这是隔离与可用性的直接冲突。disable_functions 应按站点实际依赖裁剪:面向公网的门户站可以全部禁用;需要调用 ffmpeg、imagemagick 或备份脚本的站点,保留必要的 exec 但配合 open_basedir 与独立用户限制影响面。更好的做法是把外部命令调用下沉到独立服务或队列进程,Web 侧只负责投递任务。
FAQ 4:slowlog 会不会拖慢站点
slowlog 只在请求超过 request_slowlog_timeout 时抓取一次调用栈,属于低频采样,正常业务几乎无感。真正影响性能的是把 request_slowlog_timeout 设得过小(如 1s),导致大量请求被采样并写盘。建议从 5s 起步,按站点基线调整;pm.max_requests 设置为 500 到 1000 之间,可以缓解第三方扩展的内存泄漏。
总结
PHP-FPM 进程池隔离的本质是把「一台服务器一个信任域」拆成「一个站点一个信任域」。落地顺序建议:先建独立用户与目录权限,再拆 pool 与 socket,最后叠加 open_basedir、chroot 与函数禁用;每加一层都做一次「跨站读取应失败」的验证。改完后用 ps -o user 确认 worker 归属、用探针文件确认 open_basedir 生效、用慢日志确认异常脚本可被记录。这套配置不会让漏洞消失,但能把一次 webshell 上传的代价,从「全服务器失守」压到「单站点受影响」。