PHP源码编译升级实战-从CVE告警到OpenSSL编译失败

PHP 源码编译升级实战:从一条 CVE 告警,到 OpenSSL 1.0.2 编译失败

说明:本文记录一次真实的 PHP 小版本升级全过程。文中域名、主机名、IP 均已替换为示例值,
路径前缀保留原样以便对照。所有命令都标注了”为什么这么做”,照着做之前请先理解那一句。


一、开篇:一条短信引发的升级

某天收到云厂商的安全短信,大意是:

PHP 曝出 “php2shell” 远程代码执行漏洞(CVE-2026-109618、CVE-2026-109619)。
如您的业务使用 PHP,请尽快自查并升级至安全版本。

CVSS v4.0 9.2,PHP 内核内存管理缺陷,官方回溯影响到 PHP 5.4。
修复版本:8.2.35 / 8.3.36 / 8.4.27 / 8.5.12。

看到”9.2 分 RCE”,第一反应通常是”赶紧升”。但真动手之前,有两个问题必须先回答清楚,
否则很容易做无用功、甚至把站点搞挂:

  1. 我到底跑的是哪个 PHP?(版本只是表象,”它是怎么装的”才是关键)
  2. 我这个运行形态有多危险?(决定了是”立刻停机升”还是”从容升”)

二、第一步不是升级,是搞清楚自己是什么

2.1 先看跑的是哪个版本

php -v

输出 PHP 8.3.20 —— 低于修复版 8.3.36,确实在影响范围内。

但这里有个陷阱:php -v 跑出来的,未必是网站实际用的那个 PHP。
如果机器上装过多套 PHP,PATH 里排第一的那个可能跟 Web 用的根本不是一回事。
所以还要再确认一次”Web 侧真正在用的是谁”。

2.2 关键一步:它是怎么装的?

这一步决定了升级路线,不搞清楚会走错方向:

rpm -qa | grep -i ^php          # 包管理器里有没有 PHP
which php                        # 命令在哪
ls /usr/local/ | grep php        # 有没有源码编译的
ss -xl | grep php-fpm            # Web 用的是 TCP 还是 unix socket

实测结果很有意思,这台机器上有两套毫无关系的 PHP:

口径版本说明
rpm -qa \| grep ^php5.4.45(remi 源)纯残留,根本没在用
/usr/local/php-8.3.20/bin/php -v8.3.20真正在跑的,源码编译
yum --enablerepo=remi-php83 list updates php8.3.8源里能给的比线上还旧

这一步的价值:如果没查就照着”CentOS 用 yum 升 PHP”的通用教程做,
会立刻踩两个坑——

  • yum update php* 升的是那个没人用的 5.4,线上纹丝不动;
  • 更糟的是 remi 源元数据已停更,只能给到 8.3.8,比线上的 8.3.20 还旧。

结论只有一个:这台机器的 PHP 只能源码编译升级,yum 完全管不到。

经验:rpm -qa 里有 PHP,不代表那就是在跑的 PHP。
源码编译的 PHP 对包管理器是隐形的。判定版本只有一个可信口径:
Web 实际调用的那个二进制的 -v。

2.3 我的运行形态有多危险?

php2shell 的危害跟运行方式强相关:

  • Apache + mod_php:最危险。 缺陷在应用代码执行之前就能被触发,
    等于不管你写的什么 PHP 程序都躲不掉。
  • nginx + PHP-FPM:危险低一档。 需要请求真正进入 PHP 生命周期,
    但官方仍列为易感——所以该升还是要升,只是可以从容一点。

本机是 nginx + PHP-FPM,属于后者。这意味着:有时间做一次干净的、可回退的升级,
而不是半夜抢救式操作。


三、先取证:有没有已经被打过

升级前花两分钟看一眼,决定”是修漏洞”还是”是处理入侵”——这两件事的流程完全不同。
内存破坏类漏洞被打过的典型痕迹是 worker 进程段错误(signal 11):

grep -c 'exited on signal 11' /usr/local/php-8.3.20/var/log/php-fpm.log
grep -c ' 500 ' /var/log/nginx/example.access.log
find /usr/local/nginx/html -mtime -7 -name '*.php'   # web 目录近期新增的 php
ls -lt /tmp/*.php                                     # /tmp 下的可疑 php

结果:段错误 0 条、500 为 0、无陌生 php 文件 → 没被利用,可以按正常流程从容升级。

为什么先看这个:如果是”正在被打”,那第一优先级是断网止血 + 溯源,
而不是慢悠悠编译半小时。取证结果决定了紧迫性。


四、升级前先止血:缓解措施

编译要几十分钟,这期间先让漏洞路径走不通。三条措施,一分钟生效:

措施值为什么
session.upload_progress.enabled0野外攻击主要走这条路径(对应 CVE-2026-109619)
post_max_size压小压缩攻击者可用的内存布局空间
pm.max_children15 → 10减少同时可爆破的 worker 数,兼给 2G 内存留余量

有一条官方建议我没有采纳,值得单独说:官方缓解提示里提到可以把 memory_limit 设为 -1
(不限制)。理由是攻击依赖 PHP 的 OOM 处理路径,不限制内存就不会走那条路。

但本机只有 2G 内存。设成 -1 意味着单个 worker 理论上可以吃满内存,
一个失控请求就能把整台机器拖死——为了防一个漏洞,引入一个更可能发生的可用性事故,不划算。
所以我选择关掉 upload_progress + 降低 worker 数来兜底,memory_limit 保持原样。

经验:缓解措施要在自己的环境里重新评估,不能照抄。
官方建议面向的是”一般情况”,而”一般情况”通常不包括 2G 内存的机器。

4.1 一个自己给自己挖的坑

缓解时把 post_max_size 压到了 16M。后来核对时发现本机 upload_max_filesize = 20M:

post_max_size      = 16M
upload_max_filesize = 20M

post_max_size 必须 ≥ upload_max_filesize,否则超过 16M 的上传请求会被整个 POST 拒绝,
而 WordPress 这边的表现是”点了上传没反应”——极难排查。
升级完成后已把 post_max_size 恢复到 32M。

经验:压安全参数时,顺手核对一遍所有相互依赖的参数。
安全收紧经常会在功能侧开一个口子,而且那种口子往往表现为”静默失败”。


五、升级路线:为什么选”新前缀 + 保留旧版”

源码编译升级有两种做法:

  • A. 原地覆盖:编译到同一个 prefix,直接盖掉旧版。快,但不可回退——
    一旦新版有问题,站点就一直挂着等你修。
  • B. 独立新前缀:新版装到 /usr/local/php-8.3.36,旧版原样保留。

我选 B,理由是它把”升级”从不可逆操作变成了可切换操作:

  • 旧版一个字节不动,回退 = 改一行 systemd 配置 + 重启,30 秒;
  • 编译期间旧版照常服务,站点零中断;
  • 只有真正切换的那 2~3 秒会断。

关键是让 FPM socket 路径保持不变(/run/php/php8.3-fpm.sock),
这样 nginx 配置一行都不用改——nginx 根本不知道后面换了个人。


六、下载与验签:两个不太直观的坑

curl -fLO https://www.php.net/distributions/php-8.3.36.tar.gz
curl -fsSLO https://www.php.net/distributions/php-8.3.36.tar.gz.asc
curl -fsSLO https://www.php.net/distributions/php-keyring.gpg

坑一:php.net 不提供 .sha256 文件(实测 404)。
常见教程让你 sha256sum 比对,在这里行不通,只能用 GPG 签名。

坑二:php-keyring.gpg 名字带 .gpg,实际是 ASCII-armored 文本,不是二进制钥匙环。
直接 gpg --keyring php-keyring.gpg --verify ... 会报:

gpg: invalid packet (ctb=2d)
gpg: Can't check signature: No public key

正确做法是先 --import(用独立 homedir,不污染 root 自己的钥匙环):

mkdir -p /tmp/phpgpg && chmod 700 /tmp/phpgpg
gpg --homedir /tmp/phpgpg --batch --import php-keyring.gpg
gpg --homedir /tmp/phpgpg --batch --verify php-8.3.36.tar.gz.asc php-8.3.36.tar.gz

导入时出现的几条 no valid user IDs 是无害的(部分子密钥没有绑定 UID)。
看到 Good signature 之后再核对发布经理的密钥指纹,才能解压编译。

为什么要这么较真:升级 PHP 是为了修漏洞。
如果源码本身来路不明,那等于”为了修一个漏洞,主动装了一个后门”。
安全升级的第一原则:你装进去的东西必须可验证来源。


七、编译参数复用:为什么不能只改 --prefix

源码编译升级最省事的做法是复用旧版的编译参数,否则很容易漏掉某个扩展,
上线后 WordPress 直接白屏。旧参数这样取:

/usr/local/php-8.3.20/bin/php-config --configure-options

拿到之后要替换前缀。注意:不能只替换 --prefix——
参数里 --with-config-file-path 也带着旧前缀。只换 --prefix 的结果是:
新版会去读旧版的 etc 目录,表现为”改了 php.ini 完全不生效”,极难发现。

正确做法是全局替换:

OPTS=$(/usr/local/php-8.3.20/bin/php-config --configure-options \
       | sed "s|/usr/local/php-8.3.20|/usr/local/php-8.3.36|g")
./configure $OPTS

配完再核对一次,两个值都得是新前缀:

grep -o -- '--prefix=[^ ]*\|--with-config-file-path=[^ ]*' config.log | head -2
# --prefix=/usr/local/php-8.3.36
# --with-config-file-path=/usr/local/php-8.3.36/etc

八、重头戏:编译失败,以及我是怎么定位的

./configure 顺利通过,然后 make 炸了:

ext/openssl/xp_ssl.c:144:28: error: subscripted value is neither array nor pointer nor vector
ext/openssl/xp_ssl.c:145:28: error: subscripted value is neither array nor pointer nor vector
ext/openssl/xp_ssl.c:520:...
make: *** [ext/openssl/xp_ssl.lo] Error 1

一共 20 条 error,但同一个根因。

8.1 第一步:报错行号落在宏定义内部,先找谁调用了这个宏

144、145 行在函数式宏 EXPAND_IPV6_ADDRESS 的定义体里:

#define EXPAND_IPV6_ADDRESS(_str, _bytes) \
    do { \
        snprintf(_str, 40, "%X:%X:...", \
            _bytes[0]  << 8 | _bytes[1],  \
            ...
            _bytes[14] << 8 | _bytes[15]  \
        ); \
    } while(0)

报错说 _bytes “既不是数组也不是指针”——那就要看调用时传了什么。
找到第 520 行:

EXPAND_IPV6_ADDRESS(ipbuffer, ASN1_STRING_get0_data(san->d.iPAddress));

8.2 第二步:查这个符号在 OpenSSL 1.0.2 里到底有没有

openssl version          # OpenSSL 1.0.2k-fips
php -i | grep -i openssl # Library Version => OpenSSL 1.0.2k-fips

ASN1_STRING_get0_data() 是 OpenSSL 1.1.0 才引入的访问器,1.0.2 里没有。

这下报错就说得通了:C 语言遇到未声明的函数会隐式声明为返回 int,
于是 ASN1_STRING_get0_data(...) 被当成 int,再对它做下标 [0]——
int 当然不能下标,于是报 “subscripted value is neither array nor pointer nor vector”。

为什么 20 条 error 都要看完:它们分布在 144/145/506/507/508/509/520 等行,
但全是这一个符号。找到根因比逐条修重要——20 条其实是 1 个问题。

8.3 第三步:确认这不是我的环境问题

这一步很关键,因为它决定了”我该改自己”还是”我该换上游配置”。
搜了一下,发现做 EL7 编译的 Centmin Mod 已经为这个问题发布了补丁,描述完全一致:

上游 PHP commit 19255f4b78(OpenSSL 4.0 兼容回溯)把 ext/openssl/xp_ssl.c
的 GEN_IPADD 换成了 ASN1_STRING_get0_data()。CentOS 7 的 OpenSSL 1.0.2k
缺少该访问器,构建失败。
受影响:PHP 8.2.31 / 8.3.31 及之后各分支;8.3.30 是干净的。

结论:这是 PHP 官方的回归(regression),不是我机器的问题。
也就意味着——8.3.36 在 CentOS 7 上必然编不过,换 OpenSSL 版本只是绕路,不是正解。

8.4 第四步:找出”官方为什么自己没炸”

这里有个很值得琢磨的细节。我知道 PHP 8.3 官方声明支持 OpenSSL ≥ 1.0.2,
那它自己的代码里应该本来就有兼容层。去 ext/openssl/openssl.c 一查,第 403 行:

#if PHP_OPENSSL_API_VERSION < 0x10100
static const unsigned char *ASN1_STRING_get0_data(const ASN1_STRING *asn1)
{
    return M_ASN1_STRING_data(asn1);
}
#endif

补丁本来就在! 那为什么 xp_ssl.c 还是编不过?

因为它是 static——static 函数只在当前编译单元(也就是 openssl.c 这一个 .c 文件)可见。
xp_ssl.c 是另一个编译单元,看不到它。

这就是全部症结:官方在 openssl.c 里补了 shim,但漏了 xp_ssl.c 也需要一份。

8.5 第五步:确认”只补这一处就够了”

在打补丁之前,我还想确认一件事:会不会修完这个,又冒出第二个坑?
每次重编要二十分钟,试错成本很高。于是下载了两个官方源码包做对比:

diff -ru php-8.3.20/ext/openssl php-8.3.36/ext/openssl

结果很干净——8.3.20 → 8.3.36 之间,xp_ssl.c 的改动里:

  • san->d.iPAddress->data → ASN1_STRING_get0_data(...) ← 唯一的问题点
  • san->d.iPAddress->length → ASN1_STRING_length(...) ← 1.0.2 本来就有,没问题
  • 其余全是纯 C 改动(变量类型、函数签名、新加的 SAN 匹配逻辑),不涉及 OpenSSL 新 API

结论:只补一个 shim 即可,不会有第二个坑。 这下可以放心动手了。

8.6 补丁

照抄官方在 openssl.c 里的原样(M_ASN1_STRING_data 就是 1.0.2 里 ((x)->data) 的宏):

cd /usr/local/src/php-8.3.36
cp -a ext/openssl/xp_ssl.c ext/openssl/xp_ssl.c.orig-shim

sed -i '/^\/\* Used for peer verification in windows \*\//i \
#if PHP_OPENSSL_API_VERSION < 0x10100\
static const unsigned char *ASN1_STRING_get0_data(const ASN1_STRING *asn1)\
{\
\treturn M_ASN1_STRING_data(asn1);\
}\
#endif\
' ext/openssl/xp_ssl.c

插入点在第 129 行,而首次使用在第 506 行之后——#define / 函数定义必须出现在使用之前,
这点别搞反。

打完不需要重新 ./configure,直接 make 续编即可,
已生成的 .lo 会保留,只重编 xp_ssl.c 及后续部分。

make -j1 > /tmp/php-make.log 2>&1 || tail -40 /tmp/php-make.log
grep -c 'error:' /tmp/php-make.log     # 0
tail -5 /tmp/php-make.log              # Build complete.

8.7 为什么不换 OpenSSL?

当时也考虑过两条绕路:装 EPEL 的 openssl11、或者源码编译一个独立的 OpenSSL。
最后都放弃了,理由是:

  • 它们会引入双版本 OpenSSL 链接——PHP 的 openssl 扩展链接 1.1,
    而 curl 扩展链接的 libcurl 还是 1.0.2,两个版本挤在一个进程里,风险不小;
  • 还得处理 rpath / ld.so.conf 之类的运行时查找问题;
  • 而根因已经明确是一个漏了的 shim,补上就好——改 5 行代码,比重装一个基础库安全得多。

经验:遇到编译错误,先把”根因”定位到具体符号,
再决定是”改代码”还是”换依赖”。换依赖的成本远高于打补丁,
尤其是 OpenSSL 这种全系统共用的基础库。
我这次先花时间做了 8.3~8.5 的源码 diff,就是为了确认”只有一处”——
这个确认值那二十分钟。


九、最容易忽略的一道闸:扩展清单

make install 成功,php -v 显示 8.3.36。但这绝不意味着可以切换。

上线 PHP 最常见的事故不是”PHP 起不来”,而是“PHP 起来了,但少了某个扩展,站点白屏”。
所以切换前必须比对:

/usr/local/php-8.3.20/bin/php -m | sort > /tmp/old.txt
/usr/local/php-8.3.36/bin/php -m | sort > /tmp/new.txt
diff -u /tmp/old.txt /tmp/new.txt

结果新版缺了 4 项:exif、imagick、zip、Zend OPcache。

9.1 先分清:是”没编进去”还是”没加载”

这两种情况处理方式完全不同。一条命令就能区分——-n 表示不加载任何 php.ini:

/usr/local/php-8.3.20/bin/php -n -m | grep -iE 'zip|exif|imagick|opcache'

结果:全部消失了。 说明它们是 php.ini 里 extension=xxx.so 加载的共享模块,
不是静态编译进去的。新版之所以看不到,只是因为新目录下还没有 php.ini。

如果 -n 之后它们还在,那才是静态编译的,得回头改 configure 参数重编。

9.2 共享模块可以直接复用旧 .so

PHP 的模块 API 版本在同一个小版本系列内是不变的。
8.3.20 和 8.3.36 都属于 PHP 8.3,扩展目录名都是 no-debug-non-zts-20230831——
这个数字就是 API 版本号,两边一致意味着旧 .so 能直接用。

有个现成的证据:新版挂上旧 php.ini 试跑时,Zend OPcache 加载成功且无任何警告。
这已经实证了 ABI 兼容。

NEWDIR=/usr/local/php-8.3.36/lib/php/extensions/no-debug-non-zts-20230831
OLDDIR=/usr/local/php-8.3.20/lib/php/extensions/no-debug-non-zts-20230831
cp -a "$OLDDIR"/{exif.so,imagick.so,zip.so} "$NEWDIR"/

opcache.so 新版会自己编译出来(比旧的还新),不要用旧的覆盖。

9.3 又一个顺序坑

扩展比对必须放在拷贝 php.ini 之后。 因为 ini 里写的是裸文件名(extension=exif.so), 只有 php.ini 就位,php -m 才会去加载它们。 先比对会得到一份假的”缺失”清单,白白吓自己一跳。

经验:php -m 的输出 = 编译进二进制的 + ini 加载的。 用 -n 可以把这两部分拆开看,是判断扩展来源最直接的办法。


十、切换与回退

切换前最后确认三件事:

/usr/local/php-8.3.36/sbin/php-fpm -t                    # 配置自检
grep -nE '^\s*listen' /usr/local/php-8.3.36/etc/php-fpm.d/www.conf
diff <(grep -E '^\s*listen' /usr/local/php-8.3.20/etc/php-fpm.d/www.conf) \
     <(grep -E '^\s*listen' /usr/local/php-8.3.36/etc/php-fpm.d/www.conf)

listen = /run/php/php8.3-fpm.sock,新旧逐字一致 → nginx 侧零改动。

然后切换:

cp -a /usr/lib/systemd/system/php-fpm.service \
      /usr/lib/systemd/system/php-fpm.service.bak-$(date +%F-%H%M)
sed -i 's|/usr/local/php-8.3.20|/usr/local/php-8.3.36|g' \
      /usr/lib/systemd/system/php-fpm.service
systemctl daemon-reload && systemctl restart php-fpm
curl -s -o /dev/null -w "%{http_code}\n" https://example.com/

结果:首页 200、登录页 200、php -v = 8.3.36、socket 属主 nginx:nginx、日志无段错误。

回退只要两步,30 秒:

cp -a /usr/lib/systemd/system/php-fpm.service.bak-<时间戳> /usr/lib/systemd/system/php-fpm.service
systemctl daemon-reload && systemctl restart php-fpm

为什么敢在生产环境这么干:因为每一步都可逆。 旧版没动、systemd 单元有备份、socket 路径没变。 升级的风险不来自”做了什么”,而来自”做错了退不回去”。


十一、收尾:改了 PHP,别的地方也跟着改

这部分最容易被漏掉。升级脚本只管 PHP 自己,但旧路径散落在系统各处:

位置不处理的后果
/root/ops/*.sh 运维脚本巡检、备份脚本里的 php 路径失效
/etc/logrotate.d/php-fpm-custom新日志不会轮转,会一直涨
crontab 里的定时任务写死路径的任务静默失败
systemd 单元的 Description显示的版本号对不上(纯观感问题)
旧目录 /usr/local/php-8.3.20先留着,跑稳几天再考虑清理

批量替换前先看清楚有哪些:

grep -rn 'php-8.3.20' /root/ops/ /etc/logrotate.d/ 2>/dev/null

十二、复盘:这次学到的七条

  1. rpm -qa 里有 PHP ≠ 那就是在跑的 PHP。 源码编译的软件对包管理器是隐形的。 判定版本只有”Web 实际调用的那个二进制”这一个可信口径。
  2. 安全建议要在自己的环境里重新评估。 官方让把 memory_limit 设成 -1, 但 2G 内存的机器上这是把”被入侵”换成”被拖死”。照抄之前先算一下。
  3. 压安全参数时,核对所有相互依赖的参数。 post_max_size < upload_max_filesize 会让上传静默失败,这种 bug 极难发现。
  4. 升级方案要设计成可逆的。 独立新前缀 + 保留旧版 + socket 路径不变, 把不可逆操作变成一次 30 秒可回退的切换。真正的安全感来自”退得回去”。
  5. 升级安全补丁时,源码本身必须是可验证的。 为了修漏洞而装一个未验签的包, 等于主动开后门。
  6. 编译报错先定位到具体符号,再决定改代码还是换依赖。 这次的根因只是一个漏了的 static shim——改 5 行, 比换掉全系统共用的 OpenSSL 安全得多。 换基础库的成本,远高于打补丁。
  7. 上线前比对扩展清单,而且要放在拷贝 php.ini 之后。 php -n -m 是区分”编译进去的”和”ini 加载的”最直接的办法。 站点白屏最常见的原因不是 PHP 挂了,是少了某个扩展。

附:一键脚本

整个过程固化成了一个分阶段脚本,每个子命令可单独执行、可随时停下:

bash php-upgrade.sh check      # 只读体检 + 取证 + 存扩展基线
bash php-upgrade.sh mitigate   # 临时缓解,1 分钟
bash php-upgrade.sh fetch      # 下载 + GPG 验签
bash php-upgrade.sh shim       # CentOS 7 / OpenSSL 1.0.2 必打的补丁(幂等)
bash php-upgrade.sh build      # 编译并安装到新前缀
bash php-upgrade.sh switch     # 迁配置 → 扩展比对 → 换 systemd → 重启
bash php-upgrade.sh verify     # 验收
bash php-upgrade.sh rollback   # 回退

其中 shim 阶段做了幂等保护:非 OpenSSL 1.0.x 自动跳过、已打过自动跳过、 改前自动备份原文件——重复执行不会叠加。


本文基于一次真实的升级记录整理。文中域名、主机名、IP 均为示例值。

Comments

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注