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

🚨Ultimate Member 免登入、免點擊,高危漏洞全解析📦 一個沒拆封檢查的「包裹」,路人就能接管你的網站?(CVE-2026-115968/CVSS 9.8)

🚪 免登入、免點擊、20 萬站都在用:Ultimate Member 9.8 分漏洞,從看版本到刷鹽值一次教會你(CVE-2026-115968/CVSS 9.8)

🔥 懶人包:
WordPress 會員外掛 Ultimate Member(以下簡稱 UM)有 200,000+ 活躍安裝,被登記了 CVE-2026-115968,CVSS 3.1 評分 9.8,弱點類型是不安全的反序列化(CWE-502),白話就是「PHP 物件注入」💥
2.13.1 以下全部中標,2.14.0 起修補,撰文時官方外掛頁上的最新版是 2.14.1。最麻煩的地方是「免登入」:對方不需要帳號,也不用誘騙誰點連結。目前沒有確認遭利用的紀錄,我查到的資料裡也沒看到公開的攻擊程式,但 CISA 的評估把它標成「可自動化」,意思是一旦有人寫出工具,掃描會很快 ⏳
這篇帶你看版本、備份、更新,再查有沒有被摸過。真的出事了,也有一條由淺入深的救援路線,一步一步走就好 🔒

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

[2]


🟦 🟦 🟦

內容目錄

🧳 第一章:櫃檯照單全收的那個包裹,到底藏了什麼?

想像你住的大樓有個管理室,櫃檯人員負責代收包裹。平常寄來的是衣服、書、零食,沒什麼好擔心。有一天來了個盒子,裡面不是物品,而是一張「組裝說明書」加一堆零件。櫃檯人員沒問是誰寄的,直接照說明書把東西組起來,還讓它開始運轉。盒子裡的機器如果只是玩具,沒事;可是大樓裡剛好有幾個零件,能跟它湊成一台會開保險箱的機器,事情就大了 📦

📢 大白話:序列化、反序列化、物件注入,各是什麼?
「序列化」是把一個程式裡的東西拆成零件清單,方便存起來或傳出去;「反序列化」是照清單把零件組回原樣。UM 這次的問題,是它在處理某些資料時,沒先確認清單是誰寫的,就照著組了。清單如果是攻擊者自己寫的,他就能指定「要組出哪一種東西」,這就叫物件注入 🙋‍♀️

先把能確定的、只能推論的、純屬假設的分開講,免得越讀越嚇人。官方沒有公開觸發的端點與參數,所以下面的攻擊鏈是我根據 CVE 紀錄與官方更新日誌拼出的輪廓,每一步都標了信心程度 🔍

  • ㊀ 攻擊者從網路送出一個特製請求:✅ CVSS 向量寫的是網路可達(AV:N)、免帳號(PR:N)、免使用者互動(UI:N),攻擊難度低(AC:L) 🌐
  • ㊁ 外掛把外部可控的資料丟進反序列化:🟡 官方 2.14.0 更新日誌把第一項安全修補寫成「未經驗證的 PHP 物件注入」,並新增了 um_maybe_unserialize() 函式。確切是哪個入口、哪個參數,官方沒說【❓待確認】 🚪
  • ㊂ PHP 還原物件時,會自動跑物件裡的「魔術方法」:🟡 建立、喚醒、銷毀這些時機點,PHP 會自己呼叫對應的方法,這是物件注入的通用原理,不是 UM 獨有的行為 ⚙️
  • ㊃ 站上剛好有可以串接的零件,後果才會放大:🟡 資安圈把這種可被串起來的程式片段叫 gadget 鏈。零件可能來自 UM 自己、WordPress 核心,或你裝的其他外掛。串得起來,後果可以延伸到寫檔、改資料、執行程式 🧱
  • ✅ 已確認事實:CVE 紀錄寫明受影響範圍是 2.13.1 以下,弱點類型 CWE-502;Patchstack 頁面標示所需權限為「免登入」,修補版本為 2.14.0。
  • 🟡 合理推論:9.8 分反映的是「最壞情況下的理論最大影響」。你的網站實際會不會走到接管,要看站上還裝了哪些外掛與佈景主題。
  • 🔴 假設情境:假設有個網站同時裝了某個舊外掛,那個外掛的物件在被銷毀時會順手刪除檔案。攻擊者如果能指定要刪哪個檔案,這兩個零件湊在一起就變成「刪檔案」。這是我為了說明 gadget 鏈編的例子,不是任何真實網站的紀錄。

🧊 冷知識:WordPress 核心有個函式叫 maybe_unserialize(),名字聽起來很安全
它的說明只有一句:資料如果是序列化格式才還原。那個 maybe 指的是「也許需要還原」,不是「也許很安全」。UM 這次特地新增一個自己的 um_maybe_unserialize(),名字長得很像,用意卻是替還原動作加上限制。函式名稱從來不是安全保證,有沒有限制能還原哪些東西,才是重點 🧐

🚨 畫重點:我不會給你攻擊字串
官方沒有公開入口與參數,這篇也不去猜,更不會附上任何攻擊用的字串。你只要記得這個洞對外公開、不需要你的帳號。想知道自己中不中標,第五章用「結果特徵」來查就夠了,不必靠誰猜出來的字串 🙅


🟩 🟩 🟩

⏱️ 事件時間軸一次看懂:修補版早了整整 29 天

時間點 發生了什麼大事 站長可信度判定
2026-09-03 Patchstack 頁面記錄的回報日,發現者署名 Ananda Dhakal ✅ Patchstack 頁面
2026-09-29 UM 2.14.0 發布,更新日誌列出 6 項安全修補,第一項就是未經驗證的 PHP 物件注入 ✅ WordPress.org 外掛頁、GitHub 發布頁
2026-10-22 UM 2.14.1 發布,另外修補 CVE-2026-107625(外部連結確認提示) ✅ WordPress.org 外掛頁
2026-10-24 CVE-2026-115968 這個編號被保留 ✅ CIRCL 弱點資料庫
2026-10-26 Patchstack 與 CVE 紀錄的公開日期 ✅ Patchstack 頁面、CIRCL
2026-10-28 CVE 正式發布;CISA-ADP 當天的 SSVC 評估為「尚無利用、可自動化、技術衝擊為全面」 ✅ CIRCL、CVE Watchtower
到底有沒有人受害? 我查到的資料沒有確認遭野外攻擊的紀錄,CIRCL 頁面也沒看到 CISA KEV(已知遭利用漏洞清單)的收錄 【❓待確認】這類狀態天天在變

🧊 冷知識:「CVE 公布」比「修補版」晚了 29 天
9 月 29 日 2.14.0 上線,10 月 28 日編號才正式發布。有在看外掛更新日誌的人,九月底就看得到那行安全修補;只盯著 CVE 新聞的人,等於晚了一個月才收到訊號。這不是 UM 特有的現象,WordPress 外掛圈常常這樣:先修、後公開、再發編號。這也是為什麼看到更新日誌寫著安全修補時,不用等新聞 📰

看到「尚無利用」先別鬆口氣。Patchstack 在頁面上寫的是「預期會被利用」,CISA 的評估則是「可自動化」。這兩句話合起來的意思是:現在沒事,不代表過兩週也沒事。真正的時間窗口,是從現在到有人把掃描工具寫出來之前 ⏳

🚨 畫重點:什麼是「野外攻擊」❓
把它想成猛獸被放出了籠子:實驗室裡寫出來的攻擊測試,還只是在籠子裡;一旦有人把它改成自動化工具,丟到網路上四處掃描,就是在野外了。這次目前沒有確認這件事發生,但 9.8 分加上「免登入」,是最容易走到這一步的組合 🐅

[39]


🟪 🟪 🟪

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

🩵 小資族荷包試算:修這個洞要花多少錢?
UM 核心外掛在 WordPress.org 免費下載,更新是 NT$ 0 元。官方另外販售的付費擴充套件,授權價格以官方頁面為準,我沒有核對,這裡不報數字。對照事後成本:請人清後門、處理被竄改的會員資料、通知會員、網站被搜尋引擎標記後的流量損失,隨便一項都比按一下更新貴得多 💸

同一封資安警告,小編、自己租主機的玩家、企業 IT 要做的事差很多。先找到自己,再決定往下讀到哪一章 🧭

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

你不需要懂程式。第三章全程用滑鼠:看版本、備份、按更新、測一輪會員功能。UM 管的是註冊、登入、個人檔案,更新完最該測的就是這幾個畫面 🧑‍💼

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

直接跳第五章拿工具箱。裡面有整台主機一次盤點的函式,會用各網站擁有者的身分跑 WP-CLI,不會在 root 底下留下一堆權限錯亂的檔案。這顆洞只有一條版本門檻(低於 2.14.0),判斷比「每條版本線各有修補版」的漏洞單純,但 2.14.0 動了 6 個樣板,客戶網站的佈景主題如果覆寫過,要先預檢 🔧

🧑‍🔧 DevOps 小知識:獨立多站和 Multisite,看起來像,處理方式差很多
獨立多站是同一台主機上有多個互不相干的 WordPress,各有自己的 wp-config.php 與資料庫,要逐站盤點、逐站更新。Multisite 是一份程式碼、一個資料庫養多個子站,外掛檔案只有一份,更新一次就涵蓋全網。把兩種混為一談,不是漏掉站,就是白白更新很多次 🧱

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

UM 管的正是會員資料。免費外掛沒有服務等級協議(SLA),出事時沒人保證多久修好。這次官方在公開揭露前就釋出了修補版,這點是加分;但如果網站有對外開放註冊、而且舊版曾暴露過,就要把它當成「可能被進入過」的等級來處理。個資是否外洩、要不要依《個人資料保護法》通知當事人,請交給公司法務判斷。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺

🚨 冤枉啊!我早就沒在用會員功能了,只是懶得刪外掛

🚨 高風險場景模擬(🔴 假設情境):「會員功能早就關了,外掛放著應該沒差吧?」
某個社團兩年前用 UM 做過成員登入,後來改用別的方式,外掛卻一直留在後台。管理員心想:「功能都沒在用了,總不能憑空打進來吧?」
外掛如果還在啟用狀態,它的程式碼就還在被載入。停用通常能讓它不再被載入,但我無法驗證「停用了就一定打不到」,因為檔案還躺在伺服器上,哪些入口不依賴 WordPress 載入,官方沒說明。賭這個不划算,直接更新或刪除比較穩 🆘

🚥 螢光筆時間:更新、停用、刪除,今天至少選一個
「我沒在用」不算處理。更新到 2.14.1,或停用,或徹底刪除,這三個才算把門關上。三個裡面,刪除最乾淨 🙅‍♂️


🔴 🔴 🔴

🧮 四種情境對照:你今天該做到哪一步?

你的情境 風險判斷 今天要做的事 看哪一章
外掛啟用中,會員可以註冊或登入 最高 備份 → 更新 → 測會員功能 → 查管理員名單 第三、五、六章
外掛啟用中,但只有自己人登入,沒開放註冊 中(入口是否依賴註冊,官方沒說,無法保證) 照樣更新 第三、六章
外掛已停用但沒刪除 低 直接刪除,或更新後再停用 第三章
多個網站、有 SSH 權限 依各站版本而定 先用工具箱整台盤點,再一站一站處理 第五、六章

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

產品 外掛代號(slug) 受影響範圍 ✅ 修到這版才安全 建議升到
Ultimate Member ultimate-member 2.13.1(含)以下 2.14.0 2.14.1(撰文時最新版)

最早從哪一版開始受影響,CVE 紀錄寫的是「從 n/a 到 2.13.1」,沒有下限【❓待確認】,所以請當成「2.13.1 以下一律有風險」。後面的腳本用 sort -V 逐段比大小,不會把 2.14.10 誤判成比 2.14.2 舊 🧰

[40]


🟠 🟠 🟠

😬 更新前先看:真實踩過的雷,與 2.14.0 會改變的行為

更新是對的,但別當成「按下去就一定安然無事」。我在 WordPress.org 支援論壇找到兩個公開案例,兩個都跟這次 CVE 無關,放在這裡是提醒你:更新完要實際點一輪 🧪

案例 發生了什麼 給你的提醒
2026-10-17 論壇主題「UM 2.14.0 breaks profile pages」(未見後續回報) 發文者升級到 2.14.0 後,個人檔案頁壞掉,管理員還收到錯誤通知信。論壇志工回覆,頁面可能用了自訂的 profile.php 樣板,而 2.14.0 動了好幾個預設樣板,自訂版必須跟上新版內容。發文者說會照官方的「修正過期樣板」指引試試,之後沒有回報。 佈景主題有覆寫 UM 樣板的站,更新前先預檢(第五章 ㊂),更新後第一個要開的就是個人檔案頁。
2026-10-17 論壇主題「Email address update fails with single_user_password warning in UM 2.14.0」(論壇仍標示未解決) 發文者表示,登入的使用者在帳號頁修改 Email 時,即使密碼正確也被擋下,頁面還出現 PHP 警告。他自己追到是 Email 重複檢查把表單資料蓋掉了,論壇志工也確認有這個問題,並提供一個社群修補。這是社群說法,我沒有看到官方確認或釋出修正版的紀錄,2.14.1 有沒有修也【❓待確認】。 更新後務必測「改 Email」。遇到的話,先到論壇看最新進度,不要在正式站直接改程式碼。

2.14.0 的更新日誌一口氣列了 6 項安全修補,這顆 CVE 只是其中一項。官方沒有逐項對應 CVE,所以「更新到 2.14.1」是唯一確定涵蓋全部的做法,不要試圖只手動補其中一項。我把能確定的整理成表,並標出更新後該測什麼 🧾

2.14.0 的安全修補(官方更新日誌,摘要) 白話意思 更新後請測
未經驗證的 PHP 物件注入,新增 um_maybe_unserialize() 本文主角 CVE-2026-115968 整體功能
管理員身分才能觸發的儲存型 SQL 注入(會員名錄的搜尋欄位識別碼) 會員名錄的搜尋欄位多了過濾 會員名錄的搜尋、排序
未經驗證的「不當強制流程」,新增專屬 nonce 欄位 請求多了一道一次性的驗證碼。🟡 頁面如果被快取外掛長時間快取,驗證碼可能過期,這是我的推論,官方沒這麼說 註冊與登入表單,更新後記得清快取
CVE-2026-96270(Wordfence 回報):表單送出時過濾 form_id 表單編號的值不再照單全收 每一張 UM 表單送出一次
CVE-2026-93428(Wordfence 回報):個人檔案欄位的隱私設定 顯示欄位時更確實遵守隱私設定 用不同身分看同一個個人檔案
權限提升(Intrudify 經 Patchstack 回報):帳號送出與 nonce 處理 🟡 帳號送出流程的權限與 nonce 檢查被補強(官方原文只有一行,細節沒公開) 註冊、帳號頁修改資料
另需更新的樣板:profile/comments.php、profile/posts.php、login.php、members.php、profile.php、register.php 佈景主題如果有覆寫這 6 個檔案,要跟上新版內容 登入、會員名錄、個人檔案、註冊四類頁面

🧊 冷知識:一個版本修了 6 件事,卻只有 2 項有 CVE 編號
2.14.0 那 6 項裡,只有 CVE-2026-96270 和 CVE-2026-93428 在日誌裡掛了編號,其他的當下都沒有。靠編號比對的掃描工具,看不見當下沒有編號的修補。所以光靠掃描報告判斷「有沒有事」是不夠的,把外掛更新日誌當成情報來源之一,才不會漏 🔎

🚨 測出問題的時候,不要降版
把外掛降回 2.13.1 或更舊,等於重新打開漏洞。表單壞掉時,先照上表找出是哪一項變更造成的;佈景主題的樣板問題,就去更新那幾個覆寫檔。正式站處理不完,就先停用外掛(第六章 B),拿備份到測試站慢慢查 🧯


🟢 🟢 🟢

🛟 第三章:電腦小白看過來!五分鐘無痛自救指南

💬 溫馨小叮嚀:更新外掛像是把有瑕疵的門鎖換新。它保證從現在開始,新的訪客進不來,但沒辦法倒帶,替你檢查昨天有沒有人已經進來逛過,甚至偷配了一把備用鑰匙。所以最後的檢查步驟一定要做。網站如果存有會員資料,請把這篇轉給主管或維護人員,由他們判斷要不要做更深的全面健檢 🩺

每一步我都寫了「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只需要滑鼠 🤏

  • ㊀ 先查水表,看看有沒有裝:
    登入後台,左邊選單點「外掛」→「已安裝的外掛」,在右上角搜尋框輸入 Ultimate Member。找到了就是有裝;找不到就是沒裝,這篇可以當別人家的新聞。注意名字要完全對,UM 還有幾個名字很像的擴充套件,只看到擴充套件不算有核心外掛 🔎
  • ㊁ 核對版本號:
    版本號寫在外掛名稱下方。看到 2.14.0 以上就是已修補,2.14.1 更好,因為它另外補了一個 CVE;看到 2.13.1 或更小的數字(例如 2.12.x),就是受影響,繼續往下做 🕵️‍♀️
  • ㊂ 動手前先留後路(備份):
    到主機商後台點「一鍵備份」,或用你平常的備份外掛先存一份。不知道什麼是備份的話,把這篇網址傳給幫你維護網站的人,請他代勞,這本來就是他的工作,找人幫忙很正常 💾
  • ㊃ 按下更新:
    在外掛清單點「立即更新」。沒看到更新按鈕的話,到左邊選單「儀表板」→「更新」,按一次「再檢查一次」。更新完重新整理,確認版本號跳到 2.14.1。如果網站由主機商或代管公司管理,更新權限可能被他們鎖住,這時候把第二步的版本號截圖給對方,請他們處理 🔄
  • ㊄ 更新完,先清快取:
    如果你有裝快取外掛(例如 WP Rocket、LiteSpeed Cache),到外掛設定裡按「清除所有快取」。有用 Cloudflare 之類的 CDN,也要清。快取沒清,你看到的可能還是舊頁面,會誤以為更新沒成功,或把新舊混用造成的小問題當成外掛壞了 🧹
  • ㊅ 更新完,這四件事各測一次:
    用一組測試用的假信箱,實際跑一遍:註冊新帳號、登入、編輯個人資料(記得包含「改 Email」,上一章提過有人在這裡踩雷)、打開會員名錄搜尋看看。畫面出現錯誤訊息、白畫面或版面亂掉,先把那個頁面截圖,不要自己亂改設定 🧪
  • ㊆ 順手開自動更新:
    在「外掛」→「已安裝的外掛」裡,Ultimate Member 那一列最右邊有「啟用自動更新」的連結,點一下。以後官方釋出安全更新,網站會自己換好。代價是偶爾更新後需要你點一輪畫面,所以重要網站請搭配每日備份 🔁
  • ㊇ 今天真的更新不了,怎麼辦:
    最乾淨的做法是在外掛清單把它「停用」。注意:如果你的佈景主題直接呼叫 UM 的功能,停用後網站可能整個報錯。停用完馬上打開首頁看一眼;掛了就重新啟用,並在今天內排好更新時間。停用只是縮短暴露時間,不是修補 🧯
  • ㊈ 化身名偵探,查查後台有沒有陌生人:
    點「使用者」→「所有使用者」,上方篩選選「管理員」,名單裡應該都是你認識的人、用你們自己的信箱。再到「設定」→「一般」,看「任何人都可以註冊」有沒有被你不知道的人打勾,以及「新使用者的預設角色」是不是「訂閱者」之類的低權限角色(選項名稱可能依版本略有不同)。 見鬼的情況:多出沒印象的管理員、預設角色變成管理員,或首頁出現奇怪的廣告與導向。看到這類狀況請不要自己刪,先截圖,立刻找主機商或維護人員,並告訴他們依本文第六章 A 處理 🔀

🫂 新手求助專區:看到專有名詞就頭暈怎麼辦?
直接把下面這段話傳給你的主機客服或維護人員:「你好,我的網站有安裝 Ultimate Member 外掛,請幫我確認版本是否已更新到 2.14.1 以上,漏洞編號是 CVE-2026-115968。也請協助檢查後台管理員名單有沒有異常,以及佈景主題有沒有覆寫 Ultimate Member 的樣板檔案。」對方看到這串編號就知道該做什麼。沒有備份之前,千萬不要自己亂刪東西。更新後遇到版面或功能問題,可以到 Ultimate Member 官方支援論壇 [41]搜尋同樣的症狀,很多人會先在那邊踩雷並留下紀錄 🤗

🧊 冷知識:「停用」跟「刪除」差在哪?
停用像是把電器的插頭拔掉:電器還在家裡,需要時插回去就能用,也代表哪天有人不小心插回去,問題就回來了。刪除是把電器搬出家門。很久不用的外掛,搬走比拔插頭安全;還在用的,就老實更新 🔌

[42]


🔴 🔴 🔴

🤿 第四章:技術深潛,一個免登入的請求怎麼一路走到「接管網站」?

想知道為什麼的人往下看;只想補洞的,前三章已經夠用。先講資料狀況:官方沒有公開程式差異,也沒有公開攻擊字串或觸發端點。下面整理自 CVE 紀錄、Patchstack 頁面與官方 2.14.0 更新日誌,推論的部分我都標了 🟡,查不到的標【❓待確認】⚔️

評估指標 具體資訊
外掛名稱 Ultimate Member(UM)
外掛代號(slug) ultimate-member
CVE 編號 CVE-2026-115968
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
弱點類型 CWE-502:不受信任資料的反序列化(PHP 物件注入)
受影響與修補版本 2.13.1(含)以下受影響,2.14.0 修補,建議 2.14.1
發現者 Ananda Dhakal(Patchstack)
CNA/公開時間 Patchstack;公開日 2026-10-26,CVE 發布日 2026-10-28
所需權限 免登入(Patchstack 頁面:Unauthenticated)
啟用規模 WordPress.org 顯示 200,000+ 活躍安裝
CISA-ADP SSVC Exploitation:none;Automatable:yes;Technical Impact:total
虛擬修補 Patchstack 頁面表示已有緩解規則,可在更新前先擋攻擊;哪些付費方案包含,頁面沒有說明【❓待確認】

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

  • 🏮 環節一:入口是公開的。UM 的工作就是接待訪客:註冊、登入、看會員名錄。這些請求本來就不需要先登入。問題不在「對外開放」,而在開放之後,收到的資料有沒有被當成可信的東西處理 🚪
  • 🏮 環節二:不設限的反序列化。官方的修補是新增一個專用的包裝函式 um_maybe_unserialize()。🟡 這類包裝的常見做法是:先確認資料真的是序列化格式,再用限制類別的方式還原,不讓外部資料指定要建立哪些物件。官方沒有在更新日誌裡寫細節,所以這是推論 🔧
  • 🏮 環節三:站上有沒有可串接的零件。物件注入本身不等於拿到伺服器。能走多遠,取決於站上有沒有可串接的 gadget 鏈。這也是為什麼同樣是 9.8 分,不同網站的實際風險差很多:外掛裝得越多,零件越多 🧱

🧑‍🔧 DevOps 小知識:CVSS 9.8 是怎麼拼出來的?
AV:N 網路可達、AC:L 攻擊難度低、PR:N 免帳號、UI:N 免互動,再加上機密性、完整性、可用性三項都是 High,就是 9.8 的標準組合。用同一組向量做對照:把「免帳號」改成「需要低權限帳號」,分數會從 9.8 降到 8.8;改成「需要管理員」,只剩 7.2。差距幾乎全來自「要不要先有帳號」這一個變數,UM 這顆是最壞的那一種 🛡️

🚨 畫重點:「完整觸發條件」目前查不到
哪個端點、哪個參數、需不需要特定的 UM 設定才打得到,CVE 紀錄與 Patchstack 頁面都沒有寫。所以第五章的排查我刻意不依賴某一條攻擊字串,改看結果特徵:版本、樣板、日誌裡異常的請求、帳號與檔案的變化。代價是會有一些雜訊,好處是不會因為猜錯入口而漏看真正的異常 🧭

📢 大白話:白名單,就是「只接受名單上的東西」
櫃檯人員可以代收包裹,這沒問題。問題是這次他不看名單,包裹裡寫什麼他就組什麼。白名單的做法很單純:櫃檯手上有一張紙,寫著「只能組這幾種東西」,不在紙上的一律不組。2.14.0 新增的包裝函式,用意就是替「還原」這件事加上這張紙 📋

🔷 🔷 🔷

🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 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、以檔案擁有者的身分執行、逐站迴圈,以及專門比版本號的 is_fixed。另外 –skip-plugins –skip-themes 是刻意加的:我們的檢查指令不去載入外掛與佈景主題,萬一裡面藏了後門,也不會因為我們跑 WP-CLI 而被執行 🧱

# ㊀ 工具箱:同一個 SSH 連線貼一次就好,斷線後要重貼
PLUGIN="ultimate-member"
MIN_SAFE="2.14.0"      # 低於這版:受影響(CVE-2026-115968)
RECOMMENDED="2.14.1"   # 撰文時 WordPress.org 上的最新版,之後有新版就改成新版

# HestiaCP 預設的網站日誌位置;沒有的資料夾會自動略過
LOG_DIRS=(/var/log/nginx/domains /var/log/apache2/domains)

# 找出 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指令...
# --skip-plugins --skip-themes:我們的檢查指令不去載入外掛與佈景主題,
# 萬一裡面藏了後門,也不會因為我們跑 WP-CLI 而被執行
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" --skip-plugins --skip-themes "$@" < /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" ]
}
is_fixed()  { ! version_lt "$1" "$MIN_SAFE"; }
is_latest() { ! version_lt "$1" "$RECOMMENDED"; }

um_verdict() {
  if ! is_fixed "$1"; then
    echo "🔴 受影響"
  elif ! is_latest "$1"; then
    echo "🟡 已修補,建議升到 ${RECOMMENDED}"
  else
    echo "🟢 已是建議版本以上"
  fi
}

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

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

for v in 2.9.9 2.13.1 2.14.0 2.14.1 2.14.10 2.15.0; do
  printf '%-8s %s\n' "$v" "$(um_verdict "$v")"
done

預期輸出(2.9.9 與 2.13.1 要是「受影響」,2.14.0 要提示升級,其餘都是綠燈):

2.9.9    🔴 受影響
2.13.1   🔴 受影響
2.14.0   🟡 已修補,建議升到 2.14.1
2.14.1   🟢 已是建議版本以上
2.14.10  🟢 已是建議版本以上
2.15.0   🟢 已是建議版本以上
  • 🟢 結果符合預期 👉 工具箱沒貼錯,往下進行。
  • 🟠 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
  • 🟡 2.9.9 被判成受影響、2.14.10 被判成已修補是刻意的:用 sort -V 逐段比數字,不是比字串。

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


🔎 🔎 🔎

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

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

inventory_one() {
  local wp_path="$1" kind ver status
  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)"
  printf '%s\t%s\t版本:%s\t狀態:%s\t%s\n' \
    "$(um_verdict "$ver")" "$kind" "$ver" "${status:--}" "$wp_path"
  return 0
}

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

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

預期輸出範例:

🔴 受影響	💠 一般安裝	版本:2.13.1	狀態:active	/home/admin/web/site-a.com/public_html
🟡 已修補,建議升到 2.14.1	💠 一般安裝	版本:2.14.0	狀態:active	/home/bob/web/shop.example/public_html
⚪ 未安裝	💠 一般安裝	-	-	/home/bob/web/blog.example/public_html
🟢 已是建議版本以上	✴️ Multisite	版本:2.14.1	狀態:active-network	/home/bob/web/network.example/public_html

分支判斷邏輯:

  • 🔴 受影響 👉 排入第六章的備份與更新,並接著做 ㊂ 到 ㊄,確認舊版時期有沒有被碰過。
  • 🟡 已修補但低於 2.14.1 👉 這顆 CVE 已解,建議一併升級,順便取得另一個安全修補。
  • 🟢 已是建議版本以上 👉 版本條件已解除。舊版若曾暴露一段時間,仍建議做 ㊃、㊄。
  • 🟡 狀態是 inactive 👉 外掛已停用,暫時不會被載入,仍建議更新或刪除,不要放著。
  • ✴️ Multisite 出現 active-network 👉 外掛檔案全網共用,查主站一次就代表整個網路,更新也只做一次。
  • 🟠 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。路徑若像 public_html.bak 之類的備份副本,請人工確認哪一個才是正式站。

[44]


📝 📝 📝

📝 ㊂ 樣板預檢:更新之前,先看看會不會把你的版面搞壞

為什麼這樣做:2.14.0 動了 6 個預設樣板。如果你的佈景主題把這些樣板複製了一份來客製(資安與維運圈叫它覆寫),更新後新舊內容對不上,個人檔案頁就可能像前面那個論壇案例一樣壞掉。這段指令列出每個佈景主題裡名為 ultimate-member 的資料夾,並把檔名跟那 6 個樣板重疊的標出來。UM 在後台也有檢查過期樣板的功能,選單位置依版本不同,我沒有逐版核對【❓待確認】,所以這裡以檔案比對為準。這段只讀不寫 🧩

theme_override_one() {
  local wp_path="$1" dir rel hits=0 mark
  printf '\n===== %s =====\n' "$(site_domain "$wp_path")"

  while IFS= read -r -d '' dir <&3; do
    hits=1
    printf '📁 %s\n' "$dir"
    find "$dir" -type f -name '*.php' -printf '%P\n' | sort | while IFS= read -r rel; do
      case "$rel" in
        profile/comments.php|*/profile/comments.php|profile/posts.php|*/profile/posts.php|\
        login.php|*/login.php|members.php|*/members.php|\
        profile.php|*/profile.php|register.php|*/register.php)
          mark="⚠️ 2.14.0 有改這個樣板" ;;
        *)
          mark="" ;;
      esac
      printf '   %s  %s\n' "$rel" "$mark"
    done
  done 3< <(find "${wp_path}/wp-content/themes" -maxdepth 3 -type d -name 'ultimate-member' -print0 2> /dev/null)

  if [ "$hits" -eq 0 ]; then
    echo "(佈景主題裡沒有 ultimate-member 覆寫資料夾,樣板這關可以放心)"
  fi
  return 0
}

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

# ❇️ 獨立多站
each_site theme_override_one

預期輸出範例:

===== a.example =====
📁 /home/u1/web/a.example/public_html/wp-content/themes/mytheme/ultimate-member
   custom.php
   profile.php  ⚠️ 2.14.0 有改這個樣板
   profile/posts.php  ⚠️ 2.14.0 有改這個樣板

===== b.example =====
(佈景主題裡沒有 ultimate-member 覆寫資料夾,樣板這關可以放心)
  • 🟢 沒有覆寫資料夾 👉 樣板問題不用擔心,直接更新。
  • 🟡 有覆寫,但沒有 ⚠️ 標記的檔案 👉 2.14.0 沒動到它們,更新後仍要點一輪畫面。
  • 🔴 有 ⚠️ 標記 👉 這些覆寫檔的內容要跟上新版預設樣板,交給維護佈景主題的人處理。在測試站先更新、先看畫面,沒問題再動正式站。處理期間若來不及,第六章 B 的暫時停用外掛是備案。
  • 🟡 路徑結構不同(資料夾名稱不是 ultimate-member) 👉 腳本找不到,請以 UM 官方文件的樣板覆寫位置為準【❓待確認】。

🟥 🟥 🟥

📊 ㊃ 日誌排查:看誰一直在敲「不該被敲」的門

為什麼這樣做:官方沒公開入口,標準存取日誌也只記「請求行」(方法、網址、狀態碼),不記 POST 的本文。所以日誌能回答的問題有限,我只抓五個有價值的結果特徵:網址列裡有沒有出現序列化的樣子、哪些網址被 POST 最多、哪些來源 IP 對 admin-ajax.php 送了大量 POST、錯誤日誌有沒有反序列化相關警告、有沒有人碰過 uploads 裡的 PHP 類檔案。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log,函式用 zcat -f 一併讀輪替後的 .log.1 與 .log.2.gz。前面如果有 CDN,日誌裡看到的 IP 可能是 CDN 的 🎯

# 取出某網域「目前+輪替後(含 .gz)」的存取日誌
scan_access_log() {
  local domain="$1" d
  for d in "${LOG_DIRS[@]}"; do
    [ -d "$d" ] || continue
    find "$d" -maxdepth 1 -type f -name "${domain}.log*" -print0 | xargs -0 -r zcat -f --
  done
}

# 取出某網域的錯誤日誌(同樣含輪替檔)
scan_error_log() {
  local domain="$1" d
  for d in "${LOG_DIRS[@]}"; do
    [ -d "$d" ] || continue
    find "$d" -maxdepth 1 -type f -name "${domain}.error.log*" -print0 | xargs -0 -r zcat -f --
  done
}

log_scan_one() {
  local wp_path="$1" domain
  domain="$(site_domain "$wp_path")"
  printf '\n===== %s =====\n' "$domain"

  echo "--- ❶ 網址列裡出現 PHP 序列化物件特徵的請求(最近 20 筆)---"
  scan_access_log "$domain" \
    | grep -aEi 'O(:|%3A)[0-9]+(:|%3A)(%22|")' | tail -n 20 || true

  echo "--- ❷ POST 請求最多的網址(前 15 名,已去掉問號後面的參數)---"
  scan_access_log "$domain" \
    | awk -F'"' '$2 ~ /^POST /{ split($2, a, " "); sub(/\?.*/, "", a[2]); print a[2] }' \
    | sort | uniq -c | sort -rn | head -n 15

  echo "--- ❸ 對 admin-ajax.php 送 POST 最頻繁的來源 IP(前 10 名)---"
  scan_access_log "$domain" \
    | awk -F'"' '$2 ~ /^POST [^ ?]*admin-ajax\.php/{ split($1, a, " "); print a[1] }' \
    | sort | uniq -c | sort -rn | head -n 10

  echo "--- ❹ 錯誤日誌裡與反序列化有關的警告(最近 20 筆)---"
  scan_error_log "$domain" \
    | grep -aEi 'unserialize|__PHP_Incomplete_Class|allowed_classes' | tail -n 20 || true

  echo "--- ❺ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---"
  scan_access_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

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

===== a.example =====
--- ❶ 網址列裡出現 PHP 序列化物件特徵的請求(最近 20 筆)---
198.51.100.9 - - [30/Oct/2026:10:00:03 +0800] "GET /?x=O%3A8%3A%22Exploit%22 HTTP/1.1" 403 5 "-" "python"
--- ❷ POST 請求最多的網址(前 15 名,已去掉問號後面的參數)---
      2 /wp-admin/admin-ajax.php
      1 /register/
--- ❸ 對 admin-ajax.php 送 POST 最頻繁的來源 IP(前 10 名)---
      2 203.0.113.5
--- ❹ 錯誤日誌裡與反序列化有關的警告(最近 20 筆)---
PHP Warning:  unserialize(): Error at offset 0 of 45 bytes in .../ultimate-member/includes/...
--- ❺ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---
198.51.100.9 - - [30/Oct/2026:10:00:04 +0800] "GET /wp-content/uploads/2026/10/x.php?c=1 HTTP/1.1" 200 5 "-" "curl"

分支判斷邏輯:

  • 🔴 ❺ 有命中 👉 uploads 裡有人執行過 PHP,視為高度可疑,直接進第六章 A,並同時做 ㊄ 的 ❻。
  • 🔴 ❶ 或 ❹ 有命中,來源是陌生 IP、User-Agent 像腳本工具 👉 記下 IP 與時間,做完 ㊄;若 ㊄ 也有異常,進第六章 A。
  • 🟡 ❷、❸ 突然暴衝 👉 可能是正常流量,也可能是機器人在灌註冊或登入,要結合時間與 IP 判讀。我無法確認這顆漏洞在日誌裡的專屬特徵。
  • 🟡 ❶ 只在 GET 的網址列找得到,攻擊如果走 POST 本文或 Cookie,這裡看不到 👉 所以沒命中也不能當作安全。
  • 🟢 都沒命中 👉 只能說目前沒有直接證據,不能證明沒被打過:日誌可能已輪替、前面有 CDN,攻擊也可能沒走你查的這個日誌。繼續做 ㊄。

[45]


🟣 🟣 🟣

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

為什麼這樣做:這顆漏洞最壞的終點是伺服器端執行程式碼,所以稽核要資料與檔案兩側一起看。資料側:管理員名單、最近註冊的帳號、各角色人數、幾個常被動手腳的網站設定、排程工作。檔案側:uploads 裡的 PHP、會自動載入的 mu-plugins、wp-content 根目錄的 PHP、SINCE 日期之後被改過的 PHP,以及外掛與核心檔案跟官方版本的比對。全部用 WP-CLI 內建功能完成,不寫 SQL,所以資料表前綴不一樣也不怕。UM 本身就是管會員的外掛,所以「註冊帳號」與「角色」兩項特別值得看 🕵️‍♀️

# 掃描起點:想保守就往前調。外掛 2.13.1 以下的洞存在多久沒人知道,這裡預設從回報日前後開始看
SINCE="2026-09-01"

audit_one() {
  local wp_path="$1" opt
  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 "--- ❷ 最近註冊的 15 個帳號(任何角色)---"
  wpx "$wp_path" user list --orderby=registered --order=DESC --number=15 \
    --fields=ID,user_login,user_email,user_registered,roles --format=table

  echo "--- ❸ 各種角色各有幾個人(出現不認識的角色或異常多的管理員就要查)---"
  wpx "$wp_path" user list --fields=roles --format=csv | tail -n +2 \
    | sort | uniq -c | sort -rn

  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

  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 "--- ❾ wp-config.php 最後修改時間 ---"
  stat -c '%y  %U  %a  %n' -- "${wp_path}/wp-config.php"

  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
  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  | admin      | admin@example.com | 2024-01-15 08:00:00 |
+----+------------+-------------------+---------------------+
--- ❸ 各種角色各有幾個人(出現不認識的角色或異常多的管理員就要查)---
    842 subscriber
      3 editor
      1 administrator
--- ❹ 幾個常被動手腳的網站設定 ---
users_can_register = 1
default_role = subscriber
--- ❻ uploads 底下的 PHP 類檔案(正常網站應該是空的)---
--- ⓫ 外掛與核心檔案跟官方版本比對 ---
Success: Verified 12 of 13 plugins.
Success: WordPress installation verifies against checksums.

❻ 沒有任何輸出,代表 uploads 裡沒有 PHP 類檔案。⓫ 若有付費外掛,它們不在 WordPress.org,常會出現無法驗證的提示,這很正常,實際文字依 WP-CLI 版本而異 🧾

分支判斷邏輯:

  • 🔴 ❶、❷、❸ 出現你不認識、又在 SINCE 日期之後註冊的管理員或高權限帳號 👉 高度可疑。陌生不等於惡意,可能是外包留的,先問清楚再處理。
  • 🔴 ❹ 的 default_role 變成 administrator,或 siteurl、home 被改成陌生網域 👉 網站設定被動過手腳,這類改動很少是正常維運造成的,進第六章 A。
  • 🔴 ❻ 找到 PHP 類檔案,或 ❼ 出現不是你部署的檔案 👉 視為已中招,先別刪,保留證據,進第六章 A。
  • 🟡 ❽ 揭露日之後被改過的 PHP 👉 外掛與佈景主題正常更新也會出現,要對照你自己的維運紀錄,別看到就當成入侵。
  • 🟡 ❺ 與 ❿ 出現不認識的外掛或排程 hook 👉 先查那是哪個外掛、誰裝的,查不出來就當成可疑。
  • 🟡 ⓫ 出現 File was added 之類的差異 👉 先確認是不是你自己放的客製檔案;不確定就當成可疑。
  • 🟢 全部乾淨 👉 這一輪沒有發現明確跡象,仍建議做第六章的更新與第七章的加固,並多觀察一週。

📋 📋 📋

📋 ㊅ 三環境速查表:把這一章濃縮成一張

階段 💠 單一網站 ❇️ 獨立多站 ✴️ Multisite
① 工具箱 貼一次,整個 SSH 連線通用
② 盤點版本 inventory_one 路徑 each_site inventory_one 查主站一次,狀態看 active-network
③ 樣板預檢 theme_override_one 路徑 each_site theme_override_one 佈景主題全網共用,查主站一次
④ 日誌排查 單一網域 掃所有網域日誌 共用網域日誌,再依 Host 回推子站
⑤ 帳號與檔案 角色/管理員/檔案 逐站各自檢查,不能拿 A 站代表 B 站 另加 super-admin list

[46]


🪛 🪛 🪛

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

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


💾 💾 💾

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

為什麼這樣做:A、B、C 都會用到其中一兩個,所以先定義成函式,這一步只有定義,不會執行任何動作。備份檔放在 root 家目錄、權限 700,不放在網站目錄,避免被網址直接下載。鹽值(Salts)是 wp-config.php 裡那八組長字串,WordPress 用它們替登入 Cookie 簽章。這顆漏洞的最壞結果是攻擊者在伺服器上執行程式,他可能已經拿到登入憑證,所以我的判斷是:舊版曾暴露、又出現任何可疑跡象,就用 wp config shuffle-salts 強制刷新,所有人,包含可能握有 Cookie 的攻擊者,立刻被登出。代價是正常使用者包含你自己都要重新登入。這個指令不要加 –insecure,它會跳過連線憑證驗證,鹽值是用來防守的東西,不該從沒驗證過的連線拿 🔑

📢 大白話:刷鹽值,是把全公司的通行證版本換新
你只改了某位員工的密碼,攻擊者手上可能還有一張沒過期的通行證,照樣進得來。刷新鹽值等於宣布「舊版通行證今天起全部作廢」,不管誰拿著哪一張,都得重新辦。比單純改密碼徹底得多 🪪
# ㊀ 共用零件一:備份(資料庫+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
}

# ㊂ 共用零件三:停用 Ultimate Member(Multisite 會連全網與各子站一起處理)
deactivate_one() {
  local wp_path="$1" url status home_url code
  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

  # 驗證一:狀態不能是 active 或 active-network
  status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status 2> /dev/null || true)"
  printf '%s:%s\n' "$PLUGIN" "${status:-未安裝}"
  case "$status" in
    active|active-network)
      echo "❌ 仍是啟用狀態,請看上方輸出"
      return 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
  return 0
}

預期輸出:貼完沒有任何輸出就是成功。三個函式要等到 A、B 或 C 才會被叫用,這時候的輸出範例如下:

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

驗證與失敗分支:

  • 🟢 備份:兩個檔案都在、大小合理、資料庫檔結尾有 Dump completed 👉 可以進下一步。這個結尾字樣是 mysqldump 與 MariaDB 的慣例輸出,若你的環境用別的工具匯出,字樣可能不同,腳本只會警告,不會擋你。
  • 🟠 備份失敗或是空檔 👉 函式立刻停下,先查磁碟空間(已印出 df 結果)與資料庫權限,沒有備份不要往下。
  • 🟢 鹽值:前後兩段雜湊不同 👉 已生效,你會被登出,重新登入即可。
  • 🟠 鹽值出現 ❌「沒有改變」或指令本身失敗 👉 先確認 wp-config.php 的擁有者可寫;這個指令要連到 WordPress.org 取得新鹽值,主機連不出去也會失敗,先查網路,不要急著換別的來源。
  • 🔴 刷完網站出現「建立資料庫連線時發生錯誤」 👉 用備份還原:cp -p “備份檔路徑” “網站路徑/wp-config.php”,備份檔名在上面的 ls 輸出與備份資料夾裡。這份備份含資料庫密碼,確認一切正常後再決定要不要保留。
  • 🟢 停用:狀態是 inactive,首頁 HTTP 為 2xx 或 3xx 👉 成功。
  • 🟠 停用後首頁不是 2xx/3xx 👉 佈景主題可能呼叫了 UM 的函式。先別慌,以網站擁有者身分執行 wp plugin activate ultimate-member 恢復網站,再改走 B 的備案,並把這件事交給維護人員處理。重新啟用代表門又開了,所以要盡快排更新。

[47]


🔴 🔴 🔴

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

為什麼這樣做:第五章出現明確跡象時,第一優先是保留現場,更新放在後面。順序是:資料庫快照與日誌 👉 停用外掛 👉 刷新鹽值 👉 管理員密碼重設與登出所有裝置。這個順序有道理:先拍照存證,再關門,再讓所有舊憑證作廢,最後處理帳號。如果攻擊者已經能執行程式,他可能讀過 wp-config.php,所以腳本結束時會提醒你接著換資料庫密碼 🧯

這支腳本會動到外掛、鹽值與帳號,請先確認路徑,而且只對有疑慮的那一站執行,不要整台主機無差別跑。新密碼會留在終端機的捲動紀錄裡,請在私密環境操作,用安全管道交給管理員本人,並請他登入後立刻自行更換,不要貼進聊天視窗或腳本。證據資料夾裡有資料庫傾印與日誌,含會員個資,權限已設成只有 root 能看,結案前請別隨手搬到別處 🔐

# 前提:已貼過「工具箱」與「共用零件」。取證完成前,什麼都不要刪
incident_one() {
  local wp_path="$1" domain stamp evid out uid ans d
  domain="$(site_domain "$wp_path")"
  stamp="$(date +%Y%m%d-%H%M%S)"
  evid="${HOME}/um-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
  for d in "${LOG_DIRS[@]}"; do
    [ -d "$d" ] || continue
    find "$d" -maxdepth 1 -type f -name "${domain}.*log*" -exec cp -p -- {} "$out/" \;
  done
  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 "=== ❷ 隔離:停用 Ultimate Member ==="
  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"

預期輸出範例:

=== ❷ 隔離:停用 Ultimate Member ===
Plugin 'ultimate-member' deactivated.
ultimate-member:inactive
首頁 HTTP 200,正常
=== ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)===
Success: Shuffled the salt keys.
✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin
=== ❹ 管理員名單與密碼重設 ===
(列出管理員後詢問是否重設,選 y 會顯示每位管理員的新隨機密碼)

✅ example.com 止血完成,證據放在 /root/um-incident-20261031-103500/example.com
⚠️ 還沒做完:資料庫密碼與 wp-config.php 裡的其他金鑰,攻擊者若已能執行程式就可能讀過,請接著換掉

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

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

wpx "$WP_PATH" plugin get "$PLUGIN" --field=status 2> /dev/null || echo "未安裝"
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. 短期應急:還沒確認中招,但現在還不能更新

🚨 短期應急 ≠ 正式修補。
下面兩個辦法都只是縮短暴露時間。真正的修補仍然是更新到 2.14.0 以上,最好是 2.14.1。第二個辦法(nginx 擋規則)我要先講清楚它的極限:它只看網址列裡的參數,攻擊如果走 POST 本文或 Cookie,它完全看不到,所以不能當成主要防線 🧱

選項一(最乾淨):停用外掛。直接叫用 ㊀ 貼好的 deactivate_one。Multisite 會連全網和各子站一起處理,最後驗證狀態與首頁。UM 一停用,註冊、登入、個人檔案、會員名錄都會失效,會員會看到功能消失,所以要先評估業務影響 🧯

# 💠 選項一(最乾淨):停用外掛,沿用 ㊀ 的 deactivate_one(路徑換成你的)
deactivate_one "/home/admin/web/example.com/public_html"

預期輸出範例:

Plugin 'ultimate-member' deactivated.
ultimate-member:inactive
首頁 HTTP 200,正常

選項二(較弱):在 nginx 擋掉網址列帶有序列化特徵的請求。適合選項一會讓業務停擺、必須撐到更新的情況。規則看的是網址參數裡有沒有 O:數字: 這種 PHP 序列化物件的開頭(含編碼成 O%3A數字%3A 的寫法)。HestiaCP 的網站設定檔會在重建網域或續約 SSL 時被樣板覆蓋,所以千萬不要直接改 nginx.conf;官方論壇的做法是在 /home/使用者/conf/web/網域/ 放 nginx.conf_任意名稱(HTTP)與 nginx.ssl.conf_任意名稱(HTTPS),樣板會自動載入。下面的函式在 nginx -t 沒過時會自動撤回剛建的兩個檔案,最後用兩個網址驗證:一個帶特徵、一個正常 🧩

# 暫時緩解:網址參數裡出現 PHP 序列化物件特徵,就在 nginx 層直接回 403
# 用 HestiaCP 認得的自訂檔,面板重建設定時不會被蓋掉
block_serialized_query() {
  local owner="$1" domain="$2"
  local conf_dir="/home/${owner}/conf/web/${domain}"
  local http_inc="${conf_dir}/nginx.conf_um_block_serialized"
  local ssl_inc="${conf_dir}/nginx.ssl.conf_um_block_serialized"

  if [ ! -d "$conf_dir" ]; then
    printf '找不到 %s,略過\n' "$conf_dir"
    return 1
  fi

  tee "$http_inc" > /dev/null <<'EOF'
# 暫時緩解:網址參數出現 O:數字: 或 O%3A數字%3A(PHP 序列化物件的開頭)就回 403
if ($query_string ~* "O(:|%3A)[0-9]+(:|%3A)") {
    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_serialized_query() {
  local owner="$1" domain="$2"
  local conf_dir="/home/${owner}/conf/web/${domain}"
  rm -f -- "${conf_dir}/nginx.conf_um_block_serialized" "${conf_dir}/nginx.ssl.conf_um_block_serialized"
  if nginx -t; then
    systemctl reload nginx
    printf '已解除:%s\n' "$domain"
  else
    echo "nginx 設定檢查沒過,請先查原因,不要 reload"
    return 1
  fi
}

# 驗證:帶序列化特徵的網址要 403,一般網址照常回應
check_serialized_block() {
  local domain="$1"
  curl -sS -o /dev/null -w "帶序列化特徵的網址:HTTP %{http_code}\n" --max-time 20 \
    "https://${domain}/?probe=O%3A8%3Atest" || true
  curl -sS -o /dev/null -w "一般網址:HTTP %{http_code}\n" --max-time 20 \
    "https://${domain}/?probe=hello" || true
}

# 套用(把使用者與網域換成你的)
block_serialized_query "admin" "example.com"
check_serialized_block "example.com"

預期輸出範例:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
已套用:example.com
帶序列化特徵的網址:HTTP 403
一般網址:HTTP 200
  • 🟢 選項一:狀態 inactive、首頁 2xx 或 3xx 👉 暫時安全,立刻排更新。
  • 🟠 選項一:首頁不是 2xx/3xx 👉 見 ㊀ 的失敗分支,改走選項二,並交給維護人員。
  • 🟢 選項二:帶特徵的網址 403、一般網址 200(或網站原本就會有的 301 轉址) 👉 規則生效,沒有誤擋正常請求。
  • 🟠 選項二:帶特徵的網址不是 403 👉 規則沒載入,或前面有 CDN/其他代理把請求擋在 nginx 之前。立刻執行 unblock_serialized_query 拆掉規則(⚠️ 這個函式會刪檔,但只刪我們自己建的那兩個),再請維運人員看樣板。
  • 🟠 選項二:一般網址也被擋 👉 規則誤傷,馬上拆除。
  • 🟡 如果你有訂閱 Patchstack 這類提供虛擬修補的服務,Patchstack 頁面表示這顆洞已有緩解規則,可確認自己的方案是否已啟用;哪些方案包含,頁面沒有說明【❓待確認】。虛擬修補一樣不能取代更新。

[48]


🚀 🚀 🚀

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

為什麼這樣做:UM 掛在 WordPress.org,WP-CLI 的 plugin update 可以直接更新,plugin verify-checksums 能拿官方檢查碼比對外掛檔案有沒有被動過。腳本流程是:資料庫與外掛資料夾先備份,再用 –dry-run 預覽,等你輸入 y 確認才更新,最後驗證版本、檢查碼、首頁與你指定的會員頁面 HTTP 狀態。更新前沒有先停用外掛,是因為更新是原地替換檔案,少一個停用再啟用的動作,就少一次讓會員功能中斷的機會;真的疑似中招的站,請先走 A,不是走這條。腳本用 heredoc 建立,結束標記用單引號包住,裡面的 $ 不會被提前展開,建好後立刻做語法檢查。千萬不要為了「保險」而降版,降回舊版等於把洞重新打開 📦

cat > /usr/local/sbin/um-patch.sh <<'UM_PATCH_EOF'
#!/usr/bin/env bash
# um-patch.sh:單一網站的 Ultimate Member 修補流程(備份 → 預覽 → 確認 → 更新 → 驗證)
# 用法:um-patch.sh 網站使用者 網域 [順便檢查的網址路徑 ...]
# 例如:um-patch.sh admin example.com /login/ /register/ /members/
set -euo pipefail

PLUGIN="ultimate-member"
MIN_SAFE="2.14.0"
RECOMMENDED="2.14.1"
WPCLI="$(command -v wp || command -v wp-cli || true)"
if [ -z "$WPCLI" ]; then
  echo "❌ 找不到 wp 或 wp-cli" >&2
  exit 1
fi

if [ "$#" -lt 2 ]; then
  echo "用法:$0 網站使用者 網域 [順便檢查的網址路徑 ...]" >&2
  exit 2
fi
SITE_USER="$1"
SITE_DOMAIN="$2"
shift 2
CHECK_PATHS=("$@")
[[ "$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" --skip-plugins --skip-themes "$@" < /dev/null
}

version_lt() {
  [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
is_fixed()  { ! version_lt "$1" "$MIN_SAFE"; }
is_latest() { ! version_lt "$1" "$RECOMMENDED"; }

http_check() {
  local url="$1" code
  code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "$url" || true)"
  case "$code" in
    2*|3*) printf '✅ %s:HTTP %s\n' "$url" "$code"; return 0 ;;
    *)     printf '❌ %s:HTTP %s\n' "$url" "${code:-無回應}" >&2; return 1 ;;
  esac
}

ver="$(wpx plugin get "$PLUGIN" --field=version 2> /dev/null || true)"
if [ -z "$ver" ]; then
  echo "⚪ ${SITE_DOMAIN} 沒有安裝 Ultimate Member,不需處理"
  exit 0
fi
printf '目前版本:%s\n' "$ver"

if is_latest "$ver"; then
  echo "🟢 已經是 ${RECOMMENDED} 以上,跳過更新,直接做驗證"
else
  if is_fixed "$ver"; then
    echo "🟡 已含這顆 CVE 的修補,但還沒到 ${RECOMMENDED},一併升級"
  else
    echo "🔴 ${SITE_DOMAIN} 的版本低於 ${MIN_SAFE},開始處理"
  fi

  BACKUP_DIR="${HOME}/wp-security-backup"
  STAMP="$(date +%Y%m%d-%H%M%S)"
  DB_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_db_${STAMP}.sql"
  PLUGIN_TAR="${BACKUP_DIR}/${SITE_DOMAIN}_${PLUGIN}_${ver}_${STAMP}.tar.gz"
  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
  if ! tar -czf "$PLUGIN_TAR" -C "${WP_PATH}/wp-content/plugins" "$PLUGIN"; then
    rm -f -- "$PLUGIN_TAR"
    echo "❌ 外掛目錄備份失敗,停止,沒有進行任何更新" >&2
    exit 1
  fi
  chmod 600 -- "$PLUGIN_TAR"
  printf '✅ 已備份:\n   %s\n   %s\n' "$DB_FILE" "$PLUGIN_TAR"

  echo "--- 預覽(不會真的更新)---"
  wpx plugin update "$PLUGIN" --dry-run
  read -r -p "確認要更新嗎?(y/N):" ans
  if [ "${ans:-}" = "y" ]; then
    wpx plugin update "$PLUGIN"
  else
    echo "已略過更新"
  fi
fi

echo "--- 驗證 ---"
fail=0
new_ver="$(wpx plugin get "$PLUGIN" --field=version 2> /dev/null || true)"
if [ -n "$new_ver" ] && is_fixed "$new_ver"; then
  printf '✅ 版本:%s(%s)\n' "$new_ver" "$(is_latest "$new_ver" && echo "已是建議版本以上" || echo "已修補,但建議升到 ${RECOMMENDED}")"
else
  printf '❌ 版本:%s 仍低於 %s\n' "${new_ver:-讀不到}" "$MIN_SAFE" >&2
  fail=1
fi
wpx plugin verify-checksums "$PLUGIN" || fail=1
wpx core verify-checksums || echo "⚠️ 核心檔案比對有差異,請人工確認是不是你自己改的"
wpx cache flush || true

home_url="$(wpx option get home)"
http_check "${home_url%/}/" || fail=1
for p in "${CHECK_PATHS[@]}"; do
  http_check "${home_url%/}${p}" || fail=1
done

if [ "$fail" -eq 0 ]; then
  echo "🎉 ${SITE_DOMAIN} 完成。接著手動清網站快取與 CDN 快取,並把註冊、登入、個人資料、會員名錄各測一次"
fi
exit "$fail"
UM_PATCH_EOF

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

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

使用方式:一次一站,先處理一站、確認前後台正常,再處理下一站。把第五章盤點出的 🔴 站,逐一用「使用者、網域」當參數跑。後面可以接任意多個網址路徑,腳本更新完會順便檢查它們是不是正常回應(2xx 或 3xx),路徑請換成你站上會員頁面實際的網址:

# 只檢查首頁:兩個參數
/usr/local/sbin/um-patch.sh admin example.com

# 順便檢查會員相關頁面(路徑換成你站上實際的網址)
/usr/local/sbin/um-patch.sh admin example.com /login/ /register/ /members/

預期輸出範例:

目前版本:2.13.1
🔴 example.com 的版本低於 2.14.0,開始處理
✅ 已備份:
   /root/wp-security-backup/example.com_db_20261031-111500.sql
   /root/wp-security-backup/example.com_ultimate-member_2.13.1_20261031-111500.tar.gz
--- 預覽(不會真的更新)---
(輸出格式依 WP-CLI 版本而異,會列出將從 2.13.1 更新到的版本)
確認要更新嗎?(y/N):y
Success: Updated 1 of 1 plugins.
--- 驗證 ---
✅ 版本:2.14.1(已是建議版本以上)
Success: Verified 1 of 1 plugins.
Success: WordPress installation verifies against checksums.
✅ https://example.com/:HTTP 200
✅ https://example.com/login/:HTTP 200
🎉 example.com 完成。接著手動清網站快取與 CDN 快取,並把註冊、登入、個人資料、會員名錄各測一次
  • 🟢 看到最後的 🎉 👉 這一站修補完成。接著手動清網站快取與 CDN 快取,並照第三章 ㊅ 把四個會員流程各測一次。
  • 🟠 備份失敗 👉 腳本直接停下,沒有任何更新,先查磁碟空間與資料庫權限。
  • 🟠 出現 ❌「版本仍低於 2.14.0」 👉 你可能在確認時輸入了 N,或更新來源沒生效(主機連不到 WordPress.org、外掛被鎖定版本),把輸出交給維運人員,不要反覆硬跑。
  • 🟠 verify-checksums 出現 File was added 👉 外掛資料夾裡多了官方沒有的檔案,逐一確認,不確定就視為可疑,回到 A。
  • 🟠 某個網址路徑回 404 或 5xx 👉 先確認那個路徑是不是真的存在;存在卻壞掉,多半是第二章表格提過的樣板或快取問題,從那邊對照排查。
  • 🔴 更新後網站白畫面 👉 立刻停用外掛(B 選項一),再用備份還原;之後把備份拿到測試站慢慢查,不要在正式站試錯。
  • 🟡 預覽時沒有列出可用更新 👉 不代表已是最新,以最後的版本判定為準;也要確認主機連得到 WordPress.org。

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

情境 💠 單一網站 ❇️ 獨立多站 ✴️ Multisite
疑似中招(A) incident_one 路徑 只對有疑慮的站逐一執行 對網路根目錄跑一次,腳本會連子站一起停用
短期應急(B) deactivate_one 路徑 逐站套用 對網路根目錄跑一次
正式修補(C) um-patch.sh 使用者 網域 一次一站,確認後再下一站 外掛檔案只有一份,更新一次即可,再逐子站驗證會員頁面

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


🔒 🔒 🔒

🔒 第七章:補完洞之後,把第二道門也鎖上

更新是根本,加固是「萬一下一顆漏洞又來,讓損害小一點」。先講清楚:這一章的招式擋不住這顆 CVE 本身,它們擋的是漏洞被打中之後的下一步,屬於通用加固,不是官方要求 🛡️

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

為什麼這樣做:這顆漏洞最壞的終點是伺服器端執行程式碼。disable_functions 可以讓 PHP 無法呼叫 exec、system 這類直接跟作業系統溝通的函式,攻擊者拿到執行程式的機會後,能做的事會少一截。清單刻意不含 curl_exec,因為 WordPress 對外連線要靠它,停掉會讓外掛更新與排程出問題。備份、圖片處理類外掛偶爾會用到 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)"

    printf 'PHP %s 原本的設定:' "$ver"
    grep '^disable_functions' "$ini" || echo "(沒有這一行)"
    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 原本的設定:disable_functions = pcntl_alarm,pcntl_fork
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。重啟 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

為什麼這樣做:上傳資料夾本來只該放圖片與文件。UM 的會員上傳檔案(像大頭貼、封面照)通常也放在這一帶。攻擊者想把後門丟進網站,最常見的落腳處就是這裡。用 nginx 規則讓 /wp-content/uploads/ 底下的 PHP 類檔案一律回 403,即使檔案被放進去也跑不起來。規則用 ^~ 前綴,讓這個資料夾不再被其他 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"
  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 確認即可。它同樣擋不住這顆 CVE,只是縮小事後的傷害範圍 🔒

👥 加固三:管好會員系統這一塊

UM 是管會員的外掛,它出事時,最容易被動手腳的就是「誰是管理員」。這顆洞之外,UM 在 2.13.0 之前還有另一個已登記的問題 CVE-2026-19423:INCIBE-CERT 的收錄描述,是未登入的人透過 UM 自己的註冊表單,有機會替自己取得任意權限。所以如果你的站曾經長時間停在舊版,更要確認帳號與角色沒被動過 🧾

  • ⭐ 確認每張註冊表單指派的角色都是低權限角色。UM 的表單設定裡可以指定新會員的角色,選項名稱依版本可能不同【❓待確認】,找不到就請維護人員幫你看。
  • ⭐ 用不到的註冊表單、擴充套件直接刪除。停用不等於移除,入口越少越好。
  • ⭐ 開自動更新,並搭配每日備份。這次修補版比 CVE 公布早 29 天,自動更新會在你沒空看新聞的時候就把洞補上。代價是偶爾要處理更新後的相容性問題,所以重要網站請務必有備份。
  • ⭐ 管理員的變動要有人知道。後台新增管理員、外掛被安裝,最好有通知機制。常見的資安外掛有提供,可評估是否啟用【❓待確認:依你使用的外掛而定】。
  • ⭐ 覆寫過 UM 樣板的網站,更新後要養成比對習慣。官方每次動預設樣板,都在更新日誌列出需要更新的檔案,跟你的覆寫檔對照一次,比出事後找原因省事。

🩵 荷包試算:要不要花錢買 WAF 或虛擬修補?
Patchstack 頁面表示這顆洞已有緩解規則,可在更新前先擋攻擊,但哪些方案包含、價格多少,頁面沒有說明,我也沒有核對,這裡不報數字。我的判斷是:今天能更新,更新通常比月付方案划算;真的卡在樣板相容、短期無法更新時,再把付費防護當過渡手段,別反過來拿它當不更新的理由。至於 WAF 擋不擋得到這種看起來像正常表單送出的請求,我沒有資料可以驗證 💰

[49]


🟢 🟢 🟢

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

  • 🌠 Q:我已經是 2.14.1 了,就安全了嗎?
    A:就這顆 CVE 來說,是的,那是官方的修補版。但如果舊版曾對外暴露,更新只能擋住下一次,擋不了已經發生的事,所以還是建議做完第五章的稽核 📦
  • 🌟 Q:我的網站沒開放註冊,還需要更新嗎?
    A:需要。官方沒有說明這顆洞的入口是不是依賴註冊功能,所以不能假設「沒開放註冊就打不到」。而且同一個版本還修了其他 5 項安全問題 🧭
  • ✨ Q:更新後表單壞了,可以降版嗎?
    A:不建議。降版等於重新打開漏洞。先照第二章表格找出是哪項行為變更造成,樣板問題就更新覆寫檔,或先把那個頁面下架 🙅
  • 🌠 Q:nginx 的擋規則有用嗎?有沒有一條能完整擋住的 WAF 規則?
    A:官方沒有公開入口與參數,我沒有辦法替你寫一條「保證擋得住」的規則。第六章 B 的規則只擋網址列,是過渡手段。要靠規則擋,比較實際的是有專人維護規則的服務(例如 Patchstack 的虛擬修補),但它也不能取代更新 🤗
  • ✨ Q:看不懂那些指令,是不是沒救了?
    A:不會。第三章全程用滑鼠就能完成最基本的處理,第五章以後是給有 SSH 權限的人看的補充 🔍

🧠 🧠 🧠

🧩 舉一反三:「外部資料直接還原成物件」這一類漏洞怎麼防

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

  • ⭐ 外部資料盡量用 JSON,不用序列化。JSON 只能裝純資料,還原時不會憑空長出一個會自己跑程式的物件。非用序列化不可,PHP 的 unserialize() 有個 allowed_classes 選項,可以限制能還原的類別,設成 false 就完全不還原物件。
  • ⭐ 把危險動作收斂到一個包裝函式。UM 這次的修補就是這個思路:新增一個統一的 um_maybe_unserialize(),日後要稽核、要修補,只要看一個地方。
  • ⭐ 最小化外掛足跡。物件注入能不能走遠,取決於站上有沒有可串接的零件。外掛裝越多,零件越多,定期把不用的外掛刪掉。
  • ⭐ 把外掛更新日誌當成情報來源。這次修補比編號早 29 天。養成看到 changelog 寫 security 就優先處理的習慣,比等新聞可靠。
  • ⭐ 修補與事件應變是兩件事。舊版只要曾暴露在網路上,更新完還要查日誌、查帳號、查檔案,缺一不可。

[50]


🏁 🏁 🏁

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

評估維度(10 分制,數字越高代表風險或應對品質越正向) 分數 一句話評語
漏洞嚴重程度(越高代表風險越大) 9.8 免登入、免互動、攻擊難度低,三項影響全是 High;唯一的緩衝是實際後果要看站上有沒有可串接的零件
官方應變速度 8 修補版比公開揭露早了近一個月,是加分項;但 2.14.0 動了 6 個樣板,社群有人踩雷,扣一點 🟡
一般站長自救可行性 7 核心外掛免費,後台點「更新」就好;有覆寫樣板的站、要測的會員流程多,門檻比單點更新高一些
DevOps 技術文件完整度 5 觸發端點、參數、是否需要特定設定都沒公開,排查只能靠結果特徵
🏆 加權綜合建議 立即更新 最終建議:【今天內更新到 2.14.1;有覆寫樣板先預檢;疑似中招走第六章 A;本週內做完稽核與加固】

🔥 這種漏洞最危險的是掃描工具出現、你卻還沒更新的那幾天。現在沒有確認利用,反而是你手上最便宜的一段時間。只要花五分鐘打開後台的外掛頁面看一眼版本號碼,比事後清後門便宜太多 🔢


📎 📎 📎

📎 附錄 A:一頁式決策表(推薦印出來)

你看到的狀況 先做什麼 接著做什麼
版本低於 2.14.0,沒有其他異常 備份,並預檢佈景主題樣板 更新(C),測四個會員流程,再做第五章稽核
版本低於 2.14.0,而且今天無法更新 備份,走 B(停用,或 nginx 暫時擋) 排定更新時間,更新後拆除暫時規則
管理員、uploads、mu-plugins 或網站設定出現異常 不要刪,走 A 取證與止血 換資料庫密碼,再走 C;有個資的網站通知內部資安與法務
更新後個人檔案頁或會員名錄壞掉 對照第二章表格,查覆寫樣板 更新覆寫檔,或先把該頁下架,不要降版
更新後無法改 Email 到官方論壇查最新進度 社群修補先在測試站驗證,再決定要不要上正式站
更新後網站白畫面 停用外掛(B 選項一) 用備份到測試站查原因

📖 附錄 B:名詞小辭典

  • 🔹 CVE:漏洞的國際編號,CVE-2026-115968 就是這次的編號。
  • 🔹 CVSS:漏洞嚴重度評分,滿分 10 分,9.0 以上屬於 Critical。
  • 🔹 CWE-502:「不受信任資料的反序列化」這一類弱點的分類編號。
  • 🔹 序列化/反序列化:把程式裡的東西轉成可儲存的字串,與把字串還原成原樣。
  • 🔹 PHP 物件注入:攻擊者藉由反序列化,讓程式還原出他指定的物件。
  • 🔹 Gadget 鏈(POP 鏈):站上本來就有的程式片段,被串起來造成破壞。
  • 🔹 PoC:Proof of Concept,概念驗證,能示範漏洞怎麼被利用的程式或步驟。
  • 🔹 SSVC:CISA 用來判斷漏洞處理優先順序的評估方式,這次的結論是尚無利用、可自動化、技術衝擊為全面。
  • 🔹 KEV:已知遭利用漏洞清單,被收錄代表已有實際攻擊證據。
  • 🔹 Nonce:請求裡附帶的一次性驗證碼,用來確認請求真的來自該頁面。
  • 🔹 WP-CLI:WordPress 的命令列工具,用來在主機上管理網站。
  • 🔹 鹽值(Salts):wp-config.php 裡八組長字串,用來簽章登入 Cookie。

[51]


📎 📎 📎

📚 參考資料與延伸資源

列印本文 [66] 👨‍👩‍👧‍👦 88 次瀏覽