10-21 PHP漏洞修复记录

PHP 站点加固与校验流程(宝塔 + php-fpm)

适用:宝塔面板管理的 php-fpm 站点,针对 PHP 7.4(EOL)及 php2shell 类上传进度漏洞的修复与验收。
基线:2026-10-22 ~ 10-23 在三台机器实测(阿里云 ECS / VM-4-3-opencloudos / VM-4-4-centos)。
命令均在服务器 SSH 内执行。占位符:<ver> = PHP 版本目录(74/56/85),<站> = 站点根目录名(宝塔下就是域名,--resolve 用的也是它),<域名> = 该站域名(Host: 头用)。


1. 在宝塔面板升级 PHP

装宝塔的回移构建(官方 7.4 只发到 7.4.33,面板里的 7.4.34 就是它自己打补丁的版本)。

2. 在面板「PHP 设置 → FPM 配置文件」追加以下内容

写在 [www] 段末尾(必须是 php_admin_value,写进 php.ini 会被网站代码的 ini_set() 翻回来):

1
2
3
4
php_admin_value[display_errors] = Off
php_admin_value[session.upload_progress.enabled] = Off
php_admin_value[session.upload_progress.cleanup] = On
pm.max_requests = 500

3. 确认改动真的被加载

面板点保存通常会自动重启,但要验一句:master 的启动时间必须晚于池文件的 mtime。

1
2
ls -l /www/server/php/<ver>/etc/php-fpm.conf
ps -eo pid,lstart,cmd | grep "php-fpm.*master" | grep -v grep

早于的话手动平滑重载(不断流):

1
kill -USR2 <master_pid>

注意:USR2 只重启 worker,不会换掉 master 的二进制镜像。如果只是改配置,USR2 够用;如果是第 1 步刚升级了 PHP 版本,必须真重启(/etc/rc.d/init.d/php-fpm-<ver> restart),否则内存里跑的还是旧版。判据:ls -l /proc/<pid>/exe 带 (deleted)。

4. 校验命令(文件层 / 版本层)

1
2
3
4
5
6
7
8
/www/server/php/<ver>/bin/php -v
ls -l /www/server/php/<ver>/bin/php
ps -eo pid,lstart,cmd | grep "php-fpm.*master" | grep -v grep
ls -l /www/server/php/<ver>/sbin/php-fpm
/www/server/php/<ver>/sbin/php-fpm -t
ls -l /www/server/php/<ver>/etc/php-fpm.conf
grep -n "upload_progress\|display_errors\|max_requests" /www/server/php/<ver>/etc/php-fpm.conf
grep -rn "php-cgi" /www/server/panel/vhost/ | grep -v phpfpm_status

看什么:

输出 判读
php -v = 7.4.34,built: 时间是最近 补丁版已编译
sbin/php-fpm mtime 新,且 master 启动时间晚于它 新构建真的在跑
php-fpm -t 打印出的配置路径 就是池文件真身(两种布局:内联在 php-fpm.conf,或 include php-fpm.d/*.conf)
grep 出 4 行 php_admin_value 配置层到位
最后一条 grep 每个站绑哪个 PHP 版本。同一站两个 VirtualHost 块版本不一致 = HTTP 和 HTTPS 跑在不同 PHP 上,要修成一致

5. 运行时复验(唯一能证明缓解生效的一步)

上面第 4 步全是文件层,**读不到 php_admin_value**。php-fpm -i 也不行 —— 它只加载 php.ini,对 session.upload_progress.enabled 永远回 On,纯假阴性。只有走 HTTP 的探针可信:

1
2
3
4
5
6
7
8
9
cat > /www/wwwroot/<站>/_chk.php <<'EOF'
<?php
echo PHP_VERSION,"\n";
foreach (['session.upload_progress.enabled','session.upload_progress.cleanup','session.auto_start','expose_php','display_errors'] as $k)
echo str_pad($k,34),' ini_get=',var_export(ini_get($k),true),' get_cfg_var=',var_export(get_cfg_var($k),true),"\n";
EOF
curl -sk -H 'Host: <域名>' https://127.0.0.1/_chk.php
curl -sk --resolve <站>:443:127.0.0.1 https://<站>/_chk.php # 带真 SNI,命中该站自己的 VirtualHost 块
rm -f /www/wwwroot/<站>/_chk.php

一个运行时值的四个来源

优先级从低到高,后者覆盖前者:

1
编译默认  →  php.ini  →  池的 php_admin_value  →  代码里的 ini_set()

探针里那两个函数各读其中一层,这是后面所有判读的基础:

函数 读到哪一层 读不到哪一层
get_cfg_var() 只读 ini 文件(php.ini) 池的 php_admin_value、ini_set()、编译默认值
ini_get() 最终生效值(上面四层叠加的结果) ——

于是有三条硬结论:

  • get_cfg_var=false 的唯一含义是”php.ini 里没写过这条”。此时运行时值来自编译默认还是池,这个函数分不开。
  • 两列不一致 ⇒ 中间一定有个比 php.ini 更硬的来源改了它 ⇒ 只能是池里的 php_admin_value。
  • 两列一致 ⇒ 只能说明 php.ini 给的就是这个值,看不出池有没有插手(池若写了同向的值,输出一模一样)。

所以能证明池插手的信号只有两种:① 两列冲突;② php.ini 没写(get_cfg_var=false)但运行时值与编译默认相反。两者都拿不到时,不要下”池没生效”的结论,用下面「主动翻」那一节测。

项 期望 说明
session.upload_progress.enabled ini_get = '' 空串就是 Off,不是”没配”
锁死 · 情形① ini_get='' + get_cfg_var='1' php.ini 写的是 On,运行时却是 Off ⇒ 池把它压下去了(样例里的 display_errors)
锁死 · 情形② ini_get='' + get_cfg_var=false php.ini 根本没写,而编译默认是 On,运行时却是 Off ⇒ 也是池给的(样例里的 upload_progress.enabled)
证不出来(不是”没锁”) 两列相同,或运行时值正好等于编译默认 session.auto_start 两边都 '0':php.ini 显式写了 0,池就算也写了 Off 看不出;upload_progress.cleanup 的 '1' 与编译默认同向,同样分不开
display_errors '' / Off 开着会回显物理路径与 SQL
expose_php '' / Off 去掉 X-Powered-By 指纹
PHP_VERSION 新版本号 探针走 FPM,这才是线上真正在跑的版本

get_cfg_var 的三种返回值,含义完全不同

get_cfg_var 返回 含义 样例里对应的项
'1' php.ini 里写了 On display_errors
'' 空串 php.ini 里写了,但写的是 Off —
false php.ini 里根本没写这条,运行时取的是编译默认值或池给的值 upload_progress.enabled / cleanup

false 和 '' 的区别正好能区分”没配”和”配成 Off”,别把 false 当成”读失败”或”值为假”。

最关键的一条:get_cfg_var 无论返回什么都不代表运行时状态。 它只回答”php.ini 里写了什么”,而且看不到池的 php_admin_value —— 生效值永远只看 ini_get。也正因为它看不到池,才会出现 display_errors 那种”php.ini 说 On、运行时是 Off”的差值,而这个差值本身就是池在管事的证据。

不靠推理,直接证明”锁死”

在探针里加两行再打一次:

1
2
@ini_set('session.upload_progress.enabled','1');
echo 'after_ini_set=',var_export(ini_get('session.upload_progress.enabled'),true),"\n";
  • 读回来还是 '' ⇒ 代码翻不动 = 真锁死(php_admin_value)。
  • 读回来变成 '1' ⇒ 只是 php.ini 层的值,webshell 一句 ini_set() 就能把防护关掉 —— 回去检查池里那行是不是写成了 php_value 而不是 php_admin_value。

为什么”锁死”才是目标:缓解如果不锁死,攻击者只要能执行任意 PHP,就能自己把 upload_progress.enabled 重新打开,等于没防。代价只有一个:你自己调试时也改不动,得回池文件里改。

这个测试比对照默认值可靠,因为它不依赖”我知道编译默认是什么”:翻不动 = 池锁着;翻回去了 = 之前那个值没人锁,只是恰好看着对。

探针的两种盲区

  • 值与编译默认同向:php_admin_value[session.upload_progress.cleanup] = On 确实写进了池,但 cleanup 的编译默认本来就是 On,所以 ini_get='1' 无法区分是池给的还是默认给的。这是校验手段的盲区,不是配置的错 —— 想确认只能靠上面那个主动翻的测试。
  • pm.* 根本不进 PHP 配置系统:pm.max_requests 是池自己的参数,ini_get / get_cfg_var 都读不到,只能回面板「FPM 配置文件」页看那一行在不在。

另外三项没被证明锁死,对网站有影响吗

结论:影响很小,三项都不构成防线缺口。

项 能不能被代码翻 翻了有什么后果
session.upload_progress.cleanup 不确定(池里那行若真生效就翻不动,但探针证不出来) 无所谓。cleanup 只在 enabled=On 时决定”进度文件用完删不删”,而 enabled 已锁成 Off,进度文件根本不会产生
session.auto_start 不能,它是 PHP_INI_SYSTEM 只有 php.ini / 池能改,ini_set() 直接返回失败。所以它虽然”没被池锁”,也不需要锁
expose_php 能(PHP_INI_ALL) 只是把 X-Powered-By: PHP/7.4.34 这个版本指纹头加回来,不泄露物理路径、SQL 或会话数据。真要锁,在池里补一行 php_admin_value[expose_php] = Off

真正需要”锁死”的只有两条:session.upload_progress.enabled(php2shell 的入口)和 display_errors(会回显物理路径与 SQL)。样例输出里恰好就是这两条能被证明锁死,其余三条即使停在 php.ini 层也不影响验收结论。

实测样例(一台做完全部 5 步的机器)

1
2
3
4
5
6
7.4.34
session.upload_progress.enabled ini_get='' get_cfg_var=false
session.upload_progress.cleanup ini_get='1' get_cfg_var=false
session.auto_start ini_get='0' get_cfg_var='0'
expose_php ini_get='' get_cfg_var=''
display_errors ini_get='' get_cfg_var='1'

逐条对上:PHP_VERSION 出 7.4.34 ⇒ FPM 跑的就是新构建(不是 CLI 新、FPM 旧);upload_progress.enabled 的 ini_get 是空串 ⇒ Off;display_errors 两列不一致(php.ini 写 On、运行时 Off)⇒ php_admin_value 生效的直接证据;upload_progress.enabled 的 get_cfg_var=false 说明 php.ini 里根本没写过它,那运行时的 Off 只能来自池。这一屏就是”已修复 + 已缓解”的验收凭据。

但要看清:这一屏里只有 enabled 和 display_errors 两行算锁死凭据,其余三行是”值对,可来源证不出来”(原因见上两节)。别把整屏都当成”五条全锁死”。

注意 -H 'Host: ...' 那条走的是 127.0.0.1,SNI 不是真域名,命中的可能不是该站自己的 VirtualHost 块;要验”这个站到底跑在哪个 PHP 上”,必须带真 SNI 再打一次:

1
curl -sk --resolve <站>:443:127.0.0.1 https://<站>/_chk.php

探针用完立刻 rm —— 它能被猜到就是新的泄露面。


坑清单

  • php-fpm -i 不加载池配置,验 php_admin_value 必假阴性。
  • 「配置修改」= php.ini,「FPM 配置文件」= 池;缓解要落后者。
  • 池文件两种布局(内联 / include php-fpm.d/),grep .../php-fpm.d/*.conf 报 No such file 不等于没配。
  • 版本号不能代表补丁,看 built: 时间戳。
  • Active: active (exited) + vendor preset: disabled = systemd 只是 sysv 空壳,systemctl restart 不可靠,而且机器重启后 php-fpm 不会自启。
  • systemctl 报 Another FPM instance seems to already listen on ...sock = 接管关系断了,站点其实正常在服务;**别 kill -9 主进程,别 systemctl disable**。
  • 只改配置用 USR2;升级了 PHP 二进制必须真重启。
  • 多版本共存时(54/55/56/74 同机),池配置按版本各一份,改一次只覆盖该版本上的站点。
  • 六年没重编的 5.x 二进制属于”能跑就别碰”,要动先排期把站点迁走,别直接重启。
  • curl -I 回 405 多半是 vhost 拒 HEAD,不是跳转环,用 GET 复验。
  • get_cfg_var() 只返回 php.ini 里显式写过的值,没写过就是 false,它不看编译默认值 —— 别把 false 当成”读失败”。
  • 两列一致 ≠ 池没插手,只是探针看不出来;运行时值恰好等于编译默认时同理(cleanup 就是这种)。别把”看不出”说成”没有”。
  • ini_get / get_cfg_var 都读不到 pm.*,pm.max_requests 只能回面板页确认。
  • -H 'Host: xxx' 打到 127.0.0.1 时 SNI 不是真域名,命中的可能是默认 vhost 而不是目标站那个块;要确认”这个站跑在哪个 PHP 上”必须用 --resolve 带真 SNI 再打一次。