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”,第一反应通常是”赶紧升”。但真动手之前,有两个问题必须先回答清楚,
否则很容易做无用功、甚至把站点搞挂:
- 我到底跑的是哪个 PHP?(版本只是表象,”它是怎么装的”才是关键)
- 我这个运行形态有多危险?(决定了是”立刻停机升”还是”从容升”)
二、第一步不是升级,是搞清楚自己是什么
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 ^php | 5.4.45(remi 源) | 纯残留,根本没在用 |
/usr/local/php-8.3.20/bin/php -v | 8.3.20 | 真正在跑的,源码编译 |
yum --enablerepo=remi-php83 list updates php | 8.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.enabled | 0 | 野外攻击主要走这条路径(对应 CVE-2026-109619) |
post_max_size | 压小 | 压缩攻击者可用的内存布局空间 |
pm.max_children | 15 → 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
十二、复盘:这次学到的七条
rpm -qa里有 PHP ≠ 那就是在跑的 PHP。 源码编译的软件对包管理器是隐形的。 判定版本只有”Web 实际调用的那个二进制”这一个可信口径。- 安全建议要在自己的环境里重新评估。 官方让把
memory_limit设成-1, 但 2G 内存的机器上这是把”被入侵”换成”被拖死”。照抄之前先算一下。 - 压安全参数时,核对所有相互依赖的参数。
post_max_size < upload_max_filesize会让上传静默失败,这种 bug 极难发现。 - 升级方案要设计成可逆的。 独立新前缀 + 保留旧版 + socket 路径不变, 把不可逆操作变成一次 30 秒可回退的切换。真正的安全感来自”退得回去”。
- 升级安全补丁时,源码本身必须是可验证的。 为了修漏洞而装一个未验签的包, 等于主动开后门。
- 编译报错先定位到具体符号,再决定改代码还是换依赖。
这次的根因只是一个漏了的
staticshim——改 5 行, 比换掉全系统共用的 OpenSSL 安全得多。 换基础库的成本,远高于打补丁。 - 上线前比对扩展清单,而且要放在拷贝 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 均为示例值。