返回博客

PHP-FPM 与 CLI 的 disable_functions 不对称:SSH 实测通过不代表线上能用

某天在给一个后台管理系统加「读取最新 cron 日志时间戳」的功能。逻辑很简单:SSH 到服务器,用 shell_exec 跑一句 grep | tail -1,本地测试、SSH 直接调 CLI 全部 OK,信心满满部署上线。

结果第一次 curl 接口就返回 500,日志只有一行冷冰冰的:shell_exec() has been disabled for security reasons

现象

  • ssh root@server 'php -r "echo function_exists(\"shell_exec\");"' 输出 1
  • 直接 grep disable_functions /www/server/php/82/etc/php-cli.ini 看不到 shell_exec
  • 但 Web 请求到 PHP-FPM 处理的接口,立刻 500

根因

PHP 同一版本下,CLI 和 FPM 使用两份完全独立的 php.ini

CLI  → /www/server/php/82/etc/php-cli.ini
FPM  → /www/server/php/82/etc/php.ini     ← web 请求走这里

生产环境 PHP-FPM(宝塔 / 通用 LNMP 一键包)默认会在 FPM 的 ini 里禁用一大批"危险函数":

passthru, exec, system, shell_exec, popen, proc_open,
putenv, chroot, chgrp, chown, pcntl_exec,
ini_alter, ini_restore, dl, openlog, syslog,
readlink, symlink, imap_open, apache_setenv, pcntl_*

而 CLI 模式默认不禁,因为 CLI 通常用来跑批处理任务,需要这些能力。

坑:SSH php -r 实测通过 ≠ FPM 也能用

php -r 'function_exists("shell_exec")' 跑的是 CLI 模式,读的是 php-cli.ini,与 FPM 的配置毫无关系。这个命令的返回值对"web 接口能不能用这个函数"没有任何参考价值。

正解

验证某函数在 FPM 路径是否可用,有三种方式,任选其一:

1. 直读 FPM ini(最快)

# 宝塔通用路径(注意是 php.ini 不是 php-cli.ini)
ssh root@server "grep '^disable_functions' /www/server/php/82/etc/php.ini"
 
# Debian/Ubuntu apt 安装
ssh root@server "grep '^disable_functions' /etc/php/8.2/fpm/php.ini"

2. webroot 探测脚本(100% 还原 FPM 现场)

<?php // /www/wwwroot/yoursite/probe.php  ← 用完必须立刻 rm!
header('Content-Type: text/plain');
foreach (['shell_exec','exec','system','passthru','proc_open','popen'] as $f) {
    echo "$f: " . (function_exists($f) ? 'available' : 'DISABLED') . "\n";
}
echo 'sapi: ' . PHP_SAPI . "\n"; // 应输出 fpm-fcgi
curl https://yoursite.com/probe.php
ssh root@server "rm /www/wwwroot/yoursite/probe.php"

3. 临时 controller endpoint

public function probe() {
    return $this->success('ok', [
        'sapi'     => PHP_SAPI,
        'disabled' => ini_get('disable_functions'),
    ]);
}
正解:用纯 PHP 原生函数替代 shell 调用

绝大多数"只是要读文件"的场景完全不需要 shell_exec,PHP 原生文件操作既快又不受 disable_functions 限制。

实战热修:替换 shell_exec 读日志最后一行

// ❌ 原版(FPM 下报 disabled)
$line = trim(shell_exec("grep -E '^\\[' $log | tail -1"));
 
// ✅ 替代(FPM 完全可用)
$lines = @file($log, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) ?: [];
for ($i = count($lines) - 1; $i >= 0; $i--) {
    if (preg_match('/^\[(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\]/', $lines[$i], $m)) {
        return $m[1];
    }
}

常见等价替换速查:

| 想用 | PHP 原生替代 | |------|-------------| | shell_exec("grep PATTERN file") | preg_grep('/PATTERN/', file($f)) | | shell_exec("tail -n file") | file() 反向遍历 | | exec("ls dir") | scandir($dir)glob($dir.'/*') | | proc_open 启子进程 | 消息队列 / curl 调内部 endpoint |

排查清单(FPM 路径出现莫名 500 时)

  1. 先看错误日志有没有 has been disabled
  2. grep '^disable_functions' /www/server/php/$(php -r 'echo PHP_MAJOR_VERSION.PHP_MINOR_VERSION;')/etc/php.ini
  3. 确认调用链中每个 PHP 内置函数都不在禁用列表
  4. 凡用了 shell_exec / exec / system / passthru / proc_open / popen → 视为 FPM 高危,必须用 webroot 探测脚本二次核实

一句话外卖

PHP 的 CLI 和 FPM 读的是两份 ini,SSH 实测永远只能证明 CLI 路径,验证 FPM 行为必须用 web 请求触发。