18 min read

PHP2Shell 來了:用 systemd drop-in 換掉 CloudPanel 後台的 PHP

PHP2Shell 來了:用 systemd drop-in 換掉 CloudPanel 後台的 PHP

上篇最後,我的第二台主機停在這個狀態:網站用的 PHP 已經升到 8.4.27,九個沒在用的版本都停用了,只剩 CloudPanel 後台自己的 PHP 還卡在 8.1.34。

PHP 8.1 已經在 2025 年底結束上游官方支援,PHP 專案不會再發布 8.1 的正式修補版本,雖然部分發行版或平台可能會自行回移植修補,但不能假設自己裝的套件已經被修過,我確認過這台的狀況:CloudPanel 套件庫裡的 php8.1-fpm 沒有新版本可以更新(apt policy 顯示的已安裝版本和候選版本相同),執行檔的編譯日期也還是 2025 年 12 月,早於這次修補,所以可以確定後台的 PHP 還沒有修。

於是我先把後台的埠從防火牆關掉,改走 SSH 通道進去,到這裡,後台已經不再直接暴露在公網上,遠端攻擊的風險大幅降低,但舊版 PHP 本身的漏洞仍然存在,例如主機上其他服務如果有 SSRF 之類的漏洞,或攻擊者已經從別的地方取得本機存取權限,還是有機會從主機內部連到這個後台,所以我決定進一步處理:把後台換到 8.4.27。

這篇的內容,大部分是我在過程中一直追問「為什麼可以這樣做」問出來的,指令本身沒幾行,比較值得帶走的是背後的兩個機制,以及動手前怎麼判斷會不會壞、動手後怎麼確認真的換好了。

先說清楚:這不是官方做法

CloudPanel 沒有提供切換後台 PHP 版本的設定,GitHub 上也有人開了 feature request,請官方把後台搬到有支援的版本,到我寫這篇時還沒有官方回應。

所以接下來的內容,是在我這一台主機上成功的非官方修改紀錄,不是適用於所有 CloudPanel 版本的升級程序,有幾個代價要先知道:

  • 後台程式是官方搭配 8.1 釋出的,在 8.4 上沒有官方驗證,換版本後某些功能可能會壞。
  • 以後 CloudPanel 更新時,可能會跟你的改動打架。
  • 出問題時,官方不會幫你處理。

我最後還是決定試,是因為三個條件都成立:動手前拍了 VPS 快照;改動集中在一個獨立的檔案裡,隨時可以還原;而且 8443 已經不對外,就算後台換壞了,主要影響是我自己暫時進不了後台,不會因此多開一個對外的入口。

如果你只是想先降低風險,上篇的「關埠加 SSH 通道」可以先做,這篇是給想多走一步、或想搞懂背後原理的人。

後台的 PHP 長什麼樣子

上篇用來找出所有 PHP 的那一行,在這台主機上列出了十一個 master process,其中一個長這樣:

root     php-fpm: master process (/home/clp/services/php-fpm/fpm/php-fpm.conf)

先看它是由哪個 systemd 服務啟動的:

systemctl cat clp-php-fpm
# /usr/lib/systemd/system/clp-php-fpm.service
[Unit]
Description=The CLP PHP 8.1 FastCGI Process Manager
Documentation=man:php-fpm8.1(8)
After=network.target

[Service]
Type=notify
PIDFile=/run/php/clp-php-fpm.pid
ExecStart=/usr/sbin/php-fpm8.1 -R -F -y /home/clp/services/php-fpm/fpm/php-fpm.conf -c /home/clp/services/php-fpm/fpm/php.ini
ExecReload=/bin/kill -USR2 $MAINPID
Restart=always
RestartSec=3

[Install]
WantedBy=multi-user.target

ExecStart 那行的四個參數:

  • -R:允許 FPM 的 pool 以 root 身分執行(預設不允許)。這影響的是實際處理請求的 worker process,不是 master process,master 本來就以 root 啟動,再依照每個 pool 設定的 user、group 降低 worker 的權限。而且 -R 只是「允許」,加了它不代表 worker 就會用 root 跑,實際身分還是看 pool 的設定
  • -F:在前景執行,交給 systemd 管理
  • -y:指定 FPM 的設定檔
  • -c:指定 php.ini

再向 systemd 問這個服務的主程序,看它實際跑的是哪個執行檔:

sudo readlink -f /proc/$(systemctl show -p MainPID --value clp-php-fpm)/exe
# /usr/sbin/php-fpm8.1

直接向 systemd 查 MainPID,會比用 pgrep 依名稱去找更準,不會意外抓到其他剛好含有相同字串的程序。

看到這裡,整件事的關鍵就出來了:後台用的就是系統裝的 php-fpm8.1 執行檔,只是配上自己的一套設定檔。 執行檔是一回事,設定是另一回事,兩者是分開的。

那如果把執行檔換成同一台主機上已經修好的 php-fpm8.4,設定照舊,會怎樣?要回答這個問題,得先搞懂兩件事。

為什麼換執行檔可行:nginx 和 PHP 之間隔著一個 socket

網頁請求進來之後,大致是這樣流動的:

瀏覽器 → 後台的 nginx (8443) → /run/clp-php-fpm.sock → php-fpm → 後台的 .php 程式碼
                                                        ↑
                                              這裡從 8.1 換成 8.4

nginx 本身不會執行 PHP,它是透過一個 socket 用 FastCGI 協定,把請求交給 php-fpm 處理,這個設計帶來三個結果:

nginx 不知道也不在乎後面是哪個版本,它只認得 socket 的路徑。

socket 的路徑寫在設定檔裡,不在執行檔裡,後台的 FPM 設定檔裡寫著要在哪裡開 socket,換執行檔之後,設定檔沒變,socket 也就還是同一個。

後台程式碼只是一堆 .php 檔,誰來執行都可以,只要語法和用到的函式在新版本還存在。

另外,PHP-FPM 8.1 和 8.4 都支援這裡用到的啟動參數,所以可以嘗試保留原本的設定檔、只替換執行檔。但這只是「可以嘗試」,設定指令、擴充套件和實際服務的相容性,還是要一項一項確認,後面會講怎麼確認。

順帶確認一下,這個 socket 有沒有被網站用到:

# 後台的 FPM 開了哪些 socket
sock=$(sudo grep -rhoP '^\s*listen\s*=\s*\K\S+' /home/clp/services/php-fpm/)
echo "$sock"

# 哪些設定檔有用到這些 socket
sudo grep -rl "$sock" /etc/nginx /home/clp/services 2>/dev/null

我的結果是兩個 socket(後台本體和檔案管理器各一個),只出現在 /home/clp/services 底下,網站用的 /etc/nginx 裡完全沒有。根據這台主機的 nginx 與 FPM 設定,沒有發現網站前台的請求使用 CloudPanel 的 FPM socket,所以切換後台的 PHP,理論上不會改變網站前台使用的 PHP 版本。要注意這個檢查的範圍只有 nginx 設定,不能完全排除其他服務或管理腳本依賴 CloudPanel 原本的 PHP 環境。

你可能會想:網站的 php8.4-fpm 也在跑,後台再用同一個執行檔跑一份,不會衝突嗎?它們是兩個獨立的 master process,各自讀不同的設定檔,開不同的 socket、寫不同的 PID 檔,只是剛好用同一個程式來跑。只要兩邊的 socket、PID 檔、log 路徑沒有重複,就不會互相干擾,不過兩邊各有自己的 worker,記憶體用量是分開算的,小台主機要留意一下。

為什麼可以「疊」一層設定:systemd 的 drop-in

知道換執行檔可行之後,下一個問題是:怎麼改?

最直覺的做法是直接編輯 /usr/lib/systemd/system/clp-php-fpm.service,但這個檔案是 CloudPanel 套件裝的,下次套件更新時就會被覆寫回去。

systemd 的設定其實是分層的:

  • /usr/lib/systemd/system/:套件裝的原始檔,屬於軟體廠商,套件更新時會被覆寫。
  • /etc/systemd/system/服務名稱.service.d/*.conf:管理員自己加的 drop-in,套件更新時通常不會碰 /etc 底下的這些檔案。

systemd 啟動服務時,會先讀原始檔,再把 drop-in 裡寫到的項目疊上去,沒寫到的就沿用原本的,所以 drop-in 裡只需要寫你要改的那幾行,其他像 Type=notify、PIDFile、Restart=always,全部沿用 CloudPanel 原本的設定。

這個設計本來就是留給管理員用的:不動廠商的檔案,把自己的調整放在另一個地方,套件升級也不會洗掉,要還原就把自己建立的 drop-in 刪掉。systemctl edit 就是 systemd 提供來建立 drop-in 的指令。(systemctl revert 也能還原,但它影響的範圍比較大,後面「出事怎麼還原」會說明。)

有一個小細節要注意,大部分設定是「後寫的蓋掉先寫的」,但 ExecStart 是「累加」的,寫兩次會變成兩個指令,而一般服務只允許一個,所以 drop-in 裡要先寫一行空的 ExecStart= 清掉原本的值,再寫新的:

[Service]
ExecStart=
ExecStart=/usr/sbin/php-fpm8.4 ...

動手前:檢查會壞的地方

機制搞懂了,接下來是判斷「換過去會不會壞」,我拆成三個可以事先檢查的地方,再加上一個只能實際測試的。

一、參數相容

前面 systemctl cat 已經看到原本的啟動參數,新的 ExecStart 照抄,只換執行檔路徑。也要順便確認原本的服務裡,有沒有其他地方也寫死了 8.1,例如 ExecStartPre,我這台只有 ExecStart 一處。

另外,原本的服務是 Type=notify,代表 php-fpm 啟動完成時要主動通知 systemd,新版本的執行檔也要支援這個機制,服務才會被判定為啟動成功,這點在後面實際啟動時就會知道。

二、設定檔在新版本讀得過

php-fpm 有一個 -t 參數,只檢查設定檔、不實際啟動,正好拿來測試後台的設定在 8.4 底下有沒有已經被淘汰的參數:

sudo php-fpm8.4 -t \
  -y /home/clp/services/php-fpm/fpm/php-fpm.conf \
  -c /home/clp/services/php-fpm/fpm/php.ini

看到 test is successful 就過了。要注意 -t 只代表設定檔通過檢查,不代表 socket 一定能成功建立、服務一定能正常運作,更不代表後台程式相容,它只是排除了其中一種會壞的原因。

三、擴充套件都在

換了執行檔,載入的擴充套件也會改用 8.4 那一套,如果後台用到某個 8.1 有、8.4 沒有的擴充,那個功能就會壞。

先看後台自己的 php.ini 裡,有沒有手動載入什麼擴充:

grep -nE '^\s*(zend_)?extension' /home/clp/services/php-fpm/fpm/php.ini

我這台有兩行:extension=memcached.so 和 extension=redis.so。這兩行只寫檔名、沒寫完整路徑,PHP 會到「當下這個版本」自己的擴充目錄去找,所以換成 8.4 之後,會自動載入 8.4 版的這兩個檔案,前提是 8.4 也有裝。

這裡還有一個前提:這份 php.ini 沒有另外用 extension_dir 指定擴充目錄,如果有指定、而且指到 8.1 的目錄,8.4 就會去載入 8.1 編譯的擴充而失敗。如果你看到的是寫了完整路徑、或路徑裡帶著版本號的擴充(例如某些加密載入器),也要特別小心,換版本之後它幾乎一定會載入失敗。

另外,PHP 除了讀 -c 指定的 php.ini,還會掃描各版本自己的額外設定目錄(例如 /etc/php/8.4/fpm/conf.d/),很多擴充其實是從那裡載入的,所以光看 php.ini 不夠,要看 PHP 實際載入了哪些模組。

先用 diff 比對兩個版本的差異:

diff \
  <(php-fpm8.1 -c /home/clp/services/php-fpm/fpm/php.ini -m 2>&1) \
  <(php-fpm8.4 -c /home/clp/services/php-fpm/fpm/php.ini -m 2>&1)

我的結果只有兩行差異:

< imap
> random
  • imap:PHP 8.4 把它從核心移到 PECL,所以 8.4 少了它,後台是管主機和網站的,用到收信功能的機會很低,這個差異可以接受。
  • random:8.2 之後新增的內建模組,多了不影響。

但 diff 只會顯示「兩邊不一樣」的地方,如果兩個版本出現同樣的警告,或兩邊都沒載入某個模組,diff 反而看不出來,所以還要直接檢查 8.4 這一邊:

php-fpm8.4 -c /home/clp/services/php-fpm/fpm/php.ini -m 2>&1 \
  | grep -Ei '^(redis|memcached)$|Warning|Unable to load'

要看到 redis 和 memcached 兩行都出現,而且沒有任何 Warning 或 Unable to load,才代表後台需要的擴充在 8.4 底下都載入成功了。

四、只能實際測試的:程式碼相容性

前面幾項都過之後,剩下的未知數是後台的程式碼本身在 8.4 上有沒有相容問題,這只能實際點頁面測試。這也是 CloudPanel 官方遲遲沒換的原因:他們要對所有功能、所有使用者的環境負責,測試成本比我們自己改一台高得多。

動手:建立 drop-in

開始前兩件事:先到 VPS 業者的管理介面拍一次快照,另外多開一個 SSH 視窗留著,萬一這個視窗出狀況還有退路。

systemctl edit 會打開編輯器讓你寫 drop-in,如果想直接貼指令,也可以用下面這種寫法,效果一樣:

sudo mkdir -p /etc/systemd/system/clp-php-fpm.service.d

sudo tee /etc/systemd/system/clp-php-fpm.service.d/override.conf >/dev/null <<'EOF'
[Unit]
Description=The CLP PHP FastCGI Process Manager (override: PHP 8.4)

[Service]
ExecStart=
ExecStart=/usr/sbin/php-fpm8.4 -R -F -y /home/clp/services/php-fpm/fpm/php-fpm.conf -c /home/clp/services/php-fpm/fpm/php.ini
EOF

sudo systemctl daemon-reload
sudo systemctl restart clp-php-fpm

Description 那行不是必要的,但改了之後,以後用 systemctl status 一眼就會看到這個服務被改過,不會忘記。

確認結果:

systemctl status clp-php-fpm --no-pager | head -5
sudo readlink -f /proc/$(systemctl show -p MainPID --value clp-php-fpm)/exe

我看到的是:

● clp-php-fpm.service - The CLP PHP FastCGI Process Manager (override: PHP 8.4)
     Loaded: loaded (/usr/lib/systemd/system/clp-php-fpm.service; enabled; preset: enabled)
    Drop-In: /etc/systemd/system/clp-php-fpm.service.d
             └─override.conf
     Active: active (running) since ... ; 10ms ago
/usr/sbin/php-fpm8.4

Drop-In 那段代表 systemd 有讀到我們的設定,最後一行代表服務的主程序換成 8.4 的執行檔了,如果路徑後面出現 (deleted),代表程序還在用已經被換掉的舊執行檔,要再重啟一次。服務能進入 active (running),也代表 8.4 的執行檔有正確回應 Type=notify 的啟動通知。現在再跑一次 systemctl cat clp-php-fpm,會看到原始檔和 drop-in 一前一後印出來,就是前面說的「疊加」。

確認它有在穩定運作

這裡有一個容易被騙的地方:active (running) 後面寫的是 10ms ago,也就是剛啟動的瞬間。

原本的服務設定了 Restart=always、RestartSec=3,意思是掛掉三秒後會自動重啟,如果新版本一啟動就崩潰,你剛好在某次重啟的瞬間看狀態,看到的一樣是 active (running),所以要多等幾秒,看它有沒有在反覆重啟:

sleep 10
systemctl show clp-php-fpm -p NRestarts -p ActiveEnterTimestamp

# 用「正在跑的那個程序」的執行檔查版本
exe=$(sudo readlink -f /proc/$(systemctl show -p MainPID --value clp-php-fpm)/exe)
"$exe" -v | head -1

NRestarts=0、啟動時間沒有變,代表這段觀察期間沒有被 systemd 自動重啟,不過這只能說明「這段時間沒問題」,不能直接證明長期穩定,之後幾天偶爾看一下就好。

版本那行刻意從 MainPID 反查執行檔,而不是直接跑 php-fpm8.4 -v,因為後者只是在問磁碟上那個檔案的版本,不能證明服務真的在用它。

實際點一遍後台

另開一個視窗盯著錯誤訊息:

sudo journalctl -u clp-php-fpm -f

然後透過 SSH 通道進後台,把常用的功能點一遍:登入、儀表板、網站列表、網站設定(包含 PHP 設定頁)、檔案管理、資料庫管理、使用者管理。

有兩個地方特別值得看:

  • 檔案管理:它在後台的 FPM 裡用的是另一個獨立的 pool,雖然一起換成了 8.4,但值得實際開個資料夾、看個檔案試試。
  • 資料庫管理(phpMyAdmin):首頁右側的伺服器資訊會顯示 PHP 版本,看到 8.4.27,就能確認後台與 phpMyAdmin 的頁面已經改用 8.4。

要注意這個確認的範圍:這次改的只有 clp-php-fpm 這個服務,如果 CloudPanel 還有排程工作或命令列工具是直接呼叫 php8.1,它們不會因為這次修改而跟著換。可以簡單查一下還有誰在用 8.1:

# 正在跑的程序
ps -eo pid,user,cmd | grep -v grep | grep 'php8\.1'

# 系統排程
sudo grep -rn 'php8\.1' /etc/cron* /var/spool/cron 2>/dev/null

命令列工具和排程不直接處理網頁請求,跟這次漏洞的攻擊方式關係比較小,但還是要知道它們沒有一起換。

我這台的後台各頁面都正常,後台與 phpMyAdmin 也確認改用 8.4.27 了。

出事怎麼還原

任何一頁壞掉、或整個後台打不開,就把這次建立的 drop-in 刪掉:

sudo rm /etc/systemd/system/clp-php-fpm.service.d/override.conf
sudo systemctl daemon-reload
sudo systemctl restart clp-php-fpm

服務就會回到 CloudPanel 原本的 8.1,這也是我敢動手的原因:改動只存在一個獨立的檔案裡,原本的東西一個字都沒碰。

你可能會看到別的地方建議用 systemctl revert clp-php-fpm,它也能還原,但範圍比較大:它會刪掉這個服務在 /etc/systemd/system/ 和 /run/systemd/system/ 底下所有的 drop-in,如果服務被 mask 過,也會一併解除 mask。如果你用 systemctl cat 確認過這個服務只有你自己建立的這一個 override,用 revert 沒有問題;不確定的話,只刪除自己建立的那個檔案比較保險。

這次學到的

這套思路其實不只適用於 CloudPanel,任何「廠商的服務綁著一個舊版本」的情況都可以這樣想:

  • 先找出它實際在跑什麼:systemctl cat 找啟動方式,MainPID 搭配 readlink 找執行檔。
  • 分清楚「執行檔」和「設定」:很多時候兩者是分開的,只換其中一個是可以嘗試的。
  • 改設定用 drop-in,不要動廠商的原始檔。
  • 動手前把能檢查的先檢查:參數、設定檔、相依套件,而且每一項檢查都要知道它證明了什麼、沒證明什麼。
  • 比對差異之外,也要直接確認需要的東西真的在,diff 只看得到不一樣的地方。
  • 確認結果時,問「正在跑的那個程序」,不要只問磁碟上的檔案。
  • 驗證不了的,就先準備好還原的方法,而且搞清楚還原指令會動到哪些東西,然後實際測試。
  • 不要只看一次狀態就下結論,等幾秒、看重啟次數,之後也偶爾回頭看。

參考資料