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

🚨一張公開表單,路人就能改寫你的網站設定?🔓 ACF Extended 未登入 RCE 漏洞自救全攻略(CVE-2026-94267/CVSS 9.0)

🔓 一張公開表單,路人就能改寫你的網站設定?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]

[2]


🟦 🟦 🟦

內容目錄

🪧 第一章:案發現場還原!為什麼一張「聯絡我們」表單,會變成改寫網站的遙控器?

想像你們公司大廳放了一個「意見回函箱」,旁邊坐著一位很熱心的總務小姐。她有個習慣:回函上只要寫著「請把 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 描述的那類組合。這是我用來說明的例子,不是任何真實站台的紀錄。

[40]


🟪 🟪 🟪

🎭 第二章:先別自己嚇自己,對號入座看看你是哪一種網站主?

🩵 小資族荷包試算:修這個洞要花多少錢?
免費版更新是 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 舊 🧰

[41]


🟠 🟠 🟠

😬 更新前先看這個:真實踩過的雷,與 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 表單,以及後台管理員名單有沒有異常。」對方看到這串編號就知道該做什麼。沒有備份之前,千萬不要自己亂刪東西 🤗

🧊 冷知識:「停用」跟「刪除」差在哪?
想像家裡有一桶瓦斯。「停用」是把閥門關起來,桶子還放在原位,需要時轉開就能用,也代表它還在,哪天誰不小心轉開閥門,問題就回來了。「刪除」是把整桶搬出家門。很久不用的外掛,搬走比關閥安全;還在用的,就老實更新 🧰

[42]


🔴 🔴 🔴

🤿 第四章:技術深潛,一張表單怎麼一路走到「伺服器執行程式碼」?

想知道為什麼的人往下看,只想補洞的前三章已經夠用。先講資料狀況:官方沒有公開程式差異,攻擊字串也沒公開,下面的機制整理自 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 都是示範用名稱,請換成你自己的環境 🧙

這一章的流程是:工具箱 👉 盤點版本 👉 找出對外表單 👉 日誌排查 👉 帳號與檔案稽核。這一章的指令全部只讀不寫,不會改任何東西。預期輸出只是典型範例,版本不同可能略有差異 📜

[43]


🧭 🧭 🧭

🧰 ㊀ 先貼這一段:整章都會用到的小工具

為什麼這樣做: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 之類的備份副本,請人工確認哪一個才是正式站。

[44]


📝 📝 📝

📝 ㊂ 找出對外表單:這顆漏洞的「入口」到底有沒有開

為什麼這樣做:依 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,攻擊也可能沒走你查的這個日誌。繼續做 ㊄。

[45]


🟣 🟣 🟣

🕳️ ㊄ 帳號與檔案稽核:有沒有人拿著這把鑰匙進來過

為什麼這樣做:這顆漏洞的終點是伺服器端執行程式碼,所以稽核要同時看資料與檔案兩側。資料側:近期被改過的欄位群組/欄位定義/表單,管理員名單,最近註冊的帳號,排程工作。檔案側: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

[46]


🪛 🪛 🪛

🛠️ 第六章:疑似中招、還不能更新、準備修補,三條路各走各的

前一章解決「怎麼判斷」,這一章才動手。主線固定是 備份 👉 預覽 👉 更新 👉 驗證;第五章若出現跡象,要先走 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 的「頁面轉草稿」,並把這件事交給維護人員處理。重新啟用代表門又開了,所以要盡快排更新。

[47]


🔴 🔴 🔴

🚨 ㊁ 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。

[48]


🚀 🚀 🚀

🚀 ㊃ 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 能不能擋這種「看起來像正常表單送出」的請求,我沒有資料可以驗證 💰

[49]


🟢 🟢 🟢

🤔 第八章:讀到快喘不過氣?常見焦慮與幾個通用心法

  • 🌠 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。

[50]


📎 📎 📎

📚 參考資料與延伸資源

列印本文 [70] 👨‍👩‍👧‍👦 81 次瀏覽