🔓 一張公開表單,路人就能改寫你的網站設定?ACF Extended 未登入 RCE 漏洞自救全攻略(CVE-2026-94267/CVSS 9.0)
🔥 懶人包:
WordPress 上有 100,000+ 活躍安裝的欄位擴充外掛 Advanced Custom Fields: Extended(一般簡稱 ACF Extended 或 ACFE)被登記了 CVE-2026-94267,CVSS 3.1 評分 9.0,弱點類型是程式碼注入(CWE-94)。依 WPScan 的公開描述,它前台表單裡的「更新文章」動作少了權限檢查,沒登入的訪客有機會改寫網站上的文章,包含存放欄位定義的那一類,最後走到伺服器端執行程式碼 💥免費版與 PRO 版只要在 0.9.2.8 以下就中標,兩邊的修補版都是 0.9.2.8。先別慌:要打中它,網站得先放一張設定特殊的公開表單,條件偏嚴格,所以 CVSS 向量裡的攻擊複雜度是 High。不過 WPScan 頁面註記的 PoC 預定 2026-11-21 起顯示,距離今天(2026-10-27)只剩約 25 天。這篇帶你看版本、備份、更新,再查有沒有被摸過 🔒
🌐 點我直達 WordPress 官方外掛頁確認最新版本 [1]
內容目錄
🪧 第一章:案發現場還原!為什麼一張「聯絡我們」表單,會變成改寫網站的遙控器?
想像你們公司大廳放了一個「意見回函箱」,旁邊坐著一位很熱心的總務小姐。她有個習慣:回函上只要寫著「請把 3 號檔案櫃第二層的文件,換成以下內容」,她就照辦,從來不問寫的人是誰,更沒想過他有沒有資格動那個檔案櫃。平常大家寫的都是「冷氣太冷」,相安無事。直到某天有個路人投了一張回函,指名要換掉存放公司章程的那一層 🗄️
ACF Extended 讓站長在前台放表單,訪客送出資料後,表單可以接著做事:寄信、建立使用者、新增或更新文章。這些「做事的動作」就像總務小姐的工作內容。這次的問題出在「更新文章」這個動作,依公開描述,修補前它沒有檢查送出表單的人有沒有資格改那篇文章 🙋♀️
先把前因後果交代清楚。ACF Extended 的 Forms 模組可以在表單裡設定 Post Action(文章動作),選擇「建立文章」或「更新文章」。更新時要指定兩件事:改哪一篇(官方文件稱為 Target),以及改成什麼內容(欄位對應)。站長如果把這兩項都對應成「訪客自己填的欄位」,等於把方向盤交給了訪客。WPScan 描述的情況就是這種組合 🧾
官方只在更新日誌裡列出一批安全相關的修補,沒有逐項說明對應哪個 CVE,也沒有公開攻擊字串。所以下面這條攻擊鏈,是我根據 WPScan 的描述與官方更新日誌拼出的輪廓,每一步都標了信心程度 🔍
- ㊀ 站長放了一張特殊設定的公開表單:✅ WPScan 描述,網站需要有已發布的表單,且文章更新動作的目標與內容都取自訪客送出的值 📝
- ㊁ 訪客送出表單,外掛照辦:✅ 依描述,這個動作缺少 capability(權限)檢查,沒登入也能走完流程 🚪
- ㊂ 被改寫的對象不限於一般文章:✅ 描述指出包含存放欄位定義的文章。ACF 的欄位群組本身就是以文章形式存在資料庫裡(文章類型 acf-field-group),所以「能改文章」會一路延伸到「能改欄位定義」 🧱
- ㊃ 欄位定義被改寫後,走向伺服器端執行程式碼:🟡 官方 0.9.2.8 的更新日誌同時加入了「欄位驗證函式白名單」,與這條路徑吻合,但官方沒有把兩者畫上等號 ⚙️
🧊 冷知識:NVD 顯示「Deferred」,不代表這個 CVE 沒發布
CVE-2026-94267 在 2026-10-23 就已正式發布,CNA(負責分配編號的機構)是 WPScan。NVD 頁面上的 vulnStatus: Deferred 只是代表 NVD 還沒做完自己的補充分析,跟編號是否有效是兩回事。看到這個字樣就以為是假消息,反而會錯過更新 🧐
🚨 畫重點:我不會給你攻擊字串
有些報告把送出的欄位名稱寫得很具體,這篇一律不照搬。你只要記住這個漏洞的入口是「公開表單」,攻擊者不需要你的帳號,也不需要誘騙任何人點連結。想知道自己中不中標,看第五章的結果導向檢查就夠了,不必靠誰公開的字串 🙅
🧊 冷知識:ACF 的欄位群組,其實是藏在資料庫裡的「文章」
你在後台拖拉出來的欄位群組,在資料庫裡跟一般文章共用同一張表,只是類型標籤不同。這種設計讓匯出入與同步很方便,代價是只要有「能改任意文章」的缺口,改到的就可能不只是部落格內容,連網站的欄位結構都在射程內 🗂️
⏱️ 事件時間軸一次看懂
| 時間點 | 發生了什麼大事 | 站長可信度判定 |
|---|---|---|
| 2026-10-19 | ACF Extended 官方更新日誌頁面標示 0.9.2.8 的發布日為 19/10/2026,也就是 10 月 19 日 | ✅ 廠商頁面明寫;我只找到這一個來源,WordPress.org 的實際上架時間寫的1 week ago |
| 2026-10-21 | WPScan 公開這筆漏洞,發現者署名 Jakub Herman | ✅ WPScan 頁面 |
| 2026-10-23 | CVE-2026-94267 正式發布;CISA-ADP 當日的 SSVC 評估為「尚無利用、不可自動化、技術衝擊為全面」 | ✅ CVE 紀錄與 NVD 頁面 |
| 2026-11-21 | WPScan 頁面註記 PoC 預定在這天起顯示 | 🟡 屬於預告,實際會不會準時公開要看 WPScan |
| 到底有沒有人受害? | VulDB 與 securityvulnerability.io 對「是否已被利用」的說法不一致,CISA 當日評估則是尚無利用 | 【❓待確認】 |
看到「尚無利用」不要鬆口氣。PoC 一旦公開,自動掃描的門檻就會大幅降低,所以真正的時間窗口是從現在到 11 月 21 日,這段時間拿來更新、備份、查表單,剛好夠用 ⏳
🚨 畫重點:什麼是PoC❓
你可以直接把它想成「作案教學影片」外流:原本要摸出這道漏洞的前提是你得懂技術、自己花時間摸索,現在等於有人已經把完整步驟拍成教學影片公開放上網,任何懂一點技術的人,都能照著片子依樣畫葫蘆,不用自己重新研究一次 🎥
- ✅ 已確認事實:官方更新日誌 0.9.2.8 列出多項安全修補,包含「對內部文章類型與分類法強制執行 capability 檢查」;WordPress.org 頁面顯示免費版有 100,000+ 活躍安裝。
- 🟡 合理推論:CVSS 向量 AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H 翻成白話:只要連上網路就能打、不需要帳號、不需要受害者點擊,但得先湊齊特定條件(攻擊複雜度 High),而且影響會跨出外掛本身(範圍 Changed)。
- 🔴 假設情境:假設有個招募網站放了一張「更新履歷」的公開表單,目標文章編號由訪客填寫。這個設定正好是 WPScan 描述的那類組合。這是我用來說明的例子,不是任何真實站台的紀錄。
🎭 第二章:先別自己嚇自己,對號入座看看你是哪一種網站主?
🩵 小資族荷包試算:修這個洞要花多少錢?
免費版更新是 NT$ 0 元。PRO 版要有效授權才能在後台一鍵更新,授權價格以官方頁面為準,我沒有核對,這裡不報數字。對照事後成本:請人清後門、重建被改過的欄位結構、處理網站被搜尋引擎標記的流量損失,隨便一項都比更新貴得多 💸
同一封資安警告,小編、自己租主機的玩家、企業 IT 要做的事差很多。先找到自己,再決定往下讀到哪一章 🧭
🙋 我只是負責發文、顧後台的小編或老闆
你不需要懂程式。第三章全程用滑鼠:看版本、備份、按更新、再檢查有沒有放對外表單。唯一要特別留意的是 PRO 版,它不在 WordPress.org 上,後台的「立即更新」要有有效授權才會出現,這部分第三章有交代 🧑💼
👨💻 我自己租主機架站,手上有好幾個客戶網站
直接跳第五章拿工具箱,裡面有整台主機一次盤點的函式,會用各網站擁有者的身分跑 WP-CLI,不會在 root 底下留下一堆權限錯亂的檔案。這顆漏洞只有一條版本門檻(低於 0.9.2.8),判斷比「每條版本線各有修補版」的漏洞單純,但免費版與 PRO 要分開查 🔧
🧑🔧 DevOps 小知識:為什麼 PRO 版要單獨處理?
免費版掛在 WordPress.org,wp plugin update 能直接更新,wp plugin verify-checksums 也能拿官方檢查碼比對檔案。PRO 版不在那裡,更新要靠授權機制或手動上傳 zip,檢查碼比對也做不到,所以輸出裡看到 PRO 無法驗證是正常現象,不要誤會成檔案被竄改 🧱
🏢 我們是企業網站,存了會員與訂單資料
免費外掛沒有 SLA,出事時誰也不保證多久修好。這次官方在公開揭露前兩天就釋出了修補版,這點是加分。但如果網站有對外表單、而且舊版曾暴露過,就要把它當成「可能被進入過」的等級來處理。個資是否外洩、要不要依《個人資料保護法》通知當事人,請交給公司法務判斷。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺
🚨 冤枉啊!我早就沒在用那個外掛了,只是懶得刪
某個社團兩年前用 ACF Extended 做過一張活動報名表,活動結束後把報名頁面刪了,外掛卻一直開著。管理員心想:「頁面都沒了,總不能憑空打進來吧?」
依 WPScan 描述,漏洞需要「已發布的表單」。表單刪乾淨確實會讓風險大幅下降,但我無法驗證「頁面刪了就一定打不到」,因為表單本體可能還留在後台,也可能還有別的頁面或佈景主題在呼叫它。與其賭這個,不如直接更新或刪除外掛 🆘
🚥 螢光筆時間:更新、停用、刪除,今天至少選一個
「我沒在用」不算處理。更新到 0.9.2.8,或停用,或徹底刪除,這三個才算真的把門關上 🙅♂️
🧮 四種情境對照:你今天該做到哪一步?
| 你的情境 | 風險判斷 | 今天要做的事 | 看哪一章 |
|---|---|---|---|
| 外掛啟用中,而且有對外公開的表單 | 最高 | 備份 → 更新 → 檢查表單與後台有沒有被動過 | 第三、五、六章 |
| 外掛啟用中,但沒有放任何前台表單 | 中(依描述條件不成立,但無法保證) | 照樣更新,順便把用不到的外掛刪掉 | 第三、六章 |
| 外掛已停用但沒刪除 | 低 | 直接刪除,或更新後再停用 | 第三章 |
| 使用 PRO 版 | 同上,依表單有無而定 | 確認授權有效,用 ACF → Updates 更新,或手動換 zip | 第三、六章 |
🧮 版本對照:你的版本安不安全?
| 產品 | 外掛代號(slug) | 受影響範圍 | ✅ 修到這版才安全 |
|---|---|---|---|
| ACF Extended(免費版) | acf-extended | 0.9.2.8 以下 | 0.9.2.8 |
| ACF Extended PRO | acf-extended-pro | 0.9.2.8 以下 | 0.9.2.8 |
最早從哪一版開始受影響,我取得的資料沒有寫明【❓待確認】,所以請當成「低於 0.9.2.8 一律有風險」處理。順帶一提,版本號是四段的 0.9.2.8,不是 0.9.28,中間的點不能少。後面的腳本用 sort -V 逐段比大小,不會把 0.9.2.10 誤判成比 0.9.2.8 舊 🧰
😬 更新前先看這個:真實踩過的雷,與 0.9.2.8 會改變的行為
更新是對的,但不要當成「按下去就一定安然無事」。我在 WordPress.org 支援論壇找到兩個公開案例,兩個都不是這次 CVE 造成的,放在這裡是提醒你更新後要實際測試 🧪
| 案例 | 發生了什麼 | 給你的提醒 |
|---|---|---|
| 2026-05-29 論壇主題「Critical error after update from 0.9.2.4 to 0.9.2.6」(已解決) | 發文者從 0.9.2.4 更新到 0.9.2.6 後網站出現 503,錯誤是佈景主題呼叫了未定義的函式;降回舊版就恢復。外掛作者說 0.9.2.5、0.9.2.6 清理了大量程式並移除部分已棄用函式,後來查出主題裡有舊結構的 ACF JSON 檔,發文者最後回報是主題多處過時的實作造成。 | 更新可能和你的佈景主題打架。先備份、先在測試站試跑,更新後馬上開首頁確認。 |
| 2026-08-24 論壇留言(SCF 6.9.4/6.9.5 搭配 ACFE 0.9.2.6 免費版,狀態:未解決) | 發文者表示 ACFE 渲染的前台表單送出時驗證失敗,顯示安全資訊無效或已過期。他認為是 SCF 新增的簽署機制,ACFE 表單沒送出對應欄位,這是他個人的判斷,頁面上沒有官方確認,我查看時也沒有任何回覆。 | 表單對「ACF 版本組合」很敏感。更新後每一張公開表單都要實際送出測一次。 |
0.9.2.8 本身也收緊了不少行為,官方更新日誌是這樣寫的(我整理成白話,並標出該測什麼):
| 官方更新日誌的安全項目(摘要) | 白話意思 | 更新後請測 |
|---|---|---|
| [acfe_form] 短代碼參數改為白名單,預設只允許 ID、name、attributes_submit_value、success_mesage | 短代碼裡塞了其他參數的舊寫法會失效(官方新增了 acfe/form/shortcode/allowed_args 過濾器可放行) | 每個嵌入表單的頁面是否正常顯示 |
| 短代碼參數中禁用 Template Tags,並修補 raw 情境繞過跳脫的問題 | 靠 Template Tags 動態組出來的表單設定可能不再生效 | 表單送出的結果與通知信內容 |
| 對內部文章類型與分類法強制執行 capability 檢查 | 沒有權限的人無法再透過表單動作改內部資料(疑與本 CVE 最相關🟡) | 以訪客身分送出表單,確認該更新的文章仍能更新 |
| User Action 更新非管理員使用者時,新增權限檢查;新增 acfe/form/validate_user_other_role 過濾器 | 低權限使用者透過表單改別人資料的情境會被擋下 | 會員資料編輯、使用者註冊類表單 |
| Email 動作的「刪除附件」權限檢查改為涵蓋所有附件 | 寄信後刪附件的流程可能受影響 | 有附件上傳的表單 |
| 舊表單的「Apply Shortcode」設定預設改為停用 | 靠這個選項在表單文字裡跑短代碼的舊表單可能不再展開 | 表單訊息與通知信是否還顯示原始短代碼 |
| 欄位驗證函式改為白名單,並新增 acfe/validate/allowed_functions 過濾器 | 欄位設定裡自訂的驗證函式如果不在名單內,會被擋下 | 設有進階驗證的欄位 |
🚨 測出問題的時候,不要降版
把外掛降回 0.9.2.7 以下,等於重新打開漏洞。表單壞掉時,優先用上表的過濾器放行你確實需要的項目,或把那張表單暫時下架。正式站要處理不完,就先停用外掛(第六章 B),拿備份到測試站慢慢查 🧯
🛟 第三章:電腦小白看過來!五分鐘無痛自救指南
💬 溫馨小叮嚀:更新外掛像是把壞掉的鎖換新,它保證從現在開始新的小偷進不來,但沒辦法倒帶,檢查昨天有沒有人已經進來逛過。所以最後兩步(找表單、查名單)一定要做。網站如果存有會員或訂單資料,請把這篇轉給主管或維護人員,由他們判斷要不要做更深的健檢 🩺
每一步我都寫了「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只需要滑鼠 🤏
- ㊀ 先查水表,看看有沒有裝:
登入後台,左邊選單點「外掛」→「已安裝的外掛」,在右上角搜尋框輸入 Extended。找到「Advanced Custom Fields: Extended」就是免費版,名字多一個 PRO 的是付費版,兩個都要看。找不到就是沒裝,這篇可以當作別人家的新聞 🔎 - ㊁ 核對版本號:
版本號寫在外掛名稱下方。看到 0.9.2.8 或更高就是已修補;比它低就是受影響,繼續往下做。PRO 版和免費版要各自看各自的 🕵️♀️ - ㊂ 動手前先留後路(備份):
到主機商後台點「一鍵備份」,或用你平常的備份外掛先存一份。不知道什麼是備份的話,把這篇網址傳給幫你維護網站的人,請他代勞,這本來就是他的工作 💾 - ㊃ 按下更新:
免費版在外掛清單點「立即更新」就好。PRO 版有兩條路:已有有效授權的,到後台「ACF」選單裡的「Updates」頁面確認授權狀態後更新;沒有授權可用的,到官方網站的帳號區下載最新版 zip,回後台「外掛 → 安裝外掛 → 上傳外掛」,遇到已安裝的提示時選擇用上傳的版本取代目前版本。更新完重新整理,確認版本號跳到 0.9.2.8 以上 🔄 - ㊄ 找出網站上有沒有對外的表單:
這是判斷「你有沒有被盯上」的關鍵。做兩件事:先看後台「ACF」選單底下有沒有「Forms」,有的話逐張點開,看右邊或下方的 Actions(動作)有沒有「Post」類型而且是更新文章,並確認它指定要改的文章與內容是不是來自訪客填的欄位。再到「頁面」與「文章」的搜尋框輸入 acfe_form,找出哪些頁面嵌了表單。選單名稱可能因版本略有不同,也可能被站長隱藏。看不懂設定的話,截圖交給維護人員,不要自己亂改 📝 - ㊅ 更新完,每張表單都送出一次:
拿一個測試用的假資料,把每一張公開表單實際送出一次,看畫面有沒有出現錯誤、通知信有沒有收到。上一章表格列的幾項行為變更,最容易在這一步冒出來。有壞掉的,先把那張表單所在頁面轉成草稿,再找維護人員處理 🧪 - ㊆ 今天真的更新不了,怎麼辦:
最乾淨的做法是在外掛清單把它「停用」。注意:如果你的佈景主題有直接呼叫 ACF Extended 的功能,停用後網站可能整個報錯。停用完馬上打開首頁看一眼;掛了就重新啟用,改成把有表單的頁面全部轉為草稿當作過渡,並在今天內排好更新時間。這只是縮短風險時間,不是修補 🧯 - ㊇ 化身名偵探,查查後台有沒有陌生人:
點「使用者」→「管理員」分頁,裡面應該都是你認識的人、用你們公司的信箱。再回到「已安裝的外掛」,看有沒有你從沒裝過的外掛名稱 🕵️♀️ 見鬼的情況:多出沒印象的管理員、陌生外掛,或網站首頁出現奇怪的廣告與導向。看到這類狀況請不要自己刪,先截圖,立刻找主機商或維護人員,並告訴他們依本文第六章 A 處理 🔀
🫂 新手求助專區:看到專有名詞就頭暈怎麼辦?
直接把下面這段話傳給你的主機客服或維護人員:「你好,我的網站可能有安裝 ACF Extended 外掛(免費版或 PRO),請幫我確認版本是否已更新到 0.9.2.8 以上,漏洞編號是 CVE-2026-94267。也請協助檢查網站有沒有對外公開的 ACF Extended 表單,以及後台管理員名單有沒有異常。」對方看到這串編號就知道該做什麼。沒有備份之前,千萬不要自己亂刪東西 🤗
🧊 冷知識:「停用」跟「刪除」差在哪?
想像家裡有一桶瓦斯。「停用」是把閥門關起來,桶子還放在原位,需要時轉開就能用,也代表它還在,哪天誰不小心轉開閥門,問題就回來了。「刪除」是把整桶搬出家門。很久不用的外掛,搬走比關閥安全;還在用的,就老實更新 🧰
🤿 第四章:技術深潛,一張表單怎麼一路走到「伺服器執行程式碼」?
想知道為什麼的人往下看,只想補洞的前三章已經夠用。先講資料狀況:官方沒有公開程式差異,攻擊字串也沒公開,下面的機制整理自 WPScan 的描述、CVE 紀錄與官方 0.9.2.8 更新日誌,推論的部分我都標了 🟡,查不到的標【❓待確認】⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | Advanced Custom Fields: Extended(ACF Extended,ACFE) |
| 外掛代號(slug) | acf-extended(免費版)、acf-extended-pro(PRO) |
| CVE 編號 | CVE-2026-94267 |
| CVSS 3.1 | 9.0(Critical) |
| CVSS 向量 | CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:C/C:H/I:H/A:H |
| 弱點類型 | CWE-94:程式碼生成控制不當(程式碼注入) |
| 受影響與修補版本 | 0.9.2.8 以下受影響,0.9.2.8 修補(免費版與 PRO 皆同) |
| 發現者 | Jakub Herman(WPScan 與官方更新日誌皆有署名) |
| CNA/公開時間 | WPScan;WPScan 頁面 2026-10-21,CVE 發布 2026-10-23 |
| 啟用規模 | WordPress.org 免費版 100,000+ 活躍安裝 |
| 利用前提 | 網站有已發布的表單,且文章更新動作把「目標」與「內容」都對應到訪客送出的值(WPScan 描述) |
🧩 三個環節串成一條完整的鏈
- 🏮 環節一:表單動作的入口是公開的。前台表單本來就要讓訪客送資料,這是功能不是缺陷。問題在送出之後,動作執行前的那道門沒有人站崗 🚪
- 🏮 環節二:更新文章的動作沒有確認「你有沒有資格改」。官方更新日誌寫的是「對內部文章類型與分類法強制執行 capability 檢查」,推測就是補上這一道。你可以把 capability 理解成每個身分的權限清單,例如「能不能編輯別人的文章」 🧾
- 🏮 環節三:被改寫的資料可以影響程式的行為。欄位定義不是單純的文字,裡面有些設定會在伺服器端被解讀,同一版更新日誌把驗證函式也改成白名單,與這條路吻合 🟡。最終結果是 CVSS 裡的 C/I/A 全部拉到 High 🎯
櫃台助理可以代客轉接、代客登記,這沒問題。問題是這次助理不查名單,客人說要什麼就辦什麼。白名單的做法很單純:助理手上有一張寫了十件事的紙,不在紙上的一律不做。0.9.2.8 把短代碼參數、驗證函式都加上這張紙,同時補上「辦事前先看身分」的步驟 📋
🧑🔧 DevOps 小知識:CVSS 9.0 為什麼不是 9.8?
常見的 9.8 來自「不需帳號、不需互動、攻擊難度低、範圍沒變」。這顆的攻擊複雜度是 High(AC:H,得先湊齊特定的表單設定),所以分數被壓低;但影響範圍是 Changed(S:C),代表衝擊會跨出外掛本身,兩項拉扯後仍落在 Critical。簡單說:不好打,但打中了很痛 🛡️
🧊 冷知識:同一個版本裡,其實修了不只一個地方
0.9.2.8 的更新日誌有十幾條標著鎖頭的安全項目,涵蓋短代碼、Template Tags、使用者更新、Email 附件、驗證函式與 nonce 檢查。其中至少兩條署名 Jakub Herman,另有幾條署名 Karthik Ramakrishnan。官方沒有說哪一條對應哪個 CVE,所以「更新到 0.9.2.8」是唯一能確定覆蓋到的做法,不要試圖只手動補其中一項 🔒
🔧 0.9.2.8 到底修了什麼?
能確定的只有官方更新日誌那份清單(見第二章表格)。至於哪一項對應這顆 CVE、程式碼具體怎麼改,官方沒公開,我也沒能在原始碼層級核對,所以都只當 🟡 合理推論。你能確定做出判斷的依據只有一個:版本號 ≥ 0.9.2.8,就是官方認定的已修補狀態 🧾
🚨 畫重點:「完整觸發條件」目前查不到
哪些表單設定組合一定能觸發、需不需要特定欄位,WPScan 只有簡短描述,其他資料庫的說法也有出入。所以第五章的排查我刻意不依賴某一條攻擊字串,改看結果特徵:站上有沒有對外表單、欄位定義與表單有沒有近期被改、後台有沒有多出東西、uploads 裡有沒有 PHP 檔。代價是會有一些雜訊,好處是不會因為猜錯入口而漏看真正的異常 🧭
🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 Checklist
🤖 重要提醒:以下指令由五份來源報告(ChatGPT、DeepSeek、Gemini、Grok、Meta AI)彙整,經 Claude 複查修正與整理,並依 HestiaCP 官方文件與 WP-CLI 官方文件逐項複查,所有程式碼區塊都做過 bash 語法檢查 🫡
但你的主機環境不一定跟範例一樣。正式環境(尤其是正在營業中的網站)的高風險操作——包含更新、停用、刪除檔案、變更權限、重設密碼或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin、bob 都是示範用名稱,請換成你自己的環境 🧙
這一章的流程是:工具箱 👉 盤點版本 👉 找出對外表單 👉 日誌排查 👉 帳號與檔案稽核。這一章的指令全部只讀不寫,不會改任何東西。預期輸出只是典型範例,版本不同可能略有差異 📜
🧰 ㊀ 先貼這一段:整章都會用到的小工具
為什麼這樣做:HestiaCP 的每個網站屬於不同的 Linux 使用者。用 root 直接跑 WP-CLI 會留下 root 擁有的檔案,之後網站自己寫入就會失敗。所以先定義幾個小函式:自動找 WP-CLI(有的主機叫 wp,有的叫 wp-cli)、以檔案擁有者身分執行、逐站迴圈,以及專門比版本號的 is_fixed。所有路徑變數都用雙引號包起來,路徑含空白也不會斷 🧱
# ㊀ 工具箱:同一個 SSH 連線貼一次就好,斷線後要重貼
PLUGINS=("acf-extended" "acf-extended-pro")
FIXED="0.9.2.8"
# 找出 WP-CLI(有的主機叫 wp,有的叫 wp-cli)
WPCLI="$(command -v wp || command -v wp-cli || true)"
if [ -z "$WPCLI" ]; then
echo "⚠️ 找不到 wp 或 wp-cli,請先安裝 WP-CLI 再往下"
fi
command -v runuser > /dev/null || echo "⚠️ 找不到 runuser,請改請維運人員協助"
# 列出主機上每一個 WordPress(HestiaCP 標準位置:/home/使用者/web/網域/public_html)
list_wp_configs() {
find /home -maxdepth 5 -type f -path '*/web/*/public_html/wp-config.php' -print0 2> /dev/null
}
site_owner() { stat -c '%U' -- "$1/wp-config.php"; }
site_domain() { basename -- "$(dirname -- "$1")"; }
# 以「網站檔案擁有者」的身分跑 WP-CLI:wpx 網站路徑 wp指令...
wpx() {
local wp_path="$1" owner home_dir
shift
owner="$(site_owner "$wp_path")" || return 1
home_dir="$(getent passwd "$owner" | cut -d: -f6)"
runuser -u "$owner" -- env HOME="${home_dir:-/tmp}" "$WPCLI" --path="$wp_path" "$@" < /dev/null
}
# 逐站執行:任何一站失敗就停下,不會連鎖出事
each_site() {
local callback="$1" cfg
while IFS= read -r -d '' cfg <&3; do
"$callback" "$(dirname -- "$cfg")" || {
printf '⛔ 在 %s 停下,請先處理這一站\n' "$(dirname -- "$cfg")"
return 1
}
done 3< <(list_wp_configs)
}
# 版本比較:version_lt A B 成立代表 A 比 B 舊(用 sort -V,不用字串比大小)
version_lt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
# 這顆 CVE 只有一道門檻:低於 0.9.2.8 就受影響
is_fixed() { ! version_lt "$1" "$FIXED"; }
預期輸出:貼完沒有任何輸出就是成功。看到 ⚠️ 代表缺 WP-CLI 或 runuser,先補上再往下,不要硬跑。
驗證(確認 is_fixed 判斷正確):貼完後跑下面這段,它只做版本判斷,不碰任何網站 🧪
for v in 0.9.2.7 0.9.2.8 0.9.2.10 0.9.3 0.8.9.5; do if is_fixed "$v"; then echo "$v 已修補"; else echo "$v 受影響"; fi done
預期輸出(0.9.2.7 與 0.8.9.5 要是「受影響」,其餘「已修補」):
0.9.2.7 受影響 0.9.2.8 已修補 0.9.2.10 已修補 0.9.3 已修補 0.8.9.5 受影響
- 🟢 結果符合預期 👉 工具箱沒貼錯,往下進行。
- 🟠 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
- 🟡 0.9.2.10 被判成已修補是刻意的:用 sort -V 逐段比數字,不是比字串。
🧑🔧 DevOps 小知識:為什麼迴圈長成 3< <(find …) 這個樣子?
常見寫法 find | while read 有個陷阱:迴圈裡只要有指令會讀標準輸入,就會把清單吃掉一半,結果只處理到第一個網站。改成把 find 的輸出接到檔案描述符 3、用 read … <&3 讀,迴圈內的指令就搶不到輸入;搭配 -print0 與 read -d ”,路徑含空白也安全。wpx 裡的 < /dev/null 是第二道保險 🔧
🔎 ㊁ 盤點版本:哪些站裝了它、現在哪一版?
為什麼這樣做:一台主機上有十個獨立 WordPress,跟一個 Multisite 底下有十個子站,看起來像但處理方式不同:前者要逐一找每個 wp-config.php,後者外掛檔案全網只有一份。這個函式同時判斷安裝類型,並且分別查免費版與 PRO,輸出一行一個外掛,只讀不寫 🤔
inventory_one() {
local wp_path="$1" kind slug ver status verdict found=0
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
printf '❓ 載入失敗\t-\t-\t%s\n' "$wp_path"
return 0
fi
for slug in "${PLUGINS[@]}"; do
ver="$(wpx "$wp_path" plugin get "$slug" --field=version 2> /dev/null || true)"
[ -n "$ver" ] || continue
found=1
status="$(wpx "$wp_path" plugin get "$slug" --field=status 2> /dev/null || true)"
if is_fixed "$ver"; then verdict="🟢 已修補"; else verdict="🔴 受影響"; fi
printf '%s\t%s\t%s\t版本:%s\t狀態:%s\t%s\n' \
"$verdict" "$kind" "$slug" "$ver" "${status:--}" "$wp_path"
done
if [ "$found" -eq 0 ]; then
printf '⚪ 未安裝\t%s\t-\t-\t-\t%s\n' "$kind" "$wp_path"
fi
return 0
}
# 💠 單一網站(路徑換成你的)
inventory_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:整台主機一次盤點(只讀不寫)
each_site inventory_one
預期輸出範例:
🔴 受影響 💠 一般安裝 acf-extended 版本:0.9.2.7 狀態:active /home/admin/web/site-a.com/public_html 🟢 已修補 💠 一般安裝 acf-extended-pro 版本:0.9.2.8 狀態:active /home/bob/web/shop.example/public_html ⚪ 未安裝 💠 一般安裝 - - - /home/bob/web/blog.example/public_html 🟢 已修補 ✴️ Multisite acf-extended 版本:0.9.2.8 狀態:active-network /home/bob/web/network.example/public_html
分支判斷邏輯:
- 🔴 受影響 👉 排入第六章的備份與更新,並接著做 ㊂ 到 ㊄,確認舊版時期有沒有被碰過。
- 🟢 已修補 👉 版本條件已解除,但舊版若曾暴露一段時間,仍建議做 ㊂ 到 ㊄。
- 🟡 狀態是 inactive 👉 外掛已停用,攻擊面暫時不在,仍建議更新或刪除,不要放著。
- ✴️ Multisite 出現 active-network 👉 外掛檔案全網共用,查主站一次就代表整個網路,更新也只做一次。
- 🟠 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。路徑若像 public_html.bak 之類的備份副本,請人工確認哪一個才是正式站。
📝 ㊂ 找出對外表單:這顆漏洞的「入口」到底有沒有開
為什麼這樣做:依 WPScan 描述,利用前提是站上有已發布的表單。所以比起大海撈針找攻擊字串,先弄清楚「你家有沒有這種門」更實際。這段做三件事:找出內容裡嵌了 [acfe_form 短代碼的頁面、找出佈景主題或其他外掛的程式碼裡呼叫 acfe_form( 的檔案、列出表單本體。表單的文章類型代號我推測是 acfe-form,沒有在官方文件核對到,所以多附一行列出所有名稱含 form 的文章類型,你可以自己對照。post list 的搜尋結果依 WordPress 內建搜尋邏輯,可能漏掉用特殊方式嵌入的表單,這是已知限制 🧐
exposure_one() {
local wp_path="$1"
printf '\n===== %s =====\n' "$(site_domain "$wp_path")"
echo "--- ❶ 內容裡嵌了 [acfe_form 短代碼的頁面或文章 ---"
wpx "$wp_path" post list --post_type=any --post_status=any --s='acfe_form' \
--fields=ID,post_type,post_status,post_title --format=table || true
echo "--- ❷ 佈景主題與其他外掛的程式碼裡,有呼叫 acfe_form( 的檔案 ---"
grep -rIlF --include='*.php' --exclude-dir='acf-extended*' -e 'acfe_form(' -- \
"${wp_path}/wp-content/themes" "${wp_path}/wp-content/plugins" \
"${wp_path}/wp-content/mu-plugins" 2> /dev/null || true
echo "--- ❸ ACF Extended 表單本體(文章類型代號 acfe-form 為推測值)---"
wpx "$wp_path" post-type list --fields=name,label --format=csv | grep -i 'form' || true
wpx "$wp_path" post list --post_type=acfe-form --post_status=any \
--fields=ID,post_title,post_status --format=table || true
return 0
}
# 💠 單一網站
exposure_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站
each_site exposure_one
預期輸出範例:
===== example.com ===== --- ❶ 內容裡嵌了 [acfe_form 短代碼的頁面或文章 --- +----+-----------+-------------+------------+ | ID | post_type | post_status | post_title | +----+-----------+-------------+------------+ | 42 | page | publish | 聯絡我們 | +----+-----------+-------------+------------+ --- ❷ 佈景主題與其他外掛的程式碼裡,有呼叫 acfe_form( 的檔案 --- /home/admin/web/example.com/public_html/wp-content/themes/mytheme/page-contact.php --- ❸ ACF Extended 表單本體(文章類型代號 acfe-form 為推測值)--- acfe-form,ACF Extended Forms +----+------------+-------------+ | ID | post_title | post_status | +----+------------+-------------+ | 17 | contact | publish | +----+------------+-------------+
分支判斷邏輯:
- 🔴 ❶ 或 ❷ 有結果,且對應的表單有「更新文章」動作 👉 你就是這顆漏洞描述的那類網站,更新是第一優先,㊃、㊄ 一定要做。動作設定要進後台逐張確認(第三章 ㊄),指令沒辦法替你判斷。
- 🟡 有表單,但動作只有寄信或建立新文章 👉 依描述前提不成立,仍要更新,因為同版本還修了其他項目。
- 🟢 都沒結果 👉 沒有找到入口。但搜尋可能漏掉,不要因此跳過更新。
- 🟠 ❸ 兩個指令都沒輸出 👉 文章類型代號可能不對,用第一行的清單找出名稱含 form 的類型,再把第二行指令裡的 acfe-form 換掉。
📊 ㊃ 日誌排查:看誰一直在敲「不該被敲」的門
為什麼這樣做:官方沒公開 payload,標準存取日誌也不記錄 POST 的內容,只記「請求行」:方法、網址、狀態碼。所以日誌能回答的問題有限,我只抓三個有價值的結果特徵:哪些網址被 POST 最多(對照 ㊂ 找到的表單頁面,有沒有異常暴衝)、有沒有 POST 打到不常見的 PHP 檔、有沒有人碰過 uploads 裡的 PHP 類檔案。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log,函式用 zcat -f 一併讀輪替後的 .log.1 與 .log.2.gz。前面如果有 CDN,日誌裡看到的 IP 可能是 CDN 的 🎯
# 取出某網域「目前+輪替後(含 .gz)」的 nginx 存取日誌
scan_domain_log() {
local domain="$1"
find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.log*" -print0 \
| xargs -0 -r zcat -f --
}
log_scan_one() {
local wp_path="$1" domain
domain="$(site_domain "$wp_path")"
printf '\n===== %s =====\n' "$domain"
echo "--- ❶ POST 請求最多的網址(前 15 名,已去掉問號後面的參數)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST /{ split($2, a, " "); sub(/\?.*/, "", a[2]); print a[2] }' \
| sort | uniq -c | sort -rn | head -n 15
echo "--- ❷ 打到非常規 PHP 檔的 POST(最近 20 筆)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST [^ ?]*\.php/ && $2 !~ /\/(index|wp-login|wp-cron|xmlrpc|admin-ajax|wp-comments-post)\.php/' \
| tail -n 20
echo "--- ❸ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---"
scan_domain_log "$domain" \
| awk -F'"' 'tolower($2) ~ /\/wp-content\/uploads\/[^ ?]*\.(php|phtml|phar)/' \
| tail -n 20
return 0
}
# 💠 單一網站
log_scan_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站/✴️ Multisite:掃主機上每一個網域
each_site log_scan_one
預期輸出範例(有可疑的樣子):
===== example.com =====
--- ❶ POST 請求最多的網址(前 15 名,已去掉問號後面的參數)---
412 /contact/
37 /wp-login.php
2 /wp-content/plugins/foo/bar.php
--- ❷ 打到非常規 PHP 檔的 POST(最近 20 筆)---
203.0.113.5 - - [20/Oct/2026:10:00:01 +0800] "POST /wp-content/plugins/foo/bar.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
--- ❸ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---
203.0.113.7 - - [20/Oct/2026:10:00:03 +0800] "GET /wp-content/uploads/2026/10/x.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
分支判斷邏輯:
- 🔴 ❸ 有命中 👉 uploads 裡有人執行過 PHP,視為高度可疑,直接進第六章 A,並同時做 ㊄ 的 ❺。
- 🔴 ❷ 有命中,來源是陌生 IP、User-Agent 像腳本工具 👉 記下 IP 與時間,做完 ㊄;若 ㊄ 也有異常,進第六章 A。
- 🟡 ❶ 的表單網址 POST 數量突然暴增 👉 可能是正常流量,也可能是機器人在灌表單,要結合時間與 IP 判讀。我無法確認這顆漏洞在日誌裡的專屬特徵。
- 🟢 都沒命中 👉 只能說目前沒有直接證據,不能證明沒被打過:日誌可能已輪替、前面有 CDN,攻擊也可能沒走你查的這個日誌。繼續做 ㊄。
🕳️ ㊄ 帳號與檔案稽核:有沒有人拿著這把鑰匙進來過
為什麼這樣做:這顆漏洞的終點是伺服器端執行程式碼,所以稽核要同時看資料與檔案兩側。資料側:近期被改過的欄位群組/欄位定義/表單,管理員名單,最近註冊的帳號,排程工作。檔案側:uploads 裡的 PHP、會自動載入的 mu-plugins、wp-content 根目錄的 PHP(例如 drop-in 檔案)、揭露日之後被改過的 PHP,以及外掛與核心檔案跟官方版本的比對。全部用 WP-CLI 內建功能完成,不寫 SQL,所以資料表前綴不一樣也不怕 🕵️♀️
# 掃描起點:官方修補版上架日(2026-10-19)。想更保守就往前調幾天
SINCE="2026-10-19"
audit_one() {
local wp_path="$1" opt
printf '\n===== %s(擁有者:%s)=====\n' "$wp_path" "$(site_owner "$wp_path")"
echo "--- ❶ 欄位群組、欄位定義、表單:${SINCE} 之後被修改過的項目 ---"
wpx "$wp_path" post list --post_type=acf-field-group,acf-field,acfe-form \
--post_status=any --orderby=modified --order=DESC --posts_per_page=200 \
--fields=post_modified,ID,post_type,post_title --format=csv \
| awk -v s="$SINCE" 'NR == 1 || substr($0, 1, 10) >= s'
echo "--- ❷ 全部管理員 ---"
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table
echo "--- ❸ 最近註冊的 10 個帳號(任何角色)---"
wpx "$wp_path" user list --orderby=registered --order=DESC --number=10 \
--fields=ID,user_login,user_email,user_registered,roles --format=table
echo "--- ❹ 外掛清單(有沒有你不認識的外掛)---"
wpx "$wp_path" plugin list --fields=name,status,version --format=table
echo "--- ❺ uploads 底下的 PHP 類檔案(正常網站應該是空的)---"
find "${wp_path}/wp-content/uploads" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.pht' -o -iname '*.phar' \) \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
echo "--- ❻ mu-plugins 與 wp-content 根目錄的 PHP(會自動載入,後台外掛頁看不到)---"
find "${wp_path}/wp-content/mu-plugins" -maxdepth 2 -type f \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
find "${wp_path}/wp-content" -maxdepth 1 -type f -name '*.php' \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
echo "--- ❼ ${SINCE} 之後被改過的 PHP(外掛正常更新也會出現,需人工比對)---"
find "${wp_path}/wp-content" -type f -name '*.php' -newermt "$SINCE" \
-not -path '*/cache/*' \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort | tail -n 100
echo "--- ❽ 排程工作(有沒有不認識的 hook)---"
wpx "$wp_path" cron event list --fields=hook,next_run_gmt,recurrence --format=table
echo "--- ❾ 外掛與核心檔案跟官方版本比對 ---"
wpx "$wp_path" plugin verify-checksums --all || true
wpx "$wp_path" core verify-checksums || true
echo "--- ❿ 幾個常被動手腳的網站設定 ---"
for opt in siteurl home admin_email users_can_register default_role; do
printf '%s = %s\n' "$opt" "$(wpx "$wp_path" option get "$opt")"
done
return 0
}
# 💠 單一網站
audit_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:逐站各自檢查
each_site audit_one
# ✴️ Multisite:帳號要多看一層「超級管理員」
wpx "/home/admin/web/network.example.com/public_html" super-admin list
預期輸出範例(正常的樣子,節錄):
--- ❶ 欄位群組、欄位定義、表單:2026-10-19 之後被修改過的項目 --- post_modified,ID,post_type,post_title --- ❷ 全部管理員 --- +----+------------+-------------------+---------------------+ | ID | user_login | user_email | user_registered | +----+------------+-------------------+---------------------+ | 1 | admin | admin@example.com | 2024-01-15 08:00:00 | +----+------------+-------------------+---------------------+ --- ❺ uploads 底下的 PHP 類檔案(正常網站應該是空的)--- --- ❾ 外掛與核心檔案跟官方版本比對 --- Success: Verified 12 of 13 plugins. Success: WordPress installation verifies against checksums.
❶ 只印出一行欄位名稱,代表那段時間沒有項目被改過。❾ 若有 PRO 版或其他付費外掛,它們不在 WordPress.org,常會出現無法驗證的提示,這很正常,實際文字依 WP-CLI 版本而異 🧾
分支判斷邏輯:
- 🔴 ❶ 出現你沒編輯過的欄位群組、欄位或表單 👉 這是這顆漏洞最直接的痕跡,先別動,截圖並保留資料,進第六章 A。但管理員自己改版也會出現在這裡,要對照維運紀錄。
- 🔴 ❷❸ 出現 2026-10-19 之後註冊、你不認識的帳號 👉 高度可疑。陌生不等於惡意,可能是外包留的,先問清楚再處理。
- 🔴 ❺ 找到 PHP 類檔案,或 ❻ 出現不是你部署的檔案 👉 視為已中招,先別刪,保留證據,進第六章 A。
- 🟡 ❼ 揭露日之後被改過的 PHP 👉 外掛正常更新也會出現,要對照你自己的維運紀錄,別看到就當成入侵。
- 🟡 ❹ 與 ❽ 出現不認識的外掛或排程 hook 👉 先查那是哪個外掛、誰裝的,查不出來就當成可疑。
- 🟡 ❾ 出現 File was added 之類的差異 👉 先確認是不是你自己放的客製檔案;不確定就當成可疑。
- 🔴 ❿ 的 users_can_register 變成 1、default_role 變成 administrator,或 siteurl/home 被改成陌生網域 👉 網站設定被動過手腳,這類改動很少是正常維運造成的。
- 🟢 全部乾淨 👉 這一輪沒有發現明確跡象,仍建議做第六章的更新與第七章的加固,並多觀察一週。
📋 ㊅ 三環境速查表:把這一章濃縮成一張
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 工具箱 | 貼一次,整個 SSH 連線通用 | ||
| ② 盤點版本 | inventory_one 路徑 | each_site inventory_one | 查主站一次,狀態看 active-network |
| ③ 對外表單 | exposure_one 路徑 | each_site exposure_one | 表單在各子站,需逐子站用 –url 查,這份指令只查主站 |
| ④ 日誌排查 | 單一網域 | 掃所有網域日誌 | 共用網域日誌,再依 Host 回推子站 |
| ⑤ 帳號與檔案 | 欄位/管理員/檔案 | 逐站各自檢查,不能拿 A 站代表 B 站 | 另加 super-admin list |
🛠️ 第六章:疑似中招、還不能更新、準備修補,三條路各走各的
前一章解決「怎麼判斷」,這一章才動手。主線固定是 備份 👉 預覽 👉 更新 👉 驗證;第五章若出現跡象,要先走 A 取證與止血。這一章會改動系統狀態,所以每個動作後面都接著「怎麼確認真的生效」,以及失敗時的下一步。以下沿用第五章貼好的工具箱,沒貼的話請先回去貼一次 🧹
💾 ㊀ 三個共用零件:備份、刷新鹽值、停用外掛
為什麼這樣做:A、B、C 都會用到其中一兩個,所以先定義成函式,這一步只有定義,不會執行任何動作。備份檔放在 root 家目錄、權限 700,不放在網站目錄,避免被網址直接下載。鹽值(Salts)是 wp-config.php 裡那八組長字串,WordPress 用它們替登入 Cookie 簽章。這顆漏洞的最壞結果是攻擊者在伺服器上執行程式,他可能已經拿到登入憑證,所以我的判斷是:舊版曾暴露、又出現任何可疑跡象,就用 wp config shuffle-salts 強制刷新,所有人,包含可能握有 Cookie 的攻擊者,立刻被登出。代價是正常使用者包含你自己都要重新登入 🔑
你只改了某位員工的密碼,攻擊者手上可能還有一張沒過期的通行證,照樣進得來。刷新鹽值等於宣布「舊版通行證今天起全部作廢」,不管誰拿著哪一張,都得重新辦。比單純改密碼徹底得多 🪪
# ㊀ 共用零件一:備份(資料庫+wp-content),任何一步失敗就回傳失敗
BACKUP_DIR="${HOME}/wp-security-backup"
backup_one() {
local wp_path="$1" domain stamp db_file tar_file
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
db_file="${BACKUP_DIR}/${domain}_db_${stamp}.sql"
tar_file="${BACKUP_DIR}/${domain}_wp-content_${stamp}.tar.gz"
install -d -m 700 -- "$BACKUP_DIR" || return 1
df -h -- "$BACKUP_DIR" | tail -n 1
( umask 077; wpx "$wp_path" db export - --single-transaction > "$db_file" )
if [ ! -s "$db_file" ]; then
echo "❌ 資料庫備份失敗或是空檔,停止,不進行任何變更"
return 1
fi
if ! tail -n 1 -- "$db_file" | grep -q 'Dump completed'; then
echo "⚠️ 備份檔結尾沒有 Dump completed 字樣,請人工確認檔案是否完整"
fi
tar -czf "$tar_file" -C "$wp_path" wp-content || { echo "❌ 檔案備份失敗,停止"; return 1; }
chmod 600 -- "$tar_file"
ls -lh -- "$db_file" "$tar_file"
return 0
}
# ㊁ 共用零件二:刷新 wp-config.php 裡八組金鑰與鹽值(所有人的登入狀態立刻作廢)
rotate_salts_one() {
local wp_path="$1" domain stamp before after
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
install -d -m 700 -- "$BACKUP_DIR" || return 1
# 備份放在網站目錄「外面」,因為檔案裡有資料庫密碼
install -m 600 -- "${wp_path}/wp-config.php" \
"${BACKUP_DIR}/${domain}_wp-config.php.${stamp}" || return 1
# 只留短雜湊做前後比對,不把真正的金鑰印在螢幕上
before="$(wpx "$wp_path" config get AUTH_KEY | sha256sum | cut -c1-12)"
wpx "$wp_path" config shuffle-salts || return 1
after="$(wpx "$wp_path" config get AUTH_KEY | sha256sum | cut -c1-12)"
if [ "$before" = "$after" ]; then
printf '❌ %s 的鹽值沒有改變,請檢查 wp-config.php 是否可寫\n' "$domain"
return 1
fi
printf '✅ %s 鹽值已更新(%s → %s),wp-config.php 權限:' "$domain" "$before" "$after"
stat -c '%a %U:%G' -- "${wp_path}/wp-config.php"
return 0
}
# 停用 ACF Extended(免費版與 PRO 都處理;Multisite 連全網與各子站一起處理)
deactivate_one() {
local wp_path="$1" slug url status home_url code left=0
for slug in "${PLUGINS[@]}"; do
if wpx "$wp_path" core is-installed --network 2> /dev/null; then
wpx "$wp_path" plugin deactivate "$slug" --network 2> /dev/null || true
while IFS= read -r url <&3; do
wpx "$wp_path" --url="$url" plugin deactivate "$slug" 2> /dev/null || true
done 3< <(wpx "$wp_path" site list --field=url)
else
wpx "$wp_path" plugin deactivate "$slug" 2> /dev/null || true
fi
done
# 驗證一:已安裝的版本,狀態都不能是 active 或 active-network
for slug in "${PLUGINS[@]}"; do
status="$(wpx "$wp_path" plugin get "$slug" --field=status 2> /dev/null || true)"
[ -n "$status" ] || continue
printf '%s:%s\n' "$slug" "$status"
case "$status" in
active|active-network) left=1 ;;
esac
done
# 驗證二:首頁還打得開(佈景主題若呼叫了 ACFE 的函式,停用後可能整站掛掉)
home_url="$(wpx "$wp_path" option get home)"
code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/" || true)"
case "$code" in
2*|3*) printf '首頁 HTTP %s,正常\n' "$code" ;;
*) printf '⚠️ 首頁 HTTP %s:停用後網站有問題,補救見下方分支判斷\n' "${code:-無回應}" ;;
esac
if [ "$left" -eq 1 ]; then
echo "❌ 仍有版本是啟用狀態,請看上方輸出"
return 1
fi
return 0
}
預期輸出:貼完沒有任何輸出就是成功。三個函式要等到 A、B 或 C 才會被叫用,這時候的輸出範例如下:
/dev/vda1 80G 22G 54G 29% / -rw------- 1 root root 485M Oct 27 10:30 /root/wp-security-backup/example.com_db_20261027-103022.sql -rw------- 1 root root 2.4G Oct 27 10:31 /root/wp-security-backup/example.com_wp-content_20261027-103022.tar.gz Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin Plugin 'acf-extended' deactivated. acf-extended:inactive 首頁 HTTP 200,正常
驗證與失敗分支:
- 🟢 備份:兩個檔案都在、大小合理、資料庫檔結尾有 Dump completed 👉 可以進下一步。這個結尾字樣是 mysqldump 與 MariaDB 的慣例輸出,若你的環境用別的工具匯出,字樣可能不同,腳本只會警告,不會擋你。
- 🟠 備份失敗或是空檔 👉 函式立刻停下,先查磁碟空間(已印出 df 結果)與資料庫權限,沒有備份不要往下。
- 🟢 鹽值:前後兩段雜湊不同 👉 已生效,你會被登出,重新登入即可。
- 🟠 鹽值出現 ❌「沒有改變」或指令本身失敗 👉 先確認 wp-config.php 的擁有者可寫;這個指令要連到 WordPress.org 的 API 取得新鹽值,主機連不出去也會失敗,先查網路,不要急著換別的來源。
- 🔴 刷完網站出現「建立資料庫連線時發生錯誤」 👉 用備份還原:cp -p “$BACKUP_DIR/網域_wp-config.php.時間” “網站路徑/wp-config.php”,備份檔名在上面的 ls 輸出裡。這份備份含資料庫密碼,確認一切正常後再決定要不要保留。
- 🟢 停用:已安裝的外掛狀態都是 inactive,首頁 HTTP 為 2xx 或 3xx 👉 成功。
- 🟠 停用後首頁不是 2xx/3xx 👉 佈景主題可能呼叫了 ACF Extended 的函式。先別慌,執行 wp plugin activate acf-extended(以網站擁有者身分,WP-CLI 的 activate 指令)恢復網站,再改走 B 的「頁面轉草稿」,並把這件事交給維護人員處理。重新啟用代表門又開了,所以要盡快排更新。
🚨 ㊁ A. 疑似中招:先留證據,再止血,最後才修補
為什麼這樣做:第五章出現明確跡象時,第一優先不是急著更新,而是保留現場。順序是:資料庫快照與日誌 👉 停用外掛 👉 刷新鹽值 👉 管理員密碼重設與登出所有階段。這個順序有道理:先拍照存證,再關門,再讓所有舊憑證作廢,最後處理帳號。如果攻擊者已經能執行程式,他可能讀過 wp-config.php,所以腳本結束時會提醒你接著換資料庫密碼 🧯
這支腳本會動到外掛、鹽值與帳號,請先確認路徑,而且只對有疑慮的那一站執行,不要整台主機無差別跑。新密碼會留在終端機的捲動紀錄裡,請在私密環境操作,用安全管道交給管理員本人,並請他登入後立刻自行更換,不要貼進聊天視窗或腳本 🔐
# 前提:已貼過「工具箱」「共用零件」「deactivate_one」。取證完成前,什麼都不要刪
incident_one() {
local wp_path="$1" domain stamp evid out uid ans
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
evid="${HOME}/acfe-incident-${stamp}"
out="${evid}/${domain}"
install -d -m 700 -- "$evid" "$out" || return 1
echo "=== ❶ 取證:資料庫、日誌、帳號、外掛、可疑檔案 ==="
( umask 077; wpx "$wp_path" db export - --single-transaction > "${out}/db_${stamp}.sql" )
if [ ! -s "${out}/db_${stamp}.sql" ]; then
echo "資料庫快照失敗或是空檔,停止"
return 1
fi
find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.log*" \
-exec cp -p -- {} "$out/" \;
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table > "${out}/admins.txt"
wpx "$wp_path" plugin list --fields=name,status,version --format=table > "${out}/plugins.txt"
wpx "$wp_path" cron event list --fields=hook,next_run_gmt,recurrence \
--format=table > "${out}/cron.txt" 2> /dev/null || true
find "${wp_path}/wp-content/uploads" "${wp_path}/wp-content/mu-plugins" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.phar' \) -print0 2> /dev/null \
| xargs -0 -r sha256sum > "${out}/suspect-sha256.txt"
install -m 600 -- "${wp_path}/wp-config.php" "${out}/wp-config.php.BAK.${stamp}"
echo "=== ❷ 隔離:停用 ACF Extended ==="
deactivate_one "$wp_path" || return 1
echo "=== ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)==="
rotate_salts_one "$wp_path" || return 1
echo "=== ❹ 管理員名單與密碼重設 ==="
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email --format=table
read -r -p "要替上面每一位管理員重設密碼並登出所有階段嗎?(y/N):" ans
if [ "${ans:-}" = "y" ]; then
while IFS= read -r uid <&3; do
wpx "$wp_path" user reset-password "$uid" --skip-email --show-password || return 1
wpx "$wp_path" user session destroy "$uid" --all || true
done 3< <(wpx "$wp_path" user list --role=administrator --field=ID)
else
echo "已略過密碼重設"
fi
wpx "$wp_path" cache flush || true
printf '\n✅ %s 止血完成,證據放在 %s\n' "$domain" "$out"
ls -lh -- "$out"
echo "⚠️ 還沒做完:資料庫密碼與 wp-config.php 裡的其他金鑰,攻擊者若已能執行程式就可能讀過,請接著換掉"
return 0
}
# 只對「有疑慮的那一站」執行,不要整台主機一起跑
incident_one "/home/admin/web/example.com/public_html"
預期輸出範例:
=== ❷ 隔離:停用 ACF Extended === Plugin 'acf-extended' deactivated. acf-extended:inactive 首頁 HTTP 200,正常 === ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)=== Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin === ❹ 管理員名單與密碼重設 === (列出管理員後詢問是否重設,選 y 會顯示每位管理員的新隨機密碼) ✅ example.com 止血完成,證據放在 /root/acfe-incident-20261027-103500/example.com ⚠️ 還沒做完:資料庫密碼與 wp-config.php 裡的其他金鑰,攻擊者若已能執行程式就可能讀過,請接著換掉
驗證(確認真的止血了):跑完後執行下面這段。預期結果是兩個外掛都顯示 inactive(沒安裝的會顯示「未安裝」)、管理員名單只剩你認識的人、最後一行顯示 db_ok 與 1。任何一項不符,就代表那一步沒成功,回頭看對應輸出。這段只讀不寫 🧪
WP_PATH="/home/admin/web/example.com/public_html"
for slug in "${PLUGINS[@]}"; do
printf '%s:' "$slug"
wpx "$WP_PATH" plugin get "$slug" --field=status 2> /dev/null || echo "未安裝"
done
wpx "$WP_PATH" user list --role=administrator --fields=ID,user_login,user_email --format=table
wpx "$WP_PATH" db query 'SELECT 1 AS db_ok;'
- ⭕ 全部成功 👉 保留證據資料夾,接著換資料庫密碼(見下方),再走 C 正式修補。公司或有會員資料的網站,同步通知內部資安或主管。
- 🟠 資料庫快照失敗 👉 函式立刻中斷,先處理磁碟空間或資料庫權限,沒有快照不要往下。
- 🟠 停用外掛時出現 PHP Fatal Error 👉 外掛檔案可能被改過,請交給資安專業人員做完整清理,不要自己硬刪。
- 🔴 發現後門檔案、不認識的管理員,或網站有個資與線上收款且確認曾遭觸發 👉 這已超出更新外掛的層級,請啟動內部資安事件流程,並把 SMTP、API 金鑰等放在資料庫或設定檔的憑證一併換掉。
- 🔴 更新前的備份可能已經含有後門,不能當成「乾淨的還原來源」;要還原請用事發前、已知乾淨的備份。
🔑 補一刀:換掉資料庫密碼,並讓 wp-config.php 對得起來
為什麼這樣做:wp-config.php 裡有資料庫帳密,伺服器端能執行程式的攻擊者,理論上讀得到。順序很重要:先在 HestiaCP 面板把這個資料庫使用者的密碼改掉,再讓 wp-config.php 跟上,中間網站會短暫連不上資料庫,所以挑離峰時間做。面板操作我不寫成指令,因為各版本的指令參數我沒有逐一核對;面板上的「資料庫」頁面改密碼最不容易出錯。下面的函式只負責第二步:讓你輸入新密碼(輸入時不顯示),寫進 wp-config.php,再實際連一次資料庫確認 🛡️
密碼會出現在這個指令的參數裡,同一台主機上的其他使用者在極短時間內有機會從程序清單看到。這是 WP-CLI 這個指令的限制,所以請在沒有其他人登入的時段操作,並使用只含英數字元的隨機密碼,避免特殊符號被 Shell 吃掉 🙈
# 前提:已貼過「工具箱」。資料庫密碼請先在 HestiaCP 面板改好(建議用英數字元的隨機密碼)
rotate_db_password_one() {
local wp_path="$1" db_user new_pw
db_user="$(wpx "$wp_path" config get DB_USER)" || return 1
printf '資料庫使用者:%s\n' "$db_user"
read -r -s -p "請輸入剛在面板設定的新密碼(輸入時不會顯示):" new_pw
echo
if [ -z "$new_pw" ]; then
echo "沒有輸入,停止,wp-config.php 沒有變動"
return 1
fi
wpx "$wp_path" config set DB_PASSWORD "$new_pw" || return 1
wpx "$wp_path" db query 'SELECT 1 AS db_ok;'
}
rotate_db_password_one "/home/admin/web/example.com/public_html"
- 🟢 最後印出 db_ok 與 1 👉 新密碼已生效,網站恢復連線。
- 🔴 出現 Access denied 👉 面板上設定的密碼與你剛輸入的不一致。用面板重設一次、再重新輸入,或用 ㊀ 備份的 wp-config.php 還原。
- 🟡 網站正在對外服務卻還沒完成這一步 👉 會有短暫的資料庫連線錯誤,屬預期,完成後即恢復。
🧯 ㊂ B. 短期應急:還沒確認中招,但現在還不能更新
🚨 短期應急 ≠ 正式修補。
停用外掛與下架表單頁面,只是縮短暴露時間。真正的修補仍然是更新到 0.9.2.8 以上。我刻意沒有提供 nginx 擋規則:這顆漏洞的入口是一般的表單送出請求,沒有固定的網址特徵可以擋,亂擋不是擋不到,就是把正常表單一起擋掉 🧱
選項一(最乾淨):停用外掛。直接叫用 ㊀ 貼好的 deactivate_one。它會同時處理免費版與 PRO,Multisite 會連全網和各子站一起處理,最後驗證狀態與首頁 🧯
# 💠 單一網站(路徑換成你的;Multisite 請對網路根目錄跑一次) deactivate_one "/home/admin/web/example.com/public_html"
預期輸出範例:
Plugin 'acf-extended' deactivated. acf-extended:inactive 首頁 HTTP 200,正常
選項二(較弱):只把嵌了表單的頁面轉成草稿。適合選項一會讓佈景主題報錯的網站。依 WPScan 描述,漏洞需要「已發布的表單」,但我無法確認把頁面下架後,攻擊者是否仍能直接對表單的處理端點送出請求,所以這只能當最後手段,而且要盡快排更新。頁面 ID 請拿第五章 ㊂ 的結果,驗證指令確認狀態真的變成 draft 🧩
WP_PATH="/home/admin/web/example.com/public_html" # 把第五章 ㊂ 找到的、嵌了表單的頁面(以 ID 42 為例)轉成草稿 wpx "$WP_PATH" post update 42 --post_status=draft # 驗證:狀態要變成 draft wpx "$WP_PATH" post get 42 --field=post_status
預期輸出範例:
Success: Updated post 42. draft
- 🟢 選項一:狀態 inactive、首頁 2xx 或 3xx 👉 暫時安全,立刻排更新。
- 🟠 選項一:首頁不是 2xx/3xx 👉 見 ㊀ 的失敗分支,改走選項二,並交給維護人員。
- 🟡 選項二:只代表降低風險,不代表安全。表單若也被佈景主題直接呼叫(㊂ 的 ❷ 有結果),轉草稿沒有用,只能走選項一。
- 🟡 要把頁面還原時,把 –post_status=draft 換成 –post_status=publish,驗證指令確認狀態回到 publish。
🚀 ㊃ C. 正式修補:備份 👉 預覽 👉 更新 👉 驗證
為什麼這樣做:免費版掛在 WordPress.org,WP-CLI 的 plugin update 可以直接更新,plugin verify-checksums 能拿官方檢查碼比對外掛檔案有沒有被動過。PRO 版要靠授權機制或手動 zip,所以腳本的設計是:免費版自動走更新流程;PRO 版若你提供了官方 zip 的路徑,就用 plugin install –force 覆蓋安裝,沒提供就只印出指引。腳本用 heredoc 建立,結束標記用單引號包住,裡面的 $ 不會被提前展開,建好後立刻做語法檢查。千萬不要為了「保險」而降版,降回舊版等於把洞重新打開 📦
cat > /usr/local/sbin/acfe-patch.sh <<'ACFE_PATCH_EOF'
#!/usr/bin/env bash
# acfe-patch.sh:單一網站的 ACF Extended 修補流程(備份 → 預覽 → 確認 → 更新 → 驗證)
# 用法:acfe-patch.sh 網站使用者 網域 [PRO 安裝包 zip 的完整路徑]
set -euo pipefail
BASIC="acf-extended"
PRO="acf-extended-pro"
FIXED="0.9.2.8"
WPCLI="$(command -v wp || command -v wp-cli || true)"
if [ -z "$WPCLI" ]; then
echo "❌ 找不到 wp 或 wp-cli" >&2
exit 1
fi
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "用法:$0 網站使用者 網域 [PRO 安裝包 zip 的完整路徑]" >&2
exit 2
fi
SITE_USER="$1"
SITE_DOMAIN="$2"
PRO_ZIP="${3:-}"
[[ "$SITE_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo "❌ 使用者名稱格式不對" >&2; exit 2; }
[[ "$SITE_DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "❌ 網域格式不對" >&2; exit 2; }
WP_PATH="/home/${SITE_USER}/web/${SITE_DOMAIN}/public_html"
if [ ! -f "${WP_PATH}/wp-config.php" ]; then
echo "❌ 找不到 ${WP_PATH}/wp-config.php" >&2
exit 1
fi
HOME_DIR="$(getent passwd "$SITE_USER" | cut -d: -f6 || true)"
wpx() {
runuser -u "$SITE_USER" -- env HOME="${HOME_DIR:-/tmp}" "$WPCLI" --path="$WP_PATH" "$@" < /dev/null
}
version_lt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
is_fixed() { ! version_lt "$1" "$FIXED"; }
get_ver() { wpx plugin get "$1" --field=version 2> /dev/null || true; }
basic_ver="$(get_ver "$BASIC")"
pro_ver="$(get_ver "$PRO")"
if [ -z "$basic_ver" ] && [ -z "$pro_ver" ]; then
echo "⚪ ${SITE_DOMAIN} 沒有安裝 ACF Extended,不需處理"
exit 0
fi
printf '目前版本:免費版 %s/PRO %s\n' "${basic_ver:--}" "${pro_ver:--}"
need_fix=0
for v in "$basic_ver" "$pro_ver"; do
if [ -n "$v" ] && ! is_fixed "$v"; then need_fix=1; fi
done
if [ "$need_fix" -eq 1 ]; then
echo "🔴 ${SITE_DOMAIN} 有版本低於 ${FIXED},開始處理"
BACKUP_DIR="${HOME}/wp-security-backup"
DB_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_db_$(date +%Y%m%d-%H%M%S).sql"
install -d -m 700 -- "$BACKUP_DIR"
( umask 077; wpx db export - --single-transaction > "$DB_FILE" )
if [ ! -s "$DB_FILE" ]; then
echo "❌ 資料庫備份是空檔,停止,沒有進行任何更新" >&2
exit 1
fi
if ! tail -n 1 -- "$DB_FILE" | grep -q 'Dump completed'; then
echo "⚠️ 備份檔結尾沒有 Dump completed 字樣,請人工確認後再繼續"
fi
echo "✅ 資料庫已備份:${DB_FILE}"
if [ -n "$basic_ver" ] && ! is_fixed "$basic_ver"; then
echo "--- 免費版預覽(不會真的更新)---"
wpx plugin update "$BASIC" --dry-run
read -r -p "確認要更新免費版嗎?(y/N):" ans
if [ "${ans:-}" = "y" ]; then
wpx plugin update "$BASIC"
else
echo "已略過免費版更新"
fi
fi
if [ -n "$pro_ver" ] && ! is_fixed "$pro_ver"; then
if [ -z "$PRO_ZIP" ]; then
echo "🟠 PRO 版目前 ${pro_ver}。PRO 不在 WordPress.org,請用後台 ACF → Updates 更新,"
echo " 或從官方帳號區下載 zip,放到 /tmp,再把路徑接在這支腳本的最後面重跑"
else
if [ ! -f "$PRO_ZIP" ]; then
echo "❌ 找不到 ${PRO_ZIP}" >&2
exit 1
fi
if ! runuser -u "$SITE_USER" -- test -r "$PRO_ZIP"; then
echo "❌ 網站使用者 ${SITE_USER} 讀不到 ${PRO_ZIP},請把檔案放在 /tmp 再試" >&2
exit 1
fi
read -r -p "確認要用 ${PRO_ZIP} 覆蓋安裝 PRO 版嗎?(y/N):" ans
if [ "${ans:-}" = "y" ]; then
wpx plugin install "$PRO_ZIP" --force
else
echo "已略過 PRO 版更新"
fi
fi
fi
fi
echo "--- 驗證 ---"
fail=0
for slug in "$BASIC" "$PRO"; do
v="$(get_ver "$slug")"
[ -n "$v" ] || continue
if is_fixed "$v"; then
echo "✅ ${slug}:${v}"
else
echo "❌ ${slug}:${v} 仍低於 ${FIXED}" >&2
fail=1
fi
done
if [ -n "$(get_ver "$BASIC")" ]; then
wpx plugin verify-checksums "$BASIC" || fail=1
fi
wpx cache flush || true
home_url="$(wpx option get home)"
code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/" || true)"
case "$code" in
2*|3*) echo "✅ ${SITE_DOMAIN}:首頁 HTTP ${code}" ;;
*) echo "❌ ${SITE_DOMAIN}:首頁 HTTP ${code:-無回應},請立刻檢查" >&2; fail=1 ;;
esac
exit "$fail"
ACFE_PATCH_EOF
chown root:root /usr/local/sbin/acfe-patch.sh
chmod 750 /usr/local/sbin/acfe-patch.sh
bash -n /usr/local/sbin/acfe-patch.sh && echo "語法檢查通過"
驗證(確認腳本建好了):最後一行預期印出「語法檢查通過」。沒有這行就代表 heredoc 內容有缺漏,用 ls -l /usr/local/sbin/acfe-patch.sh 看檔案是否存在,再重新貼一次整段 🧪
使用方式:一次一站,先處理一站、確認前後台正常,再處理下一站。把第五章盤點出的 🔴 站,逐一用「使用者、網域」當參數跑:
# 只有免費版:兩個參數 /usr/local/sbin/acfe-patch.sh admin example.com # 有 PRO 版的站,改用下面這行(把開頭的 # 拿掉):第三個參數是官方 zip 的完整路徑, # 請先把檔案放在 /tmp,讓網站使用者讀得到 # /usr/local/sbin/acfe-patch.sh admin example.com /tmp/acf-extended-pro.zip
預期輸出範例:
目前版本:免費版 0.9.2.7/PRO - 🔴 example.com 有版本低於 0.9.2.8,開始處理 ✅ 資料庫已備份:/root/wp-security-backup/example.com_db_20261027-111500.sql --- 免費版預覽(不會真的更新)--- +---------------+-------------+-------------+-----------+ | name | old_version | new_version | status | +---------------+-------------+-------------+-----------+ | acf-extended | 0.9.2.7 | 0.9.2.8 | available | +---------------+-------------+-------------+-----------+ 確認要更新免費版嗎?(y/N):y Success: Updated 1 of 1 plugins. --- 驗證 --- ✅ acf-extended:0.9.2.8 Success: Verified 1 of 1 plugins. ✅ example.com:首頁 HTTP 200
- 🟢 看到最後的 ✅ 👉 這一站修補完成。接著手動清網站快取與 CDN 快取,並照第三章 ㊅ 把每張公開表單送出一次。
- 🟠 備份失敗 👉 腳本直接停下,沒有任何更新,先查磁碟空間與資料庫權限。
- 🟠 出現 ❌「仍低於 0.9.2.8」 👉 更新來源沒生效,把輸出交給維運人員,不要反覆硬跑。
- 🟠 verify-checksums 出現 File was added 👉 外掛資料夾裡多了官方沒有的檔案,逐一確認,不確定就視為可疑,回到 A。
- 🟠 PRO 版印出「請用後台 ACF → Updates 更新」 👉 照指示處理,或照第三章 ㊃ 手動換 zip,再重跑一次腳本確認版本。
- 🔴 更新後網站白畫面 👉 立刻停用外掛(B 選項一),再用備份還原;之後把備份拿到測試站慢慢查,不要在正式站試錯。
- 🟡 預覽時沒有列出可用更新 👉 不代表已是最新,以最後的版本判定為準;也要確認主機連得到 WordPress.org。
🚨 驗證的侷限:
verify-checksums 只能證明免費版目錄裡的檔案和官方發布版一致,檢查不到 PRO 版、mu-plugins、uploads、資料庫裡的欄位定義,也看不出別的外掛有沒有被動過。它只是完整性檢查的其中一環,必須搭配第五章 ㊄ 的稽核。另外,如果這一站已經中招,更新前的備份本身就含有後門,不能當成乾淨的還原來源 🔐
| 情境 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| 疑似中招(A) | incident_one 路徑 | 只對有疑慮的站逐一執行 | 對網路根目錄跑一次,腳本會連子站一起停用 |
| 短期應急(B) | deactivate_one 路徑 | 逐站套用 | 對網路根目錄跑一次 |
| 正式修補(C) | acfe-patch.sh 使用者 網域 | 一次一站,確認後再下一站 | 外掛檔案只有一份,更新一次即可,再逐子站驗證表單 |
🔥 一句話總結:「批次」不等於把所有東西塞進一條迴圈。真正安全的做法是先盤點、再備份、預覽,確認後逐站更新,最後驗證,而且每一步失敗就停下 🎯
🔒 第七章:補完洞之後,把第二道門也鎖上
更新是根本,加固是「萬一下一顆漏洞又來,讓損害小一點」。先講清楚:這一章的招式擋不住這顆 CVE 本身,它們擋的是漏洞被打中之後的下一步,屬於通用加固,不是官方要求 🛡️
🧱 加固一:把 PHP 的系統指令函式關掉
為什麼這樣做:這顆漏洞的終點是伺服器端執行程式碼。disable_functions 可以讓 PHP 無法呼叫 exec、system 這類直接跟作業系統溝通的函式,攻擊者拿到執行程式的機會後,能做的事會少一截。清單刻意不含 curl_exec,因為 WordPress 對外連線要靠它,停掉會讓外掛更新與排程出問題;也不要寫 eval,它是 PHP 的語言結構,不是函式,寫進去不會有效果。備份、圖片處理類外掛偶爾會用到 exec,改完請實際測前後台。腳本先備份 php.ini,用 sed -i -E 替換設定行,找不到那一行才用 tee -a 補上,所以跑幾次都不會疊加;設定檢查通過才重啟,沒過就自動還原 ⚠️
# 對主機上每一個 PHP-FPM 版本做「可重複執行」的替換(跑幾次都不會疊加)
harden_php_ini() {
local ini ver bak line
line='disable_functions = exec,passthru,shell_exec,system,proc_open,popen'
while IFS= read -r -d '' ini <&3; do
ver="$(basename -- "$(dirname -- "$(dirname -- "$ini")")")"
bak="${ini}.BAK.$(date +%F_%H%M%S)"
cp -- "$ini" "$bak"
sed -i -E "s/^[;#]*[[:space:]]*disable_functions[[:space:]]*=.*/${line}/" "$ini"
# 檔案裡本來就沒有這一行的話,補在最後
if ! grep -q '^disable_functions' "$ini"; then
printf '\n%s\n' "$line" | tee -a "$ini" > /dev/null
fi
# 設定檢查通過才重啟;沒過就還原備份
if "php-fpm${ver}" -t > /dev/null 2>&1; then
systemctl restart "php${ver}-fpm"
printf 'PHP %s:已套用並重新啟動\n' "$ver"
else
cp -- "$bak" "$ini"
printf 'PHP %s:設定檢查沒過,已還原備份:%s\n' "$ver" "$bak"
fi
grep -H '^disable_functions' "$ini"
done 3< <(find /etc/php -maxdepth 3 -type f -path '*/fpm/php.ini' -print0)
}
harden_php_ini
# 檢查個別網域的 pool 設定有沒有另外覆蓋(沒輸出代表沒覆蓋)
grep -H 'disable_functions' /etc/php/*/fpm/pool.d/*.conf
預期輸出範例:
PHP 8.4:已套用並重新啟動 /etc/php/8.4/fpm/php.ini:disable_functions = exec,passthru,shell_exec,system,proc_open,popen
最後那行 grep … pool.d/*.conf 沒有輸出,代表沒有個別網域的 pool 設定在覆蓋它,這是好事。出現「設定檢查沒過,已還原備份」代表設定沒套用、網站不受影響,把輸出貼給維運人員;要復原就把 .BAK.時間 備份檔蓋回 php.ini 再重啟 PHP-FPM。
驗證加固真的有效:光看設定檔不夠,要實際從外面打一次。下面的探針用隨機檔名,內容只回報 exec 能不能用,測完立刻刪除。預期回應是 EXEC-DISABLED;若是 EXEC-AVAILABLE,代表這個網站實際用的 PHP-FPM 版本不是你剛改的那一個,或被別處覆蓋。請確認最後印出「探針已移除」🧪
# 驗證 disable_functions 真的生效:放一個「隨機檔名」的無害探針,從外面打一次,馬上刪掉
probe_disabled() {
local wp_path="$1" domain name probe out
domain="$(site_domain "$wp_path")"
name="__probe_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
probe="${wp_path}/${name}"
cat > "$probe" <<'EOF'
<?php
echo function_exists('exec') ? 'EXEC-AVAILABLE' : 'EXEC-DISABLED';
EOF
out="$(curl -sS --max-time 20 "https://${domain}/${name}" || true)"
rm -f -- "$probe"
printf '探針回應:%s\n' "${out:-(無回應)}"
if [ -e "$probe" ]; then
printf '⚠️ 探針檔案還在,請手動刪除:%s\n' "$probe"
else
echo "探針已移除"
fi
}
probe_disabled "/home/admin/web/example.com/public_html"
- 🟢 回應 EXEC-DISABLED、探針已移除 👉 加固生效。
- 🟠 回應 EXEC-AVAILABLE 👉 查這個網域實際用哪個 PHP 版本,並檢查 pool 設定有沒有覆蓋。
- 🟠 印出「探針檔案還在」 👉 手動刪掉那個檔案,別留在網站根目錄。
- 🟡 沒有回應 👉 可能是憑證、防火牆或網域解析問題,先確認網站本身連得上。
🚨 要誠實講的侷限
disable_functions 擋的是「呼叫系統指令」這條路。攻擊者若已經能執行 PHP,他仍然可以用 PHP 本身的檔案函式寫入、讀取檔案。所以它只是把牆加高,不是把牆變成保險箱,不能取代更新與稽核 🧱
🚫 加固二:禁止執行 uploads 資料夾裡的 PHP
為什麼這樣做:上傳資料夾本來只該放圖片與文件。攻擊者想把後門丟進網站,最常見的落腳處就是這裡。用 nginx 規則讓 /wp-content/uploads/ 底下的 PHP 類檔案一律回 403,即使檔案被放進去也跑不起來。HestiaCP 的網站設定檔會在重建網域或續約 SSL 時被樣板覆蓋,所以千萬不要直接改 nginx.conf;官方論壇的做法是在 /home/使用者/conf/web/網域/ 放 nginx.conf_任意名稱(HTTP)與 nginx.ssl.conf_任意名稱(HTTPS),樣板會自動載入。下面的函式在 nginx -t 沒過時會自動撤回剛建的兩個檔案。規則用 ^~ 前綴,讓這個資料夾不再被其他 PHP 規則攔走。不同 HestiaCP 樣板(例如 nginx 搭配 Apache 的組合)對這條規則的反應可能不同,我沒有逐一驗證,所以最後一定要跑驗證:一般圖片還能正常顯示,才算沒副作用 🧱
# 用 HestiaCP 認得的自訂檔,禁止執行 uploads 底下的 PHP(面板重建設定時不會被蓋掉)
block_uploads_php() {
local owner="$1" domain="$2"
local conf_dir="/home/${owner}/conf/web/${domain}"
local http_inc="${conf_dir}/nginx.conf_no_uploads_php"
local ssl_inc="${conf_dir}/nginx.ssl.conf_no_uploads_php"
if [ ! -d "$conf_dir" ]; then
printf '找不到 %s,略過\n' "$conf_dir"
return 1
fi
tee "$http_inc" > /dev/null <<'EOF'
# 上傳資料夾只該放圖片與文件,PHP 類檔案一律回 403
location ^~ /wp-content/uploads/ {
location ~* \.(php|phtml|phar)$ {
return 403;
}
}
EOF
ln -sf "$http_inc" "$ssl_inc"
# 語法檢查通過才重新載入;沒過就把剛建的兩個檔案撤掉
if nginx -t; then
systemctl reload nginx
printf '已套用:%s\n' "$domain"
else
rm -f -- "$http_inc" "$ssl_inc"
printf 'nginx 設定檢查沒過,已撤回規則:%s\n' "$domain"
return 1
fi
}
# ⚠️ 拆除規則(會刪檔,只刪我們自己建的兩個檔案)
unblock_uploads_php() {
local owner="$1" domain="$2"
local conf_dir="/home/${owner}/conf/web/${domain}"
rm -f -- "${conf_dir}/nginx.conf_no_uploads_php" "${conf_dir}/nginx.ssl.conf_no_uploads_php"
if nginx -t; then
systemctl reload nginx
printf '已解除:%s\n' "$domain"
else
echo "nginx 設定檢查沒過,請先查原因,不要 reload"
return 1
fi
}
# 驗證:PHP 探針要 403,uploads 裡的一般圖片仍然是 200(探針用完立刻刪除)
check_uploads_block() {
local wp_path="$1" image_url="$2" domain name probe
domain="$(site_domain "$wp_path")"
name="__probe_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
probe="${wp_path}/wp-content/uploads/${name}"
printf '<?php echo "PROBE-RAN";\n' > "$probe"
curl -sS -o /dev/null -w "uploads 內的 PHP 探針:HTTP %{http_code}\n" --max-time 20 \
"https://${domain}/wp-content/uploads/${name}" || true
rm -f -- "$probe"
[ -e "$probe" ] && printf '⚠️ 探針檔案還在,請手動刪除:%s\n' "$probe"
curl -sS -o /dev/null -w "一般圖片:HTTP %{http_code}\n" --max-time 20 "$image_url" || true
}
# 套用(把使用者與網域換成你的)
block_uploads_php "admin" "example.com"
check_uploads_block "/home/admin/web/example.com/public_html" \
"https://example.com/wp-content/uploads/你網站上任一張圖片.jpg"
預期輸出範例:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 已套用:example.com uploads 內的 PHP 探針:HTTP 403 一般圖片:HTTP 200
- 🟢 探針 403、圖片 200 👉 規則生效且沒有副作用。
- 🟠 探針回 200 或 500 👉 規則沒被載入,或被別的設定蓋掉。立刻執行 unblock_uploads_php 把規則拆掉,再請維運人員看樣板。⚠️ 這個函式會刪檔,但只刪我們自己建的那兩個。
- 🟠 圖片也變成 403 或 404 👉 規則影響到靜態檔,同樣先拆除。
- 🟠 nginx -t 報錯 👉 函式已自動撤回,請保留錯誤訊息給維運人員。
- 🟡 探針檔案還在 👉 手動刪掉,別留著。
🧑🔧 DevOps 小知識:open_basedir 不用你再全域設定
有些教學建議在 php.ini 全域寫死 open_basedir,但寫死某個網站的路徑,其他網站就全壞。HestiaCP 的 PHP-FPM 樣板本來就會為每個網域設好,用 grep -H open_basedir /etc/php/*/fpm/pool.d/*.conf 確認即可。它同樣擋不住這顆 CVE,只是縮小事後的傷害範圍 🔒
📝 加固三:管好你自己的表單
這顆漏洞的前提是表單設定。就算更新了,下面這幾個習慣也能讓下一次出事時損害更小 🧼
- ⭐ 不要讓訪客決定「改哪一篇」。如果表單的更新動作需要指定目標,請固定寫死,或由登入者自己的資料決定,不要來自表單欄位。
- ⭐ 用不到的表單直接刪除。下架活動、結束的報名表,連同嵌入它的頁面一起清掉。
- ⭐ 對公開表單加一道人機驗證。ACF Extended 有 reCAPTCHA 欄位類型,可降低機器人自動送出的機會,但這不能取代更新【❓待確認:實際設定畫面依版本而異】。
- ⭐ 開自動更新,並搭配每日備份。PoC 公開之後留給你的時間很短;自動更新能把「有空再更新」的空窗壓到最小。代價是偶爾要處理更新後的相容性問題,所以重要網站請務必有備份。
- ⭐ 管理員的變動要有人知道。後台新增管理員、外掛被安裝,最好有通知機制。常見資安外掛有提供,可評估是否啟用【❓待確認:依你使用的外掛而定】。
🩵 荷包試算:要不要花錢買 WAF 或虛擬修補?
付費防火牆可以在你更新前先擋一層,但這顆漏洞在後台點一下「更新」就解決,今天能更新,通常比月付方案划算。真的卡在相容性、短期無法更新時,再把付費防護當過渡手段,別反過來拿它當不更新的理由。至於 WAF 能不能擋這種「看起來像正常表單送出」的請求,我沒有資料可以驗證 💰
🤔 第八章:讀到快喘不過氣?常見焦慮與幾個通用心法
- 🌠 Q:我已經是 0.9.2.8 了,就安全了嗎?
A:就這顆 CVE 來說,是的,那是官方的修補版。但如果舊版曾對外暴露,更新只能擋住下一次,擋不了已經發生的事,所以還是建議做完第五章的稽核 📦 - 🌟 Q:我沒有放任何表單,還需要更新嗎?
A:需要。依 WPScan 描述,沒有表單就不符合利用前提,但同一個版本還修了短代碼、使用者更新、Email 附件等多項問題,而且我無法驗證「沒有表單就一定打不到」 🧭 - ✨ Q:更新後我的表單壞了,可以降版嗎?
A:不建議。降版等於重新打開漏洞。先照第二章表格找出是哪項行為變更造成,用官方提供的過濾器放行,或先把那張表單下架 🙅 - 🌠 Q:PRO 版沒有授權,更新不了怎麼辦?
A:到官方網站的帳號區下載 zip,在後台上傳覆蓋,或用第六章 C 腳本的第三個參數安裝。授權是否過期、能不能下載最新版,我無法替你確認,請以官方帳號區為準 🤗 - ✨ Q:看不懂那些指令,是不是沒救了?
A:不會。第三章全程用滑鼠就能完成最基本的處理,第五章以後是給有 SSH 權限的人看的補充 🔍
🧩 舉一反三:「讓訪客指定要改什麼」這一類漏洞怎麼防
下面這些是通用防禦知識,不是本 CVE 的官方要求,但下次看到類似新聞能對號入座 🫡
- ⭐ 動手前,先問一次「你有沒有資格」。表單送出的動作不論多方便,只要會改資料,就該有對應的權限檢查,而且放在最靠近動作的地方,不要只靠前面的過濾。
- ⭐ 目標與內容不能同時由外部決定。只要「改哪一筆」跟「改成什麼」都來自訪客,攻擊面就等於整張資料表。
- ⭐ 設定資料也可能是程式的一部分。欄位定義、外掛設定這類「看起來只是資料」的東西,若會被程式解讀成函式名稱或規則,就該用白名單限制。
- ⭐ 修補與事件應變是兩件事。舊版只要曾暴露在網路上,更新完還要查日誌、查帳號、查檔案,缺一不可。
🏆 第九章:這次到底該多緊張?事件綜合評估
| 評估維度(10 分制,數字越高代表風險或應對品質越正向) | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 9.0 | 免登入、免互動,影響會跨出外掛本身;扣在攻擊複雜度 High,得先有特定的表單設定 |
| 官方應變速度 | 8 | 修補版比公開揭露早兩天(依官方更新日誌頁面標示)是加分項;官方沒有逐項對應 CVE,扣一點 🟡 |
| 一般站長自救可行性 | 7 | 免費版後台點「更新」就好;PRO 版要授權或手動換 zip,加上更新後要逐張測表單,門檻比單點更新高一些 |
| DevOps 技術文件完整度 | 5 | 觸發條件只有簡短描述、PoC 還沒公開、是否已被利用的資料互相矛盾,排查只能靠結果特徵 |
| 🏆 加權綜合建議 | 立即更新 | 最終建議:【今天內更新到 0.9.2.8 以上;有對外表單就做第五章稽核,疑似中招走第六章 A;11 月 21 日前全部做完】 |
🔥 這種漏洞最危險的是 PoC 公開、你卻還沒更新的那幾天,被發現的當天反而還好。條件嚴格不代表沒人會試,因為自動化工具就是專門去試「嚴格但成立」的組合。花五分鐘打開外掛頁看一眼版本號,比事後花五天清後門便宜太多 🔢
📎 附錄 A:一頁式決策表(印出來貼在螢幕旁)
| 你看到的狀況 | 先做什麼 | 接著做什麼 |
|---|---|---|
| 版本低於 0.9.2.8,沒有其他異常 | 備份 | 更新(C),逐張測表單,再做第五章稽核 |
| 版本低於 0.9.2.8,而且有對外表單 | 備份,若今天無法更新就走 B | 更新(C),做第五章 ㊃ ㊄ |
| 欄位群組、管理員、uploads 或 mu-plugins 出現異常 | 不要刪,走 A 取證與止血 | 換資料庫密碼,再走 C;有個資的網站通知內部資安與法務 |
| 更新後表單壞掉 | 對照第二章表格找原因 | 用官方過濾器放行,或把該表單下架,不要降版 |
| 更新後網站白畫面 | 停用外掛(B 選項一) | 用備份到測試站查原因 |
| PRO 版無法更新 | 確認授權與官方帳號區 | 手動換 zip,或用第六章 C 腳本第三個參數 |
📖 附錄 B:名詞小辭典
- 🔹 ACF 欄位群組:ACF 讓你自訂欄位的設定,資料庫裡以文章形式存放。
- 🔹 CVE:漏洞的國際編號,CVE-2026-94267 就是這次的編號。
- 🔹 CVSS:漏洞嚴重度評分,滿分 10 分,9.0 以上屬於 Critical。
- 🔹 CWE-94:程式碼注入類弱點的分類編號。
- 🔹 Capability(權限檢查):WordPress 判斷「這個身分能不能做這件事」的機制。
- 🔹 PoC:Proof of Concept,概念驗證,指能示範漏洞怎麼被利用的程式或步驟。
- 🔹 RCE:Remote Code Execution,遠端程式碼執行,也就是別人能在你的伺服器上跑他的程式。
- 🔹 SSVC:CISA 用來判斷漏洞處理優先順序的評估方式,這次的結論是尚無利用、不可自動化、技術衝擊為全面。
- 🔹 WP-CLI:WordPress 的命令列工具,用來在主機上管理網站。
- 🔹 鹽值(Salts):wp-config.php 裡八組長字串,用來簽章登入 Cookie。
📚 參考資料與延伸資源
- 📌 WordPress.org|Advanced Custom Fields: Extended 外掛頁 [1]
- 📌 ACF Extended 官方|0.9.2.8 更新日誌 [51]
- 📌 WPScan|ACF Extended 漏洞條目 [52]
- 📌 CVE.org|CVE-2026-94267 [53]
- 📌 NVD|CVE-2026-94267 [54]
- 📌 VulDB|CVE-2026-94267 [55]
- 📌 securityvulnerability.io|CVE-2026-94267 [56]
- 📌 ACF Extended 文件|Forms 模組 [57]
- 📌 ACF Extended 文件|Post Action [58]
- 📌 ACF Extended 文件|How to Update [59]
- 📌 WordPress.org 論壇|Critical error after update from 0.9.2.4 to 0.9.2.6 [60]
- 📌 WordPress.org 論壇|Since SCF 6.9.4/6.9.5 ACFE 前台表單驗證失敗 [61]
- 📌 WP-CLI 官方文件|wp plugin update [62]
- 📌 WP-CLI 官方文件|wp plugin verify-checksums [63]
- 📌 WP-CLI 官方文件|wp config shuffle-salts [64]
- 📌 WP-CLI 官方文件|wp user reset-password [65]
- 📌 WP-CLI 官方文件|wp user session [66]
- 📌 WP-CLI 官方文件|wp post list [67]
- 📌 HestiaCP 官方文件|CLI 參考 [68]
- 📌 HestiaCP 論壇|Custom nginx settings per domain [69]












