PHP函数禁用实战:disable_functions配置

disable_functions 是 PHP 在解释器层提供的函数黑名单机制,能把命令执行、进程控制、文件写入等高危函数直接摘掉。相比 WAF 的规则匹配,它不依赖请求特征,即使攻击者用编码绕过也能生效;相比 open_basedir 的目录约束,它管的是「能做什么」而不是「能碰哪里」。本文给出一套可落地的配置方案:按业务类型分档的禁用清单、open_basedir 的正确写法、FPM 池级隔离,以及上线前的兼容性验证脚本。

适用场景

  • 共享主机与多站点服务器:一台机器跑几十个站点,无法保证每个站点的代码质量,需要一层与代码无关的兜底。
  • 已发生过 WebShell 植入:清理后需要切断攻击者的再次利用路径。多数一句话木马依赖 system、exec、assert 等函数,禁用后即使文件残留也难以执行命令。
  • 第三方 CMS 与插件不可控:WordPress 生态插件质量参差,无法逐个审计,只能从运行环境收口。
  • 等保与安全基线合规:多数基线要求「禁用不必要的危险函数」,需要可核查的配置证据。

反之,如果业务本身需要执行外部命令(如视频转码、PDF 生成调用 wkhtmltopdf),强行禁用会直接打断业务,这类场景应改用白名单包装而非一刀切。

前置条件

  • PHP 7.4 及以上,使用 PHP-FPM 运行(mod_php 模式配置方式不同且不推荐)。
  • 能修改 php.ini 或 FPM 池配置,具备重启 FPM 的权限。
  • 有一份站点功能清单,明确哪些功能依赖外部命令、哪些依赖临时目录写入。
  • 可回滚:修改前备份原配置,记录当前 disable_functions 的实际值。

先确认当前值,很多发行版或面板已经预置了部分禁用项,直接覆盖会丢掉原有配置:

php -i | grep -i "disable_functions"
php-fpm -i 2>/dev/null | grep -i "disable_functions"
grep -rn "disable_functions" /etc/php* /www/server/php/*/etc/ 2>/dev/null

原理说明

disable_functions 在 PHP 启动时生效,被列出的函数会从函数表中移除。调用时抛出 Warning: function_name() has been disabled for security reasons 并返回 null,脚本不会中断但功能失效。它属于 PHP_INI_SYSTEM 级别,只能改 php.ini 或 FPM 池配置,不能用 ini_set 在运行时修改,这一点决定了它无法被应用层代码绕过。

几个容易被忽略的细节:

  • 被禁用的是函数名而非方法,exec 被禁不影响 PDO::exec。
  • 反引号运算符 `cmd` 等价于 shell_exec,禁用 shell_exec 后反引号同样失效。
  • pcntl_exec、proc_open、popen、passthru 是独立入口,必须逐个列出。
  • eval 是语言结构而非函数,无法通过 disable_functions 禁用,需要用 disable_functions 之外的方案(如 Suhosin 或代码层审计)。
  • assert 在 PHP 7 之后行为变化较大,字符串参数不再执行代码,但仍建议禁用。

open_basedir 是另一层:限定 PHP 可访问的目录前缀,任何文件函数越过边界都会被拒绝。它与 disable_functions 是互补关系,前者防越权读文件,后者防执行命令。

操作步骤

第一步:按业务分档确定禁用清单

不要一次禁用到底。建议分成三档,按站点实际需要选择:

基础档(几乎所有站点适用):关闭命令执行与信息泄露类函数。

disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,proc_nice,proc_terminate,proc_get_status,proc_close,phpinfo,dl,sys_get_temp_dir,get_current_user,putenv,ini_set,ini_alter,ini_restore

增强档(CMS、论坛、企业官网):在基础档之上关闭文件写入与网络类高危函数。

disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,proc_nice,phpinfo,dl,putenv,ini_set,ini_alter,ini_restore,parse_ini_file,show_source,highlight_file,symlink,link,chgrp,chown,chmod,mb_send_mail,mail,fsockopen,pfsockopen,socket_create,socket_connect,curl_exec,curl_multi_exec

严格档(纯展示型站点、静态化站点):进一步关闭目录遍历与压缩包处理。

disable_functions = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,phpinfo,dl,putenv,ini_set,parse_ini_file,show_source,highlight_file,symlink,link,chmod,chown,chgrp,scandir,opendir,readdir,glob,mail,fsockopen,pfsockopen,curl_exec,curl_multi_exec,set_time_limit,ignore_user_abort

注意:curl_exec 在需要调用第三方 API 的站点上必须保留,否则支付、短信、对象存储全部失效。chmod 被禁用后,部分安装程序与缓存清理逻辑会报错,需实测。

第二步:配置 open_basedir

在 php.ini 或 FPM 池中设置站点可访问的目录范围,多个路径用冒号分隔(Windows 用分号):

open_basedir = /www/wwwroot/example.com/:/tmp/sessions/:/var/lib/php/sessions/

; 临时目录也要放开,否则上传与会话写入失败
upload_tmp_dir = /tmp/sessions/
session.save_path = "/var/lib/php/sessions"
sys_temp_dir = /tmp/sessions/

常见坑:只写了站点根目录但没放开 session 目录,表现为用户登录后立即掉线;没放开 upload_tmp_dir,表现为文件上传报「临时文件不存在」。这两条是 open_basedir 上线后最主要的故障来源。

第三步:用 FPM 池做站点级隔离

多站点服务器上,全局 php.ini 只能设一份,无法按站点差异化。正确做法是每个站点一个 pool,在池配置里覆盖:

; /etc/php/8.2/fpm/pool.d/example.conf
[example.com]
user = www-example
group = www-example
listen = /run/php/php8.2-fpm-example.sock
listen.owner = nginx
listen.group = nginx
listen.mode = 0660

pm = dynamic
pm.max_children = 20
pm.start_servers = 4
pm.max_requests = 500

; 关键:池级覆盖,优先级高于 php.ini
php_admin_value[open_basedir] = /www/wwwroot/example.com/:/var/lib/php/sessions/
php_admin_value[upload_tmp_dir] = /tmp/sessions/
php_admin_value[disable_functions] = exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec,phpinfo,putenv,ini_set,mail
php_admin_value[expose_php] = Off
php_admin_flag[allow_url_fopen] = Off
php_admin_value[error_log] = /var/log/php/example-error.log

必须用 php_admin_value 而不是 php_value:前者不可被 ini_set 或 .htaccess 覆盖,后者可以被应用层改写。这是隔离能否成立的分界线。

另一条不重启 FPM 就能生效的路径是站点根目录下的 .user.ini,但注意它只对 CGI/FastCGI 生效,且不能在 .user.ini 里设置 disable_functions(该指令为 PHP_INI_SYSTEM,用户级配置文件无法覆盖):

; /www/wwwroot/example.com/.user.ini
open_basedir = /www/wwwroot/example.com/:/var/lib/php/sessions/
; disable_functions 写在这里无效,必须走 php.ini 或 pool 配置

第四步:重载并验证生效

php-fpm -t
systemctl reload php8.2-fpm
systemctl status php8.2-fpm --no-pager | head -5

# 确认池级配置已加载
php-fpm -tt 2>&1 | grep -A2 "disable_functions" | head -20

验证函数是否真的被禁用,用一个临时脚本逐项测试,测试完必须删除:

cat > /www/wwwroot/example.com/_chk.php <<'PHP'
<?php
$funcs = ['exec','shell_exec','system','passthru','proc_open','popen','pcntl_exec','phpinfo','putenv','ini_set','mail'];
foreach ($funcs as $f) {
    printf("%-14s %s\n", $f, function_exists($f) ? 'ENABLED' : 'DISABLED');
}
echo "open_basedir: " . ini_get('open_basedir') . "\n";
echo "user: " . get_current_user() . "\n";
PHP

php /www/wwwroot/example.com/_chk.php
rm -f /www/wwwroot/example.com/_chk.php

注意 CLI 与 FPM 的配置可能不同:命令行执行 php 读的是 /etc/php/8.2/cli/php.ini,不带池配置。要验证 FPM 侧,需通过 HTTP 访问该脚本,或用 php-fpm -tt 查看池解析结果。

配置验证

配置生效不等于业务正常。上线后按下列四项检查:

  • 函数层:HTTP 访问探针,确认目标函数全部 DISABLED,且 open_basedir 输出为预期路径。
  • 站点层:逐页点击核心功能,重点验证登录、上传、支付回调、邮件通知、缓存清理。任一环节报 500 立即查 error_log。
  • 越界层:尝试读取 /etc/passwd,预期被 open_basedir 拒绝并记录 open_basedir restriction in effect 警告。
  • 日志层:观察 24 小时 error_log,统计 has been disabled 与 open_basedir restriction 的出现次数,零出现才算真正无影响。
grep -c "has been disabled" /var/log/php/example-error.log
grep -c "open_basedir restriction" /var/log/php/example-error.log
tail -n 50 /var/log/php/example-error.log

如果 has been disabled 持续出现,说明有功能在调用被禁函数,需要判断是业务正常依赖还是攻击尝试。正常依赖应当从禁用列表移除该函数并改用更细粒度的控制;攻击尝试则说明站点已有木马,应转入查杀流程。

常见问题

Q1:禁用后站点报 500,但错误日志里没有明显线索,如何定位?

先把 display_errors 临时打开或用 error_log 记录完整堆栈,同时在 php.ini 里把 log_errors 设为 On、error_reporting 设为 E_ALL。多数情况下是某处调用了被禁函数导致返回 null,进而引发后续的类型错误,真正的报错点在下游几行。定位技巧是用 strace -f -e trace=openat 跟踪 FPM 子进程,看它在哪个文件处中断;另一种做法是二分法,把禁用列表按十项一组分批恢复,快速锁定冲突函数。

Q2:WordPress 或某插件提示缺少函数,能否只对某个目录放开?

disable_functions 是进程级配置,不能在目录级差异化放开。可行方案有三个:一是把该站点单独拆到一个 FPM 池,仅对它的池配置放宽;二是把插件依赖的命令执行改为调用独立的本地服务(如通过 HTTP 调内部转码接口),从代码层消除函数依赖;三是用 sudo 加白名单包装脚本,只允许执行固定的命令与参数。不推荐的做法是为了一个插件直接全局放开 exec,那等于放弃了这层防护。

Q3:open_basedir 会不会明显拖慢站点?

会有影响但通常可忽略。open_basedir 的检查发生在每次文件操作时,需要对路径做规范化与前缀比对,开启 open_basedir 后 realpath 缓存会失效,高 IO 的站点可能观察到 5% 到 15% 的性能下降。缓解办法是把路径数量控制在 3 个以内、避免使用过多符号链接、并开启 OPcache。如果站点本身就是文件密集型(如大量缩略图生成),建议先在预发环境压测再上生产。

总结

disable_functions 的价值在于「与代码无关」:不管应用层写得多烂,被禁的函数就是调不到。它的正确用法是分档配置加池级隔离,而不是复制一份超长清单全局套用。配套的 open_basedir 解决越权读文件,两者合起来才构成运行时的最小权限边界。

最后提醒两个边界:eval 无法通过该机制禁用,需要代码审计或扩展层方案;php_value 与 php_admin_value 的区别决定了配置能否被应用层改写,写错这一处,整套隔离就形同虚设。上线前务必用探针实际验证,而不是只看配置文件写了什么。