PHP 官方發布 8.5.12、8.4.27、8.3.36、8.2.35,修補兩個未經驗證即可遠端執行程式碼的漏洞(CVSS 4.0 評分高達 9.2),漏洞細節與官方建議的緩解措施請見 CyberQ 針對該議題的前一篇報導。這篇記錄則是我們實際管理諸多主機中,處理幾台不同平台雲端主機的過程,套件還沒上架時怎麼緩解、怎麼讓修補版一上架就自動安裝、修補後怎麼收尾,以及過程中碰到的一些問題。
環境與起點
| 主機 | 系統 | PHP 來源 | 執行方式 | 站台 |
|---|---|---|---|---|
| A(Linode) | Ubuntu 24.04 | ondrej PPA 8.4.26 | nginx → Apache → PHP-FPM | WordPress、Laravel |
| B(OCI) | Ubuntu 24.04 | ondrej PPA 8.4.26 | nginx → Apache → PHP-FPM | WordPress、Laravel |
| C(GCP) | Ubuntu 24.04 | Ubuntu 官方 php8.3 | Apache prefork + mod_php | WordPress、Laravel |
| D(AWS) | Rocky Linux 9.8 | Remi Repo 8.4.26 | Apache event → PHP-FPM | WordPress、Laravel |
四台前面都有 Cloudflare,Linode、OCI、GCP 和 AWS 都是透過特殊通道連線。
10 月 21 日晚上 10 點開始處理時,apt upgrade 在使用 ondrej PPA 的主機上都拿不到修補版,PPA 仍停在 8.4.26 與 8.5.11,Ubuntu 官方的 php8.3 也還沒出,而 Cent/RAEL/Rocky Linux 熱門的 Remi repo php 也都還沒有修補版,所以關鍵的第一步是先緩解。
第一步:緩解措施
PHP 設定
依 PHP 官方文件與 CyberQ 原文建議做的幾件事:關閉 session.upload_progress、把請求大小壓到 post_max_size = 32M/upload_max_filesize = 16M,以及讓 memory_limit 不生效(-1)。原文也特別說明不能改用 RLIMIT_AS 或 RLIMIT_DATA 限制記憶體來代替。
設定分兩層寫。全域的放在 conf.d:
; /etc/php/8.4/fpm/conf.d/99-cve-2026-109618-9.ini
session.upload_progress.enabled = 0
post_max_size = 32M
upload_max_filesize = 16M但 WordPress 外掛、.user.ini 都可能用 ini_set() 把值改回來,所以每個 FPM pool 檔尾再加 php_admin_*,這一層不能被程式覆寫:
; CVE-2026-109618/109619 temporary mitigation (2026-10-21), revert memory_limit after PHP >= 8.4.27
php_admin_value[memory_limit] = -1
php_admin_flag[session.upload_progress.enabled] = off
php_admin_value[post_max_size] = 32M
php_admin_value[upload_max_filesize] = 16Mmod_php 的主機則寫在 Apache 設定的 <IfModule php_module> 裡,用 php_admin_value 達到同樣效果。
某些系統的管理套件,在重建網域或切換 PHP 版本時會重寫 pool 檔,這些修改屬於暫時措施,我們在每一行加上註解與日期,方便之後辨認與回復。
memory_limit 拿掉之後,由 cgroup 接手
memory_limit = -1 等於拿掉 PHP 自己的記憶體保險,單一請求失控就可能吃光整台主機。我們用 systemd 的 cgroup 把上限移到服務層:
# /etc/systemd/system/php8.4-fpm.service.d/memory.conf
[Service]
MemoryHigh=4G
MemoryMax=5G
OOMPolicy=continue上限依主機記憶體調整。
OOMPolicy=continue 是關鍵。 systemd 的預設值是 stop,只要服務裡有一個行程被 OOM killer 殺掉,整個 PHP-FPM 服務就會被停掉,等於把單一請求的失控放大成全站停擺。改成 continue 後,只有那個 worker 被 kill,其他請求照常處理。
請求大小也在前端擋
nginx 的 client_max_body_size 從 1024m 改成 32m,對沒有 Content-Length 的 chunked 請求同樣有效。
有些主機前面沒有 nginx,只有 Apache event 搭 proxy_fcgi。這裡碰到的問題是,在這台 Apache 2.4.58 上,LimitRequestBody 對轉給 proxy_fcgi 的請求沒有生效。 不論寫在 server 層還是 <Location />,送 40 MB 的請求照樣進到 FPM。最後改成直接檢查標頭:
LimitRequestBody 33554432
<If "%{HTTP:Content-Length} -gt 33554432">
Require all denied
</If>至於 chunked 請求,實測 mod_proxy_fcgi 不會把 chunked 的本文轉給 FPM(FPM 收到 0 bytes),所以不需要另外處理。同樣的 LimitRequestBody 在 mod_php 的主機上,寫在 server 層就有效,因此實際操作都要看實際狀況,再來決定不同主機環境的處理方式。
控制面板的 PHP
某些控制平台的 php.ini 是另外的,也要加上 session.upload_progress.enabled = 0,8.5 版另外加 fatal_error_backtraces = 0。面板只對固定幾個 IP 開放,風險相對低,但成本很小,順手做。
怎麼確認設定真的生效
php-fpm8.4 -tt 在這幾台主機上不會印出 pool 設定,不能拿來驗證。CyberQ 改用 cgi-fcgi 直接連每個 pool 的 socket,執行一個只印 ini_get() 結果的小檔:
SCRIPT_FILENAME=/path/to/check.php REQUEST_METHOD=GET \
cgi-fcgi -bind -connect /run/php/php8.4-fpm-example.sock上傳上限與實際需求的取捨
上傳的部分官方建議降到 16M 沒有影響,因此同意接受修補前幾個小時的不便,修補後立刻調回。
第二步:修補版一上架就自動安裝
ondrej PPA 和 Remi rpm 的修補版什麼時候出來沒有人能保證,而 unattended-upgrades 預設只處理 Ubuntu 官方來源,不會自動安裝 PPA 的更新。因此簡單的一個做法,是在每台主機放一支類似下方的程式碼,視情況再改寫適合的環境,由 systemd timer 每小時執行一次,就可以去睡覺等通知了,(RandomizedDelaySec=5m、Persistent=true):
apt-get update,比對候選版本是否大於等於修補版本,沒有就記一筆紀錄後結束。- 有的話,把已安裝的
php8.4-*套件一起install --only-upgrade,並加上--force-confold,保留我們改過的php.ini與 pool 檔。 php-fpm8.4 -t通過後重啟服務,再從主機本機對每個站台做健康檢查。- 全部通過就停用自己的 timer。
這邊先以 GCP 主機用的版本為範例,主機差異放在 /etc/default/php-cve-autopatch,OCI 與 AWS 的有些又不同:
PKG=php8.4-fpm
FIXED=8.4.27
UPGRADE="php8.4-*"
SERVICE=php8.4-fpm
CHECKS="example.com/=200 example.com/wp-admin/=302 example.com/health.txt=200"程式碼本體:
#!/bin/bash
# CVE-2026-109618/109619: install fixed PHP as soon as the distro / PPA publishes it.
# Runs hourly from php-cve-autopatch.timer and disables the timer once patched.
set -u
. /etc/default/php-cve-autopatch
STATE=/var/lib/php-cve-autopatch
TAG=php-cve-autopatch
DRY=0
[ "${1:-}" = "--dry-run" ] && DRY=1
APT_OPTS=(-y -q -o DPkg::Lock::Timeout=600 -o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef)
export DEBIAN_FRONTEND=noninteractive NEEDRESTART_MODE=l
mkdir -p "$STATE"
log() { logger -t "$TAG" -p "user.${2:-notice}" -- "$1"; echo "$1"; }
installed_ver() { dpkg-query -W -f='${Version}' "$1" 2>/dev/null; }
candidate_ver() { apt-cache policy "$1" 2>/dev/null | awk '/Candidate:/{print $2}'; }
health() {
local c hp want code bad=0
for c in $CHECKS; do
hp=${c%=*}; want=${c#*=}
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 -H "Host: ${hp%%/*}" \
-H 'X-Forwarded-Proto: https' "http://127.0.0.1/${hp#*/}")
[ "$code" = "$want" ] || { log "health $hp returned $code (expected $want)" err; bad=1; }
done
return $bad
}
finish() {
touch "$STATE/done"
[ $DRY = 0 ] && systemctl disable --now php-cve-autopatch.timer
}
if dpkg --compare-versions "$(installed_ver "$PKG")" ge "$FIXED"; then
# installed by someone else (e.g. unattended-upgrades): make sure the service runs the new code
pkg_time=$(stat -c %Y "/var/lib/dpkg/info/$PKG.list" 2>/dev/null || echo 0)
svc_time=$(date -d "$(systemctl show "$SERVICE" -p ActiveEnterTimestamp --value)" +%s 2>/dev/null || echo 0)
if [ "$svc_time" -lt "$pkg_time" ]; then
log "$PKG $(installed_ver "$PKG") installed after $SERVICE started, restarting"
[ $DRY = 0 ] && { systemctl restart "$SERVICE" && sleep 3 && health || log "$SERVICE restart/health FAILED, check now" crit; }
fi
log "$PKG $(installed_ver "$PKG") already >= $FIXED, disabling timer"
finish
exit 0
fi
apt-get -q -o DPkg::Lock::Timeout=600 update >/dev/null 2>&1 || log "apt-get update failed" warning
cand=$(candidate_ver "$PKG")
if [ -z "$cand" ] || [ "$cand" = "(none)" ] || ! dpkg --compare-versions "$cand" ge "$FIXED"; then
log "$PKG installed $(installed_ver "$PKG"), candidate $cand: fix $FIXED not published yet" info
exit 0
fi
pkgs=$(dpkg-query -W -f='${Package} ${db:Status-Status}\n' $UPGRADE 2>/dev/null | awk '$2=="installed"{print $1}')
log "$PKG $(installed_ver "$PKG") -> $cand available, upgrading: $(echo $pkgs)"
[ $DRY = 1 ] && exit 0
if ! apt-get "${APT_OPTS[@]}" install --only-upgrade $pkgs; then
log "apt-get install FAILED" err
exit 0
fi
if systemctl restart "$SERVICE" && sleep 3 && health; then
log "patched: $PKG $(installed_ver "$PKG"), $SERVICE restarted. Revert memory_limit=-1 manually."
finish
else
log "upgraded but $SERVICE restart/health FAILED, check now" crit
fi
exit 0CyberQ 在幾個設計上的考量是這樣的:
健康檢查不經過 Cloudflare
Linode 主機用 curl --resolve 網域:443:原站IP,GCP 主機直接打 127.0.0.1 並帶 Host 標頭。經過 CDN 測到的可能是快取,不能代表原站狀態。
期望的狀態碼逐站記錄
有的主機上所有網站的首頁本來就回 302,健康檢查以變更前記下的基準為準,不是一律要求 200。
別人先裝好的情況也要處理
如果管理者手動更新或 unattended-upgrades 先裝了,腳本會比對服務啟動時間與套件安裝時間,服務還在跑舊程式就重啟它。
腳本刻意不自動回復 memory_limit
無人值守時改 pool 設定的風險高於多跑幾小時緩解設定,所以回復一律由人確認後手動處理。
用 --dry-run 先跑一次,確認比對邏輯正確(比對程式碼應回報「8.4.26 < 8.4.27,尚未上架」)。
第三步:等待上架
各家上架的時間差了好幾個小時(以下皆為台灣時間):
| 時間 | 事件 |
|---|---|
| 10/21 22:10 | 開始緩解,Ubuntu 官方與 ondrej PPA 、Remi rpm 都還沒有修補版 |
| 10/21 22:45 | ondrej PPA 的 php8.4 8.4.27 原始碼套件在 Launchpad 顯示為已發布 |
| 10/21 22:48 | Ubuntu 已發布 USN-8948-1,24.04 的 php8.3 修補版為 8.3.6-0ubuntu0.24.04.13,主機 的 timer 偵測到後自動安裝並重啟 Apache,這時該主機的緩解設定才剛做完不久。 |
| 10/21 23:17 | 套件庫仍是 8.4.26,二進位套件還在編譯 |
| 10/22 02:18 | 部分主機的 apt-get update 已看到 8.4.27,Launchpad API 上同一批二進位套件仍顯示 Pending |
| 10/22 02:19–02:20 | 自動安裝三次失敗,下載 .deb 時 Launchpad 回 503 Service Unavailable |
| 10/22 02:21 | 主機 D 手動更新成功 |
| 10/22 02:25 | 主機 A 的腳本重試成功,健康檢查通過 |
| 10/22 02:30 | 主機 B 手動更新成功 |
幾個觀察:
USN-8948-1 只涵蓋 Ubuntu 自己的 php8.3
用 ondrej PPA 裝 8.4 或 8.5 的主機,看到 USN 發布並不代表自己有更新可裝。這台 8.3 主機安裝順利後,隨著 8.4 也釋出了,就順便也一起更新到了 8.4.27。
PPA 是原始碼先發布,二進位套件後到
Launchpad 頁面上看到新版本、甚至看到編譯完成,都還不等於 apt 抓得到。從原始碼發布到套件庫出現二進位套件,這次大約隔了三個半小時。
套件庫索引會在不同節點陸續更新
同一時間,有些主機已經能看到 8.4.27,某些主機卻只能看到舊索引,用 apt-get -o Acquire::https::No-Cache=true update 強制刷新後才出現。
上架初期下載會遇到 503
全世界的主機在同一時段湧入時,Launchpad 撐不住。好在 apt 下載失敗時 dpkg 還沒開始動作,主機維持完整的舊版,沒有留下裝到一半的狀態,等一下重試即可。
第四步:修補後收尾
每台主機修補後依序確認:
php-fpm8.4 -v 或 php -v 顯示 8.4.27。
服務的 ActiveEnterTimestamp 晚於套件安裝時間,代表正在跑的是新程式。舉例來說,主機套件如果是 2 點裝好,FPM 是 2 點 1 分重啟。
移除 pool 的 php_admin_value[memory_limit] = -1,讓各台回到 php.ini 原本的值。
重啟 FPM 後再用 cgi-fcgi 逐一檢查每個 pool,確認 memory_limit 已回復、PHP 版本是 8.4.27。
對所有站台做原站健康檢查,狀態碼與變更前一致。
保留下來作為長期防護的設定如下:
session.upload_progress 維持關閉,用不到上傳進度功能的網站就可把這個關閉。
cgroup 記憶體上限與 OOMPolicy=continue。即使 memory_limit 回來了,這層保護仍然有用。
32M/16M 上傳上限是可用的,若實際用不到更大的上傳,就沒有理由開更大。前端的請求大小限制也需要留意。
CyberQ 建議總結
以後碰到類似事件,維運團隊在官方修補版沒上架時,緩解設定要寫在 pool 的 php_admin_*,不能只改 php.ini,並且用 cgi-fcgi 實際驗證每個 pool。
拿掉 memory_limit 之後要有 cgroup 接手,OOMPolicy=continue 不能省。
Apache 搭 proxy_fcgi 時,LimitRequestBody 不一定有效,要實際送大請求測過。
用 ondrej PPA 的主機不能看 USN 判斷有沒有修補版,PPA 通常比 Ubuntu 官方晚幾個小時,而且剛上架時全球下載容易遇到 503。
自動安裝的腳本要能處理「別人先裝了」的情況,健康檢查要繞過 CDN,回復緩解設定則留給人確認。
修補完一定要確認服務在安裝之後重啟過,否則跑的仍然是舊程式。
從晚上 10 點開始緩解,到隔天凌晨 3 點前四台主機全部修補完成並撤除暫時設定,前後不到五個小時,其中大部分時間是在等各家套件的編譯與上架。







