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

🚨填一個「訂閱表單」,怎麼變成管理員的萬能鑰匙?📮 MailOptin 高危漏洞全解析(CVE-2026-109515/CVSS 9.8)

🚪 管理員名單看起來沒事,密碼卻早被換掉?MailOptin 權限提升漏洞自救全攻略(CVE-2026-109515/CVSS 9.8)

🔥 懶人包:
電子報、彈出式訂閱視窗的超夯外掛 MailOptin(WordPress 官方顯示活躍安裝數高達 10,000+),最近被爆出一個超瞎的漏洞(CVE-2026-109515,CVSS 9.8 嚴重等級)。它危險在哪?駭客「完全不用登入」,只要在對外公開的訂閱表單動點手腳,就能發動!
破口出在系統沒有檢查「表單欄位可不可以寫進帳號權限」,導致路人甲隨便填個表單,就有機會把你的網站管理員身分給搶走!受影響版本落在 1.2.78.6 以下(含),官方已緊急在 1.2.78.7 推出修補包。雖然目前還沒傳出大規模屠殺,也還沒上美國 CISA 的黑名單,但沒上報不代表沒人在偷偷敲門。今天花個五分鐘確認版本、點擊更新,再順手看一眼管理員名單,保你今晚睡個好覺 🥱

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

[2]


🟦 🟦 🟦

內容目錄

🏨 第一章:擺在門口的「免費試吃券」,怎麼會變成駭客的「店長萬能鑰匙」?

(🔴 以下是假設情境,用來幫你抓畫面,不是實際的攻擊紀錄)想像一下你開了一間超人氣的甜點店,平時在店門口放了一疊「免費試吃券」的登記表,路過的人都可以隨便拿來填。表單上有「姓名」、「電話」、「Email」,櫃台工讀生收到後,就乖乖照著欄位把資料抄進會員系統裡。平常這樣運作都沒事,直到有天某個奧客在表單角落自己手寫加了一欄「身分:甜點店店長」,結果工讀生居然也沒檢查,直接傻傻照抄進去… 於是,系統裡原本的店長資料就被蓋掉了,這個奧客瞬間成了這間店的新主人 🏨

這就是這次「權限提升(Privilege Escalation)」漏洞的白話版。我們今天的主角 MailOptin 是一套用來做電子報訂閱、收集名單的 WordPress 外掛。它能把訪客留下的 Email 轉化成你網站的使用者帳號。結果出事的,偏偏就是這條「把表單資料寫進使用者帳號」的處理路徑 📝

📢 大白話:什麼是「權限提升(Privilege Escalation)」漏洞?
第一次聽到這個詞的新手,可以把它想成「偷換證件」。一般訪客逛你的網站,本來只該拿到「訪客參觀證」,但權限提升就是系統腦袋當機,讓有心人士自己拿筆在證件上塗改,硬生生把參觀證升級成了「總經理萬能通行證」。這次被點名的 CWE-269 弱點,最可怕的地方在於:駭客拿到的是「合法的管理員身分」,看起來就像是你自己人,防毒軟體跟日誌根本抓不到異狀 🪪

🧊 冷知識:WordPress 建立帳號的函式,其實「新增」和「修改」是同一個
就像戶政事務所的櫃檯,辦理「新生兒入戶籍」跟「改名字」居然是用同一個系統按鈕!在 WordPress 裡,函式 wp_insert_user() 當你給它的資料裡帶有 ID 時,它會當成「更新舊會員」,沒帶 ID 才是「新增」。這個設計本來沒錯,但外掛把「訪客填的資料」直接丟進去又沒擋掉敏感權限,新增會員就悄悄變成了竄改老闆帳號 🧰


🟩 🟩 🟩

⏱️ 案發時間軸:這場「表單之亂」到底是怎麼捲起來的?

這次的資安警報集中在短短兩天之內爆發,而且網路上流傳的幾個關鍵日期還有點對不上。我幫大家交叉比對過,把確定的寫成「已確認」,羅生門的部分老實標出來 📅

日期 事件節點 可信度與備註
2026-10-15 Wordfence 率先對外揭露此漏洞,回報者為研究員 Kuba ✅ 多份資料一致
2026-10-16 Wordfence 漏洞頁面更新(也就是本文寫作當天) ✅ 多份資料一致
1.2.78.5 官方更新日誌記載:替公開的訂閱處理入口加上「請求頻率限制」 ✅ WP 官方外掛頁可查;發布日期【❓待確認】
1.2.78.7 真正修補版出爐。修正「欄位未經淨化」的致命安全問題 ✅ WP 官方外掛頁可查;發布日期【❓待確認】
CVE 紀錄日期 不同資料庫出現 10/12 與 10/16 兩種說法 【❓待確認】通常編號保留跟發布時間會混淆,我們不選邊站

補充一個容易看漏的細節:官網另有一份 mailoptin.io 的更新日誌,版本號寫成 2.2.78.7,跟 WordPress 免費版的 1.2.78.7 只差開頭一個數字。這兩個到底是不是同一個版本線的不同寫法?我找不到明確說明【❓待確認】。如果你用的是付費版(Pro),請直接看你後台顯示的版本與官方公告對照為主 🔍

🩵 小資族荷包試算:這次補洞要花錢嗎?
免啦!外掛頁面上的免費版更新是 NT$0,後台點一下就搞定。真正會燒錢的是出事之後:請資安專家做鑑識、重建被搞爛的網站、通知個資外洩的客戶… 這些隱形成本絕對讓你痛不欲生,乖乖花五分鐘更新最划算 💸

[33]


🟧 🟧 🟧

⚠️ 說大白話:如果不處理,最慘會落得什麼下場?幫你把「鐵證」與「猜想」分清楚!

看資安新聞最怕自己嚇自己。我幫你把情報分成三層,讓你看懂哪些是鐵證、哪些是推論、哪些只是最壞的假設 👇

  • ✅ 已確認事實:漏洞評分是極度危險的 9.8 分。翻成白話就是「從網路上就能打、難度超低、不用帳號、不用你配合點擊」,而且把網站的機密性、完整性全給端了。受影響版本為 1.2.78.6(含)以下,安全版為 1.2.78.7。
  • 🟡 合理推論:破口位在公開的訂閱處理流程,駭客能隨便亂指「表單資料要對應哪個帳號欄位」。如果你的網站剛好有啟用「註冊成網站會員」的整合表單,駭客就能順水推舟改寫你家管理員的密碼。(這部分來自多方情資彙整,非逐行原始碼核對)。
  • 🔴 假設情境:路人甲填表 👉 老闆密碼被竄改 👉 駭客用新密碼登入後台 👉 植入木馬後門 👉 整個網站變成駭客的詐騙跳板。這是拿到管理員權限後的「常見劇本」,不代表單靠這顆漏洞就會自動走完所有流程,刻意分開寫免得大家過度恐慌。

🚥 畫重點:別以為「我沒放這個表單」就絕對安全!
目前的情報指出,要引爆漏洞的前提是「站上存在公開的註冊整合表單」。如果你確定沒放,風險確實比較低;但是!只要這個外掛還亮著「啟用」的綠燈,你就沒辦法保證系統深處有沒有殘留舊的表單代碼。所以別跟運氣對賭,更新才是唯一解藥 🙅‍♂️

🧊 冷知識:為什麼網站被駭了,日誌裡卻像「歲月靜好」?
網站伺服器的警衛(存取日誌)通常只會登記「誰去了哪個網址」,但不會登記「他填了什麼表單內容」。所以當駭客送出竄改權限的表單時,警衛只會紀錄「有人送出了一次請求」,根本看不出異狀。沒看到可疑紀錄,千萬別當作安全證明 🕵️


🟪 🟪 🟪

🎭 第二章:對號入座!先別急著恐慌,看看你是哪一種「網站頭家」?

資安警報一響,不同身分的人要做的動作完全不一樣。先看看自己屬於哪種情境,再決定往下看哪一章 🧭

你的情境 要不要緊張? 現在最該做的事
🙋 第一種:社群小編、老闆(單一網站) 中等,點擊更新就能解決 跳到第三章:確認版本 👉 更新 👉 看一眼管理員名單(全程免指令)
👨‍💻 第二種:自架站玩家、接案工程師(管多個站) 中偏高,站點越多越容易漏 建議直接跳到第五章(保留原文後半段),用腳本盤點全主機
🏢 第三種:企業電商(握有一大把客戶名單) 高度警戒,牽涉個資法責任 先更新!再請工程師保留日誌證據,並由法務評估是否須通報客戶
🚨 第四種:外掛裝了放著生灰塵的「地雷區」 最高!隨時引爆 外掛沒在更新、又開放公開註冊,請立刻停用外掛!

🙋 第一種:我只是發文、顧後台的社群小編或老闆

你完全不需要懂什麼 PHP 或指令碼。你要做的只有超簡單三步:確認版本、點擊更新、看一眼管理員名單有沒有多出陌生人。第三章把每一步「滑鼠要點哪、看到什麼才正常」都拆解好了。更新是免費的,大膽點下去就對了 ✅

👨‍💻 第二種:我是包山包海的自架站玩家(多站點)

如果你用 HestiaCP 這類面板管著十幾個客戶的網站,你需要的是「一次掃完整台主機」的火力。記得更新完外掛後,一定要順手清空一次快取(Cache),不然前台常常還卡在舊版狀態 🔧

🧑‍🔧 DevOps小知識:多站共用帳號的慘痛代價
很多接案仔為了貪圖方便,把十幾個客戶的網站全塞在同一個系統使用者底下。這時候原本的「帳號隔離」就形同虛設了!只要 A 客戶的網站被這顆漏洞打穿、管理員被接管,駭客就能順藤摸瓜把 B 客戶、C 客戶的網站全部端掉。拜託,一個客戶給他一個獨立的家目錄好嗎 🧱

🏢 第三種:我們是企業網站,握有一大把客戶訂閱名單

對公司行號來說,免費外掛出事是沒有人會賠償你的。由於這款外掛本來就是做「名單收集」的,如果真的被植入木馬,台灣《個人資料保護法》可是會找上門的【❓待確認:實際法律責任需由專業法務判定】。請務必保留伺服器日誌,重大決策前記得諮詢專業資安顧問 🩺

🚨 第四種:超級危險群!鐵定中招的「地雷區」

🚨 高風險情境模擬:「兩年前的活動早結束了,外掛放著沒差吧?」
(🔴 假設情境)某間行銷公司兩年前用 MailOptin 辦過抽獎,後來活動網頁撤了,但外掛一直「忘記停用」。老闆以為「沒在用就沒事」。
🙅 大錯特錯!這就像你家兩年前裝了一扇後門,平常雖然不走,但鎖頭生鏽了你也不知道。只要外掛還亮著「啟用」綠燈,它註冊的公開入口就依然敞開,駭客照樣能長驅直入 🆘

🚥 畫重點!千萬別踩雷:
更新與停用,你今天最少要選一個做!如果因為相容性不敢馬上更新,退而求其次就是立刻停用。記住,「沒在用」不代表沒風險,「已停用」才是真正的拉下鐵門 🙅‍♂️

[34]


🟩 🟩 🟩

🛟 第三章:電腦小白專屬!五分鐘無痛自救法(全滑鼠操作,免敲指令)

不管你是哪一種網站主,我都強烈建議你跟著這幾步走一次。每一步都有防呆說明,照著點就好 🤏

🫂 新手求助:我連後台都進不去,看不懂怎麼辦?
直接把這段話複製貼給你的主機商或接案工程師:「我的網站可能裝了 MailOptin,可能有相關漏洞編號 CVE-2026-109515,請幫我確認版本是不是在 1.2.78.7 以上。」內行人一看就秒懂!千萬不要自己在主機後台亂刪檔案喔 🤗

  • ㊀ 先摸摸口袋,確認你有沒有裝:
    登入 WordPress 後台,點左邊選單的「外掛」→「已安裝的外掛」。在右上角搜尋框打上 MailOptin。如果出現它,代表中獎了;如果沒搜到,恭喜你,這篇文章你可以關掉了 🔎
  • ㊁ 檢查版本號碼,判斷吉凶:
    看一下外掛名稱下方的數字。如果是 1.2.78.6(含)或更舊,請馬上往下看;如果是 1.2.78.7 或更新,代表你已經度過危險期,但建議還是把最後兩步(檢查表單跟管理員)做完。用付費版的朋友,版本號可能長得不一樣,記得去官網對照 🕵️‍♀️
  • ㊂ 更新前,先買個保險(備份):
    去主機控制台按一下「一鍵備份」,或是用你平常用的備份外掛。如果你連備份是什麼都不知道…沒關係,直接把這篇文章網址傳給你的網站工程師,跟他說「救命!幫我備份」,這是他們的工作 💾
  • ㊃ 勇敢按下「立即更新」:
    在外掛列表點選「立即更新」,等轉圈圈轉完後重整網頁,確認版本跳到 1.2.78.7 以上。接著去前台點一下你的彈出視窗,確認版面沒跑掉。萬一畫面變全白怎麼辦?千萬別狂按 F5,趕快找工程師求救,並暫時把外掛選「停用」 🔄
  • ㊄ 當柯南,檢查有沒有「註冊會員」的表單:
    去左側找 MailOptin 的專屬選單,點進你的表單(Optin)清單,看看有沒有勾選類似「WordPress User Registration(註冊為網站使用者)」的功能。如果有,代表你就是這次漏洞的超級標靶,請務必嚴格執行下一步 🧾
  • ㊅ 最後的底線:抓出幽靈管理員 👻
    點左邊的「使用者」選單,上方篩選出「管理員」。正常狀況:裡面都是你認識的老闆、同事。危險狀況:出現一堆亂碼名字、免洗 Email,或你根本沒印象的帳號!這時候先截圖存證留給工程師,然後請所有【真・管理員】立刻把密碼換掉 🔀
  • ㊆ 靈異現象:「奇怪,我怎麼登不進後台?」
    如果這幾天有人抱怨密碼突然失效,千萬別當作他金魚腦,這絕對是最高級別的紅色警戒!但反過來說,「最近大家都登得進去」也不代表沒被駭,因為駭客通常會悄悄「多加」一個帳號,而不是把你踢出去 🚨
  • ㊇ 懶人福音:把自動更新打開吧:
    在外掛列表右邊點一下「啟用自動更新」。以後這種要命的安全修補,系統就會自己在半夜幫你裝好,不用再提心吊膽 ⏰

💬 溫馨提醒:更新外掛只是「現在幫你換一副新鎖」,沒辦法證明「以前有沒有🦹小偷拿萬能鑰匙進來過」。這就是為什麼一定要再多看一眼管理員名單的原因!如果有疑慮,請找專業技術人員做進一步排查 🩺

🧊 冷知識:「停用」跟「刪除」到底差在哪?
「停用」就像是把店家鐵門拉下來暫停營業,裡面的裝潢(資料)都還在,隨時可以重新開張,也能成功擋住這次的漏洞攻擊;「刪除」則是直接把整間店拆了!萬一你網站上還有舊文章在呼叫這個表單,版面就會直接破一個大洞 🧰


🔴 🔴 🔴

🤿 第四章:欄位對應是怎麼被拿來亂寫帳號的?(以及為什麼五份報告細節對不上)

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

評估指標 具體資訊
外掛名稱 MailOptin – Popup, Optin Forms & Email Newsletters
外掛 slug mailoptin
CVE 編號 CVE-2026-109515
CVSS 3.1 9.8(Critical)
CVSS 向量 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
受影響版本 ≤ 1.2.78.6
安全版本 ≥ 1.2.78.7
漏洞類型 未授權權限提升(CWE-269;部分報告另標 CWE-266,【❓待確認】)
觸發流程 公開的訂閱處理流程 subscribe_to_email_list,經 WordPressUserRegistrationConnect 的 subscribe() 進入 wp_insert_user()
發現者 兩份報告提到 Kuba,其餘沒有提及,我讀不到 Wordfence 頁面【❓待確認】
WordPress.org 實測資料 活躍安裝 10,000+、需 WP 6.4+、PHP 7.4+

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

  • 🏮 缺陷一:參數由訪客掌控。表單資料裡的 form_custom_field_mappings 本來該由管理員在後台決定,卻可以在公開請求裡被帶進來。
  • 🏮 缺陷二:沒有「目的地白名單」。對照表的目的地欄位沒有限定只能是「信箱、暱稱」這類安全欄位,ID、user_pass 這種不該由訪客決定的欄位也一起放行。
  • 🏮 缺陷三:新增與更新共用同一條路。wp_insert_user() 只要看到有效的 ID,就會切換成更新模式,攻擊者因此能把「建立訪客帳號」的功能,變成「覆寫既有帳號」。
📢 大白話:為什麼「白名單」比「黑名單」可靠?
這裡屬於推論,標為 🟡 黑名單是門口貼一張「這幾種人不准進」,攻擊者只要換個長相就能混進去;白名單是「只有名單上的人能進」,名單外的一律攔下。這次的修補,推測就是把欄位對應限縮成允許的欄位,等於從「想到什麼擋什麼」改成「只放行這幾個」。修補的細節官方沒有公開 🚪

🧑‍🔧 DevOps 小知識:前提條件比 CVSS 分數更值得你關心
這個攻擊需要一張已發布、整合類型為「WordPress User Registration」的表單。CVSS 把「前提條件」算進攻擊複雜度時偏保守,所以給了 9.8 分,但實務上,你的站有沒有這種表單,會決定你是「立刻會被打」還是「理論上有風險」。這也是第五章㊂會教你去找這種表單的原因 🎚️

🔧 1.2.78.7 到底修了什麼?

WordPress.org 的更新日誌只有一句話:修正「未經清理的欄位對應參數」所造成的安全問題。沒有程式差異可供逐行核對 🧾 有些報告推測是加上了欄位白名單,或在寫入使用者前剔除 ID 與 user_pass,這些都是合理推論,我沒能直接比對程式碼,所以不當作事實寫死【❓待確認】

[35]


🔷 🔷 🔷

🕵️ 第五章:怎麼知道有沒有人先進來過?SSH 排查 Checklist

🤖 重要提醒:以下指令由五份來源報告(ChatGPT、DeepSeek、Gemini、Grok、Meta AI)彙整,經 Claude 複查修正與整理,並依 HestiaCP 官方文件與 WP-CLI 官方文件逐項複查,所有程式碼區塊都做過 bash 語法檢查 🫡
但你的主機環境不一定跟範例一樣。正式環境(尤其是正在營業中的網站)的高風險操作——包含更新、停用、刪除檔案、變更權限、重設密碼或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin、bob 都是示範用名稱,請換成你自己的環境 🧙

🚨 畫重點:這次報告之間的分歧,我怎麼處理?
五份來源報告在「觸發的 AJAX 動作名稱」「CWE 分類」「發現者」「CVE 公開日」這幾點互相矛盾,其中有幾份還提供了「看起來很專業」的偵測規則。我的處理原則是:只採用多份一致、且和 WordPress.org 公開資料對得上的資訊,其他一律標【❓待確認】。因此第五章的排查不依賴某個特定的 AJAX 動作名稱,而是從「結果」去找:管理員、登入階段、檔案、外掛 🧭

這一章的流程是:環境判斷 👉 盤點版本 👉 帳號與設定 👉 檔案與日誌 👉 加固五個關卡,每一關都拆成「決策原理、指令、預期輸出、分支判斷」。預設環境是 Debian 12 + HestiaCP,以 root 身分操作。AlmaLinux 的差異是:檔案權限遇到 Permission denied 時,請先用 getenforce 看 SELinux 有沒有擋;版本比較用的 dpkg 在 AlmaLinux 沒有,可改用 sort -V 💻

🧰 先貼一次工具箱

決策原理:HestiaCP 每個網站都屬於不同的 Linux 使用者,檔案放在 /home/使用者/web/網域/public_html。用 root 直接跑 WP-CLI 會產生 root 擁有的檔案,網站自己之後寫入就會失敗,所以先定義四個小函式:找出所有網站、用檔案擁有者身分執行 WP-CLI、取出網域名稱、逐站執行並在失敗時停下。Debian 最小安裝可能沒有 sudo,所以這裡用內建的 runuser。所有路徑變數都用雙引號包起來,路徑含空白也不會出錯 🧱

# ㊀ 先貼這一段(同一個 SSH 工作階段只要貼一次,後面所有步驟都會用到)
PLUGIN="mailoptin"
FIXED="1.2.78.7"

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

# 以「網站檔案擁有者」的身分執行 WP-CLI,避免產生屬於 root 的檔案
# 最後的 < /dev/null 是刻意的:避免 wp 把迴圈正在讀的清單吃掉
wpx() {
  local wp_path="$1" owner home_dir
  shift
  owner="$(stat -c '%U' -- "$wp_path/wp-config.php")" || return 1
  home_dir="$(getent passwd "$owner" | cut -d: -f6)"
  runuser -u "$owner" -- env HOME="${home_dir:-/tmp}" wp --path="$wp_path" "$@" < /dev/null
}

# 從路徑取出 HestiaCP 的網域名稱(網站裝在子目錄也算得對)
domain_of() {
  local rest="${1#*/web/}"
  printf '%s\n' "${rest%%/*}"
}

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

# 防呆:三個條件缺一個,後面的指令都會失敗
command -v wp >/dev/null      || echo "⚠️ 找不到 wp 指令,請先安裝 WP-CLI"
command -v runuser >/dev/null || echo "⚠️ 找不到 runuser,請確認你是在 Debian/AlmaLinux 的 root 身分下操作"
[ "$(id -u)" -eq 0 ]          || echo "⚠️ 目前不是 root,請先用 su - 或 sudo -i 切換"

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

  • 🟢 沒有警告 👉 往下進入環境判斷。
  • 🟠 找不到 wp 👉 先安裝 WP-CLI,用 wp –info 確認,不要硬跑後面的指令。
  • 🟡 command not found 出現在 runuser 👉 通常是 PATH 不完整,改用 su – 取得完整 root 環境再貼一次。

🧭 🧭 🧭

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

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

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

# 把整台主機掃一遍
each_site detect_one

預期輸出範例:

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

[36]


🔎 🔎 🔎

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

決策原理:版本比對用 Debian 內建的 dpkg –compare-versions,不要用字串比大小,否則 1.2.9 會被誤判成比 1.2.78 大。判斷條件是「低於 1.2.78.7 就是受影響」。名稱含 mailoptin 但不是 Lite 的外掛,版本編號體系可能不同(例如官網 Premium 頁出現 2.2.78.7),所以腳本只對 mailoptin 這個 slug 做自動判斷,其他一律提醒人工比對,避免「2 開頭看起來比 1 大」的誤判 🔢

inventory_one() {
  local wp_path="$1" name status ver risk found=0
  if ! wpx "$wp_path" core is-installed 2>/dev/null; then
    printf '❓ WordPress 載入失敗,請先排查\t-\t-\t-\t%s\n' "$wp_path"
    return 0
  fi
  # 名稱含 mailoptin 的外掛全部列出來(Lite 與其他變體都抓得到)
  while IFS=, read -r name status ver; do
    found=1
    if [ "$name" = "$PLUGIN" ]; then
      if dpkg --compare-versions "$ver" lt "$FIXED"; then
        risk="🔴 受影響"
      else
        risk="🟢 已達修補版"
      fi
    else
      risk="❓ 版本編號體系不同,請人工比對"
    fi
    printf '%s\t%s\t%s\t%s\t%s\n' "$risk" "$name" "$ver" "$status" "$wp_path"
  done < <(wpx "$wp_path" plugin list --fields=name,status,version --format=csv | grep -i 'mailoptin')
  [ "$found" -eq 1 ] || printf '⚪ 未安裝\t-\t-\t-\t%s\n' "$wp_path"
  return 0
}

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

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

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

預期輸出範例:

🔴 受影響	mailoptin	1.2.78.6	active	/home/user1/web/example.com/public_html
❓ 版本編號體系不同,請人工比對	mailoptin-premium	2.2.78.6	active	/home/user1/web/example.com/public_html
⚪ 未安裝	-	-	-	/home/user3/web/blog.example.net/public_html
  • 🔴 受影響 👉 排入第六章的備份、預覽、更新流程;公開已多日的站,同時繼續做 ㊂、㊃。
  • 🟢 已達修補版 👉 版本條件已解除,但舊版若暴露過一段時間,仍建議做一次 ㊂、㊃。
  • ❓ 其他 MailOptin 相關外掛 👉 把外掛名稱、版本拿去 WordPress.org 或官方更新頁對照,不要用腳本猜。
  • 🟡 status 顯示 inactive 👉 外掛已停用,攻擊面暫時不在,但仍建議更新或刪除。

🟥 🟥 🟥

👤 ㊂ 帳號與設定:被改密碼的管理員,名單上看不出來

決策原理:第一章說過,這次手法偏向「覆寫既有管理員的密碼」,所以只看名單不夠。這個腳本一次看四件事:管理員名單、網站層級設定(通知信箱、是否開放註冊、新註冊者預設角色)、最近註冊的帳號,以及每位管理員目前的登入階段(WordPress 會記錄每個登入階段的 IP 與瀏覽器)。資料表前綴(wp_)每站可能不同,不能寫死:先用 wp db prefix 取得,再把 SQL 用單引號包住的 heredoc 寫進 mktemp 的暫存檔,最後用 sed 把前綴填進去,Shell 就不會誤解 SQL 裡的特殊字元。暫存檔用完立刻刪掉,不留在磁碟上 🕵️‍♀️

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

  echo "--- ❶ 管理員名單 ---"
  wpx "$wp_path" user list --role=administrator \
    --fields=ID,user_login,user_email,user_registered --format=table

  echo "--- ❷ 網站層級設定(通知信箱、是否開放註冊、新註冊者預設角色)---"
  printf 'admin_email=%s\n' "$(wpx "$wp_path" option get admin_email)"
  printf 'users_can_register=%s\n' "$(wpx "$wp_path" option get users_can_register)"
  printf 'default_role=%s\n' "$(wpx "$wp_path" option get default_role)"

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

  echo "--- ❹ 每位管理員目前的登入階段(看 IP 與瀏覽器是否陌生)---"
  while IFS= read -r uid; do
    printf '管理員 ID=%s\n' "$uid"
    wpx "$wp_path" user session list "$uid" 2>&1 | head -n 20
  done < <(wpx "$wp_path" user list --role=administrator --field=ID)
  return 0
}

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

# ❇️ 獨立多站
each_site audit_accounts_one

# ✴️ Multisite:除了管理員,另外看一眼「超級管理員」
audit_accounts_one "/home/user2/web/network.example.com/public_html"
wpx "/home/user2/web/network.example.com/public_html" super-admin list

預期輸出範例:

===== /home/user1/web/example.com/public_html =====
--- ❶ 管理員名單 ---
+----+------------+-------------------+---------------------+
| ID | user_login | user_email        | user_registered     |
+----+------------+-------------------+---------------------+
|  1 | admin      | admin@example.com | 2024-01-15 08:00:00 |
+----+------------+-------------------+---------------------+
--- ❷ 網站層級設定(通知信箱、是否開放註冊、新註冊者預設角色)---
admin_email=admin@example.com
users_can_register=0
default_role=subscriber
  • 🔴 管理員名單出現註冊時間在漏洞公開前後、你不認識的帳號 👉 高度可疑,進入第六章「A」。
  • 🔴 某位管理員的 user_email 變成你不認識的信箱 👉 攻擊者可能想用「忘記密碼」流程長期掌控帳號,直接進入「A」。
  • 🔴 users_can_register 是 1 且 default_role 是 administrator 👉 嚴重異常,進入「A」並立即修正。
  • 🟡 登入階段出現陌生 IP 或奇怪的瀏覽器資訊 👉 弱訊號,需搭配其他線索。輸出欄位格式依 WP-CLI 版本略有差異【❓待確認】。
  • 🟢 一切正常 👉 這一輪沒發現異常。「陌生」不等於「惡意」,也可能是外包或維運商的帳號,先問過再判斷。

[37]


🟣 🟣 🟣

🔍 ㊃ 檔案與日誌:連鎖反應有沒有發生?

決策原理:攻擊者拿到管理員身分後,最常見的下一步是裝一個惡意外掛、改佈景主題,或在 mu-plugins 放檔案(不用在後台啟用就會自動載入)。這個腳本因此檢查:核心與外掛檔案的官方檢查碼、mu-plugins、uploads 裡不該出現的 PHP、近 14 天被改過的 PHP、所有外掛與佈景主題清單,以及日誌裡誰最頻繁地對 admin-ajax.php 發出 POST。最後一項只能當弱訊號:Nginx 的預設日誌不會記錄 POST 的內容,也看不到攻擊者夾帶了什麼參數,所以我不寫「用 grep 搜尋 user_pass」這類看起來很專業、其實抓不到東西的規則。如果你的網站掛在 Cloudflare 後面,日誌裡的 IP 可能是 Cloudflare 的,需要改看真實 IP 欄位 🎯

file_scan_one() {
  local wp_path="$1" domain
  local -a logs
  domain="$(domain_of "$wp_path")"
  printf '\n===== %s(%s)=====\n' "$wp_path" "$domain"

  echo "--- ❶ 核心與外掛檔案是否跟官方版本一致 ---"
  wpx "$wp_path" core verify-checksums 2>&1 | tail -n 10
  wpx "$wp_path" plugin verify-checksums --all 2>&1 | tail -n 20

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

  echo "--- ❸ uploads 底下的 PHP 類檔案(正常應為空)---"
  find "$wp_path/wp-content/uploads" -type f \
    \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.phar' -o -iname '*.php[0-9]' \) \
    -printf '%TY-%Tm-%Td %TH:%TM %u:%g %m %p\n' 2>/dev/null

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

  echo "--- ❺ 目前所有外掛與佈景主題(找不認得的)---"
  wpx "$wp_path" plugin list --fields=name,status,version --format=table
  wpx "$wp_path" theme list --fields=name,status,version --format=table

  echo "--- ❻ 日誌:誰打 admin-ajax.php 最頻繁(POST 內容不會被記錄,只能當弱訊號)---"
  logs=( "/var/log/nginx/domains/${domain}.log"* )
  if [ -e "${logs[0]}" ]; then
    zgrep -ahE 'POST /wp-admin/admin-ajax\.php' "${logs[@]}" 2>/dev/null \
      | awk '{print $1}' | sort | uniq -c | sort -rn | head -n 10
  else
    echo "(找不到 nginx 日誌,請確認網域名稱)"
  fi
  return 0
}

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

# ❇️ 獨立多站
each_site file_scan_one

# ✴️ Multisite:mu-plugins 與外掛屬於整個網路,查主站一次
file_scan_one "/home/user2/web/network.example.com/public_html"

預期輸出範例(正常網站):

--- ❶ 核心與外掛檔案是否跟官方版本一致 ---
Success: WordPress installation verifies against checksums.
Success: Verified 12 of 12 plugins.
--- ❷ mu-plugins(會自動載入、後台外掛頁看不到)---
--- ❸ uploads 底下的 PHP 類檔案(正常應為空)---
--- ❻ 日誌:誰打 admin-ajax.php 最頻繁(POST 內容不會被記錄,只能當弱訊號)---
    312 203.0.113.10
     27 198.51.100.7
  • 🔴 mu-plugins 出現不是你部署的 PHP、或 uploads 裡出現 PHP 檔 👉 先不要刪,保留時間與副本做鑑識,進入第六章「A」。
  • 🔴 外掛清單裡有你沒裝過的外掛,或 checksum 驗證回報檔案被修改 👉 進入「A」。
  • 🟡 有些外掛會在 uploads 放 index.php 這種「空白保護檔」 👉 看到 index.php 先別當成後門,打開確認內容是不是只有一行 silence 註解。
  • 🟡 非 wordpress.org 發布的外掛(例如付費版)做 checksum 驗證會出現無法取得檢查碼的警告 👉 這是正常的,不代表被改過。
  • 🟡 某個 IP 的 POST 次數遠高於其他人 👉 弱訊號,對照 ㊂ 的帳號異常時間再判斷,別單獨拿它定罪。
  • 🟢 都沒有輸出 👉 沒有直接證據,但 POST 內容不在日誌裡,仍建議做完 ㊂ 與第六章的 C。

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


🔒 🔒 🔒

🔒 ㊄ 加固:讓「下一顆」類似的漏洞沒那麼好用

決策原理:更新外掛是根本,加固是「萬一又出現同類漏洞,讓攻擊者進後台之後能做的事變少」。這顆漏洞的最壞結果是管理員被接管,所以加固的重點是:開自動更新(縮短暴露時間)、關掉後台的檔案編輯器(少一條直接改程式碼的路)、停用 PHP 的高風險函式(就算攻擊者在後台裝了惡意外掛,也比較難直接執行系統指令)。每一步後面都跟著驗證。wp plugin auto-updates enable 需要較新的 WP-CLI,指令不存在時請改用後台的「啟用自動更新」連結【❓待確認】 🔧

# ❶ 開啟 MailOptin 自動更新,並用清單確認
WP_PATH="/home/user1/web/example.com/public_html"
wpx "$WP_PATH" plugin auto-updates enable "$PLUGIN"
wpx "$WP_PATH" plugin list --name="$PLUGIN" --fields=name,auto_update --format=table

# ❷ 關掉後台的外掛/佈景主題檔案編輯器(冪等:重複執行結果相同)
wpx "$WP_PATH" config set DISALLOW_FILE_EDIT true --raw
wpx "$WP_PATH" config get DISALLOW_FILE_EDIT

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

驗證加固真的有效。停用函式有可能被 HestiaCP 的 PHP-FPM pool 設定覆蓋,所以用探針檔看「實際生效的值」:

# 放一個無害的探針檔,從外面看「實際生效的」disable_functions(測完立刻刪)
WP_PATH="/home/user1/web/example.com/public_html"
DOMAIN="example.com"
PROBE="__probe_$(date +%s).php"
printf '%s' '<?php echo ini_get("disable_functions");' > "$WP_PATH/$PROBE"
curl -sS --max-time 20 "https://${DOMAIN}/${PROBE}"; echo
rm -f -- "$WP_PATH/$PROBE"
ls -l -- "$WP_PATH/$PROBE" 2>&1 | head -n 1

預期輸出範例(不同版本的 WP-CLI 訊息可能略有差異):

name       auto_update
mailoptin  on
Success: Added constant 'DISALLOW_FILE_EDIT' to the 'wp-config.php' file with the value 'true'.
1
PHP 8.2:已套用並重新啟動
disable_functions = exec,passthru,shell_exec,system,proc_open,popen
exec,passthru,shell_exec,system,proc_open,popen
ls: cannot access '/home/user1/web/example.com/public_html/__probe_1760600000.php': No such file or directory
  • 🟢 auto_update 顯示 on、探針印出剛設定的函式清單、最後一行顯示探針檔已不存在 👉 加固生效,探針檔也已清除。
  • 🟠 探針印出空白或舊設定 👉 pool 設定可能覆蓋了 php.ini,請檢查該網域的 PHP-FPM pool 檔,不要假設已生效。
  • 🟠 php-fpm 語法檢查沒過 👉 腳本不會重啟,用 .BAK 檔還原後再查原因。
  • 🟡 部分備份或圖片處理外掛會用到 exec 等函式 👉 套用後實際測試,必要時改在個別網域的 pool 處理,而不是全域停用。
  • ⚠️ 探針檔會短暫暴露在網站上,請務必確認最後一行顯示檔案已被刪除。

🧑‍🔧 DevOps 小知識:為什麼不建議順手把 DISALLOW_FILE_MODS 也打開?
它會同時擋掉後台的外掛安裝與更新,雖然 WP-CLI 的行為未必受影響,但我沒有在文件中找到足以保證的說明【❓待確認】。在你還沒測過自己的更新流程前,別用它換取「看起來更安全」的感覺 🧪

階段 💠 單一網站 ❇️ 獨立多站 ✴️ Multisite
① 環境判斷 each_site detect_one 只有一行 多行 💠,路徑各自不同 出現 ✴️
② 盤點版本 inventory_one 路徑 each_site inventory_one 查主站一次,status 看 active-network
③ 帳號與設定 單一網站 逐站各自檢查 另加 super-admin list
④ 檔案與日誌 單一網域 逐網域各看各的 共用日誌,外掛屬於整個網路
⑤ 加固 逐站 WP-CLI 設定 逐站設定,php.ini 逐版本 php.ini 屬伺服器層級,一次到位

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

[38]


🪛 🪛 🪛

🛠️ 第六章:止血、補洞、驗收——一站一個閉環

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


🔴 🔴 🔴

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

決策原理:只要第五章出現明確跡象(陌生管理員、管理員信箱被換、mu-plugins 或 uploads 裡的可疑檔案、不認得的外掛),第一優先不是急著更新,而是「先保留現場」。順序是:資料庫快照與日誌 👉 停用外掛 👉 更換 wp-config.php 的金鑰與鹽值 👉 重設管理員密碼 👉 清掉所有登入階段。證據資料夾裡有資料庫匯出,權限會設成 700,請別外傳 🧯

❶ 保留證據

這一段用 heredoc 產生一份 sed 腳本,把 wp-config.php 裡的資料庫密碼與 8 組金鑰、鹽值遮成 REDACTED 後才存檔。這樣你把證據交給資安人員時,不會把網站機密一起交出去。腳本會自己檢查遮蔽有沒有命中 🗂️

# ⚠️ 只對「有疑慮的那一站」執行。證據資料夾含資料庫匯出,權限設 700,請勿外傳
STAMP="$(date +%Y%m%d-%H%M%S)"
EVIDENCE_DIR="${HOME}/mailoptin-incident-${STAMP}"
mkdir -p -- "$EVIDENCE_DIR" && chmod 700 -- "$EVIDENCE_DIR"

# 用 heredoc 產生「遮蔽密碼與鹽值」的 sed 腳本,留存 wp-config.php 時不把機密一起存進去
REDACT_SED="${EVIDENCE_DIR}/redact.sed"
cat > "$REDACT_SED" <<'EOF'
s/^(define\([[:space:]]*['"](DB_PASSWORD|AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|NONCE_KEY|AUTH_SALT|SECURE_AUTH_SALT|LOGGED_IN_SALT|NONCE_SALT)['"][[:space:]]*,[[:space:]]*)(['"]).*(['"])[[:space:]]*\)/\1\3REDACTED\4)/
EOF

evidence_one() {
  local wp_path="$1" domain out cfg
  local -a logs
  domain="$(domain_of "$wp_path")"
  out="${EVIDENCE_DIR}/${domain}"
  mkdir -p -- "$out" && chmod 700 -- "$out"

  # ❶ 資料庫快照(失敗就中斷,沒有快照不要往下做)
  wpx "$wp_path" db export - --single-transaction > "$out/db_${STAMP}.sql" || return 1
  [ -s "$out/db_${STAMP}.sql" ] || { echo "❌ 資料庫匯出是空的"; return 1; }

  # ❷ 帳號、外掛清單快照
  wpx "$wp_path" user list --fields=ID,user_login,user_email,roles,user_registered --format=csv > "$out/users.csv"
  wpx "$wp_path" plugin list --fields=name,status,version --format=csv > "$out/plugins.csv"

  # ❸ 日誌(含輪替檔)
  logs=( "/var/log/nginx/domains/${domain}.log"* )
  [ -e "${logs[0]}" ] && cp -a -- "${logs[@]}" "$out/"

  # ❹ mu-plugins 與 uploads 裡的 PHP 類檔案(只打包可疑目標,不整包備份)
  if [ -d "$wp_path/wp-content/mu-plugins" ]; then
    tar -czf "$out/mu-plugins_${STAMP}.tar.gz" -C "$wp_path/wp-content" mu-plugins
  fi
  find "$wp_path/wp-content/uploads" -type f \
    \( -iname '*.php' -o -iname '*.phtml' -o -iname '*.phar' \) -print0 2>/dev/null \
    | tar -czf "$out/uploads-php_${STAMP}.tar.gz" --null -T - 2>/dev/null

  # ❺ wp-config.php 的遮蔽版(金鑰、資料庫密碼都已被 REDACTED 取代)
  cfg="$(wpx "$wp_path" config path)" || return 1
  sed -E -f "$REDACT_SED" -- "$cfg" > "$out/wp-config.redacted.php"
  if grep -qE "DB_PASSWORD'[[:space:]]*,[[:space:]]*['\"]REDACTED" "$out/wp-config.redacted.php"; then
    echo "✅ 已留存遮蔽版 wp-config.php"
  else
    echo "⚠️ 遮蔽規則沒有命中 DB_PASSWORD,請手動檢查後再外傳 $out/wp-config.redacted.php"
  fi

  chmod -R go-rwx -- "$out"
  printf '✅ %s 的證據已保存在 %s\n' "$domain" "$out"
  return 0
}

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

預期輸出範例:

✅ 已留存遮蔽版 wp-config.php
✅ example.com 的證據已保存在 /root/mailoptin-incident-20261016-170339/example.com
  • ⭕ 兩行都是 ✅ 👉 進入下一步「止血」。
  • 🟠 資料庫匯出失敗或檔案是空的 👉 函式會立刻中斷,先處理磁碟空間或資料庫權限(df -h 看空間),不要在沒有快照的情況下繼續。
  • 🟠 出現「遮蔽規則沒有命中 DB_PASSWORD」 👉 你的設定檔格式特殊,請手動檢查後再外傳該檔案。
  • 🟡 找不到日誌 👉 日誌可能被輪替或網域名稱不同,不影響後續步驟。

❷ 止血:隔離、換鹽值、重設密碼、清登入階段

決策原理:要先停用外掛,不然你重設完密碼,攻擊者又打一次就前功盡棄。接著用 wp config shuffle-salts 更換 8 組金鑰與鹽值,所有人(包含攻擊者手上的登入 Cookie,也包含你自己)會立即被登出。這顆漏洞會直接動到密碼雜湊,嚴重程度高於一般外掛漏洞,所以我建議不論有沒有看到明確證據,只要曾經用過「註冊型」表單,都做這一步。驗證的方式是比對金鑰區塊的指紋:前後不同才算成功,而且不會把金鑰印在螢幕上。管理員密碼則重設成隨機值,不顯示在螢幕,由管理員用登入頁的「忘記密碼」自己設定新的,驗證方式一樣是比對密碼雜湊的指紋 🔐

# ㊁ 隔離:停用外掛(Multisite 連全網與各子站一起處理)
isolate_one() {
  local wp_path="$1" url status
  if wpx "$wp_path" core is-installed --network 2>/dev/null; then
    wpx "$wp_path" plugin deactivate "$PLUGIN" --network
    while IFS= read -r url; do
      wpx "$wp_path" --url="$url" plugin deactivate "$PLUGIN" 2>/dev/null
    done < <(wpx "$wp_path" site list --field=url)
  else
    wpx "$wp_path" plugin deactivate "$PLUGIN"
  fi
  status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status)" || return 1
  case "$status" in
    inactive) echo "✅ $PLUGIN 目前狀態:inactive" ;;
    *)        echo "❌ $PLUGIN 目前狀態:$status,沒有停用成功"; return 1 ;;
  esac
}

# ㊂ 強制重置 wp-config.php 的 8 組金鑰與鹽值,舊的登入 Cookie 全部失效
salts_fingerprint() {
  local wp_path="$1" cfg
  cfg="$(wpx "$wp_path" config path)" || return 1
  grep -E "^[[:space:]]*define\([[:space:]]*['\"](AUTH_KEY|SECURE_AUTH_KEY|LOGGED_IN_KEY|NONCE_KEY|AUTH_SALT|SECURE_AUTH_SALT|LOGGED_IN_SALT|NONCE_SALT)['\"]" "$cfg" \
    | sha256sum | cut -d' ' -f1
}

rotate_salts_one() {
  local wp_path="$1" before after
  before="$(salts_fingerprint "$wp_path")" || return 1
  wpx "$wp_path" config shuffle-salts || return 1
  after="$(salts_fingerprint "$wp_path")" || return 1
  if [ "$before" != "$after" ]; then
    echo "✅ 金鑰與鹽值已更換(指紋不同)"
  else
    echo "❌ 指紋沒變:wp-config.php 可能唯讀,或金鑰定義在別的檔案"
    return 1
  fi
}

# ㊃ 管理員密碼全部重設成隨機值(不顯示在螢幕上,改走「忘記密碼」流程)
reset_admins_one() {
  local wp_path="$1" uid before after
  while IFS= read -r uid; do
    before="$(wpx "$wp_path" user get "$uid" --field=user_pass | sha256sum | cut -d' ' -f1)"
    wpx "$wp_path" user reset-password "$uid" --skip-email >/dev/null || {
      printf '❌ ID=%s 重設失敗\n' "$uid"; return 1; }
    after="$(wpx "$wp_path" user get "$uid" --field=user_pass | sha256sum | cut -d' ' -f1)"
    if [ "$before" != "$after" ]; then
      printf '✅ 管理員 ID=%s 的密碼雜湊已改變\n' "$uid"
    else
      printf '❌ 管理員 ID=%s 的密碼雜湊沒變\n' "$uid"; return 1
    fi
  done < <(wpx "$wp_path" user list --role=administrator --field=ID)
}

# ㊄ 清掉所有人的登入階段
destroy_sessions_one() {
  local wp_path="$1" uid
  while IFS= read -r uid; do
    wpx "$wp_path" user session destroy "$uid" --all >/dev/null 2>&1 \
      || printf '⚠️ ID=%s 沒有階段可清,或指令不支援,略過\n' "$uid"
  done < <(wpx "$wp_path" user list --field=ID)
  echo "✅ 登入階段清除流程跑完"
}

# 綁成一站一個閉環:任何一步失敗就停
contain_one() {
  local wp_path="$1"
  isolate_one "$wp_path" \
    && rotate_salts_one "$wp_path" \
    && reset_admins_one "$wp_path" \
    && destroy_sessions_one "$wp_path" \
    || { printf '❌ 止血停在 %s,請先排查\n' "$wp_path"; return 1; }
}

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

預期輸出範例:

Plugin 'mailoptin' deactivated.
✅ mailoptin 目前狀態:inactive
Success: Shuffled the salt keys.
✅ 金鑰與鹽值已更換(指紋不同)
✅ 管理員 ID=1 的密碼雜湊已改變
✅ 登入階段清除流程跑完

以上是典型輸出,不同版本的 WP-CLI 訊息文字可能略有差異,判斷標準是每一行的 ✅ 與 ❌。

  • ⭕ 全部 ✅ 👉 保留證據資料夾,接著走「C. 正式修補」。企業或會員制網站請同步通知內部資安或維運負責人。
  • 🟠 「指紋沒變」 👉 常見原因有兩個:wp-config.php 是唯讀(先用 ls -l 看擁有者與權限),或金鑰其實定義在別的檔案裡。函式會中斷,後面的步驟不會執行。
  • 🟠 管理員密碼雜湊沒變 👉 先確認該帳號是否存在,錯誤訊息會在上面一行,不要重複硬跑。
  • 🟡 登入階段清除顯示「略過」 👉 該使用者本來就沒有階段,或你的 WP-CLI 不支援,這一步的效果主要已被換鹽值涵蓋【❓待確認】。
  • 🔴 郵件功能壞掉,管理員收不到「忘記密碼」信 👉 才考慮改在 reset-password 後面加上 –show-password,但新密碼會顯示在螢幕,可能留在終端機的捲動紀錄,請在私密環境操作,並要求管理員登入後立刻自行更換。

🚨 畫重點:換鹽值會把你自己也登出
這是正常現象,不是出錯。重新登入後,其他還在線上的人(包含攻擊者)也都需要重新登入,這正是目的。如果你有設定「保持登入」的外部工具(例如行動版管理 App),也要重新登入 🔁

❸ 清掉陌生帳號(⚠️ 無法復原)

確認某個管理員真的是陌生人之後,才用下面的函式。它會先把該帳號的資料存成 JSON 留證據,再要求你輸入 yes 才會刪除,並把該帳號名下的內容轉給你信任的管理員,最後確認帳號真的消失 🗑️

# ⚠️ 刪除帳號無法復原。先確認那個 ID 真的是陌生帳號,並且挑一位你信任的管理員接手內容
remove_user_one() {
  local wp_path="$1" bad_id="$2" keep_id="$3" ans
  wpx "$wp_path" user get "$bad_id" --format=json > "${EVIDENCE_DIR}/user_${bad_id}.json" || return 1
  printf '準備刪除 ID=%s,內容轉給 ID=%s。確定請輸入 yes:' "$bad_id" "$keep_id"
  read -r ans
  [ "$ans" = "yes" ] || { echo "已取消,沒有刪除任何東西"; return 0; }
  wpx "$wp_path" user delete "$bad_id" --reassign="$keep_id" --yes || return 1
  if wpx "$wp_path" user get "$bad_id" >/dev/null 2>&1; then
    echo "❌ 帳號仍然存在"; return 1
  else
    echo "✅ 帳號已移除(原始資料留存在 ${EVIDENCE_DIR}/user_${bad_id}.json)"
  fi
}

# 範例:刪除 ID 7,內容轉給 ID 1
remove_user_one "/home/user1/web/example.com/public_html" 7 1
  • 🟢 看到「帳號已移除」 👉 完成。
  • 🟠 輸入其他字元 👉 顯示「已取消」,沒有刪除任何東西。
  • 🔴 發現檔案層級的後門(mu-plugins 或 uploads 裡的 PHP) 👉 保留證據,不要直接刪除,交給資安事件應變人員。若確認後門可能讀過 wp-config.php,請連資料庫密碼一起更換(HestiaCP 有 v-change-database-password 指令,用法請先查官方文件,更換後也要同步更新設定檔【❓待確認】)。

[39]


🟡 🟡 🟡

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

🚨 短期應急 ≠ 正式修補。
停用外掛只是緩衝,目的是縮短暴露時間,真正的修補仍然是更新到 1.2.78.7 以上 🧱

決策原理:這顆漏洞的惡意內容在 POST 請求的內容裡,而 Nginx 的規則只看得到網址和標頭,看不到 POST 內容,所以我不建議用 Nginx 規則去擋,那種規則看起來很專業,實際上擋不到東西。最乾淨的做法是直接停用外掛(Multisite 加 –network)。如果網站版面離不開它,先把「註冊型」的表單改成草稿或取消發布(第三章㊅),並盡快安排更新 🔒

# 暫時止血:直接停用外掛(Multisite 加 --network),然後確認狀態
WP_PATH="/home/user1/web/example.com/public_html"
wpx "$WP_PATH" plugin deactivate "$PLUGIN"
wpx "$WP_PATH" plugin get "$PLUGIN" --field=status

預期輸出範例:

Plugin 'mailoptin' deactivated.
inactive
  • 🟢 最後一行顯示 inactive 👉 外掛已經停用,漏洞暫時碰不到。
  • 🟠 顯示 active 或 active-network 👉 停用沒成功,Multisite 請加上 –network 再試。
  • 🟡 停用後前台彈窗不見了 👉 預期現象,更新完再重新啟用。

🚀 🚀 🚀

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

決策原理:備份檔放在 root 才讀得到的資料夾,不放網站目錄,避免被外部直接下載。預覽用 plugin update –dry-run,只看會更新什麼,不真的動手。更新時原本啟用的維持啟用,原本停用的(例如剛做完 A 的止血)維持停用,等你確認沒問題再手動啟用。驗證分三層:版本、檔案完整性、前台首頁回應 💾

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

# ❶ 備份:資料庫+wp-content,放在 root 才讀得到的位置(不要放網站目錄)
backup_one() {
  local wp_path="$1" domain
  domain="$(domain_of "$wp_path")"
  wpx "$wp_path" db export - --single-transaction > "$BACKUP_DIR/${domain}_db_${STAMP}.sql" || return 1
  [ -s "$BACKUP_DIR/${domain}_db_${STAMP}.sql" ] || { echo "❌ 資料庫備份是空的"; return 1; }
  tar -czf "$BACKUP_DIR/${domain}_wp-content_${STAMP}.tar.gz" -C "$wp_path" "wp-content" || return 1
  ls -lh -- "$BACKUP_DIR/${domain}_"*"${STAMP}"*
}

# ❷ 預覽:只看會更新什麼,不真的動手
dryrun_one() {
  local wp_path="$1"
  printf '▶ %s\n' "$wp_path"
  wpx "$wp_path" plugin update "$PLUGIN" --dry-run
}

# ❸ 更新:原本啟用的維持啟用,原本停用(例如剛止血完)的維持停用
update_one() {
  local wp_path="$1"
  if ! wpx "$wp_path" plugin get "$PLUGIN" --field=status >/dev/null 2>&1; then
    printf '⚪ %s 沒裝 %s,略過\n' "$wp_path" "$PLUGIN"
    return 0
  fi
  wpx "$wp_path" plugin update "$PLUGIN"
}

# ❹ 驗證:版本、檔案完整性、前台功能三層都要過
verify_one() {
  local wp_path="$1" ver home_url code
  ver="$(wpx "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null)" || {
    printf '⚪ %s 沒裝 %s,略過\n' "$wp_path" "$PLUGIN"; return 0; }
  if dpkg --compare-versions "$ver" lt "$FIXED"; then
    printf '❌ %s 版本仍是 %s,還沒修補\n' "$wp_path" "$ver"
    return 1
  fi
  wpx "$wp_path" plugin verify-checksums "$PLUGIN" || return 1
  home_url="$(wpx "$wp_path" option get home)" || return 1
  code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/")"
  case "$code" in
    2*|3*) printf '✅ %s → 版本 %s、首頁 HTTP %s\n' "$wp_path" "$ver" "$code" ;;
    *)     printf '❌ %s → 首頁 HTTP %s,請立刻檢查\n' "$wp_path" "$code"; return 1 ;;
  esac
}

# ❺ 綁成一站一個閉環
patch_one() {
  local wp_path="$1"
  printf '\n🚀 開始處理 %s\n' "$wp_path"
  backup_one "$wp_path" \
    && dryrun_one "$wp_path" \
    && update_one "$wp_path" \
    && verify_one "$wp_path" \
    || { printf '❌ 停在 %s,請先排查再處理下一站\n' "$wp_path"; return 1; }
}

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

# ❇️ 獨立多站:一次一站,遇到失敗就停
each_site patch_one

預期輸出範例:

🚀 開始處理 /home/user1/web/example.com/public_html
-rw-r--r-- 1 root root 1.2M ... example.com_db_20261016-170341.sql
-rw-r--r-- 1 root root  48M ... example.com_wp-content_20261016-170341.tar.gz
▶ /home/user1/web/example.com/public_html
(預覽:顯示 mailoptin 1.2.78.6 → 1.2.78.7)
Success: Updated 1 of 1 plugins.
Success: Verified 1 of 1 plugins.
✅ /home/user1/web/example.com/public_html → 版本 1.2.78.7、首頁 HTTP 200
  • 🟢 最後一行 ✅ 👉 這一站完成。實際再送一次訂閱表單,確認訂閱者進得了名單;如果剛做過止血,確認沒問題後用 wp plugin activate mailoptin 重新啟用。
  • 🟠 備份失敗 👉 函式中斷,先看磁碟空間與權限,不要進入更新。
  • 🟠 預覽看不到可用更新 👉 先確認目前是否已是最新版,或更新來源有沒有連線問題,不要因為沒看到更新就直接認定安全。
  • 🟠 更新失敗 👉 別反覆重跑,看錯誤訊息,確認檔案權限、磁碟空間與主機能否連到 WordPress.org。
  • 🔴 更新後首頁不是 2xx/3xx,或前台白畫面 👉 不要降回舊版(舊版就是有洞的那一版),先把外掛停用,再用備份與錯誤日誌排查。

🧑‍🔧 多站環境的實務原則:一次只處理一站,至少第一批這樣做。先完成一站的「備份 👉 預覽 👉 更新 👉 驗證」,確認沒有 PHP Fatal Error、白畫面或前台表單異常,再處理下一站。each_site 遇到失敗會自動停下,不會把後面的站也一起帶下水。全部完成後,別忘了清一次網站快取與 CDN 快取 🎛️

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

🔥 一句話總結:先判斷環境,再盤點、備份、預覽,確認無誤才逐站更新,最後驗證。有中招跡象,就把「保留證據 → 止血」排在更新之前。換成下一個外掛、下一個 CVE,這套閉環一樣可以直接套用 🦸

[40]


🟢 🟢 🟢

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

  • 🏮 Q:更新後彈窗或訂閱串接會不會壞掉?
    A:官方更新日誌寫的是安全修正,理論上不動功能,但第三方外掛我無法保證完全不變【❓待確認】。所以更新前先備份、更新後實際送一次表單,是最穩的做法 💾
  • 🏮 Q:外包說「那張註冊表單早就沒在用,不用管」,我該聽嗎?
    A:別聽。只要外掛還啟用、表單還在已發布狀態,就沒關門。請對方給你看版本截圖,或直接停用 💣
  • 🏮 Q:看不懂第五、六章,是不是就沒救了?
    A:完全不會。第三章從頭到尾不用打指令,點幾下滑鼠就能做完最基本的防護;第四到第六章是給有 SSH 權限、想深入排查的人看的補充 🤗

🧠 🧠 🧠

🧩 舉一反三:「權限提升」這一類漏洞的通用防禦心法

以下是這個漏洞類型的通用防禦知識,雖然不是本 CVE 的官方要求,但下次遇到同類問題就能用得上 🦸

  • ⭐ 管理員越少越好。每多一位管理員,就多一個可被覆寫密碼的目標。能用「編輯」角色完成的工作,就不要給管理員。
  • ⭐ 外掛如果會替訪客「建立使用者」,就當成高風險功能。用不到就別開;一定要開,也要確認新註冊者的預設角色是最低權限。
  • ⭐ 開自動更新,並用通知告訴你「更新了什麼」。這類漏洞從公開到有人拿來掃描,窗口很短,自動更新是最便宜的一道防線。
  • ⭐ 後台加一道兩步驟驗證。它不是萬靈丹,但至少讓「只改密碼」的攻擊多一關。

🏁 🏁 🏁

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

評估維度(10 分制,數字越高代表風險或應對品質越正向) 分數 一句話評語
漏洞嚴重程度(越高代表風險越大) 9.8 不用登入、不用互動,可覆寫管理員密碼;但需要「註冊型」表單這個前提,實際曝險因站而異
官方應變速度 7 修補版已釋出且更新日誌可查;但三個月內連兩顆 9.8,1.2.78.5 的緩衝也沒有一次補到位,略扣分
一般站長自救可行性 9 後台點「更新」就能解決,要多做的只有看管理員名單與換密碼
DevOps 技術文件完整度 5 官方沒公開觸發的動作名稱、也沒有程式差異,排查只能靠「結果特徵」,而且各家報告細節互相矛盾
🏆 加權綜合建議 立即更新 最終建議:【今天排進行程、最慢 24~48 小時內完成更新;有「註冊型」表單或存有會員資料的站優先,並同步重設管理員密碼】

🔥 這起事件提醒我們:漏洞不一定藏在複雜的核心系統,有時候就藏在「訪客填一張訂閱表單」這種最日常的功能裡。細節少、攻擊程式不明,不等於可以放著不管,因為真正有差別的,是你在別人公開攻擊方法之前有沒有把門關好。與其等管理員密碼莫名其妙失效才發現不對勁,不如現在花五分鐘,打開後台看一眼版本號碼 🔢

[41]


📎 📎 📎

📚 參考資料與延伸資源

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