- 逆向行駛 - https://520.be/ -

🚨一個「簽名欄位」害慘整個網站?Extensions For CF7 高危漏洞全解析與自救(CVE-2026-94589/CVSS 9.8)

🖊️ 一個「簽名欄位」,怎麼變成整個網站的後門?Extensions For CF7 高危漏洞全解析(CVE-2026-94589/CVSS 9.8)

🔥 懶人包:
幫 Contact Form 7 加料的熱門外掛 Extensions For CF7(WordPress.org 顯示活躍安裝數 5,000+),被登記了一個「不用登入、不用誘導你點任何東西」就能發動的任意檔案上傳漏洞(CVE-2026-94589,CVSS 9.8 嚴重等級,CWE-434)。破口出在「簽名欄位」:它沒檢查上傳的到底是不是圖片,攻擊者可以把一個 PHP 程式檔偽裝成簽名丟進來,運氣好還能被伺服器直接執行,演變成遠端程式碼執行(RCE)。受影響版本是 3.4.5 以下(含),官方已在 3.4.6 修補。如果你的網站有裝這個外掛,今天就花五分鐘確認版本、更新,再順手看一眼媒體庫跟管理員名單 🔒

🌐 前往 WordPress 官方外掛頁面確認版本 [1]

[2]


🟦 🟦 🟦

內容目錄

🏢 第一章:櫃台收到的「簽名」,怎麼會是一把萬能鑰匙?

先給你一個畫面。你開了一間公司,大廳擺了一台平板讓訪客簽到,旁邊貼著「請在這裡簽名」。櫃台小姐的工作很單純:收下簽名、存進檔案櫃。問題是,這台平板從來不檢查你交回來的是不是一張「簽名」——有人遞上一張寫著「我可以自由進出這棟大樓」的偽造門禁卡,櫃台也照單全收,而且還好心地把它放進了「會自動生效」的那個抽屜裡 🏢

這就是這次漏洞的本質。主角叫 Extensions For CF7,全名是 Contact form 7 Database, Conditional Fields and Redirection,由 HT Plugins 維護,專門幫最老牌的表單外掛 Contact Form 7 加上「把表單資料存進資料庫、依條件顯示欄位、送出後自動跳轉、讓訪客手寫簽名」這些功能。出事的就是那個「簽名欄位」 🖊️

📢 大白話:什麼是「任意檔案上傳」漏洞?
正常的上傳功能像是一位盡責的收件員:你說要交圖片,他會翻開來確認「嗯,這真的是張圖」,太大的、副檔名怪怪的、內容對不上的,一律退件。「任意檔案上傳」就是這位收件員下班了,門口變成無人收件櫃,你塞什麼他收什麼。最怕的是塞進去的是一個 .php 程式檔,而存放的那個資料夾又剛好「看到 PHP 就會執行」,那這個檔案就不再是一張死圖片,而是一段能在你伺服器上跑起來的程式了 📂

🧊 冷知識:這個「簽名欄位」其實是半路加進來的
翻官方更新紀錄會發現,簽名欄位是 2025 年 4 月底(3.2.7 版)才補上的新功能,之後在 3.3.2、3.3.3 又修過幾次。很多上傳型漏洞都長在這種「後來才加、想支援多種格式所以放寬檢查」的功能上——為了讓使用者方便,白名單就忘了關緊,典型的好心辦壞事 🫠


🟢 🟢 🟢

⏱️ 事情怎麼爆開的?時間軸我幫你對過一遍

這件事從「悄悄保留編號」到「正式公開」中間隔了快三週,官方也趕在公開前把修補版推出來了。我把 CVE 紀錄、Wordfence 與官方更新日誌交叉比對,整理成下面這張表 📅

日期 事件節點 可信度與備註
2026-09-21 CVE-2026-94589 保留編號(Reserved),CNA 為 Wordfence ✅ CVE 官方紀錄的 Reserved 時間
2026-10-04 官方釋出修補版 3.4.6,更新日誌寫明「強化簽名欄位與表單提交儲存的檔案上傳處理」 ✅ WordPress.org 外掛頁更新日誌載明此日期
2026-10-09 Wordfence 頁面標示的公開日期;研究者 Osman Hussein ✅ 多份來源一致
2026-10-10 CVE 紀錄正式發布(UTC 03:26) ✅ CVE 官方紀錄的 Published 時間

你可能注意到 Wordfence 寫 10/9、CVE 紀錄寫 10/10——這是同一件事在不同時區、不同平台的標示差異,不是兩次獨立的披露,不用緊張 🕐

🩵 荷包試算:這次修補要花錢嗎?
好消息,免費版外掛的安全更新是 NT$0,直接從後台或 WP-CLI 就能更新。真正會燒錢的是「出事之後」:找人做鑑識、通知受影響的客戶、重建被搜尋引擎標記後的信譽,隨便都是五位數起跳。簽名欄位在免費版就有,所以別以為「我沒買 Pro 就沒事」,有裝就可能中招 💸

[36]


🟠 🟠 🟠

⚠️ 講白了,最壞會怎樣?先把三層分清楚

資安消息最怕的就是把「確定的事」跟「理論上可能的事」混在一起嚇人。我分成三層講,你看得出哪些是鐵的、哪些是推的、哪些只是假設 👇

  • ✅ 已確認事實:CVSS 向量是 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H——翻成人話就是「從網路上就能打、難度低、不用帳號、不用你配合點任何東西」,而且機密性、完整性、可用性全是「高衝擊」。破口在未授權就能觸發的 extcf7_submit 流程,簽名欄位沒驗證副檔名、MIME 類型與檔案大小。
  • 🟡 合理推論:如果上傳的檔案落在「可以執行 PHP」的位置,攻擊者就能讓伺服器跑他的程式,接著讀取資料庫憑證、翻出會員資料、植入後門。能造成多大災情,取決於你的檔案權限、PHP 執行環境與站台之間有沒有隔離好。
  • 🔴 假設情境:陌生人上傳後門 → 伺服器執行 → 建立幽靈管理員 → 整站被接管 → 波及同主機上的其他網站。這條鏈每一步都還需要額外條件配合,不是這顆 CVE 單獨就能一路打到底,我刻意跟上面兩層分開寫。
📢 大白話:為什麼「能執行 PHP」這麼關鍵?
同樣是把怪檔案收進來,差別在那個資料夾的「性格」。如果上傳目錄只當它是一張死圖片放著,那頂多佔點空間;但如果目錄的設定是「任何 .php 我都當程式跑」,那攻擊者上傳的後門就會真的活過來。這也是為什麼後面的加固,我會特別教你怎麼把上傳目錄的「執行權」關掉——這是整起事件最划算的一道保險 🔐

🧊 冷知識:WordPress 的上傳目錄,預設並不會幫你擋 PHP
很多人以為 wp-content/uploads 這種放圖片的地方天生就安全,其實不然。WordPress 核心跟多數主機商預設都沒有在這個目錄加上「禁止執行 PHP」的規則,要你自己或主機設定。這就是為什麼「檔案上傳漏洞」在 WordPress 圈特別容易一路升級成接管整站——東西只要躺在網頁能存取到的地方,就有機會被叫出來跑 🪤


🟪 🟪 🟪

🎭 第二章:先別慌,你是哪一種網站主?

漏洞公開的當天,不同身分的人該做的事其實差很多。先對號入座,再決定你要往下讀到哪一章 🧭

🙋 我只是發文、顧後台的小編或老闆

你完全不用懂 PHP 是什麼。要做的只有三件事:確認版本、更新、看一眼媒體庫跟管理員名單。第三章把每一步「去哪裡點、看到什麼算正常、不對勁怎麼辦」都拆開了,照著做就好,做不來也有退場方式。更新免費,不會多花你一毛錢 ✅

👨‍💻 我自己用 HestiaCP 架站,底下還掛著好幾個客戶網站

你要的是「一次掃完整台主機」。第五章的工具箱會用 find 找出主機上每一個 WordPress,再用各網站檔案擁有者的身分逐站執行 WP-CLI,不會在你的 root 家目錄裡留下一堆屬於 root 的檔案。這個外掛屬於 Contact Form 7 生態系,更新後記得清一次快取外掛與 CDN 快取,不然你會遇到「明明更新了,前台表單還是舊的」 🔧

🧑‍🔧 DevOps 小知識:HestiaCP 預設一個網站使用者一個 PHP-FPM pool
官方預設範本裡,每個網域的 pool 都綁了各自的 user 與 open_basedir,所以單一網站被打穿,通常摸不到別的使用者家目錄。但這層隔離的前提是「一個客戶一個系統使用者」;如果你為了省事把好幾個客戶塞進同一個 Hestia 使用者底下,隔離就形同虛設——這顆漏洞的橫向擴散風險也會跟著放大 🧱

🏢 我們是公司,網站存了會員或訂單資料

對企業來說,免費外掛沒有 SLA(服務等級協議),出事沒有人保證幾小時內修好。這次官方節奏算正常(先出修補版、再公開 CVE),但這個元件在 2025~2026 年反覆出包(下一節列給你看),就變成「採購與維運」層級要評估的事了。萬一會員個資可能被讀走,台灣《個人資料保護法》對當事人可能有事故通知義務,細節請交給法務判斷【❓待確認】。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺

🩵 荷包試算:要不要花錢買 WAF/虛擬修補?
有些資安廠商提供付費的 WAF 或虛擬修補,可以在你更新前先擋一層。但說真的,這顆漏洞後台點一下「更新」就解決了,如果你今天就能完成更新,通常比額外付月費划算得多。真的因為相容性卡住、短期內無法更新,再把付費方案當成過渡手段考慮,別反過來把它當成不更新的藉口 💰

[37]


🔴 🔴 🔴

🚨 這個外掛到底出過幾次事?我查得到的都列給你

與其空口說「這個外掛常出事」,不如直接看紀錄。下面這幾筆都是在公開資料庫查得到、有 CVE 編號的案例,你可以自己對照 🔍

公開時間 受影響版本 類型與門檻 CVE 編號
2025-07-22 ≤ 3.2.8 任意檔案刪除(delete-file 欄位),不需登入 CVE-2025-7645
2025-01-24 ≤ 2.0 系列 伺服器端請求偽造(SSRF) CVE-2025-24695
2024-03-19 早期版本 儲存型跨站腳本(Stored XSS) CVE-2024-29102
2026-10-10 ≤ 3.4.5 未授權任意檔案上傳(本文主角),3.4.6 修補 CVE-2026-94589

看出來了嗎?從檔案刪除、SSRF、XSS 到這次的檔案上傳,這個外掛的攻擊面一直在被挖。這不代表它不能用,但代表你不該讓它長期停在「手動、有空再更新」的狀態 🧯

🚨 高風險場景模擬(🔴 假設情境):「那個簽名欄位我們根本沒在用」
某工作室兩年前用這個外掛做了一張線上同意書,需要客戶手寫簽名。後來流程改了,同意書下架,但外掛一直是「啟用」狀態。老闆覺得「既然表單都拿掉了,漏洞應該碰不到我們」。
盲點在於:官方並未公開完整的觸發條件,而破口是在外掛的提交流程裡。只要外掛還在啟用清單裡,你就沒把握那扇門是不是關著的。就像家裡兩年沒走的後門,你以為它沒事,其實鎖有沒有生鏽,你根本沒去檢查過 🆘

🚥 畫重點!千萬別踩雷:
更新與停用,至少要選一個今天做。更新暫時做不到,退而求其次是先停用;「沒在用」不是不處理的理由,「已停用」才算真正不在攻擊面裡 🙅‍♂️


🟢 🟢 🟢

🛟 第三章:五分鐘無痛自救,不用打任何指令

不管你屬於哪一種網站主,這一段都建議先跑一遍。全程只要會用滑鼠,每一步我都寫了「點哪裡、看到什麼算正常、不對勁怎麼辦」 🤏

  • ㊀ 先確認你有沒有裝:
    登入 WordPress 後台,左側選單點「外掛」→「已安裝的外掛」。在右上角搜尋框輸入 Extensions For CF7,如果清單裡出現名稱類似「Extensions For CF7 (Contact form 7 Database, Conditional Fields and Redirection)」的項目,就是它;搜尋不到就代表這站沒裝,這篇可以先放下 🔎
  • ㊁ 看版本號,判斷安不安全:
    名稱下方會寫「版本 3.4.xx」。3.4.5 或更舊=受影響,請繼續往下;3.4.6 或更新=已經跨過這顆漏洞的修補門檻,可以先鬆一口氣,但最後兩步(看媒體庫、看管理員名單)還是建議做 🕵️‍♀️
  • ㊂ 更新前先備份:
    最簡單的做法是用主機商控制台的一鍵備份,或你本來就在用的備份外掛,先備一份再說。如果你連備份在哪裡都不知道,請直接把這篇文章的網址轉給維護你網站的工程師或主機商客服,這正是他們的工作,不用不好意思 💾
  • ㊃ 按下「立即更新」:
    回到「已安裝的外掛」,在該外掛下方點「立即更新」,等轉圈圈結束,重新整理頁面,確認版本變成 3.4.6 以上。接著實際送出一次聯絡表單,看看資料有沒有正常存下來、簽名功能正不正常。如果更新失敗或畫面變白,別一直重複按,截圖後找人幫忙,並先把外掛「停用」,這樣至少漏洞碰不到你 🔄
  • ㊄ 順手開自動更新:
    同一個外掛列表裡,每個外掛右側有「啟用自動更新」的連結,點一下就行。以後類似的安全修補版會自己裝,你不用每次都記得來看 ⏰
  • ㊅ 到媒體庫看一眼有沒有怪檔案:
    左側選單點「媒體」→「媒體庫」,切換成清單檢視,依上傳日期排序。正常的樣子:都是你或同事上傳的圖片、PDF。可疑的樣子:出現副檔名是 .php 的項目、檔名是一串亂碼、或你完全沒印象的上傳。媒體庫通常不該出現 PHP 檔,看到先別自己刪,截圖交給懂技術的人判斷 🖼️
  • ㊆ 最後看一眼管理員名單:
    左側選單點「使用者」,清單上方有「管理員」的篩選連結,點進去。正常的樣子:全部都是你認得的人、用的是公司或本人信箱。可疑的樣子:名稱像亂碼、信箱是免洗信箱、建立時間你完全沒印象。看到不認得的,先別自己刪,截圖交給懂技術的人;保險起見,順便把所有管理員密碼換掉 🔀

🫂 新手求助:後台找不到更新、也看不懂這些選單怎麼辦?
把這句話原封不動傳給你的主機商或網站維護人員:「我的網站可能有裝 Extensions For CF7,請幫我確認版本是否在 3.4.6 以上,漏洞編號是 CVE-2026-94589。」對方看到編號就知道要處理什麼。在沒有備份的情況下,請不要自己進主機底層刪除任何檔案 🤗

🧊 冷知識:「停用」跟「刪除」差在哪?
停用的外掛不會被 WordPress 載入,裡面的功能也就不會啟動,所以對這類「要外掛跑起來才會被觸發」的漏洞來說,停用就足以暫時擋住。但外掛檔案還留在主機上,想用隨時能重新啟用;刪除則是連檔案一起拿掉,萬一有舊頁面用到它的簽名欄位,版面就會缺一塊 🧰

💬 溫馨提醒:更新只是「從現在開始把門關好」,沒辦法倒帶確認「更新之前有沒有人進來過」。這就是為什麼最後兩步要看媒體庫跟管理員名單;如果你的網站有會員或訂單資料,也請告知負責人,由他們判斷是否要進一步檢查 🩺

[38]


🔴 🔴 🔴

🤿 第四章:漏洞底層到底哪裡出包?(以及為什麼五份報告細節對不上)

以下給想知道「為什麼會這樣」的人看。如果你只想補洞,前面三章已經夠用;想知道這顆漏洞長什麼樣,繼續往下拆 ⚔️

評估指標 具體資訊
外掛名稱 Extensions For CF7 (Contact form 7 Database, Conditional Fields and Redirection)
外掛 slug extensions-for-cf7
CVE 編號 CVE-2026-94589
CVSS 3.1(CNA:Wordfence) 9.8(Critical)
CVSS 向量 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
受影響版本 ≤ 3.4.5
安全版本 ≥ 3.4.6
漏洞類型 未經驗證的任意檔案上傳(CWE-434)
觸發函式 extcf7_submit(簽名欄位的檔案處理流程)
發現者 Osman Hussein

🧩 三個缺陷串成一條完整攻擊鏈

單獨看,每個缺陷好像都還好;串起來才致命。目前公開資訊與程式碼追蹤指向這三點 👇

  • 🏮 缺陷一:簽名欄位驗證不足。處理上傳時沒檢查副檔名、MIME 類型與檔案大小。簽名理論上該只收圖片(例如 PNG),實際上它什麼都收。
  • 🏮 缺陷二:上傳目錄沒擋 PHP 執行。就算把 .php 丟進去,只要目錄有防護就跑不起來;偏偏這裡沒有,等於把可執行檔放進了會自動點火的抽屜。
  • 🏮 缺陷三:檔名清理可被繞過。把檔名取成 shell.php-,經過 sanitize_file_name() 處理後,結尾那個連字號被清掉,反而還原成可執行的 shell.php。
📢 大白話:為什麼「清理檔名」反而幫了倒忙?
你可以把 sanitize_file_name() 想成一位有潔癖的清潔工,看到檔名結尾有多餘的符號就手癢想清乾淨。攻擊者故意遞上 shell.php-,清潔工好心把尾巴的 – 擦掉,結果擦出了一個貨真價實的 shell.php。防線不是被硬闖,而是被自己的好意拆了台 🧹

🧑‍🔧 DevOps 小知識:向量裡的 S:U 跟前一顆 SQL 注入的 S:C 差在哪?
這顆是 S:U(Scope Unchanged,範圍未改變),代表衝擊集中在被攻擊元件的安全權限範疇內;而 C、I、A 全部 H(高),這在檔案上傳導向 RCE 的案例很典型——一旦程式能跑,讀、寫、搞垮三件事他都做得到。所以事後的重點不只是「資料被讀走了嗎」,還要查「有沒有被寫入後門」,跟純讀取的 SQL 注入要分開看 🛡️

🔧 3.4.6 到底修了什麼?

官方更新日誌只寫了一句「強化簽名欄位與表單提交儲存的檔案上傳處理」,沒有公開完整的程式差異。有來源報告(可信度較低的那份)引用第三方漏洞庫,說 3.4.6 加了 2MB 大小上限、只收 image/png 的白名單校驗、新增一個依檔案內容判斷真實類型的函式,並改用伺服器端自己產生的檔名。這些細節我沒能在官方程式碼直接核對,所以標 🟡 合理推論,不當成事實寫死【❓待確認】 🧾

🚨 畫重點:破口的確切入口【❓待確認】
官方沒有公開可直接觸發的端點與參數,各家報告的描述也不完全一致。所以接下來第五章的排查,我刻意不依賴某個特定參數名稱,而是用「上傳目錄裡不該有 PHP」這種通用特徵去找。代價是比較容易有雜訊,好處是不會因為猜錯入口而漏掉真正的異常 🧭


🔷 🔷 🔷

🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 Checklist

🤖 重要提醒:以下指令由五份來源報告(DeepSeek、Gemini、Meta AI、Grok、ChatGPT)彙整,並經 Claude 複查修正
指令已通過 bash 語法檢查,路徑變數一律用雙引號包覆、迴圈任一站失敗即停。但正式環境(尤其正在營業中的網站)的高風險操作——更新、停用、刪檔、改權限、跑批次腳本——請務必由熟悉該站架構的人做最後確認再執行,切勿直接複製貼上就按 Enter 🧙

這一章的流程是:環境判斷 👉 盤點版本 👉 搜可疑檔案與日誌 👉 後門與帳號檢查 👉 加固五個關卡,每一關都拆成「決策原理、指令、預期輸出、分支判斷」。預設環境是 Debian 12 + HestiaCP,以 root 身分操作 📜

🧰 先貼一次工具箱

決策原理:HestiaCP 的每個網站都屬於不同的 Linux 使用者,檔案放在 /home/使用者/web/網域/public_html。用 root 直接跑 WP-CLI 會產生 root 擁有的檔案,之後網站自己寫入就會失敗。所以先定義三個小函式:找出所有網站、以檔案擁有者身分執行 WP-CLI、逐站執行並在失敗時停下。逐站迴圈刻意用檔案描述符 3 讀清單,避免迴圈裡的指令把後面的路徑吃掉 🧱

# ㊀ 先貼這一段(同一個 SSH 工作階段只要貼一次,後面都會用到)
PLUGIN="extensions-for-cf7"
FIXED="3.4.6"

# 找出本機所有 WordPress(HestiaCP 預設在 /home/使用者/web/網域/public_html)
list_wp_sites() {
  find /home -maxdepth 7 -type f -name "wp-config.php" \
    -not -path "*/wp-content/*" -print0 2>/dev/null
}

# 以「網站檔案擁有者」身分執行 WP-CLI,避免留下 root 擁有的檔案
wpx() {
  local wp_path="$1" owner
  shift
  owner="$(stat -c '%U' -- "$wp_path/wp-config.php")" || return 1
  sudo -u "$owner" -- wp --path="$wp_path" "$@"
}

# 逐站執行:任何一站失敗就停下,避免連鎖出事
each_site() {
  local callback="$1" cfg
  while IFS= read -r -d '' -u 3 cfg; do
    "$callback" "$(dirname -- "$cfg")" || {
      printf '⛔ 在 %s 停下,請先處理這一站\n' "$(dirname -- "$cfg")"
      return 1
    }
  done 3< <(list_wp_sites)
}

# 防呆:確認 WP-CLI 與 sudo 都在
command -v wp   >/dev/null || echo "⚠️ 找不到 wp 指令,請先安裝 WP-CLI"
command -v sudo >/dev/null || echo "⚠️ 找不到 sudo(root 可改用 runuser -u 使用者 -- 指令)"

預期輸出範例:貼完沒有任何輸出就是正常的;看到 ⚠️ 代表缺了 WP-CLI 或 sudo,先補上再往下。

分支判斷邏輯:

  • 🟢 沒有警告 👉 往下進入環境判斷。
  • 🟠 找不到 wp 👉 先安裝 WP-CLI(wp –info 可確認),不要硬跑下面的指令。
  • 🟡 你是 root 且沒有 sudo 👉 把函式裡的 sudo -u “$owner” — 換成 runuser -u “$owner” —。

[39]


🧭 🧭 🧭

🧭 ㊀ 環境判斷:你在管一個 WordPress,還是一整台?

決策原理:單一站、獨立多站、Multisite 三者的處理範圍完全不同,判斷錯後面全跑偏。用官方的 core is-installed –network 判斷 Multisite,比去 grep wp-config.php 裡的字樣可靠,因為註解裡的字樣也會被 grep 抓到 🤔

detect_one() {
  local wp_path="$1" kind
  if wpx "$wp_path" core is-installed --network 2>/dev/null; then
    kind="✴️ Multisite"
  elif wpx "$wp_path" core is-installed 2>/dev/null; then
    kind="💠 單一安裝"
  else
    kind="❓ 載入失敗"
  fi
  printf '%s\t%s\n' "$kind" "$wp_path"
  return 0
}

# 把整台主機掃一遍
each_site detect_one

預期輸出範例:

💠 單一安裝	/home/user1/web/example.com/public_html
✴️ Multisite	/home/user2/web/network.example.com/public_html

分支判斷邏輯:

  • ⭕ 只有一行 💠 👉 💠 單一網站。
  • ⭕ 多行 💠、路徑各自不同 👉 ❇️ 獨立多站,之後每站各自處理。
  • ⭕ 出現 ✴️ 👉 ✴️ Multisite,外掛檔案是全網共用的。
  • 🟠 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。
  • 🟡 路徑看起來像備份或測試副本(例如 public_html.bak) 👉 人工確認哪個才是正式環境。

🔎 🔎 🔎

🔎 ㊁ 盤點版本:哪些站裝了它、現在哪一版?

決策原理:版本比對用 Debian 內建的 dpkg –compare-versions,不要用字串比大小,否則 3.4.9 會被誤判成比 3.4.20 大。判斷條件是「低於 3.4.6 就是受影響」,比單純寫「≤3.4.5」更保險,萬一冒出 3.4.5.1 這種小版號也不會漏 🔢

inventory_one() {
  local wp_path="$1" ver status risk
  if ! ver="$(wpx "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null)"; then
    printf '⚪ 未安裝\t-\t-\t%s\n' "$wp_path"
    return 0
  fi
  status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status 2>/dev/null)"
  if dpkg --compare-versions "$ver" lt "$FIXED"; then
    risk="🔴 受影響"
  else
    risk="🟢 已修補"
  fi
  printf '%s\t%s\t%s\t%s\n' "$risk" "$ver" "$status" "$wp_path"
  return 0
}

# 💠 單一網站(路徑換成你的)
WP_PATH="/home/user1/web/example.com/public_html"
inventory_one "$WP_PATH"

# ❇️ 獨立多站:整台主機一次盤點
each_site inventory_one

# ✴️ Multisite:外掛檔案全網共用,查主站一次即可(status 顯示 active-network 代表全網啟用)
inventory_one "/home/user2/web/network.example.com/public_html"

預期輸出範例:

🔴 受影響	3.4.5	active	/home/user1/web/example.com/public_html
🟢 已修補	3.4.6	active-network	/home/user2/web/network.example.com/public_html
⚪ 未安裝	-	-	/home/user3/web/blog.example.net/public_html

分支判斷邏輯:

  • 🔴 受影響 👉 排入第六章的備份、預覽、更新流程;已公開多日的站,同時繼續做 ㊂、㊃。
  • 🟢 已修補 👉 版本條件已解除,但若舊版曾暴露一段時間,仍建議做一次 ㊂、㊃。
  • ⚪ 未安裝 👉 這站不受影響。
  • 🟡 status 顯示 inactive 👉 外掛已停用,攻擊面暫時不在,但仍建議找時間更新或刪除。

[40]


🟥 🟥 🟥

📊 ㊂ 搜可疑檔案與日誌:上傳目錄裡不該有 PHP

決策原理:因為官方沒公布確切入口(第四章的【❓待確認】),這裡用的是「結果特徵」:正常情況下 wp-content/uploads 裡不該出現任何 .php 類檔案。再搭配日誌,看有沒有人在對上傳目錄裡的 PHP 檔發出請求。zgrep 可以一併掃輪替過的 .log.1、.log.2.gz 🎯

upload_scan_one() {
  local wp_path="$1" domain
  local -a logs
  domain="$(basename -- "$(dirname -- "$wp_path")")"
  printf '\n===== %s =====\n' "$domain"

  echo "--- ❶ uploads 底下的 PHP 類檔案(正常應為空)---"
  # 多重副檔名務必用 \( \) 括起來,否則 -o 的優先序會讓條件錯亂
  find "$wp_path/wp-content/uploads" -type f \
    \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.phar' -o -iname '*.php[0-9]' \) \
    -printf '%TY-%Tm-%Td %TH:%TM %u:%g %m %p\n' 2>/dev/null

  echo "--- ❷ 日誌裡「存取 uploads 內 PHP 檔」的請求(最多 20 筆)---"
  logs=( "/var/log/nginx/domains/${domain}.log"* )
  if [ -e "${logs[0]}" ]; then
    zgrep -aihE -- 'wp-content/uploads/[^ "?]+\.(php|phtml|phar)([?" /]|$)' \
      "${logs[@]}" 2>/dev/null | tail -n 20
  else
    echo "(找不到 nginx 日誌,請確認網域資料夾名稱)"
  fi
  return 0
}

# 💠 單一網站
upload_scan_one "/home/user1/web/example.com/public_html"

# ❇️ 獨立多站(每個網域各看各的日誌)
each_site upload_scan_one

# ✴️ Multisite:子站共用日誌,掃主站網域即可,再依日誌裡的 Host/網址回推子站
upload_scan_one "/home/user2/web/network.example.com/public_html"

預期輸出範例:

===== example.com =====
--- ❶ uploads 底下的 PHP 類檔案(正常應為空)---
2026-10-10 03:14 user1:user1 644 /home/user1/web/example.com/public_html/wp-content/uploads/2026/10/sig_x9f.php
--- ❷ 日誌裡「存取 uploads 內 PHP 檔」的請求(最多 20 筆)---
1.2.3.4 - - [10/Oct/2026:03:15:02 +0800] "GET /wp-content/uploads/2026/10/sig_x9f.php HTTP/1.1" 200 42

分支判斷邏輯:

  • 🔴 uploads 裡出現 PHP 類檔案,尤其檔名像亂碼、建立時間落在漏洞公開前後 👉 高度可疑,進入第六章「A. 疑似中招」,先別刪,保留證據。
  • 🟡 有些外掛會在 uploads 放 index.php 這種「空白保護檔」 👉 看到 index.php 先別當成後門,打開確認內容是不是只有一行 silence 註解。
  • 🔴 日誌命中「存取 uploads 內 PHP」且來源是陌生 IP、重複出現 👉 視為疑似遭觸發,進入「A」。
  • 🟢 兩項都沒輸出 👉 沒有直接證據,但別忘了:POST 內容不在預設日誌裡,仍建議繼續做 ㊃。

🟣 🟣 🟣

🕳️ ㊃ 後門與帳號檢查:有沒有人拿著讀到的東西進來過

決策原理:這顆漏洞最壞會讓攻擊者寫入並執行程式,所以要同時看「檔案」跟「帳號」。mu-plugins 目錄裡的檔案不用在後台啟用就會自動載入,是常見藏身處。資料表前綴(wp_)每站可能不同,不能寫死,先用 wp db prefix 取得,再把 SQL 寫進 mktemp 的暫存檔,用單引號包住的 heredoc 避免 Shell 去解讀 SQL 裡的特殊字元 🕵️‍♀️

audit_one() {
  local wp_path="$1" prefix qf
  printf '\n===== %s =====\n' "$wp_path"

  echo "--- ❶ mu-plugins(會自動載入、後台外掛頁看不到)---"
  find "$wp_path/wp-content/mu-plugins" -maxdepth 2 -type f -name '*.php' -ls 2>/dev/null

  echo "--- ❷ 近 7 天被改過的 PHP(排除快取)---"
  find "$wp_path" -type f -name '*.php' -mtime -7 \
    -not -path '*/wp-content/cache/*' -ls 2>/dev/null | head -n 50

  echo "--- ❸ 管理員帳號 ---"
  wpx "$wp_path" user list --role=administrator \
    --fields=ID,user_login,user_email,user_registered --format=table

  echo "--- ❹ 最近 10 筆註冊的帳號(任何角色)---"
  prefix="$(wpx "$wp_path" db prefix)" || return 0
  qf="$(mktemp)"
  cat > "$qf" <<'EOF'
SELECT ID, user_login, user_email, user_registered
FROM __PREFIX__users
ORDER BY user_registered DESC
LIMIT 10;
EOF
  sed -i "s/__PREFIX__/${prefix}/g" "$qf"
  wpx "$wp_path" db query < "$qf"
  rm -f -- "$qf"
  return 0
}

# 💠 單一網站
audit_one "/home/user1/web/example.com/public_html"

# ❇️ 獨立多站
each_site audit_one

# ✴️ Multisite:mu-plugins 屬於整個網路,帳號要多看一層「超級管理員」
audit_one "/home/user2/web/network.example.com/public_html"
wpx "/home/user2/web/network.example.com/public_html" super-admin list

預期輸出範例:

===== /home/user1/web/example.com/public_html =====
--- ❶ mu-plugins(會自動載入、後台外掛頁看不到)---
--- ❷ 近 7 天被改過的 PHP(排除快取)---
--- ❸ 管理員帳號 ---
+----+------------+-------------------+---------------------+
| ID | user_login | user_email        | user_registered     |
+----+------------+-------------------+---------------------+
|  1 | admin      | admin@example.com | 2024-01-15 08:00:00 |
+----+------------+-------------------+---------------------+

分支判斷邏輯:

  • 🔴 mu-plugins 出現不是你部署的 PHP、或檔名是亂數字串 👉 先不要刪,保留時間與副本做鑑識,進入第六章「A」。
  • 🔴 管理員名單出現註冊時間在漏洞公開前後、你不認識的帳號 👉 高度可疑,進入「A」。
  • 🟡 近 7 天有被改過的 PHP 👉 對照你自己的更新時間,剛更新過外掛這裡本來就會有一堆檔案,不是你改的再往下查內容。
  • 🟢 一切正常 👉 這一輪沒發現異常。「陌生」不等於「惡意」,也可能是外包或維運商的帳號,先問過再判斷。

[41]


🔍 🔍 🔍

🔧 ㊄ 加固:把上傳目錄的「執行權」關掉

決策原理:更新外掛是根本,加固是「萬一下次又有同類漏洞,讓它跑不起來」。對檔案上傳漏洞來說,最有效的一道保險就是讓 uploads 目錄不執行 PHP。HestiaCP 多半由 nginx 在前、PHP-FPM 在後,所以 .htaccess 常常沒用,要在 nginx 的自訂 include 加規則。順手再收緊 wp-config.php 權限、停用高風險 PHP 函式 🔒

# ❶ 收緊 wp-config.php 權限(裡面有資料庫密碼)
harden_cfg_one() {
  local wp_path="$1" cfg="$1/wp-config.php"
  stat -c '調整前:%a %U:%G %n' -- "$cfg"
  chmod 640 -- "$cfg"
  stat -c '調整後:%a %U:%G %n' -- "$cfg"
  return 0
}
each_site harden_cfg_one

# ❷ 在 nginx 禁止 uploads 執行 PHP(HestiaCP 認得的自訂 include)
block_uploads_php() {
  local wp_path="$1" huser domain conf_dir rule="cf7-cve-2026-94589"
  huser="$(stat -c '%U' -- "$wp_path/wp-config.php")" || return 1
  domain="$(basename -- "$(dirname -- "$wp_path")")"
  conf_dir="/home/${huser}/conf/web/${domain}"
  [ -d "$conf_dir" ] || { printf '❌ 找不到 %s\n' "$conf_dir"; return 1; }

  cat > "${conf_dir}/nginx.conf_${rule}" <<'EOF'
# 緩解 CVE-2026-94589:禁止 uploads 目錄執行任何 PHP 類檔案
location ~* ^/wp-content/uploads/.*\.(php|phtml|phar|php[0-9])$ {
    deny all;
    access_log off;
    log_not_found off;
}
EOF
  ln -sfn -- "nginx.conf_${rule}" "${conf_dir}/nginx.ssl.conf_${rule}"

  if nginx -t; then
    systemctl reload nginx && printf '✅ 已為 %s 擋下 uploads 的 PHP 執行\n' "$domain"
  else
    rm -f -- "${conf_dir}/nginx.conf_${rule}" "${conf_dir}/nginx.ssl.conf_${rule}"
    printf '❌ nginx 設定檢查沒過,已自動撤回規則\n'
    return 1
  fi
}
block_uploads_php "/home/user1/web/example.com/public_html"

# ❸ PHP-FPM 停用高風險函式(逐版本處理,注意:不要停 curl_exec,會弄壞 WP 更新)
for ini in /etc/php/*/fpm/php.ini; do
  [ -f "$ini" ] || continue
  ver="$(basename -- "$(dirname -- "$(dirname -- "$ini")")")"
  cp -- "$ini" "${ini}.BAK.$(date +%F_%H%M%S)"
  sed -i -E 's/^[;#]*[[:space:]]*disable_functions[[:space:]]*=.*/disable_functions = exec,passthru,shell_exec,system,proc_open,popen/' "$ini"
  if "php-fpm${ver}" -t >/dev/null 2>&1; then
    systemctl restart "php${ver}-fpm"
    printf 'PHP %s:已套用並重新啟動\n' "$ver"
  else
    printf 'PHP %s:設定檢查沒過,先別重啟,備份在 %s.BAK.*\n' "$ver" "$ini"
  fi
  grep -E '^disable_functions' "$ini"
done

驗證加固真的有效(放一個測試檔,從外面打打看):

# 放一個無害的測試檔,從瀏覽器角度確認它「不會被執行」
WP_PATH="/home/user1/web/example.com/public_html"
DOMAIN="example.com"
printf '%s' '<?php echo "EXEC-TEST-OK"; ?>' > "$WP_PATH/wp-content/uploads/__exectest.php"

# 預期:回 403,而且畫面「不會」出現 EXEC-TEST-OK(出現就代表 PHP 還是被執行了)
curl -sS -o /dev/null -w '%{http_code}\n' "https://${DOMAIN}/wp-content/uploads/__exectest.php"

# 測完立刻刪掉,別留在站上
rm -f -- "$WP_PATH/wp-content/uploads/__exectest.php"

預期輸出範例:

調整前:644 user1:user1 /home/user1/web/example.com/public_html/wp-config.php
調整後:640 user1:user1 /home/user1/web/example.com/public_html/wp-config.php
✅ 已為 example.com 擋下 uploads 的 PHP 執行
PHP 8.2:已套用並重新啟動
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
403

分支判斷邏輯:

  • 🟢 測試檔回 403、畫面沒有 EXEC-TEST-OK 👉 uploads 的執行權已成功關閉,這道保險生效。
  • 🟠 nginx 設定檢查沒過 👉 函式會自動撤回剛建立的兩個檔案,確認路徑與使用者名稱後再試。
  • 🟠 PHP 設定檢查沒過 👉 不要重啟,用 .BAK 還原後再查原因。
  • 🟡 有些備份/圖片處理外掛會用到 exec 等函式 👉 套用後實際測試,必要時改成在個別網域的 pool 設定裡處理,而不是全域停用。

🧑‍🔧 DevOps 小知識:640 會不會讓網站讀不到設定檔?
HestiaCP 預設讓 PHP-FPM 以網站使用者身分執行,擁有者本來就讀寫得到,640 不影響。如果你改過架構(例如另外裝了以 www-data 執行的 mod_php),請先在測試站驗證再套用;驗證 disable_functions 時也請看 FPM 的 php.ini,不要用 php -i 🧪


🕵️ 🕵️ 🕵️

📋 ㊅ 三環境速查表

階段 💠 單一網站 ❇️ 獨立多站 ✴️ Multisite
① 環境判斷 each_site detect_one 只有一行 多行 💠,路徑各自不同 出現 ✴️
② 盤點版本 inventory_one 路徑 each_site inventory_one 查主站一次,status 看 active-network
③ 可疑檔案與日誌 單一網域 逐網域各看各的 共用日誌,再依 Host 回推子站
④ 後門與帳號 mu-plugins+管理員+近期註冊 逐站各自檢查 mu-plugins 全網共用,另加 super-admin
⑤ 加固 uploads 擋 PHP+wp-config 權限 逐站擋 PHP,php.ini 逐版本 php.ini 屬伺服器層級,一次到位

🔥 核心原則:「批次」不等於「把所有東西塞進一條迴圈」。真正安全的批次排查,是先判斷環境、再逐層縮小範圍、一次處理一站,而且每一步失敗就停下 🎯

[42]


🪛 🪛 🪛

🛠️ 第六章:修不了就先擋——止血、補洞、驗收三部曲

前一章解決「怎麼判斷」,這一章是實際動手。無論哪種情境,順序都是同一套:備份 👉 預覽(Dry-run)👉 更新 👉 驗證,只是已經有中招跡象時,要先插入「保留證據與止血」。以下都沿用第五章的工具箱函式,沒貼的話請先回去貼一次 🧹


🔴 🔴 🔴

🚨 ㊀ A. 疑似中招:先留證據,再止血,最後才修補

決策原理:只要第五章出現明確跡象(uploads 有 PHP 檔、陌生管理員、mu-plugins 可疑檔),第一優先不是急著更新,而是「先保留現場」。順序是:資料庫快照與日誌 👉 停用外掛 👉 強制重置 wp-config.php 的金鑰與鹽值(讓所有人、包含攻擊者手上的 Cookie 立刻失效)👉 重設管理員密碼 👉 銷毀所有登入階段。證據資料夾裡有 wp-config.php 的備份(含資料庫密碼),權限設 700,請別外傳 🧯

STAMP="$(date +%Y%m%d-%H%M%S)"
EVIDENCE_DIR="${HOME}/wp-incident-${STAMP}"
mkdir -p -- "$EVIDENCE_DIR" && chmod 700 -- "$EVIDENCE_DIR"

incident_one() {
  local wp_path="$1" domain out
  domain="$(basename -- "$(dirname -- "$wp_path")")"
  out="${EVIDENCE_DIR}/${domain}"
  mkdir -p -- "$out"

  # ❶ 先留證據:資料庫快照、日誌、mu-plugins、uploads、wp-config.php(含密碼,別外傳)
  wpx "$wp_path" db export - --single-transaction > "$out/db_${STAMP}.sql" || return 1
  cp -a -- "/var/log/nginx/domains/${domain}.log"* "$out/" 2>/dev/null
  if [ -d "$wp_path/wp-content/mu-plugins" ]; then
    tar -czf "$out/mu-plugins_${STAMP}.tar.gz" -C "$wp_path/wp-content" mu-plugins
  fi
  # 只打包 uploads 裡的 PHP 類檔案做鑑識,不整包備份(可能很大)
  find "$wp_path/wp-content/uploads" -type f \
    \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.phar' \) \
    -print0 2>/dev/null | tar -czf "$out/uploads-php_${STAMP}.tar.gz" --null -T - 2>/dev/null
  cp -p -- "$wp_path/wp-config.php" "$out/wp-config.php.BAK.${STAMP}"
  chmod 600 -- "$out/wp-config.php.BAK.${STAMP}"

  # ❷ 隔離:停用外掛(Multisite 連全網與各子站一起處理)
  if wpx "$wp_path" core is-installed --network 2>/dev/null; then
    wpx "$wp_path" plugin deactivate "$PLUGIN" --network
    local url
    while IFS= read -r url; do
      wpx "$wp_path" --url="$url" plugin deactivate "$PLUGIN" 2>/dev/null
    done < <(wpx "$wp_path" site list --field=url)
  else
    wpx "$wp_path" plugin deactivate "$PLUGIN"
  fi

  # ❸ 強制重置金鑰與鹽值,所有人(含攻擊者手上的 Cookie)立刻被登出
  wpx "$wp_path" config shuffle-salts || return 1

  # ❹ 逐一重設管理員密碼,新密碼直接顯示在螢幕,請在私密環境操作
  local uid
  while IFS= read -r uid; do
    wpx "$wp_path" user reset-password "$uid" --skip-email --show-password
  done < <(wpx "$wp_path" user list --role=administrator --field=ID)

  # ❺ 銷毀所有人的登入階段(用官方指令,不手寫 SQL)
  wpx "$wp_path" user session destroy --all --all-users 2>/dev/null \
    || wpx "$wp_path" user session destroy --all

  printf '✅ %s 止血完成,證據放在 %s\n' "$domain" "$out"
  return 0
}

# 只對「有疑慮的那一站」執行,不要整台主機一起跑
incident_one "/home/user1/web/example.com/public_html"

預期輸出範例:

Plugin 'extensions-for-cf7' deactivated.
Success: Shuffled the salt keys.
Password: (隨機產生的新密碼,每位管理員各一組)
✅ example.com 止血完成,證據放在 /root/wp-incident-20261011-120000/example.com

分支判斷邏輯:

  • ⭕ 全部成功 👉 保留證據資料夾,接著走「C. 正式修補」;企業或會員制網站請同步通知內部資安或維運負責人。
  • 🟠 db export 失敗 👉 函式會立刻中斷,先處理磁碟空間或資料庫權限,不要在沒有快照的情況下繼續。
  • 🟠 確認有後門檔案 👉 先隔離保留,不要直接刪除,把證據交給資安事件應變人員。
  • 🔴 網站有會員個資或線上收款、且確認曾遭觸發 👉 這已超出更新外掛的層級,請啟動內部資安事件流程,並把 SMTP、API 金鑰等存在資料庫或設定檔的憑證一併換掉。

🚨 畫重點:新密碼會直接顯示在螢幕上,可能留在終端機的捲動紀錄裡。請在私密環境操作,用安全管道交給管理員本人,並請他們登入後立刻自行更換 🔐

[43]


🟡 🟡 🟡

🧯 ㊁ B. 短期應急:還沒確認中招,但現在還不能更新

🚨 短期應急 ≠ 正式修補。
停用外掛與伺服器層規則都只是緩衝,目的是縮短暴露時間,真正的修補仍然是更新到 3.4.6 以上 🧱

決策原理:最乾淨的做法是直接停用外掛(Multisite 加 –network)。如果網站版面離不開它,那就先套用第五章 ㊄ 的「禁止 uploads 執行 PHP」規則當緩衝——這道規則不管破口入口在哪,都能讓「上傳的 PHP 跑不起來」,對檔案上傳漏洞特別對症。等有空再安排正式更新 🔒

# ❶ 最乾淨:直接停用外掛(Multisite 請加 --network)
wpx "/home/user1/web/example.com/public_html" plugin deactivate "$PLUGIN"

# ❷ 非得讓外掛繼續跑時:套用第五章 ㊄ 的 block_uploads_php 當緩衝
block_uploads_php "/home/user1/web/example.com/public_html"

分支判斷邏輯:

  • 🟢 網站不依賴這個外掛的簽名欄位 👉 直接停用,最乾淨。
  • 🟠 網站離不開它 👉 套用 uploads 擋 PHP 的規則並盡快安排更新;記得用第五章的測試檔確認 403 真的生效。
  • 🟡 想更保守 👉 停用外掛+擋 PHP 兩個一起上,不衝突。

🚀 🚀 🚀

㊂ C. 正式修補:備份 👉 預覽 👉 更新 👉 驗證

❶ 備份:遵循 3-2-1 原則

決策原理:至少 3 份副本、2 種儲存媒介、1 份異地。這裡先做第一層:資料庫與 wp-content。備份檔不要放在網站根目錄,避免被外部直接下載;用 db export – 輸出到螢幕、再由 root 導向存檔,因為網站使用者沒權限寫進 root 家目錄 💾

BACKUP_DIR="${HOME}/wp-security-backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p -- "$BACKUP_DIR" && chmod 700 -- "$BACKUP_DIR"

backup_one() {
  local wp_path="$1" domain
  domain="$(basename -- "$(dirname -- "$wp_path")")"
  wpx "$wp_path" db export - --single-transaction \
    > "$BACKUP_DIR/${domain}_db_${STAMP}.sql" || return 1
  tar -czf "$BACKUP_DIR/${domain}_wp-content_${STAMP}.tar.gz" \
    -C "$wp_path" "wp-content" || return 1
  ls -lh -- "$BACKUP_DIR/${domain}_"*"${STAMP}"*
  return 0
}

# 💠 單一網站
backup_one "/home/user1/web/example.com/public_html"

# ✴️ Multisite:資料庫是整個網路共用的一顆,只匯出一次
backup_one "/home/user2/web/network.example.com/public_html"

分支判斷邏輯:兩個檔案都在、大小合理 👉 進入預覽;任何一個失敗 👉 函式會中斷,先排除磁碟空間或權限問題,不要進入更新 🛑

🧊 冷知識:為什麼備份要寫 root,不寫網站使用者?
萬一網站被入侵,攻擊者通常拿到的是「網站使用者」的權限。如果備份放在他寫得到的位置,備份也可能一起被動手腳。放在只有 root 讀寫得到的資料夾,是最簡單的一層保護 🗄️

❷ 預覽(Dry-run):先看更新會做什麼,不真的動手

dryrun_one() {
  local wp_path="$1"
  printf '▶ %s\n' "$wp_path"
  wpx "$wp_path" plugin update "$PLUGIN" --dry-run
}

# 💠 單一網站
dryrun_one "/home/user1/web/example.com/public_html"

# ❇️ 獨立多站(只預覽,不會真的改任何東西)
each_site dryrun_one

# ✴️ Multisite:外掛檔案只有一份,預覽一次即可(plugin update 沒有 --network 參數)
dryrun_one "/home/user2/web/network.example.com/public_html"

預期輸出範例:

▶ /home/user1/web/example.com/public_html
+---------------------+-------------+-------------+-----------+
| name                | old_version | new_version | status    |
+---------------------+-------------+-------------+-----------+
| extensions-for-cf7  | 3.4.5       | 3.4.6       | available |
+---------------------+-------------+-------------+-----------+

分支判斷邏輯:看到版本會往上跳 👉 可以正式更新;沒有可用更新 👉 先確認目前是否已是最新版,或更新來源有沒有連線問題,不要因為沒看到更新就直接認定安全 🔮

❸ 更新:只對原本啟用的外掛做停用再啟用

決策原理:更新前先把狀態記下來,原本啟用的才會停用再啟用,原本停用的維持原樣。這樣可以避免「更新完卻把本來不啟用的外掛打開」這種意外。更新失敗時,外掛會停在停用狀態——漏洞碰不到你,只是簽名欄位會暫時缺席,這是刻意設計成「失敗時偏向安全」🧰

update_one() {
  local wp_path="$1" status
  if ! status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status 2>/dev/null)"; then
    printf '⚪ %s 沒裝這個外掛,略過\n' "$wp_path"
    return 0
  fi

  case "$status" in
    active)         wpx "$wp_path" plugin deactivate "$PLUGIN" || return 1 ;;
    active-network) wpx "$wp_path" plugin deactivate "$PLUGIN" --network || return 1 ;;
  esac

  wpx "$wp_path" plugin update "$PLUGIN" || return 1

  case "$status" in
    active)         wpx "$wp_path" plugin activate "$PLUGIN" ;;
    active-network) wpx "$wp_path" plugin activate "$PLUGIN" --network ;;
  esac
}

# 💠 單一網站
update_one "/home/user1/web/example.com/public_html"

# ✴️ Multisite:外掛檔案只更新一次,全網啟用狀態會被原樣還原
update_one "/home/user2/web/network.example.com/public_html"

預期輸出範例:

Plugin 'extensions-for-cf7' deactivated.
Success: Updated 1 of 1 plugins.
Plugin 'extensions-for-cf7' activated.

分支判斷邏輯:更新成功 👉 立即進入驗證;更新失敗 👉 別反覆重跑,先看錯誤訊息,確認檔案權限、磁碟空間與主機能否連到 WordPress.org 💾

🧑‍🔧 多站環境的實務原則:一次只處理一站,至少第一批這樣做。先完成一站的「備份 👉 預覽 👉 更新 👉 驗證」,確認沒有 PHP Fatal Error、白畫面或前台表單異常,再處理下一站,比一條迴圈從頭跑到尾更好控制風險 🎛️

❹ 驗證:更新成功 ≠ 工作完成

決策原理:至少確認三層:版本、檔案完整性、前台功能。檔案完整性用 WP-CLI 對照 WordPress.org 的官方檢查碼;前台功能用 curl 確認首頁回應 2xx 或 3xx;最後再實際送一次帶簽名的表單 🔍

verify_one() {
  local wp_path="$1" ver home_url code
  ver="$(wpx "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null)" || {
    printf '⚪ %s 沒裝這個外掛,略過\n' "$wp_path"; return 0; }

  # ❶ 版本層
  if dpkg --compare-versions "$ver" lt "$FIXED"; then
    printf '❌ %s 版本仍是 %s,還沒修補\n' "$wp_path" "$ver"
    return 1
  fi

  # ❷ 檔案完整性層:外掛檔案要跟 WordPress.org 的發布版一致
  wpx "$wp_path" plugin verify-checksums "$PLUGIN" || return 1
  wpx "$wp_path" core verify-checksums || echo "⚠️ 核心檔案有差異,請人工確認是不是你自己放的檔案"

  # ❸ 功能層:首頁要正常回應(2xx 或 3xx 都算正常)
  home_url="$(wpx "$wp_path" option get home)" || return 1
  code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/")"
  case "$code" in
    2*|3*) printf '✅ %s → 版本 %s、首頁 HTTP %s\n' "$wp_path" "$ver" "$code" ;;
    *)     printf '❌ %s → 首頁 HTTP %s,請立刻檢查\n' "$wp_path" "$code"; return 1 ;;
  esac
}

verify_one "/home/user1/web/example.com/public_html"

預期輸出範例:

Success: Verified 1 of 1 plugins.
✅ /home/user1/web/example.com/public_html → 版本 3.4.6、首頁 HTTP 200

🚨 Checksum 驗證的侷限:它只能證明外掛目錄裡的檔案跟官方發布版一致,檢查不到 mu-plugins、uploads、資料庫裡的異常帳號,也看不出別的外掛或主題有沒有被動過。所以請把它當成完整性檢查的其中一環,搭配第五章 ㊂、㊃ 的檔案與帳號檢查,不是唯一的安全保證 🔐

❺ 整合:把四步綁成一站一個完整閉環

# 把「備份 → 預覽 → 更新 → 驗證」綁成一站一個閉環,任何一步失敗就停
patch_one() {
  local wp_path="$1"
  printf '\n🚀 開始處理 %s\n' "$wp_path"
  backup_one "$wp_path" \
    && dryrun_one "$wp_path" \
    && update_one "$wp_path" \
    && verify_one "$wp_path" \
    || { printf '❌ 停在 %s,請先排查再處理下一站\n' "$wp_path"; return 1; }
  sleep 3   # 給伺服器喘口氣,也讓你有機會按 Ctrl+C
}

# 💠 單一網站
patch_one "/home/user1/web/example.com/public_html"

# ❇️ 獨立多站:一次一站,遇到失敗就停(each_site 會自動中斷)
each_site patch_one

分支判斷邏輯:任何一站在任何一步失敗,函式都會印出 ❌ 並停下,each_site 也不會往下一站。確認問題處理完再重跑即可,因為前面的步驟都可重複執行。全部完成後,別忘了清一次網站快取與 CDN 快取 🧼

🔥 一句話總結:先判斷環境,再盤點、備份、預覽,確認無誤才逐站更新,最後驗證。這套閉環換成下一個外掛、下一個 CVE,一樣可以直接套用 🦸

[44]


🟢 🟢 🟢

🤔 讀到快喘不過氣?三個最常見的焦慮,一次解掉

  • 🏮 Q:更新後表單或簽名功能會不會壞掉?
    A:官方更新日誌寫的是「強化檔案上傳處理」,理論上不動版面,但第三方外掛我無法保證完全不變【❓待確認】。所以更新前先備份、更新後實際送一次帶簽名的表單,是最穩的做法 💾
  • 🏮 Q:外包說「表單早就沒在用,不用管」,我該聽嗎?
    A:別聽。這顆漏洞跟你有沒有在用簽名欄位的關係沒那麼單純,而是跟外掛有沒有啟用、版本是不是 3.4.6 以上有關。請對方給你看版本截圖,或直接停用 💣
  • 🏮 Q:看不懂技術深潛篇,是不是就沒救了?
    A:完全不會。第三章從頭到尾不用打指令,點幾下滑鼠就能做完最基本的防護;第四到第六章是給有 SSH 權限、想深入排查的人看的補充 🤗

🧠 🧠 🧠

🧩 舉一反三:這一類漏洞的通用防禦心法

以下是任意檔案上傳(CWE-434)這個類型的通用防禦知識,不是本 CVE 的官方要求,但下次遇到同類問題用得上 🧠

  • ⭐ 上傳目錄一律禁止執行 PHP。這是最便宜也最有效的最後一道防線。在 nginx 用 location 配 deny all、Apache 用 <FilesMatch>,即使檔案被上傳也只是一團純文字,跑不起來。
  • ⭐ 副檔名不是驗證依據。後端應同時檢查 MIME 類型與檔案標頭(magic bytes),必要時用 PHP 的 finfo 讀內容確認;簽名、頭貼、履歷這種「看起來無害」的上傳欄位,往往最容易被放鬆檢查。
  • ⭐ 檔名隨機化、縮小攻擊面。儲存時用伺服器端產生的隨機檔名(例如 UUID),別讓攻擊者預測路徑;外掛不用就停用、不要長期擱置,這次的漏洞需要外掛「啟用」才會被碰到,停用就是最省事的防禦。

🏁 🏁 🏁

🏆 第七章:這次到底該多緊張?事件綜合評估

評估維度(10 分制,數字越高代表風險或應對品質越正向) 分數 一句話評語
漏洞嚴重程度(越高代表風險越大) 9.8 不用登入、不用互動,且 C/I/A 全高、可導向 RCE,是最危險的那一類
官方應變速度 8 修補版 10/4 先於 CVE 公開上線,節奏正常;但這個元件近兩年反覆出包,略扣分
一般站長自救可行性 9 後台點「更新」就能解決,門檻很低,要多做的只有看媒體庫與管理員名單
DevOps 技術文件完整度 6 官方沒公開觸發端點與修補差異,排查只能靠「uploads 不該有 PHP」這類通用特徵
🏆 加權綜合建議 立即更新 最終建議:【今天排進行程、最慢本週內完成更新;有公開簽名表單或存有會員資料的站優先】

🔥 這起事件提醒我們:漏洞不一定躲在複雜的核心系統,有時候就藏在你以為「只是讓客戶簽個名」的小功能裡。細節少、攻擊程式不明,不等於可以放著不管,因為真正有差別的,是你在「別人公開攻擊方法之前」有沒有把門關好。與其等到媒體庫冒出奇怪的 PHP 檔才發現不對勁,不如現在花五分鐘,打開後台看一眼版本號碼 🔢

[45]


📎 📎 📎

📚 參考資料與延伸資源

列印本文 [55] 👨‍👩‍👧‍👦 68 次瀏覽