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

🚨聯絡表單的 PDF 小工具,竟成了遠端刪檔遙控器?🧰 Ultra Addons for CF7 高危漏洞全解析(CVE-2026-102435/CVSS 9.8)

🧰 聯絡表單的 PDF 小工具,竟成了遠端刪檔遙控器?Ultra Addons for CF7 高危漏洞全解析(CVE-2026-102435/CVSS 9.8)

🔥 懶人包:
WordPress.org 上有 60,000+ 活躍安裝的 Contact Form 7 擴充外掛 Ultra Addons for Contact Form 7(簡稱 UACF7),被登記了 CVE-2026-102435,CVSS 3.1 評分 9.8,弱點類型是不可信資料反序列化(CWE-502)。3.5.54 以下(含)的版本,只要 PDF Generator 有啟用、至少一張表單設定要產生 PDF,沒登入的訪客就有機會刪掉伺服器上的任意檔案,包括網站的設定檔 wp-config.php 💥
好消息是官方在 2026-10-11 就釋出了修補版 3.5.55,比公開揭露的 2026-11-02 早了三週左右,目前最新版是 3.5.57。有開自動更新的站多半已經安全;還沒更新的,這篇帶你看版本、備份、更新,再確認關鍵檔案有沒有少 🔒

🌐 點我直達 WordPress 官方外掛頁確認最新版本 [1]

[2]


🟦 🟦 🟦

內容目錄

🪧 第一章:案發現場還原!一個「PDF 小幫手」,怎麼變成刪檔遙控器?

先想像你公司的櫃台,每天都會收到客人寄來的包裹。櫃台人員有個很貼心的習慣:拆開後如果是「零件加組裝說明」,就順手組好再放上收件架。平常寄來的都是文件和小禮物,從沒出過事。直到某天來了一個包裹,零件一組起來是台碎紙機,說明書最後還寫著「組好後,請立刻處理 3 號房那份文件」。櫃台照做了,連寄件人是誰都沒問 🧰

📢 大白話:PHP 物件注入,就是寄來一包「拆開會自己動手」的零件
程式要傳遞資料的時候,會先把物件壓成一串文字(序列化),收到後再拆回物件(反序列化)。這個動作本身很正常,出事的情況是:拆的是陌生人寄來的包裹,而包裹裡的零件剛好能組出一個「會自己動手」的物件。這次出事的是替 Contact Form 7 加功能的擴充外掛,Contact Form 7 本身沒有被點名 🧩

UACF7 是一套把 Contact Form 7 的功能大幅加量的外掛,條件欄位、多步驟表單、PDF 產生都在它的菜單裡。出包的位置在 PDF Generator 這個模組:依 Wordfence 提供給 CVE 的描述,外掛裡一個叫 _getObjAttr 的函式,對檔案路徑的檢查不夠嚴格。再加上外掛會把不可信的資料當成 PHP 物件還原,兩件事湊在一起,就有機會讓 Contact Form 7 自己的 WPCF7_Submission 物件在收尾時去刪檔案 🗑️

官方更新日誌只寫了「修正 Wordfence 回報的問題」,沒有公開程式差異。所以下面這條攻擊鏈,是我拿 Wordfence 的 CVE 描述和多份報告拼起來的輪廓,每一步都標了信心程度 🔍

  • ㊀ 網站先具備三個條件:✅ 外掛版本在 3.5.54 以下(含)、PDF Generator 已啟用、至少一張 Contact Form 7 表單設定了產生 PDF。Themefic 的官方文件寫得很清楚:要先到 Ultra Addons > All Addons 打開 PDF 模組,再到表單編輯頁的 Ultra Addons for CF7 Options > PDF Generator 啟用,兩關都過才會產生 PDF 📝
  • ㊁ 訪客送出表單,資料裡夾帶特製內容:✅ CVSS 向量顯示不需要帳號(PR:N),也不需要受害者點任何東西(UI:N) 🚪
  • ㊂ 外掛把不可信資料還原成物件:✅ 這就是 CWE-502 的意思,而檔案路徑檢查不足讓物件裡的路徑可以被操控 ⚙️
  • ㊃ 物件收尾時執行刪檔:✅ 描述指出透過 WPCF7_Submission 的 __destruct() 觸發,能刪的範圍受 PHP 執行帳號的權限限制 🗂️
  • ㊄ 刪到關鍵檔案,網站就出事:🟡 Wordfence 指出,刪除 wp-config.php 這類檔案有機會進一步導致遠端程式碼執行,但這是條件式的後果,不代表每次都會走到那一步 💣

🧊 冷知識:「解構子」是物件臨終前自動執行的遺言
PHP 的 __destruct() 會在物件被銷毀的那一刻自動跑,不需要有人呼叫它。原本是設計來關閉連線、清暫存檔用的,所以 Contact Form 7 才會在這裡處理上傳的暫存檔。攻擊者看上的正是這一點:只要能控制物件裡的資料,就能讓這句「臨終遺言」變成別的內容 ⚰️

🚨 畫重點:這篇不會給你攻擊字串
Wordfence 沒公開可重現的細節,各家報告也只講到機制為止,我就跟著停在這裡。你只要記住入口是「公開的表單」,攻擊者不需要你的帳號,也不用誘騙誰點連結。想知道自己中沒中標,看第五章的結果導向檢查就夠了,不必靠誰公開的字串 🙅


🟩 🟩 🟩

⏱️ 事件時間軸一次看懂

時間點 發生了什麼大事 站長可信度判定
2026-09-29 CVE 編號保留,CVE 時間軸記載已通知廠商 🟡 只有一份報告讀到 CVE 原始紀錄】
2026-10-11 3.5.55 上架,更新日誌寫「修正 Wordfence 回報的問題」 ✅ WordPress.org 更新日誌(頁面日期格式是日/月/年)
2026-10-27 3.5.56 上架,修正隱藏條件欄位的必填驗證 ✅ WordPress.org 更新日誌
2026-11-01 3.5.57 上架,修正上傳檔案 404,並改善 PDF 檔名過濾與 mPDF 內容處理 ✅ WordPress.org 更新日誌
2026-11-02 Wordfence 公開漏洞資訊,CVE 發布 ✅ 多份報告一致
到底有沒有人受害? 公開資料裡找不到實際遭利用的證據 【保持戒慎恐懼】

時間軸有個很有意思的地方:修補版比公開揭露早了三週。對有開自動更新的站來說,這三週剛好是緩衝期;對沒開的站來說,從 11 月 2 日起,任何人都知道「≤3.5.54 有洞」這件事了。最危險的時間窗口就是現在 ⏳

我目前沒有看到公開的概念驗證程式碼(PoC)。什麼是 PoC❓你可以把它想成考前外流的「考古題加詳解」:原本要摸出漏洞怎麼打,得自己研究好幾天;題目和解答一旦流出來,會看懂的人都能照著寫一遍。再往上一層是「野外攻擊(In-the-Wild)」,意思是這份解答已經被改成自動化工具,在網路上四處掃描,不再只是實驗室裡的樣品。這兩件事目前都查不到確切紀錄,但「查不到」不等於「沒有」,所以別拿它當不更新的理由 🧪

  • ✅ 已確認事實:WordPress.org 外掛頁顯示 60,000+ 活躍安裝、目前最新版 3.5.57;更新日誌 3.5.55 的說明是修正 Wordfence 回報的問題;Wordfence 的描述指出,利用需要 PDF Generator 啟用且表單已設定產生 PDF。
  • 🟡 合理推論:CVSS 向量 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H 翻成白話:連上網路就能打、門檻低、不需要帳號、不需要受害者點擊,機密性、完整性、可用性都可能受到高度影響。實際能刪到哪裡,還要看 PHP 執行帳號的權限。
  • 🔴 假設情境:假設有間補習班的報名網站,報名表單設定了「自動產生報名回條 PDF」,外掛停在 3.5.54。某天半夜有人刪掉 wp-config.php,隔天家長打開網站,看到的是 WordPress 的安裝畫面。這是我用來說明的例子,不是任何真實站台的紀錄。

[44]


🟪 🟪 🟪

🎭 第二章:先別自己嚇自己,你的網站有開 PDF 功能嗎?對號入座看看

🩵 小資族荷包試算:修這個洞要花多少錢?
外掛在 WordPress.org 是免費的,更新費用是 NT$ 0 元;Themefic 的文件也把 PDF Generator 列在免費模組。另外有標示為 Pro 的 PDF 相關模組,Pro 版是否另有安裝包、是否同樣受影響,我查不到,有買 Pro 的站請順便到授權頁面確認更新。拿事後成本對照:請人救被刪光的網站、重建設定、處理停機的損失,隨便一項都比一次免費更新貴得多 💸

同一封資安警告,小編、自己租主機的玩家、企業 IT 要做的事差很多。這顆漏洞又多了一道「有沒有開 PDF」的條件,所以第一件事是先找出自己落在哪一格,再決定往下讀到哪一章 🧭

🧮 先對一次:你的網站落在哪一格?

你的情況 風險判斷 今天要做的事 看哪一章
外掛 3.5.54 以下(含),PDF Generator 啟用,且有表單設定產生 PDF 最高 備份 → 更新 → 確認關鍵檔案還在 第三、五、六章
外掛 3.5.54 以下(含),但 PDF Generator 沒開 中(依描述條件不成立,但我無法保證) 照樣更新;用不到的模組順手關掉 第三、六章
外掛已停用但沒刪除 低 更新後再決定要不要刪,不用就直接刪 第三章
外掛 3.5.55 以上 這顆 CVE 已解除 舊版若曾暴露過一陣子,做第五章的稽核 第五章
沒有安裝這個外掛 不受影響 這篇當別人家的新聞看 無

🧮 版本對照:你的版本安不安全?

產品 外掛代號(slug) 受影響範圍 ✅ 修到這版才安全 目前最新版
UACF7 ultimate-addons-for-contact-form-7 3.5.54 以下(含) 3.5.55 3.5.57

最早從哪一版開始受影響,公開資料沒有寫明,所以請當成「3.5.54 以下一律有風險」處理。版本號要逐段比數字,不是比字串:3.5.100 其實比 3.5.55 新。後面的腳本用 sort -V 處理,不會誤判 🧰

更新前也看一眼環境:WordPress.org 外掛頁目前標示需要 WordPress 6.8 以上、PHP 7.4 以上。主機太舊導致更新失敗的話,不是你的問題,直接跳到第六章的 B 先把風險關掉 🛑

🧊 冷知識:同一個外掛有兩個名字
WordPress.org 頁面上的名稱是 UACF7,網址和外掛資料夾卻還叫 ultimate-addons-for-contact-form-7。所以你在後台搜尋時,兩個名字都要試;讀資安報告也一樣,有的寫 Ultimate、有的寫 Ultra,講的都是同一個外掛 🏷️


🟧 🟧 🟧

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

你不需要懂 PHP。第三章全程用滑鼠:看版本、備份、按更新、確認 PDF 開關,再看一眼網站有沒有跳出奇怪的安裝畫面。這顆漏洞最需要你留意的,是「我們有沒有做過 PDF 回條」這個問題,因為多半只有你記得 📄

👨‍💻 我自己租主機架站,手上有好幾個客戶的網站

直接跳第五章拿工具箱。這顆漏洞的版本門檻只有一道(3.5.55),判斷比「每條版本線各有修補版」的漏洞單純,麻煩在後果:它可能刪掉你的 wp-config.php,而工具箱平常正是靠這個檔案找網站的。所以我多加了一支專門找「有 WordPress 程式、卻沒有 wp-config.php」的檢查,不然被刪光的那一站會直接從清單裡消失 🔧

🧑‍🔧 DevOps 小知識:為什麼 WP-CLI 要用網站擁有者的身分跑?
HestiaCP 一個網站對應一個 Linux 使用者。用 root 直接跑 wp 會留下 root 擁有的檔案,之後網站自己寫入就會失敗。所以第五章的 wpx 函式會先用 stat 抓出擁有者,再用 runuser 切過去。還有一點:這份腳本預設的 Hestia 結構是 /home/使用者/web/網域/public_html,如果你的 wp-config.php 放在上一層(WordPress 允許),請改走單站函式並手動指定路徑 🧱

🏢 我們是企業網站,存了會員和訂單資料

免費外掛沒有 SLA,出事時沒人保證多久修好。這次官方在揭露前三週就釋出修補版,這是加分;但如果網站的舊版曾對外暴露、又有 PDF 表單,就要用「可能被進入過」的等級處理。個資是否外洩、要不要依《個人資料保護法》通知當事人,請交給公司法務判斷。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺

🚨 冤枉啊!我早就沒在用 PDF 了,只是懶得關

🚨 高風險場景模擬(🔴 假設情境):「活動辦完了,PDF 回條應該沒人在用吧?」
某個社團兩年前用 PDF Generator 做過報名回條,活動結束後把報名頁面下架,PDF 開關和那張表單卻一直留在後台。管理員心想:「頁面都沒了,總不會憑空打進來吧?」
🙅‍♂️ 依 Wordfence 資安媒體的描述,利用需要的是「PDF 模組啟用,加上表單已設定產生 PDF」,並沒有說頁面一定要公開。表單本體還在、開關還開著,我就沒辦法保證打不到。與其賭,不如關掉開關或更新 🆘

🚥 螢光筆時間:更新、關 PDF、停用外掛,今天至少選一個
「我沒在用」不算處理。把外掛更新到 3.5.55 以上,或關掉 PDF Generator,或乾脆停用外掛,這三個才算真的把門關上 🫡

[45]


🟠 🟠 🟠

😬 更新前先看這個:論壇上真的有人遇到這些狀況

更新是對的,但別當成按下去就一定平安。我在外掛的 WordPress.org 支援論壇找到幾則最近的主題,標題都和更新後的表單行為有關。它們不是這次 CVE 造成的,內容我只看了標題與日期,放在這裡是提醒你更新後要實際測試 🧪

案例 發生了什麼 給你的提醒
2026-11-02 論壇主題「Issue with Uploaded PDF Files – Files Returning 404 Page Not Found」 [46] 標題指出上傳的 PDF 檔連結回傳 404。3.5.57 的更新日誌寫了修正上傳檔案 404,兩者很可能有關,但我沒有逐則核對 🟡 用到 PDF 或上傳欄位的站,更新後要實際打開一次檔案連結
2026-09-10 論壇主題「Hidden conditional required fields still trigger validation errors」 [47],以及 2026-10-29 的「Mandatory text fields ignored on validation」 [48] 兩則都是必填欄位驗證的問題。3.5.56 的更新日誌寫了修正隱藏條件欄位的必填驗證 🟡 有「條件欄位」的表單,更新後要故意漏填一次,看驗證有沒有正常擋下
2026-09-12 論壇主題「Form still will not submit with 3.5.50 update」 [49] 標題指出更新到 3.5.50 後表單仍送不出去 表單送不出去,比外掛有洞更快讓你丟訂單。更新後務必送出一次真的

所以更新完請照這張清單測一輪,全部通過才算收工:

  • PDF:送出一張設定了 PDF 的表單,確認有產生、檔名正常(3.5.57 改了 PDF 檔名過濾)。
  • 信件附件:收信後實際打開附件,不要只看有沒有附件圖示。
  • 上傳欄位:上傳一個檔案,再點開連結,確認不是 404(3.5.57 修正的項目)。
  • 條件欄位:故意漏填被隱藏又必填的欄位,看表單能不能正常送出(3.5.56 修正的項目)。

🚨 測出問題的時候,不要降版
把外掛降回 3.5.54 以下,等於把洞重新打開。表單壞掉時,最好還是先把 PDF 模組或有問題的那張表單先下架或是暫停處理,再拿備份到測試站慢慢查;正式站來不及處理,就先停用外掛(第六章 B) 🧯


🟢 🟢 🟢

🛟 第三章:電腦小白看過來!五分鐘自救:查版本、更新、確認鑰匙還在

💬 溫馨小叮嚀:更新外掛像是把壞掉的鎖換新,從現在起新的小偷進不來,但沒辦法倒帶檢查昨天有沒有人逛過。這顆漏洞的特別之處是,它的後果是「檔案被刪」,所以我在最後加了一步:確認網站的「地契加大門鑰匙」還在。wp-config.php 就是這份地契,裡面寫著網站怎麼連到資料庫,少了它,WordPress 就不知道自己是誰了 🗝️

每一步我都寫了「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只需要動動滑鼠。碰到看不懂的畫面,截圖丟給維護人員或主機商,這是很正常的求助,不是你的問題 🤏

  • ㊀ 先查有沒有裝:
    登入後台,左邊選單點「外掛」→「已安裝的外掛」,在右上角搜尋框輸入 Ultra Addons,沒結果再試 Ultimate Addons。找到「Ultra Addons for Contact Form 7」就是它。找不到就是沒裝,這篇可以當作別人家的新聞 🔎
  • ㊁ 核對版本號:
    版本號寫在外掛名稱下方。看到 3.5.55 或更高就是已修補(目前最新是 3.5.57);比它低就是受影響,繼續往下做。拿不準的話,把版本號截圖拍照問人也行 🕵️‍♀️
  • ㊂ 動手前先留後路(備份):
    到主機商後台點「一鍵備份」,或用你平常的備份外掛先存一份。有件事要確認:備份有沒有包含「網站檔案」,只備份資料庫的話,wp-config.php 被刪時救不回來。不知道怎麼看,就把這篇網址傳給維護人員,請他代勞 💾
  • ㊃ 按下更新:
    在外掛清單點「立即更新」,完成後重新整理,確認版本號跳到 3.5.55 以上。更新失敗、訊息提到「需要較新版本的 WordPress 或 PHP」,不是你的操作有誤,是主機環境太舊。截圖給主機商,並先做下面的 ㊇ 把風險關掉 🔄
  • ㊄ 順手開啟自動更新:
    外掛清單裡,這個外掛右邊有一個「啟用自動更新」的小連結,點一下。這次官方在揭露前三週就放出修補版,有開自動更新的站,其實早就悄悄補好了。這不是萬靈丹(偶爾會和別的外掛打架),但對安全修補來說值得開 ⏰
  • ㊅ 確認 PDF 開關有沒有開:
    左邊選單找「Ultra Addons」→「All Addons」,找到 PDF 相關的開關(官方文件的拼法是 PDF Genrate,各版本的字樣可能不同)。正常的樣子:你記得做過 PDF 回條,所以開關是開的;或你根本沒用過,開關是關的。怪怪的:你完全沒印象,開關卻是開著。沒在用就關掉。要看單張表單,到「聯絡」→「聯絡表單」→ 編輯 → 「Ultra Addons for CF7 Options」,看 PDF Generator 有沒有打開 📄
  • ㊆ 每張表單都送出一次:
    拿測試用的假資料,把每一張公開表單實際送出一次,確認畫面沒報錯、通知信有收到,有 PDF 的話附件要能打開。第二章列的測試清單,最容易在這一步冒出問題。有壞掉的表單,先把它所在頁面轉成草稿,再找維護人員處理 🧪
  • ㊇ 今天真的更新不了,怎麼辦:
    最乾淨的做法是把 ㊅ 的 PDF 開關關掉,關完重新整理一次 All Addons 頁面,確認開關真的是關的;再送出一次有設定 PDF 的測試表單,看看是不是不再產生 PDF。還想更保險,就在外掛清單把它「停用」,停用後馬上打開首頁看一眼,掛了就重新啟用,改成只關 PDF 開關。這些都只是縮短風險時間,今天內還是要排好更新 🧯
  • ㊈ 最後看一眼:鑰匙還在嗎?
    打開你網站的首頁,再打開後台。正常的樣子:看到你的網站、後台登入畫面。見鬼的情況:白畫面、「建立資料庫連線時發生錯誤」,或是跳出一個要你選語言、填資料庫名稱的 WordPress 安裝畫面。看到後兩種,請不要填任何東西、也不要按「繼續」,直接聯絡主機商或維護人員,並告訴他們依本文第六章處理。接著到「使用者」→「管理員」分頁,裡面應該都是你認識的人;多出陌生帳號也先截圖,不要自己刪 🕵️

🧊 冷知識:WordPress 找不到 wp-config.php 的時候,會自己帶你去「安裝畫面」
邏輯很單純:沒有設定檔,就當成是剛架好的新網站,請你填資料庫資訊。問題是這個畫面不會核對訪客的身分,依報告的描述,這也是 Wordfence 提到「刪掉設定檔有機會導致被接管」的原因。所以看到安裝畫面,正確反應是「把手放開,找人處理」,不是「那我來填一下」 🏚️

🫂 新手求助專區:看到專有名詞就頭暈怎麼辦?
直接把下面這段話傳給你的主機客服或維護人員:「你好,我的網站可能有安裝 UACF7(也可能叫 Ultimate Addons),請幫我確認版本是否已更新到 3.5.55 以上,也確認 PDF Generator 有沒有啟用,漏洞編號是 CVE-2026-102435。另外請檢查網站的 wp-config.php 還在不在,以及管理員名單有沒有異常。」對方看到這串編號就知道該做什麼。還想自己查,可以到 外掛的 WordPress.org 支援論壇 [50] 看看大家的回報。沒有備份之前,千萬不要自己亂刪東西 🤗

[51]


🔴 🔴 🔴

🤿 第四章:技術深潛,一個物件被「拆封」的瞬間,檔案是怎麼被刪掉的?

想知道為什麼的人往下看,只想補洞的前三章已經夠用。先交代資料狀況:官方沒有公開程式差異,Wordfence 的原始頁面我這邊讀到空白(需要 JavaScript),所以下面的機制整理自 CVE 描述與多份報告的一致內容,推論的部分標 🟡,查不到的會標【❓待確認】⚔️

評估指標 具體資訊
外掛名稱 UACF7(UACF7,開發商 Themefic)
外掛代號(slug) ultimate-addons-for-contact-form-7
CVE 編號 CVE-2026-102435
CVSS 3.1 9.8(Critical,來源是 Wordfence,也就是 CVE 的 CNA)
CVSS 向量 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
弱點類型 CWE-502:不可信資料的反序列化(PHP 物件注入),後果是任意檔案刪除
受影響與修補版本 3.5.54 以下(含)受影響,3.5.55 起修補,目前最新 3.5.57
發現者 Sawyer(兩份報告一致;另有一份寫成其他名字,只有單一來源,我不採用)
公開時間 2026-11-02(Wordfence、CVE)
啟用規模 WordPress.org 顯示 60,000+ 活躍安裝
利用前提 PDF Generator 啟用,且至少一張 CF7 表單設定產生 PDF;Contact Form 7 必須存在,才能組成描述中的物件呼叫鏈

🧩 三個環節串成一條完整的鏈

  • 🏮 環節一:資料被當成物件還原。PDF 流程處理表單資料時,不可信的內容進了 PHP 的反序列化。這是 CWE-502 的核心,後面的事都建立在這一步 📦
  • 🏮 環節二:檔案路徑沒有被框住。描述點名的 _getObjAttr 對路徑檢查不足。放進比喻,就像清潔人員只准清自己樓層的垃圾桶,但沒人核對他手上的清單寫的是幾樓 🧹
  • 🏮 環節三:借 Contact Form 7 自己的物件動手。攻擊鏈用的是 WPCF7_Submission 這個「現成零件」,在 __destruct() 裡完成刪檔。這種借現成零件串起來的手法,圈內叫 POP 鏈(Property-Oriented Programming),攻擊者不用自己帶程式,只要把別人的零件擺成對的順序 🎯

🧑‍🔧 DevOps 小知識:CVSS 9.8 和 Patchstack 的 8.6 差在哪?
9.8 是 Wordfence 以 CNA 身分給的分數,NVD 目前的狀態是 Deferred(還沒做自己的補充分析,不代表編號無效)。Patchstack 另列 8.6,但頁面沒有給出完整向量,我無法重算差異。可以確定的是:兩家都把它放在高優先處理,差的只是「需要 PDF 模組啟用」這個前提算不算降低攻擊難度。要排工單的話,用 9.8 比較保守 🛡️

🔧 3.5.55、3.5.56、3.5.57 各修了什麼?

能確定的只有 WordPress.org 的更新日誌:3.5.55(2026-10-11)寫修正 Wordfence 回報的問題;3.5.56(2026-10-27)修正隱藏條件欄位的必填驗證;3.5.57(2026-11-01)修正上傳檔案 404 與副檔名遺失,並改善 PDF 檔名過濾與 mPDF 內容處理。後者和這顆 CVE 有沒有關係,官方沒有講,我只能把它當 🟡 合理推論:PDF 模組在同一波被反覆加固。你唯一確定能做的判斷是:版本在 3.5.55 以上,就是官方認定的已修補狀態 🧾

🚨 畫重點:「完整觸發條件」目前查不到
哪些表單設定組合一定能觸發、攻擊走的是 REST 還是別的入口,官方沒公開,各家報告的描述也不完全一致。所以第五章的排查,我刻意不依賴某一條攻擊字串,改看結果特徵:關鍵檔案還在不在、日誌有沒有暴衝、帳號與檔案有沒有多出東西。代價是會有一些雜訊,好處是不會因為猜錯入口而漏看真正的異常 🧭


🔷 🔷 🔷

🕵️ 第五章:怎麼確認你家的檔案有沒有少?由淺入深的排查 Checklist

🤖 重要提醒:以下指令由五份來源報告(ChatGPT、DeepSeek、Gemini、Grok、Meta AI)彙整,經 Claude 複查修正與整理,並對照 WP-CLI 與 HestiaCP 官方文件逐項檢查 🫡
所有程式碼區塊都做過 bash 語法檢查與 shellcheck 掃描,也在模擬環境(假的 WP-CLI、假的網站目錄)裡實跑過主要分支,但我沒有在真的 HestiaCP 主機上跑過,所以請先拿測試站驗證。你的環境不一定跟範例一樣;正式環境的高風險操作,包含更新、停用、改設定檔、重設密碼或執行批次腳本,請務必由熟悉該站架構的專業人員最後確認,切勿直接複製貼上就按 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin 都是示範用名稱,請換成你自己的。腳本預設以 root 身分在 Debian 12/13 的 HestiaCP 主機上執行 🧙

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

[52]


🧭 🧭 🧭

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

為什麼這樣做:HestiaCP 的每個網站屬於不同的 Linux 使用者。先定義幾個小函式:自動找 WP-CLI(有的主機叫 wp,有的叫 wp-cli)、以網站擁有者身分執行、逐站迴圈,再加一個專門比版本號的 is_fixed。所有路徑變數都用雙引號包起來,路徑含空白也不會斷;逐站迴圈用 -print0 配 read -d ”,同樣不怕空白 🧱

# ㊀ 工具箱:同一個 SSH 連線貼一次就好,斷線後要重貼
PLUGIN="ultimate-addons-for-contact-form-7"
FIXED="3.5.55"
SINCE="2026-10-11"
BACKUP_DIR="${HOME}/wp-security-backup"

# 找出 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 只有一道門檻:低於 3.5.55 就受影響
is_fixed() { ! version_lt "$1" "$FIXED"; }

預期輸出:貼完沒有任何輸出就是成功。看到 ⚠️ 代表缺 WP-CLI 或 runuser,先補上再往下,不要硬跑。

驗證(確認 is_fixed 判斷正確):貼完後跑下面這段,它只做版本判斷,不碰任何網站 🧪

for v in 3.5.54 3.5.55 3.5.9 3.5.100; do
  if is_fixed "$v"; then echo "$v 已修補"; else echo "$v 受影響"; fi
done

預期輸出(3.5.54 與 3.5.9 要是「受影響」,其餘「已修補」):

3.5.54 受影響
3.5.55 已修補
3.5.9 受影響
3.5.100 已修補
  • 🟢 結果符合預期 👉 工具箱沒貼錯,往下進行。
  • 🟠 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
  • 🟡 3.5.100 被判成已修補是刻意的:sort -V 逐段比數字,不是比字串。

🧑‍🔧 DevOps 小知識:為什麼迴圈長成 3< <(find …) 這個樣子?
常見寫法 find | while read 有個陷阱:迴圈裡只要有指令會讀標準輸入,就會把清單吃掉一半,結果只處理到第一個網站。把 find 的輸出接到檔案描述符 3、改用 read … <&3 讀,迴圈內的指令就搶不到輸入;wpx 裡的 < /dev/null 是第二道保險 🔧


🔎 🔎 🔎

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

為什麼這樣做:一台主機上有十個獨立 WordPress,跟一個 Multisite 底下有十個子站,看起來像但處理方式不同。前者要逐一找每個 wp-config.php,後者外掛檔案全網只有一份。這個函式同時判斷安裝類型與版本,一行一站,只讀不寫 🤔

inventory_one() {
  local wp_path="$1" kind ver status verdict
  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-\t%s\n' "$wp_path"
    return 0
  fi

  ver="$(wpx "$wp_path" plugin get "$PLUGIN" --field=version 2> /dev/null || true)"
  if [ -z "$ver" ]; then
    printf '⚪ 未安裝\t%s\t-\t-\t%s\n' "$kind" "$wp_path"
    return 0
  fi
  status="$(wpx "$wp_path" plugin get "$PLUGIN" --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\n' "$verdict" "$kind" "$ver" "${status:--}" "$wp_path"
  return 0
}

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

# ❇️ 獨立多站:整台主機一次盤點(只讀不寫)
each_site inventory_one

預期輸出範例:

🔴 受影響	💠 一般安裝	版本:3.5.54	狀態:active	/home/alice/web/a.example.com/public_html
🟢 已修補	💠 一般安裝	版本:3.5.57	狀態:active	/home/bob/web/b.example.com/public_html
⚪ 未安裝	💠 一般安裝	-	-	/home/bob/web/blog.example.com/public_html
🟢 已修補	✴️ Multisite	版本:3.5.57	狀態:active-network	/home/bob/web/network.example.com/public_html
  • 🔴 受影響 👉 排入第六章的備份與更新,並接著做 ㊂ 到 ㊄,確認舊版期間檔案有沒有少。
  • 🟢 已修補 👉 版本條件已解除,但舊版若曾暴露一段時間,仍建議做 ㊂ 到 ㊄。
  • 🟡 狀態是 inactive 👉 外掛已停用,攻擊面暫時不在,仍建議更新或刪除,不要放著。
  • ✴️ Multisite 出現 active-network 👉 外掛檔案全網共用,查主站一次就代表整個網路,更新也只做一次。
  • 🟠 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。路徑若像 public_html.bak 之類的備份副本,請人工確認哪一個才是正式站。
  • 🟠 這一步看不到 PDF Generator 有沒有啟用:WP-CLI 沒有公開、可驗證的指令,資料庫裡的選項名稱我也查不到。請依第三章 ㊅ 從後台確認。

[53]


🗝️ 🗝️ 🗝️

🗝️ ㊂ 確認關鍵檔案:這顆漏洞的「後果」到底發生了沒?

為什麼這樣做:這顆漏洞最直接的後果是檔案被刪,所以檢查要從「少了什麼」開始,而不是「多了什麼」。這裡有個陷阱:工具箱靠 wp-config.php 找網站,被刪掉設定檔的那一站,反而不會出現在清單裡。所以第一段專門找「有 WordPress 程式、卻沒有 wp-config.php」的目錄(上一層有的也算有);第二段逐站確認幾個關鍵檔案在不在、資料庫連線設定有沒有被換掉(只印主機、資料庫名稱與使用者,不印密碼),再用官方檢查碼比對核心檔案 🧐

# ❶ 找出「有 WordPress 程式、卻找不到 wp-config.php」的網站(工具箱靠 wp-config.php 找站,這種站會被漏掉)
find_wp_without_config() {
  local ver_file wp_path found=0
  while IFS= read -r -d '' ver_file <&3; do
    wp_path="${ver_file%/wp-includes/version.php}"
    if [ ! -f "${wp_path}/wp-config.php" ] && [ ! -f "$(dirname -- "$wp_path")/wp-config.php" ]; then
      printf '🔴 缺少 wp-config.php:%s\n' "$wp_path"
      found=1
    fi
  done 3< <(find /home -maxdepth 6 -type f -path '*/web/*/public_html/wp-includes/version.php' -print0 2> /dev/null)
  if [ "$found" -eq 0 ]; then
    echo "🟢 每個 WordPress 都找得到 wp-config.php"
  fi
  return 0
}

# ❷ 逐站確認關鍵檔案還在,並看資料庫連線設定有沒有被換掉(不印出密碼)
keyfiles_one() {
  local wp_path="$1" f key missing=0
  printf '\n===== %s =====\n' "$(site_domain "$wp_path")"

  if [ ! -f "${wp_path}/wp-config.php" ]; then
    echo "🔴 wp-config.php 不見了,請直接走第六章的 wp-config.php 救援"
    return 0
  fi

  for f in wp-config.php index.php wp-load.php wp-settings.php; do
    if [ -f "${wp_path}/${f}" ]; then
      printf '🟢 %s\n' "$f"
    else
      printf '🔴 %s 不見了\n' "$f"
      missing=1
    fi
  done
  stat -c '權限 %a,擁有者 %U:%G,最後修改 %y' -- "${wp_path}/wp-config.php"

  echo "--- 資料庫連線設定(密碼不顯示)---"
  for key in DB_HOST DB_NAME DB_USER; do
    printf '%s = %s\n' "$key" "$(wpx "$wp_path" config get "$key" 2> /dev/null || echo '讀取失敗')"
  done

  echo "--- WordPress 核心檔案比對(缺檔或被改過會列出警告)---"
  wpx "$wp_path" core verify-checksums || true
  if [ "$missing" -eq 1 ]; then
    echo "👆 這一站有關鍵檔案不見了,掃完整台之後優先處理"
  fi
  return 0
}

find_wp_without_config

# 💠 單一網站
keyfiles_one "/home/admin/web/example.com/public_html"

# ❇️ 獨立多站:逐站檢查(唯讀,缺檔的站會標紅色,不會中斷)
each_site keyfiles_one

預期輸出範例:

🔴 缺少 wp-config.php:/home/carol/web/c.example.com/public_html

===== a.example.com =====
🟢 wp-config.php
🟢 index.php
🟢 wp-load.php
🟢 wp-settings.php
權限 640,擁有者 alice:alice,最後修改 2026-09-12 08:00:00.000000000 +0800
--- 資料庫連線設定(密碼不顯示)---
DB_HOST = localhost
DB_NAME = alice_wp1
DB_USER = alice_wpuser
--- WordPress 核心檔案比對(缺檔或被改過會列出警告)---
Success: WordPress installation verifies against checksums.
  • 🔴 開頭出現「缺少 wp-config.php」 👉 那一站的設定檔不見了,先別碰,直接跳第六章 A 的 wp-config.php 救援。
  • 🔴 某個關鍵檔案「不見了」,或核心比對列出缺檔、不一致的警告 👉 檔案被刪過(也可能是你自己清過),對照維運紀錄;沒有紀錄就走第六章 A。核心比對的缺檔字樣,官方文件只示範了「不一致」的情況,確切文字依版本可能不同【❓待確認】,所以上面逐檔確認「在不在」的結果才是主要依據。
  • 🔴 DB_HOST 不是 localhost,也不是你認得的位址 👉 設定檔可能被換過,視為已中招。DB_NAME、DB_USER 也請和 HestiaCP 面板的資料庫清單對一次。
  • 🟢 全部都在、連線設定符合預期 👉 沒有發現檔案被刪的跡象,繼續 ㊃。
  • 🟡 wp-config.php 放在 public_html 的上一層,不會出現在工具箱清單裡 👉 這種站請用單站函式、手動指定路徑逐項檢查。

🟥 🟥 🟥

📊 ㊃ 日誌排查:看誰一直在敲「表單那扇門」

為什麼這樣做:官方沒公開 payload,標準存取日誌也不記錄 POST 的內容,只記請求行:方法、網址、狀態碼。所以日誌能回答的問題有限,我只抓有價值的結果特徵:哪些網址被 POST 最多(表單送出一般走 /wp-json/contact-form-7/ 底下的路徑)、哪幾個小時突然暴衝、有沒有 POST 打到不常見的 PHP 檔、nginx 錯誤日誌裡有沒有刪檔相關的紀錄。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log,函式用 zcat -f 一併讀輪替後的 .log.1 與 .log.2.gz。前面如果有 CDN,日誌裡看到的 IP 可能是 CDN 的;PHP 本身的錯誤不一定寫進這個錯誤日誌 🎯

# 取出某網域「目前+輪替後(含 .gz)」的 nginx 日誌:scan_domain_log 網域 [log|error.log]
scan_domain_log() {
  local domain="$1" kind="${2:-log}"
  find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.${kind}*" -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 請求最多的網址(前 10 名,已去掉問號後面的參數)---"
  scan_domain_log "$domain" \
    | awk -F'"' '$2 ~ /^POST /{ split($2, a, " "); sub(/\?.*/, "", a[2]); print a[2] }' \
    | sort | uniq -c | sort -rn | head -n 10

  echo "--- ❷ 哪幾個小時 POST 特別多(前 5 名,格式:日/月/年:時)---"
  scan_domain_log "$domain" \
    | awk -F'"' '$2 ~ /^POST /{ split($1, a, "["); print substr(a[2], 1, 14) }' \
    | sort | uniq -c | sort -rn | head -n 5

  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 "--- ❹ nginx 錯誤日誌裡和外掛、刪檔有關的紀錄(最近 20 筆)---"
  scan_domain_log "$domain" error.log \
    | grep -Eai 'uacf7|ultimate-addons-for-contact-form-7|pdf-generator|unlink|Permission denied' \
    | tail -n 20 || true
  return 0
}

# 💠 單一網站
log_scan_one "/home/admin/web/example.com/public_html"

# ❇️ 獨立多站/✴️ Multisite:掃主機上每一個網域
each_site log_scan_one

預期輸出範例(有可疑的樣子):

===== a.example.com =====
--- ❶ POST 請求最多的網址(前 10 名,已去掉問號後面的參數)---
    412 /wp-json/contact-form-7/v1/contact-forms/12/feedback
     37 /wp-login.php
      2 /wp-content/plugins/foo/bar.php
--- ❷ 哪幾個小時 POST 特別多(前 5 名,格式:日/月/年:時)---
    390 02/Nov/2026:22
     41 03/Nov/2026:09
--- ❸ 打到非常規 PHP 檔的 POST(最近 20 筆)---
203.0.113.6 - - [03/Nov/2026:11:00:02 +0800] "POST /wp-content/plugins/foo/bar.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
--- ❹ nginx 錯誤日誌裡和外掛、刪檔有關的紀錄(最近 20 筆)---
2026/11/03 10:00:02 [error] unlink(wp-config.php) Permission denied
  • 🔴 ❹ 有 unlink 或 Permission denied 對到 wp-config.php 之類的檔案 👉 有人(或有程式)試過刪它,立刻做 ㊂ 與 ㊄,必要時進第六章 A。
  • 🔴 ❸ 有命中,來源是陌生 IP、User-Agent 像腳本工具 👉 記下 IP 與時間,把 ㊄ 做完整。
  • 🟡 ❷ 某個小時的 POST 暴增,且對應 ❶ 裡的表單網址 👉 可能是機器人灌表單,也可能是在試探,要結合時間與 IP 判讀。我無法確認這顆漏洞在日誌裡的專屬特徵。
  • 🟢 都沒命中 👉 只能說目前沒有直接證據,不能證明沒被打過:日誌可能已輪替、前面有 CDN,攻擊也可能沒走你查的這個日誌。繼續 ㊄。

[54]


🟣 🟣 🟣

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

為什麼這樣做:刪檔之後,下一步常是「趁亂進來」,所以這一輪同時看資料側與檔案側:管理員名單、最近註冊的帳號、外掛清單、排程;檔案側看 uploads 裡的 PHP、會自動載入的 mu-plugins、設定的起點日之後被改過的 PHP,以及外掛與核心檔案跟官方版本的比對。全部用 WP-CLI 內建功能完成,不寫 SQL,資料表前綴不同也不怕。起點日 SINCE 預設 2026-10-11(3.5.55 上架日,修補內容從那天起可被拿來逆向);漏洞存在的時間更長,想保守就把日期往前調 🕵️‍♀️

audit_one() {
  local wp_path="$1" opt
  if [ ! -f "${wp_path}/wp-config.php" ]; then
    printf '🔴 %s 找不到 wp-config.php,請先走第六章的 wp-config.php 救援\n' "$wp_path"
    return 0
  fi
  printf '\n===== %s(擁有者:%s)=====\n' "$wp_path" "$(site_owner "$wp_path")"

  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

預期輸出範例(正常的樣子,節錄):

--- ❶ 全部管理員 ---
+----+------------+-------------------+---------------------+
| ID | user_login | user_email        | user_registered     |
+----+------------+-------------------+---------------------+
| 1  | alice      | alice@example.com | 2024-01-15 08:00:00 |
+----+------------+-------------------+---------------------+
--- ❹ uploads 底下的 PHP 類檔案(正常網站應該是空的)---
--- ❺ mu-plugins 與 wp-content 根目錄的 PHP(會自動載入,後台外掛頁看不到)---
--- ❽ 外掛與核心檔案跟官方版本比對 ---
Success: Verified 12 of 13 plugins.
Success: WordPress installation verifies against checksums.

❾ 若有付費外掛不在 WordPress.org,檢查碼比對常出現無法驗證的提示,這很正常,實際文字依 WP-CLI 版本而異 🧾

  • 🔴 ❶❷ 出現起點日之後註冊、你不認識的帳號 👉 高度可疑。「陌生」不等於「惡意」,可能是外包留的,先問清楚再處理。
  • 🔴 ❹ 找到 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
③ 關鍵檔案 keyfiles_one 路徑 先 find_wp_without_config,再 each_site keyfiles_one 外掛檔案只有一份,但 wp-config.php 也只有一份,查主站即可
④ 日誌排查 單一網域 掃所有網域日誌 共用網域日誌,再依 Host 回推子站
⑤ 帳號與檔案 管理員/檔案 逐站各自檢查,不能拿 A 站代表 B 站 另加 super-admin list

[55]


🪛 🪛 🪛

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

前一章解決「怎麼判斷」,這一章才動手。主線固定是 備份 👉 預覽 👉 更新 👉 驗證;第五章若出現跡象,要先走 A 取證與止血,而且 A 裡有一條專門處理 wp-config.php 被刪的支線。這一章會改動系統狀態,所以每個動作後面都接著「怎麼確認真的生效」,以及失敗時的下一步。以下沿用第五章貼好的工具箱,沒貼的話請先回去貼一次 🧹


💾 💾 💾

💾 ㊀ 三個共用零件:備份、刷新鹽值、停用外掛

為什麼這樣做:A、B、C 都會用到其中一兩個,所以先定義成函式,這一步只有定義,不會執行任何動作。備份檔放在 root 家目錄、權限 700,不放在網站目錄,避免被網址直接下載;而且除了資料庫與 wp-content,我另外把 wp-config.php 單獨留一份,因為這顆漏洞最可能刪掉的就是它,備份裡有它,才有救。注意這份備份含資料庫密碼,不要寄給任何人 🔐

📢 大白話:刷鹽值,是把飯店所有房卡的密碼整批換新
鹽值(Salts)是 wp-config.php 裡那八組長字串,WordPress 用它們替登入的 Cookie 簽章。你只改了某位住客的房號,有人手上可能還有一張沒過期的房卡;整批換密碼就是宣告「舊房卡今天起全部失效」,不管誰握著哪一張,都得重新辦。代價是正常使用者包含你自己,也要重新登入。我的判斷是:舊版曾暴露、又出現任何可疑跡象,就用 wp config shuffle-salts 強制刷新 🪪
# ㊀ 共用零件一:備份(資料庫+wp-config.php+wp-content),任何一步失敗就回傳失敗
backup_one() {
  local wp_path="$1" domain stamp db_file tar_file cfg_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"
  cfg_file="${BACKUP_DIR}/${domain}_wp-config.php.${stamp}"

  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

  # wp-config.php 單獨留一份在網站目錄外面,被刪掉時用它救回來
  install -m 600 -- "${wp_path}/wp-config.php" "$cfg_file" || return 1

  tar -czf "$tar_file" -C "$wp_path" wp-content || { echo "❌ 檔案備份失敗,停止"; return 1; }
  chmod 600 -- "$tar_file"
  ls -lh -- "$db_file" "$cfg_file" "$tar_file"
  return 0
}

# 判斷 wp-config.php 有沒有被 chattr +i 鎖住(第七章才會用到鎖定功能)
config_is_locked() {
  lsattr -- "$1/wp-config.php" 2> /dev/null | awk '{print $1}' | grep -q 'i'
}

# ㊁ 共用零件二:刷新 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)"

  if config_is_locked "$wp_path"; then
    printf '⛔ %s 的 wp-config.php 被鎖住了(chattr +i),請先執行 unlock_config_one,刷完再鎖回去\n' "$domain"
    return 1
  fi

  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
}

# ㊂ 共用零件三:停用外掛(Multisite 連全網與各子站一起處理),並確認首頁還打得開
deactivate_one() {
  local wp_path="$1" url status home_url code left=0
  if wpx "$wp_path" core is-installed --network 2> /dev/null; then
    wpx "$wp_path" plugin deactivate "$PLUGIN" --network 2> /dev/null || true
    while IFS= read -r url <&3; do
      wpx "$wp_path" --url="$url" plugin deactivate "$PLUGIN" 2> /dev/null || true
    done 3< <(wpx "$wp_path" site list --field=url)
  else
    wpx "$wp_path" plugin deactivate "$PLUGIN" 2> /dev/null || true
  fi

  status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status 2> /dev/null || true)"
  if [ -z "$status" ]; then
    echo "⚪ 這個網站沒有安裝 ${PLUGIN}"
    return 0
  fi
  printf '%s:%s\n' "$PLUGIN" "$status"
  case "$status" in
    active|active-network) left=1 ;;
  esac

  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 才會被叫用。想先單獨試試看,可以這樣叫(一次一站):

WP_PATH="/home/admin/web/example.com/public_html"

backup_one "$WP_PATH"
deactivate_one "$WP_PATH"

預期輸出範例:

/dev/vda1  80G  22G  54G  29% /
-rw------- 1 root root 485M Nov  3 10:30 /root/wp-security-backup/example.com_db_20261103-103022.sql
-rw------- 1 root root  3.2K Nov  3 10:30 /root/wp-security-backup/example.com_wp-config.php.20261103-103022
-rw------- 1 root root 2.4G Nov  3 10:31 /root/wp-security-backup/example.com_wp-content_20261103-103022.tar.gz
Success: Shuffled the salt keys.
✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin
Plugin 'ultimate-addons-for-contact-form-7' deactivated.
ultimate-addons-for-contact-form-7:inactive
首頁 HTTP 200,正常

驗證與失敗分支:

  • 🟢 備份:三個檔案都在、大小合理,資料庫檔結尾有 Dump completed 👉 可以進下一步。這個結尾字樣是 mysqldump 與 MariaDB 的慣例輸出,若你的環境用別的工具匯出,字樣可能不同,腳本只會警告,不會擋你。
  • 🟠 備份失敗或是空檔 👉 函式立刻停下,先查磁碟空間(已印出 df 結果)與資料庫權限,沒有備份不要往下。
  • 🟢 鹽值:前後兩段雜湊不同 👉 已生效,你會被登出,重新登入即可。
  • 🟠 鹽值出現 ❌「沒有改變」或指令本身失敗 👉 先確認 wp-config.php 的擁有者可寫;wp config shuffle-salts 要連到 WordPress.org 的 API 取得新鹽值,主機連不出去也會失敗,先查網路,不要急著手動改檔。
  • 🟠 鹽值出現 ⛔「被鎖住了」 👉 你在第七章鎖過設定檔,先 unlock_config_one,刷完再鎖回去。
  • 🔴 刷完網站出現「建立資料庫連線時發生錯誤」 👉 用備份還原:cp -p “$BACKUP_DIR/網域_wp-config.php.時間” “網站路徑/wp-config.php”,備份檔名在上面的 ls 輸出裡。
  • 🟢 停用:狀態是 inactive、首頁 HTTP 為 2xx 或 3xx 👉 成功。
  • 🟠 停用後首頁不是 2xx/3xx 👉 佈景主題或別的外掛可能呼叫了它的函式。先執行 wp plugin activate ultimate-addons-for-contact-form-7(以網站擁有者身分)讓網站恢復,再改走 B 的「只關 PDF 模組」,並交給維護人員處理。

[56]


🔴 🔴 🔴

🚨 ㊁ A. 疑似中招:先留證據,再救檔案,最後才修補

第五章只要出現紅色警示,就走這一節。順序很重要:先看設定檔還在不在,再決定走哪條路。設定檔還在,走 A-1 的取證與止血;設定檔不見了,走 A-2 先把鑰匙救回來,再回頭做 A-1。兩條都只對「有疑慮的那一站」做,不要整台主機無差別跑 🧯

🧯 A-1:設定檔還在,先取證、再止血

為什麼這樣做:順序是資料庫快照與日誌 👉 停用外掛 👉 刷新鹽值 👉 管理員密碼與登出所有階段。先拍照存證,再關門,再讓所有舊憑證作廢,最後處理帳號。腳本一律用 –skip-email –show-password 把新密碼顯示在螢幕上:沒帶 –show-password,WP-CLI 只會告訴你「已重設」,你卻不知道新密碼是什麼。新密碼會留在終端機的捲動紀錄裡,請在私密環境操作,用安全管道交給管理員本人,並請他登入後立刻自行更換,不要貼進聊天視窗 🔐

這支函式會動到外掛、鹽值與帳號,請先確認路徑,並確認取證完成前什麼都沒刪

# 前提:已貼過「工具箱」與「共用零件」。取證完成前,什麼都不要刪
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}/uacf7-incident-${stamp}"
  out="${evid}/${domain}"

  if [ ! -f "${wp_path}/wp-config.php" ]; then
    echo "🔴 wp-config.php 不見了,這支函式跑不起來。先做下一節的 wp-config.php 救援,再回來"
    return 1
  fi
  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*" -o -name "${domain}.error.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
  ls -la -- "$wp_path" > "${out}/docroot-listing.txt"
  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 "=== ❷ 隔離:停用外掛 ==="
  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"
  return 0
}

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

預期輸出範例:

=== ❷ 隔離:停用外掛 ===
Plugin 'ultimate-addons-for-contact-form-7' deactivated.
ultimate-addons-for-contact-form-7:inactive
首頁 HTTP 200,正常
=== ❸ 強制刷新金鑰與鹽值(舊的登入 Cookie 一併作廢)===
Success: Shuffled the salt keys.
✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin
=== ❹ 逐一重設管理員密碼(新密碼會顯示在螢幕上)並登出所有階段 ===
(列出管理員後詢問是否重設,選 y 會逐位顯示新的隨機密碼)

✅ example.com 止血完成,證據放在 /root/uacf7-incident-20261103-103500/example.com

驗證(確認真的止血了):跑完後執行下面三行。預期結果是外掛狀態為 inactive、管理員名單只剩你認識的人、最後印出 db_ok 與 1。任何一項不符,就代表那一步沒成功,回頭看對應輸出。這三行只讀不寫 🧪

WP_PATH="/home/admin/web/example.com/public_html"

wpx "$WP_PATH" plugin get "$PLUGIN" --field=status
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 金鑰等放在資料庫或設定檔的憑證一併換掉。
  • 🔴 更新前的備份可能已經含有後門,不能當成「乾淨的還原來源」;要還原請用事發前、已知乾淨的備份。

🗝️ A-2:設定檔不見了,先把鑰匙救回來

第五章 ㊂ 看到「缺少 wp-config.php」,或網站已經跳出安裝畫面,就走這條支線。先講三個鐵則,因為這時候最容易越幫越忙:一,不要去走安裝畫面;二,不要刪掉任何東西,包括可疑檔案;三,一次只救一站。要是網站正在對外服務,建議先請主機商或你自己暫時關閉該網域的網站服務,免得有人搶先填了安裝表單 🛑

第一步:找備份。下面兩行會列出第六章 ㊀ 留下的 wp-config.php 備份,以及 HestiaCP 有沒有自己的整站備份指令。HestiaCP 的備份指令名稱我沒有逐一核對,請以你的版本實際列出的為準,不確定的指令不帶參數執行就會顯示用法 🔎

# 找 wp-config.php 的備份(最新的排最前面)
ls -1t "${BACKUP_DIR}"/*_wp-config.php.* 2> /dev/null | head -n 5
# 沒有的話,看看 HestiaCP 自己有沒有做過整站備份(指令名稱請以你的版本為準)
ls /usr/local/hestia/bin | grep -i backup

第二步:有備份,就還原。函式會檢查備份檔是否存在、像不像 wp-config.php,已經有設定檔的話它會拒絕覆蓋(懷疑被改過的檔案要先改名留證據)。還原後立刻驗證三件事:WordPress 認得到安裝、資料庫連得上、首頁有回應 🧰

# 用法:restore_wp_config_one 網站使用者 網域 備份檔完整路徑
restore_wp_config_one() {
  local owner="$1" domain="$2" backup="$3" wp_path cfg grp code home_url
  wp_path="/home/${owner}/web/${domain}/public_html"
  cfg="${wp_path}/wp-config.php"

  [ -d "$wp_path" ] || { printf '找不到 %s\n' "$wp_path"; return 1; }
  [ -s "$backup" ]  || { printf '備份檔不存在或是空的:%s\n' "$backup"; return 1; }
  if [ -e "$cfg" ]; then
    echo "這裡已經有 wp-config.php 了,這支函式不會覆蓋它。懷疑被改過的話,先改名留證據再處理"
    return 1
  fi
  if ! grep -q 'DB_NAME' "$backup"; then
    echo "這份檔案看起來不像 wp-config.php(找不到 DB_NAME),停止"
    return 1
  fi

  grp="$(id -gn "$owner")" || return 1
  install -o "$owner" -g "$grp" -m 640 -- "$backup" "$cfg" || return 1
  printf '已還原:'
  stat -c '%a %U:%G %n' -- "$cfg"

  # 驗證一:WordPress 認得到安裝;驗證二:資料庫連得上;驗證三:首頁正常
  wpx "$wp_path" core is-installed || { echo "❌ WordPress 載入失敗,請看上方訊息"; return 1; }
  wpx "$wp_path" db query 'SELECT 1 AS db_ok;' || return 1
  home_url="$(wpx "$wp_path" option get home)"
  code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/" || true)"
  printf '首頁 HTTP %s\n' "${code:-無回應}"
}

# 用法:rebuild_wp_config_one 網站使用者 網域 資料庫名稱 資料庫使用者
# 沒有任何備份可用時的最後手段;Multisite 網站請不要用,改從備份還原
rebuild_wp_config_one() {
  local owner="$1" domain="$2" db_name="$3" db_user="$4"
  local wp_path cfg home_dir db_pass tables prefix count
  wp_path="/home/${owner}/web/${domain}/public_html"
  cfg="${wp_path}/wp-config.php"

  [ -d "$wp_path" ] || { printf '找不到 %s\n' "$wp_path"; return 1; }
  if [ -e "$cfg" ]; then
    echo "這裡已經有 wp-config.php 了,停止"
    return 1
  fi
  [[ "$db_name" =~ ^[A-Za-z0-9_]+$ ]]    || { echo "資料庫名稱格式不對"; return 1; }
  [[ "$db_user" =~ ^[A-Za-z0-9_.-]+$ ]]  || { echo "資料庫使用者格式不對"; return 1; }

  read -r -s -p "請輸入這個資料庫使用者的密碼(輸入時不會顯示):" db_pass
  echo
  [ -n "$db_pass" ] || { echo "沒有輸入,停止"; return 1; }

  # 從資料表名稱反推前綴:wp_options → wp_
  tables="$(MYSQL_PWD="$db_pass" mysql -N -u "$db_user" "$db_name" -e "SHOW TABLES LIKE '%\\_options';")" \
    || { echo "資料庫連線失敗,請確認名稱與密碼"; return 1; }
  count="$(printf '%s\n' "$tables" | grep -c . || true)"
  if [ "$count" -ne 1 ]; then
    printf '找到 %s 組 options 資料表,無法判斷前綴(可能是 Multisite),請改從備份還原\n' "$count"
    return 1
  fi
  prefix="${tables%options}"
  [[ "$prefix" =~ ^[A-Za-z0-9_]+$ ]] || { echo "資料表前綴格式不對,停止"; return 1; }
  printf '偵測到資料表前綴:%s\n' "$prefix"

  home_dir="$(getent passwd "$owner" | cut -d: -f6)"
  runuser -u "$owner" -- env HOME="${home_dir:-/tmp}" "$WPCLI" --path="$wp_path" config create \
    --dbname="$db_name" --dbuser="$db_user" --dbpass="$db_pass" \
    --dbhost=localhost --dbprefix="$prefix" < /dev/null || return 1
  chmod 640 -- "$cfg"

  # 驗證:WordPress 認得安裝、資料庫連得上、新鹽值已寫入
  wpx "$wp_path" core is-installed || { echo "❌ WordPress 載入失敗"; return 1; }
  wpx "$wp_path" db query 'SELECT 1 AS db_ok;' || return 1
  stat -c '%a %U:%G %n' -- "$cfg"
  echo "⚠️ 重建出來的檔案只有基本設定,原本自訂的常數(例如 WP_HOME、快取、Redis 設定)都要自己補回"
}

使用方式(有備份):

restore_wp_config_one "admin" "example.com" \
  "/root/wp-security-backup/example.com_wp-config.php.20261103-103022"

預期輸出範例:

已還原:640 admin:admin /home/admin/web/example.com/public_html/wp-config.php
db_ok
1
首頁 HTTP 200

第三步:完全沒備份,才用重建。重建需要資料庫名稱、資料庫使用者與密碼;密碼忘了的話,先到 HestiaCP 面板重設,再回來輸入。函式會從資料表名稱反推前綴(wp_options 的前面那段),找到不只一組 options 資料表,就代表可能是 Multisite 或同一個資料庫裝了多個網站,它會直接停下來,要求你改從備份還原,不會亂猜。重建出來的檔案只有基本設定,原本自訂的常數都要自己補回 ⚠️

# 先查資料庫名稱與使用者(HestiaCP);名稱參數請以你主機上的實際輸出為準
v-list-databases admin

rebuild_wp_config_one "admin" "example.com" "admin_wp1" "admin_wpuser"

預期輸出範例:

請輸入這個資料庫使用者的密碼(輸入時不會顯示):
偵測到資料表前綴:wp_
Success: Generated 'wp-config.php' file.
db_ok
1
640 admin:admin /home/admin/web/example.com/public_html/wp-config.php
⚠️ 重建出來的檔案只有基本設定,原本自訂的常數(例如 WP_HOME、快取、Redis 設定)都要自己補回
  • 🟢 驗證三項都通過 👉 鑰匙救回來了。接著回到 A-1,把取證、停用、刷鹽值的流程走完,因為設定檔曾經被刪,就代表網站曾被碰過。
  • 🟠 還原後出現「建立資料庫連線時發生錯誤」 👉 備份的設定檔太舊,裡面的資料庫密碼已經換過。到面板確認目前的資料庫密碼,用 wp config set DB_PASSWORD 或 A-3 的函式更新。
  • 🟠 重建時印出「資料庫連線失敗」 👉 名稱或密碼不對,函式不會動任何檔案,重新確認再試一次。
  • 🔴 資料庫裡的表被清空,或網站已被別人重新安裝(資料庫連到不認識的位址) 👉 這已經是事故,不是救檔案能解決的,請找資安專業人員,並從事發前的備份整站還原。

🔑 A-3:懷疑設定檔被讀過,才換資料庫密碼

這顆漏洞本身是「刪檔」,不是「讀檔」,所以資料庫密碼不是每次都要換。只有出現「攻擊者可能執行過程式」的跡象(例如 uploads 底下有 PHP 檔、多出不明管理員)才需要。順序也很重要:先在 HestiaCP 面板把這個資料庫使用者的密碼改掉,再讓 wp-config.php 跟上,中間網站會短暫連不上資料庫,所以挑離峰時間做。下面的函式只負責第二步:讓你輸入新密碼(輸入時不顯示),寫進設定檔,再連一次資料庫確認。密碼會出現在這個指令的參數裡,同一台主機上的其他使用者極短時間內有機會從程序清單看到,所以請在沒有其他人登入的時段操作,並使用只含英數字元的隨機密碼 🙈

# 前提:已貼過「工具箱」,且資料庫密碼已先在 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. 短期應急:還沒確認中招,但現在還不能更新

🚨 短期應急不是正式修補。
關掉 PDF 模組或停用外掛,都只是縮短暴露時間,真正的修補仍然是更新到 3.5.55 以上。我也沒有提供「把表單頁面轉成草稿」這個選項:表單送出走的是 Contact Form 7 的 REST 端點,頁面下架不一定擋得住,這點我無法驗證,所以不拿來當保險 🧱

選項一(最小代價):只關 PDF 模組。依 Wordfence 的描述,利用需要 PDF Generator 啟用,關掉它等於抽掉其中一個必要條件。步驟:後台 Ultra Addons > All Addons,把 PDF 開關切到關閉並儲存。這一步 WP-CLI 沒有可驗證的指令,所以驗證靠畫面:重新整理該頁,確認開關仍是關的;再送出一次原本會產生 PDF 的測試表單,確認不再產生 PDF。表單本身若還能正常送出,就代表其他功能沒被影響 🧩

選項二(最乾淨):停用外掛。直接叫用 ㊀ 的 deactivate_one,它會處理 Multisite 的全網與各子站,最後驗證狀態與首頁。預期輸出、成功與失敗的判斷,都在 ㊀ 的驗證清單裡,一路對照即可 🧯

# 💠 單一網站(路徑換成你的;Multisite 請對網路根目錄跑一次)
deactivate_one "/home/admin/web/example.com/public_html"
  • 🟢 選項一:開關已關、測試表單不再產生 PDF 👉 暫時降低風險,立刻排更新。
  • 🟠 選項一:重新整理後開關又跳回開啟 👉 外掛儲存失敗或快取,清快取再試;不行就改走選項二。
  • 🟢 選項二:狀態 inactive、首頁 2xx 或 3xx 👉 暫時安全,立刻排更新。
  • 🟠 選項二:首頁不是 2xx/3xx 👉 見 ㊀ 的失敗分支,改回選項一。

[57]


🚀 🚀 🚀

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

為什麼這樣做:外掛在 WordPress.org 上,WP-CLI 的 plugin update 可以直接更新,plugin verify-checksums 能拿官方檢查碼比對外掛檔案有沒有被動過。單站最穩的做法是做成一支獨立腳本:先備份資料庫與 wp-config.php、再預覽、要你輸入 y 才更新、更新後驗證。heredoc 的結束標記用單引號包住,裡面的 $ 不會被提前展開,建好後立刻做語法檢查。Multisite 的外掛檔案只有一份,更新一次就好,WP-CLI 的 plugin update 也沒有 –network 這個參數,不要自己加。千萬不要為了「保險」而降版,降回舊版等於把洞重新打開 📦

cat > /usr/local/sbin/uacf7-patch.sh <<'UACF7_PATCH_EOF'
#!/usr/bin/env bash
# uacf7-patch.sh:單一網站的 UACF7 修補流程
# (備份 → 預覽 → 確認 → 更新 → 驗證)
# 用法:uacf7-patch.sh 網站使用者 網域
set -euo pipefail

PLUGIN="ultimate-addons-for-contact-form-7"
FIXED="3.5.55"
WPCLI="$(command -v wp || command -v wp-cli || true)"
if [ -z "$WPCLI" ]; then
  echo "❌ 找不到 wp 或 wp-cli" >&2
  exit 1
fi

if [ "$#" -ne 2 ]; then
  echo "用法:$0 網站使用者 網域" >&2
  exit 2
fi
SITE_USER="$1"
SITE_DOMAIN="$2"
[[ "$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,請先做 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"; }

ver="$(wpx plugin get "$PLUGIN" --field=version 2> /dev/null)" || {
  echo "⚪ ${SITE_DOMAIN} 沒有安裝 ${PLUGIN},不需處理"
  exit 0
}

if is_fixed "$ver"; then
  echo "🟢 ${SITE_DOMAIN} 目前 ${ver},已經是 ${FIXED} 以上,略過更新,只做驗證"
else
  echo "🔴 ${SITE_DOMAIN} 目前 ${ver},低於 ${FIXED},開始處理"

  BACKUP_DIR="${HOME}/wp-security-backup"
  STAMP="$(date +%Y%m%d-%H%M%S)"
  DB_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_db_${STAMP}.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
  install -m 600 -- "${WP_PATH}/wp-config.php" "${BACKUP_DIR}/${SITE_DOMAIN}_wp-config.php.${STAMP}"
  echo "✅ 資料庫與 wp-config.php 已備份到 ${BACKUP_DIR}"

  echo "--- 預覽(不會真的更新)---"
  wpx plugin update "$PLUGIN" --dry-run

  read -r -p "確認要更新 ${SITE_DOMAIN} 嗎?(y/N):" ans
  if [ "${ans:-}" != "y" ]; then
    echo "已取消,沒有任何變更"
    exit 0
  fi

  wpx plugin update "$PLUGIN"
  ver="$(wpx plugin get "$PLUGIN" --field=version)"
  if ! is_fixed "$ver"; then
    echo "❌ 更新後版本是 ${ver},仍低於 ${FIXED},請把輸出交給維運人員" >&2
    exit 1
  fi
fi

echo "--- 驗證 ---"
wpx plugin verify-checksums "$PLUGIN"
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}:版本 ${ver}、首頁 HTTP ${code}" ;;
  *)     echo "❌ ${SITE_DOMAIN}:首頁 HTTP ${code:-無回應},請立刻檢查" >&2; exit 1 ;;
esac
echo "📝 還沒完成:請實際送出一次每張聯絡表單,並確認 PDF 與郵件附件正常"
UACF7_PATCH_EOF

chown root:root /usr/local/sbin/uacf7-patch.sh
chmod 750 /usr/local/sbin/uacf7-patch.sh
bash -n /usr/local/sbin/uacf7-patch.sh && echo "語法檢查通過"

驗證(確認腳本建好了):最後一行預期印出「語法檢查通過」。沒有這行就代表 heredoc 內容有缺漏,用 ls -l /usr/local/sbin/uacf7-patch.sh 看檔案是否存在,再重新貼一次整段 🧪

使用方式:一次一站,先處理一站、確認前後台正常,再處理下一站。把第五章盤點出的 🔴 站,逐一用「使用者、網域」當參數跑:

/usr/local/sbin/uacf7-patch.sh admin example.com

預期輸出範例:

🔴 example.com 目前 3.5.54,低於 3.5.55,開始處理
✅ 資料庫與 wp-config.php 已備份到 /root/wp-security-backup
--- 預覽(不會真的更新)---
(WP-CLI 會列出一張 3.5.54 → 3.5.57 的更新預覽表)
確認要更新 example.com 嗎?(y/N):y
Success: Updated 1 of 1 plugins.
--- 驗證 ---
Success: Verified 1 of 1 plugins.
✅ example.com:版本 3.5.57、首頁 HTTP 200
📝 還沒完成:請實際送出一次每張聯絡表單,並確認 PDF 與郵件附件正常
  • 🟢 看到最後的 ✅ 👉 這一站修補完成。接著手動清網站快取與 CDN 快取,並照第二章的測試清單把每張表單送出一次。
  • 🟠 備份失敗 👉 腳本直接停下,沒有任何更新,先查磁碟空間與資料庫權限。
  • 🟠 更新時出現「需要較新的 WordPress 或 PHP」之類的訊息 👉 主機環境太舊,先走 B 的選項一或二關掉風險,再排環境升級。
  • 🟠 出現 ❌「仍低於 3.5.55」 👉 更新來源沒生效,把輸出交給維運人員,不要反覆硬跑。
  • 🟠 verify-checksums 出現 File was added 👉 外掛資料夾裡多了官方沒有的檔案,逐一確認,不確定就視為可疑,回到 A。
  • 🔴 更新後網站白畫面 👉 立刻停用外掛(B 選項二),再用備份還原;之後把備份拿到測試站慢慢查,不要在正式站試錯。
  • 🟡 預覽時沒有列出可用更新 👉 不代表已是最新,以最後的版本判定為準;也要確認主機連得到 WordPress.org。

🚨 驗證的侷限:
verify-checksums 只能證明外掛目錄裡的檔案和官方發布版一致,檢查不到 mu-plugins、uploads、資料庫裡的異常帳號,也看不出別的外掛有沒有被動過。它只是完整性檢查的其中一環,必須搭配第五章 ㊄ 的稽核。另外,如果這一站已經中招,更新前的備份本身就含有後門,不能當成乾淨的還原來源 🔐

情境 💠 單一網站 ❇️ 獨立多站 ✴️ Multisite
疑似中招(A) incident_one 路徑 只對有疑慮的站逐一執行 對網路根目錄跑一次,腳本會連子站一起停用
設定檔不見(A-2) restore_wp_config_one 逐站還原,Multisite 不要用重建 只能從備份還原
短期應急(B) 關 PDF 開關,或 deactivate_one 路徑 逐站套用 對網路根目錄跑一次
正式修補(C) uacf7-patch.sh 使用者 網域 一次一站,確認後再下一站 外掛檔案只有一份,更新一次即可,再逐子站驗證

🔥 一句話總結:「批次」不等於把所有東西塞進一條迴圈。比較安全的做法是先盤點、再備份、預覽,確認後逐站更新,最後驗證,而且每一步失敗就停下 🎯


🔒 🔒 🔒

🔒 第七章:補完洞之後,把「被刪檔」的殺傷力降到最小

更新是根本,加固是「萬一下一顆漏洞又來,讓損害小一點、也比較救得回來」。先把話說在前面:這一章的招式擋不住這顆 CVE 本身,它們擋的是漏洞被打中之後的下一步,屬於通用加固,不是官方要求。這顆漏洞的特色是後果太直接,所以加固的重點也跟別的漏洞不太一樣:除了防「再被執行程式」,還要想辦法讓關鍵檔案「被刪了也有救」 🛡️

🧱 加固一:把 PHP 的系統指令函式關掉

為什麼這樣做:Wordfence 提到刪除設定檔有機會往遠端程式碼執行發展。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" <<'PROBE_EOF'
<?php
echo function_exists('exec') ? 'EXEC-AVAILABLE' : 'EXEC-DISABLED';
PROBE_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-DISABLED、探針已移除 👉 加固生效。
  • 🟠 回應 EXEC-AVAILABLE 👉 查這個網域實際用哪個 PHP 版本,並檢查 pool 設定有沒有覆蓋。
  • 🟠 印出「探針檔案還在」 👉 手動刪掉那個檔案,別留在網站根目錄。
  • 🟡 沒有回應 👉 可能是憑證、防火牆或網域解析問題,先確認網站本身連得上。

🚨 要誠實講的侷限
disable_functions 擋的是「呼叫系統指令」這條路。攻擊者若已經能執行 PHP,仍然可以用 PHP 本身的檔案函式讀寫檔案。所以它只是把牆加高,不是把牆變成保險箱,不能取代更新與稽核 🧱

[58]


🟧 🟧 🟧

🚫 加固二:禁止執行 uploads 資料夾裡的 PHP

為什麼這樣做:上傳資料夾本來只該放圖片與文件。攻擊者想把後門丟進網站,最常見的落腳處就是這裡;這個外掛又有上傳檔案與 PDF 相關的功能,更值得多設一道門。用 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 <<'NGINX_EOF'
# 上傳資料夾只該放圖片與文件,PHP 類檔案一律回 403
location ^~ /wp-content/uploads/ {
    location ~* \.(php|phtml|phar)$ {
        return 403;
    }
}
NGINX_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"
  if [ -e "$probe" ]; then
    printf '⚠️ 探針檔案還在,請手動刪除:%s\n' "$probe"
  fi

  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 確認即可。有份報告建議全域寫 /home/ 開頭的路徑,我不採用:那樣等於讓每個網站都能碰到彼此的目錄 🔒


🟦 🟦 🟦

🔐 加固三(選配):把 wp-config.php 鎖成「連擁有者都刪不掉」

🧊 冷知識:刪檔看的是「資料夾」的權限,不是「檔案」的權限
很多人以為把 wp-config.php 改成 440 或 400,檔案就刪不掉。其實 Linux 刪除一個檔案,要的是它所在資料夾的寫入權限,跟檔案本身的權限無關。PHP 執行帳號本來就能寫自己的網站資料夾,所以改檔案權限,擋得住「改內容」,擋不住「刪檔」。真正擋得住刪檔的,是檔案屬性裡的 immutable(不可變)旗標 🗑️

為什麼這樣做:前面的招式都是「事後」,這一招是少數能直接針對「被刪檔」的事前防線。chattr +i 之後,這個檔案連 root 都不能刪、不能改,直到解鎖為止。代價很明確:任何要寫 wp-config.php 的動作,包含 wp config shuffle-salts、wp config set,還有部分面板或搬站流程,都要先解鎖。我沒有逐一驗證 HestiaCP 的備份還原、續約等流程會不會碰到它,所以這招列為選配,建議先在一個不重要的站試用一週。所有網站的檔案系統也不一定支援 chattr,函式有驗證,不支援會直接告訴你 ⚠️

# 選配加固:把 wp-config.php 鎖成「連擁有者都刪不掉」(chattr +i)
lock_config_one() {
  local wp_path="$1"
  chattr +i -- "${wp_path}/wp-config.php" || return 1
  lsattr -- "${wp_path}/wp-config.php"
  if config_is_locked "$wp_path"; then
    echo "🔒 已鎖定(屬性欄有 i)"
  else
    echo "❌ 沒有鎖成功:這個檔案系統可能不支援 chattr"
    return 1
  fi
}

# ⚠️ 要刷鹽值、改設定、搬網站之前,先解鎖
unlock_config_one() {
  local wp_path="$1"
  chattr -i -- "${wp_path}/wp-config.php" || return 1
  lsattr -- "${wp_path}/wp-config.php"
  if config_is_locked "$wp_path"; then
    echo "❌ 還是鎖著"
    return 1
  fi
  echo "🔓 已解鎖"
}

lock_config_one "/home/admin/web/example.com/public_html"

預期輸出範例:

----i---------e------- /home/admin/web/example.com/public_html/wp-config.php
🔒 已鎖定(屬性欄有 i)

解鎖:要刷鹽值、改設定、搬網站之前,先解鎖;做完再鎖回去 🔓

# 要刷鹽值、改設定、搬網站之前,先解鎖;做完再鎖回去
unlock_config_one "/home/admin/web/example.com/public_html"
  • 🟢 鎖定後屬性欄看得到 i 👉 成功。想親眼確認,可以用 lsattr 看一次。
  • 🟠 印出「沒有鎖成功」 👉 檔案系統可能不支援,這招對你的主機無效,改靠下一個「巡檢」。
  • 🟠 日後 shuffle-salts 或改設定失敗 👉 先 unlock_config_one,第六章 ㊀ 的刷鹽值函式也會主動提醒你這件事。

👀 加固四:每 15 分鐘巡一次,設定檔不見了就留下紀錄

為什麼這樣做:鎖檔案是「防」,巡檢是「知道」。最糟的情況不是設定檔被刪,而是被刪了你兩天後才發現。這支小腳本每 15 分鐘掃一次所有網站,哪一站的 wp-config.php 不見了(上一層也沒有),就寫進系統日誌,並以非 0 的結束碼離開,方便你之後接到任何監控或通知工具。它不會刪任何東西,也不會寫網站目錄。heredoc 結束標記同樣用單引號包住,建好立刻做語法檢查 👀

cat > /usr/local/sbin/wp-keyfile-watch.sh <<'WATCH_EOF'
#!/usr/bin/env bash
# wp-keyfile-watch.sh:檢查每個 WordPress 的 wp-config.php 還在不在,不見就寫進系統日誌
# 測試時可以用 WATCH_ROOT=/某個測試目錄 指定掃描範圍
set -euo pipefail

ROOT="${WATCH_ROOT:-/home}"
missing=0

while IFS= read -r -d '' ver_file <&3; do
  wp_path="${ver_file%/wp-includes/version.php}"
  if [ ! -f "${wp_path}/wp-config.php" ] && [ ! -f "$(dirname -- "$wp_path")/wp-config.php" ]; then
    logger -p user.crit -t wp-keyfile-watch "wp-config.php 不見了:${wp_path}" || true
    printf '🔴 wp-config.php 不見了:%s\n' "$wp_path"
    missing=1
  fi
done 3< <(find "$ROOT" -maxdepth 6 -type f -path '*/web/*/public_html/wp-includes/version.php' -print0 2> /dev/null)

if [ "$missing" -eq 0 ]; then
  echo "🟢 所有 wp-config.php 都還在"
fi
exit "$missing"
WATCH_EOF

chown root:root /usr/local/sbin/wp-keyfile-watch.sh
chmod 750 /usr/local/sbin/wp-keyfile-watch.sh
bash -n /usr/local/sbin/wp-keyfile-watch.sh && echo "語法檢查通過"

# 每 15 分鐘巡一次
cat > /etc/cron.d/wp-keyfile-watch <<'CRON_EOF'
*/15 * * * * root /usr/local/sbin/wp-keyfile-watch.sh > /dev/null 2>&1
CRON_EOF
chmod 644 /etc/cron.d/wp-keyfile-watch

驗證一(確認腳本與排程都建好了):預期腳本印出 🟢、結束碼 0,排程檔內容正確,cron 服務是 active。要看警報紀錄,用最後那行 journalctl 🧪

# 正常情況:結束碼應該是 0
/usr/local/sbin/wp-keyfile-watch.sh
echo "結束碼:$?"
# 確認排程已建立、cron 服務在跑
cat /etc/cron.d/wp-keyfile-watch
systemctl is-active cron
# 警報會寫進系統日誌,要查的時候用這行
journalctl -t wp-keyfile-watch --since today
🟢 所有 wp-config.php 都還在
結束碼:0
*/15 * * * * root /usr/local/sbin/wp-keyfile-watch.sh > /dev/null 2>&1
active

驗證二(確認警報真的會響):不要拿真的網站來測。下面用一個臨時假目錄,只放 version.php、故意沒有 wp-config.php,預期會看到 🔴 和結束碼 1。測完那行 rm -rf 只刪我們剛建的臨時目錄,執行前請再確認路徑沒被你改過 🧪

# 用一個臨時的假目錄測試警報,不會碰到任何真的網站
mkdir -p /tmp/watchtest/zed/web/z.example.com/public_html/wp-includes
touch /tmp/watchtest/zed/web/z.example.com/public_html/wp-includes/version.php

WATCH_ROOT=/tmp/watchtest /usr/local/sbin/wp-keyfile-watch.sh
echo "結束碼:$?"

# ⚠️ 只刪我們剛建的臨時目錄
rm -rf /tmp/watchtest
🔴 wp-config.php 不見了:/tmp/watchtest/zed/web/z.example.com/public_html
結束碼:1
  • 🟢 驗證一印出 🟢 與結束碼 0 👉 正常網站沒有誤報。
  • 🟢 驗證二印出 🔴 與結束碼 1 👉 警報邏輯正確。
  • 🟠 驗證一出現 🔴 但你知道網站都在 👉 看一下是不是舊的測試目錄、已搬走的網站殘留了 wp-includes,不是真的被刪。
  • 🟠 cron 不是 active 👉 排程不會跑,先用 systemctl 檢查 cron 服務,這部分依主機而異。
  • 🟡 警報只寫進日誌,不會主動通知你。要推播或寄信,需要自己接上其他工具,這裡不展開。

🧼 加固五:把 PDF 模組與表單管乾淨

這顆漏洞的前提是「PDF 模組啟用,且表單有設定產生 PDF」。更新之後,下面幾個習慣仍能讓下一次出事時損害更小 🧼

  • ⭐ 用不到的模組直接關掉。PDF Generator 只在需要時開,活動結束就關。
  • ⭐ 用不到的表單直接刪除。連同嵌入它的頁面一起清掉,別讓設定了 PDF 的舊表單留在後台。
  • ⭐ 開自動更新,並搭配每日備份。這次修補版比揭露早三週,有開自動更新的站,其實沒受到什麼影響。
  • ⭐ 備份要包含 wp-config.php。只備份資料庫的話,設定檔被刪時救不回來。

🩵 荷包試算:要不要花錢買 WAF 或虛擬修補?
這章的招式全部 NT$ 0 元。付費防火牆可以在你更新前先擋一層,但這顆漏洞在後台點一下「更新」就解決,今天能更新,通常比月付方案划算。真的卡在相容性、短期無法更新時,再把付費防護當過渡手段,別反過來拿它當不更新的理由。至於 WAF 能不能擋這種「看起來像正常表單送出」的請求,我沒有資料可以驗證 💰

[59]


🟢 🟢 🟢

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

  • 🌠 Q:我已經是 3.5.57 了,就安全了嗎?
    A:就這顆 CVE 來說,是的,3.5.55 起就是官方修補版。但舊版如果曾對外暴露,更新只能擋住下一次,擋不了已經發生的事,所以還是建議做完第五章的關鍵檔案與帳號稽核 📦
  • 🌟 Q:我從來沒開過 PDF Generator,還需要更新嗎?
    A:需要。依 Wordfence 的描述,沒開就不符合利用條件,但我無法保證「沒開就一定打不到」,而且日後有人把它打開,洞就跟著回來了 🧭
  • ✨ Q:更新後表單壞掉,可以降版嗎?
    A:不建議。降版等於重新打開漏洞。先照第二章的測試清單找出是哪個環節壞掉,把有問題的表單或 PDF 模組先下架,再拿備份到測試站慢慢查 🙅
  • 🌠 Q:網站突然跳出 WordPress 安裝畫面,我可以先填一下看看嗎?
    A:不可以。依報告描述,這個畫面不會核對訪客身分,填下去就可能變成別人接管。先關站,找主機商,走第六章 A-2 🛑
  • ✨ Q:看不懂那些指令,是不是沒救了?
    A:不會。第三章全程用滑鼠就能完成最基本的處理,第五章以後是給有 SSH 權限的人看的補充 🔍

🧠 🧠 🧠

🧩 舉一反三:「外面來的資料,直接拿去當檔案路徑或物件」這一類漏洞怎麼防

下面這些是通用防禦知識,不是本 CVE 的官方要求,但下次看到類似新聞能對號入座 🫡

  • ⭐ 不可信的資料,不要直接丟進反序列化。能用 JSON 就用 JSON;非用不可,也要先限制能還原的類別範圍,並對資料做簽章驗證。
  • ⭐ 檔案操作前,先確認完整路徑落在預期資料夾內。刪除、讀取、寫入之前,把路徑換算成完整路徑,再比對是不是在白名單目錄之下,不要只靠前面的過濾。
  • ⭐ PHP 執行帳號採最小權限,網站之間彼此隔離。這次 HestiaCP 一站一使用者的設計,讓一個網站被打中時,至少不會連累隔壁站。
  • ⭐ 非必要的功能預設關閉。PDF 產生、匯入匯出、檔案上傳這類會碰檔案系統的模組,用的時候才開。
  • ⭐ 修補與事件應變是兩件事。舊版只要曾暴露在網路上,更新完還要查日誌、查帳號、查檔案,缺一不可。

[60]


🏁 🏁 🏁

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

評估維度(十分制,數字越高代表風險或應對品質越正向) 分數 一句話評語
漏洞嚴重程度(越高代表風險越大) 9.8 免登入、免互動,後果可從「網站中斷」到「被接管」;扣不掉的是利用前提:要有 PDF 模組與設定了 PDF 的表單
官方應變速度 8 修補版比公開揭露早三週,後續兩版持續加固 PDF 模組,是加分項;更新日誌沒說明細節,扣一點 🟡
一般站長自救可行性 8 後台點「更新」就能修補,門檻低;扣分在於要多做「確認 PDF 開關」和「看一眼設定檔還在不在」這些事後檢查
DevOps 技術文件完整度 5 官方沒公開程式差異,觸發入口、是否遭利用都無法確認,排查只能靠結果特徵
🏆 加權綜合建議 立即更新 最終建議:【今天內更新到 3.5.55 以上(建議直接上當下最新版);有 PDF 表單就確認關鍵檔案還在;設定檔不見了走第六章 A-2;不能更新就先關 PDF 模組】

🔥 這次最特別的地方在於,修補版早了三週,漏洞資訊才公開。真正有差別的是你有沒有在細節公開的這段空窗,把門關好。花五分鐘打開外掛頁看一眼版本號,順便看一眼 PDF 開關,比事後花五天救一個被刪光設定檔的網站便宜太多 🔢


📎 📎 📎

📎 附錄 A:一頁式決策表(印出來貼在螢幕旁)

你看到的狀況 先做什麼 接著做什麼
版本 3.5.54 以下(含),沒有其他異常 備份(要含 wp-config.php) 更新(C),測每張表單,再做第五章稽核
版本 3.5.54 以下(含),而且有 PDF 表單 備份;今天無法更新就走 B 更新(C),做第五章 ㊂ ㊃ ㊄
wp-config.php 不見了、或跳出安裝畫面 不要填安裝畫面,不要刪任何東西,走 A-2 救回後走 A-1 取證與止血,再走 C
uploads、mu-plugins 出現不明 PHP,或有不認識的管理員 不要刪,走 A-1 取證與止血 必要時換資料庫密碼(A-3),再走 C;有個資的網站通知內部資安與法務
更新後表單壞掉 對照第二章測試清單找原因 先下架有問題的表單或 PDF 模組,不要降版
更新後網站白畫面 停用外掛(B 選項二) 用備份到測試站查原因
更新失敗,訊息提到 WordPress 或 PHP 版本太舊 先走 B 關掉風險 排環境升級,再走 C

📖 附錄 B:名詞小辭典

  • 🔹 CNA:負責分配 CVE 編號的機構。這次是 Wordfence。
  • 🔹 CVE:漏洞的國際編號,CVE-2026-102435 就是這次的編號。
  • 🔹 CVSS:漏洞嚴重度評分,滿分 10 分,9.0 以上屬於 Critical。
  • 🔹 CWE-502:「不可信資料的反序列化」這一類弱點的分類編號。
  • 🔹 immutable(不可變):用 chattr +i 設在檔案上的屬性,設了之後連擁有者與 root 都不能改或刪,直到解除為止。
  • 🔹 POP 鏈:借用程式裡現成的類別,把它們的方法串成一條,最後做到本來不該做的事。
  • 🔹 PoC:Proof of Concept,概念驗證,能示範漏洞怎麼被利用的程式或步驟。
  • 🔹 反序列化:把一串文字拆回程式裡的物件。拆的如果是陌生人給的資料,就可能出事。
  • 🔹 鹽值(Salts):wp-config.php 裡八組長字串,用來簽章登入 Cookie。
  • 🔹 WP-CLI:WordPress 的命令列工具,用來在主機上管理網站。
  • 🔹 wp-config.php:WordPress 的核心設定檔,記錄網站怎麼連到資料庫。

[61]


📎 📎 📎

📚 參考資料與延伸資源

列印本文 [77] 👨‍👩‍👧‍👦 94 次瀏覽