🎪 留言框裡塞了一顆詐彈?活動行事曆神級外掛全站淪陷風暴全解析(CVE-2026-78006/CVSS 9.8)
🔥 懶人包:
WordPress 裝機量超過 60 萬 站的活動行事曆外掛 The Events Calendar,被抓到一個「連登入都不用、光靠留言就能拿下整台主機」的反序列化漏洞(CVE-2026-78006,CVSS 9.8 嚴重等級)。可怕的地方不是攻擊手法多花俏,而是它專挑「活動頁開放留言」這個大家覺得再平常不過的功能下手——你以為只是讓訪客留言問問活動地點,其實正親手幫攻擊者開了一道側門。目前官方已經修補到 6.17.4.1,強烈建議還在 6.17.4 以下版本的網站,今天就處理掉 ☄️
內容目錄
🕵️ 一則「還沒審核」的留言,怎麼就把主機鑰匙送出去了?
先問你一個問題:如果佈告欄旁邊有一個意見箱,規定是「管理員審核過才會貼出來」,但系統貼心地讓你投完意見後,能立刻用一組專屬號碼牌,搶先看到自己投的那張紙條長怎樣——這個設計聽起來很人性化對吧?這次的漏洞,玩的就是這套邏輯的陰暗面 🦹
The Events Calendar 就像社區活動中心的公告系統,負責展示每一場講座、市集、派對的資訊,訪客可以在活動頁下方留言問問題。原本的流程是:留言先進「待審核」狀態,管理員點頭之後才會公開顯示。但為了不讓留言者誤以為自己按錯、重複狂送留言,WordPress 貼心地在你送出留言後,發給你一組專屬的「預覽通行證」網址,讓你能立刻看到自己那則還沒審核的留言長什麼樣子。問題是,The Events Calendar 在渲染活動頁面時,會把這則「還沒被任何人看過」的留言內容,直接丟進一套會解析「區塊標記」的引擎裡處理——而這套引擎裡負責「檢查小工具資料安不安全」的驗貨員,剛好有一個看似無害卻要命的邏輯漏洞 🕳️
攻擊者要做的事情,說出來其實有點荒謬:他們不用註冊帳號、不用登入、甚至不用等審核,只要在公開的活動頁面留言,把惡意內容偽裝成一段看起來像「舊版小工具資料」的區塊標記塞進去,然後立刻用系統發給自己的那組「預覽通行證」網址回頭去看一眼——就這一眼,伺服器背後的解析引擎就會把這段還沒被任何人核准的內容,當成合法資料處理,最終讓一段本來應該只是「文字」的東西,被誤判成可以執行的程式物件 🎭
🧊 冷知識:PHP 的「魔術方法」為什麼要取這麼中二的名字
PHP 裡有一群以雙底線開頭的函式,像 __wakeup()、__destruct(),被稱為「魔術方法」。之所以叫魔術,是因為你從來不用主動呼叫它們——PHP 直譯器會在特定時機(例如物件被「喚醒」還原、或被銷毀的瞬間)自動幫你觸發,就像魔術師趁你眼睛一眨的瞬間變完戲法。這次漏洞的麻煩之一,正是這些自動觸發的時機點,比外掛原本設計好的安全檢查關卡還要早一步發生,等於警衛還沒看完證件,門就先自己開了 🪄
換句話說,這條攻擊鏈完全不需要密碼、不需要任何身分,只要你的活動頁「開放留言」而且「留言在前台看得到」,理論上任何路過的陌生人都構成威脅——這也是這顆漏洞被評到 CVSS 9.8 嚴重等級的核心原因 🚨
🥲 碎碎念:留言送出後那組「秒看預覽」的網址,原本是一片好意
未登入訪客留言後,WordPress 會給一組帶著雜湊驗證碼的連結,讓留言者能立刻確認「喔,我剛剛真的有留成功」,而不用傻傻等管理員審核完才知道。這個設計本身完全是為了使用者體驗著想,結果在這次事件裡,卻變成攻擊者跳過審核關卡、搶先讓伺服器「處理」惡意內容的快速通道。有點像圖書館為了方便讀者,設了一個「先讓你確認預約成功」的窗口,結果被人拿來鑽漏洞插隊拿書 📚
📅 這場風暴是怎麼被揪出來、又是怎麼被補上的?
把時間軸攤開來看,你會發現這其實是資安圈裡走得算快的一次通報到修補流程——但快歸快,公開之後留給大家的反應時間依然很緊迫 ⏱️
| 日期 | 事件節點 | 細節 |
|---|---|---|
| 2026-09-03 | 官方發布 6.17.4 | 屬於例行更新,尚未修補本篇主角這顆漏洞 |
| 2026-09 上旬 | 官方發布安全修補版 6.17.4.1 | Changelog 明確寫著「強化複製小工具實例的驗證邏輯」;不同資安情資來源記載的確切發布日期在 9 月 9 日到 10 日之間略有出入,本文以官方 WordPress.org 公開資訊為準【❓待確認精確日期】 |
| 2026-09-11 | 漏洞正式公開揭露 | 由 Wordfence 資安研究員 Chloe Chamberland 與 Wordfence Argus 團隊揭露技術細節 |
| 2026-09-12 | CVE 正式收錄 | NVD 收到通報但當下 enrichment 狀態仍在處理中,CVSS 分數採用 Wordfence(CNA)提供的 9.8 |
| 2026-09-13 | 本文查證日期 | 資料仍可能持續更新 |
🧊 冷知識:修補版為什麼叫 6.17.4.1,不是 6.17.5?
WordPress 生態圈遇到緊急安全性修補時,常會刻意選用「第四段版號」(也就是在既有版本後面多補一碼)而不是往上跳一個功能版號。這樣做的巧妙之處在於,很多用 Composer 鎖版本規則(例如鎖定 ~6.17.4)的環境,能自動吃到這個安全修補,而不會被誤判成「這是新功能,不能隨便自動更新」。看到 .1 結尾的安全版,通常就是這個邏輯 🔐
值得一提的是,查了一輪之後會發現,這個外掛在小工具驗證與反序列化這一整塊功能,其實在同一段時間內經歷了不只一次的安全性強化——6.17.3、6.17.3.1、一路到 6.17.4.1 的更新紀錄裡都出現過跟安全性相關的字眼。這比較像是同一片程式碼區域被連續補強,而不是憑空冒出一顆孤立的漏洞,算是給維護這款外掛的團隊按個讚,至少他們有持續在挖同一塊地雷區 👍
🚨 畫重點!千萬別踩雷:別被「6.18 才安全」這種說法騙了
有些第三方情資頁面會寫「建議升級到 6.18 或更新版本」,但同一頁往往又標示「官方尚未提供修補」,這其實是自動化摘要工具產生的矛盾說法。真正該相信的是官方 WordPress.org 的 Changelog 與 Wordfence 這類直接負責分派 CVE 編號的機構——他們一致把 6.17.4.1 列為修補版本。判斷要不要更新,別只看某一份摘要頁的建議版號,回頭查一次官方 Changelog 通常比較保險 🧯
😱 萬一真的被摸到,我的網站會發生什麼事?
先講白話一點的比喻:這顆漏洞如果真的被打穿,等於是把你家大門鑰匙直接寄給陌生人,而且對方完全不用先敲門確認你在不在家 🔑
- ✅ 已確認事實:不用任何登入資訊,攻擊者就能在伺服器上執行任意程式碼。 CVSS 向量顯示機密性、完整性、可用性三項評估全部是「高」,代表一旦成功利用,網站的資料讀取、內容竄改、服務中斷這三種傷害都可能同時發生。
- ✅ 已確認事實:觸發條件很明確——活動頁面必須開啟留言功能,而且留言在前台可見。 如果你的活動頁從頭到尾都沒開放留言,這條特定攻擊路徑就不成立,但漏洞本身仍然存在,只是少了這個進入點。
- 🟡 合理推論:一旦真的達成遠端程式碼執行,通常不會只停在「外掛壞掉」這個層級。 常見的後續可能包括:植入偽裝成外掛檔案的木馬、建立看起來人畜無害的隱藏管理員帳號、讀取 wp-config.php 裡的資料庫帳密。這些是 RCE 類漏洞的一般後果推論,不代表每個受影響網站都已經真的發生。
- 🔴 假設情境:如果你的網站跟其他客戶站台擠在同一台共用主機上,而主機層級的隔離設定(例如 open_basedir)沒做好,攻擊者有機會從你的網站當跳板,橫向摸到隔壁棚的網站。 這純粹是共用主機架構下的通用風險推演,不是這顆 CVE 官方報告裡證實的特定案例。
👻 碎碎念:不同資安平台講的「攔截次數」怎麼兜不起來?
查證過程中會發現,幾家不同的資安情資來源,各自公布的「24 小時內攔截攻擊次數」數字並不一致,落在幾百次上下不等的區間。這不是誰在唬爛,而是每家安全防護系統的感測器覆蓋範圍、統計時間切點本身就不同,就像兩支溫度計量出來的體溫會差個 0.1 度一樣正常。真正該記住的訊號不是那個精確數字,而是「這件事已經進入被主動掃描的階段」這個大方向 🌡️
另外要老實講一件事:關於「網路上是不是已經流傳公開的攻擊程式碼(PoC)」,《什麼是PoC❓你可以直接把它想成「作案教學影片」外流:原本要摸出這道漏洞的前提是你得懂技術、自己花時間摸索,現在等於有人已經把完整步驟拍成教學影片公開放上網,任何懂一點技術的人,都能照著片子依樣畫葫蘆,不用自己重新研究一次。》查證這幾份資料時發現不同來源的說法並不一致,有人說已經出現在 GitHub 上,也有人說截至查證當下並未找到可靠來源證實。與其糾結這一點的真假,比較務實的做法是直接把它當成「已經有 PoC 存在」來應對,畢竟這種情況下,寧可高估風險提早處理,也不要低估風險拖到出事 😖
🧭 先別自己嚇自己,看看你屬於哪一種情境
看到「CVSS 9.8」這種數字很容易心跳加速,但不同角色該做的事情真的差很多,我們一種一種來看 💆
🙋 我只是負責發文、排活動的網站小編
如果你平常的工作是上架活動、回覆留言、更新海報,完全不碰程式碼跟伺服器設定,那你要做的事其實很單純:確認版本、按更新、必要時先把留言關掉。不需要理解後面「技術深潛篇」在講什麼,直接跳到下面的「五分鐘無痛自救指南」照著做就好,這件事不會花你太多時間 💪
👨💻 我自己架站,同時管著好幾個 WordPress
如果你手上同時顧著好幾個客戶站台,一個一個點後台更新真的太沒效率。這種情況適合用 WP-CLI 批次盤點所有站台的外掛版本,技術深潛篇會直接給你可以貼上就用的排查指令,而且已經把「一次跑完所有站可能拖垮伺服器」這種坑先幫你避開了,建議直接拉到那一段 👇
🏢 我的網站有會員系統,或是有在線上收款
如果你的網站牽涉到會員個資、金流或訂單資料,這件事就不只是「更新一個外掛」這麼簡單了——它同時牽涉到資料外洩的通報責任。一旦被證實資料曾經暴露,企業面對的往往不只是商譽受損,還有主管機關的裁罰風險。除了更新之外,建議同步啟動內部資安事件應變流程,把稽核日誌留存下來,之後不管是要跟保險公司對帳、還是配合調查,都用得上 🩺
🩵 荷包試算:修這個洞要花多少錢?
如果你有 SSH 權限,本篇提到的所有排查與修補步驟本質上都是免費操作,頂多花你或工程師一些工時。真正會燒到錢的地方,通常是「已經中招之後」——請資安顧問做鑑識、清除後門的工時費,或是網站被瀏覽器標記為不安全之後重建流量與信任的成本。這筆帳怎麼算,都是現在花半小時走完流程比較划算 💸
🚨 這個情境最容易被忽略:那個「三年前設定完就沒再碰過」的活動頁
一間學校或社團三年前架設官網時,裝了 The Events Calendar 來公告校慶、社課、講座資訊,活動辦完之後網站小編換了好幾輪人,沒有人記得當初有開放活動留言這個設定,外掛版本也停在很久以前那個版本。網站管理者主觀認為「反正活動早就辦完了,這個外掛應該只是躺在那邊沒作用」。問題是只要外掛還處於啟用狀態、活動留言功能還開著,這條攻擊鏈就完全成立,攻擊者根本不在乎你的活動是不是三年前的舊資料 🆘
🚥 畫重點:「很久沒動」不等於「沒有風險」。只要外掛還在啟用清單裡、留言功能還開著,攻擊者就有機可乘——這也是這次事件最容易被忽略的地方 🙅♂️
🛟 五分鐘不用碰高深指令的無痛自救指南
不管你屬於上面哪一種情境,這一段的步驟建議每個人都先跑一遍——不用懂程式,跟著畫面點就好 🤏
- ㊀ 先去哪裡確認版本?
登入你的 WordPress 後台,點左側選單的「外掛(Plugins)」,再點「已安裝外掛(Installed Plugins)」。在清單裡找到名字叫 The Events Calendar 的項目,它下方或右側會標示目前的版本號碼 🔢 - ㊁ 看到版本號之後,怎麼判斷自己安不安全?
如果版本號是 6.17.4 或更舊(例如 6.17.3、6.17.2、6.16.x 等),代表你目前暴露在風險裡;如果已經顯示 6.17.4.1 或更新,可以先鬆一口氣。判斷標準就是這一個數字,不是看「有沒有更新提示」,因為有些環境的自動更新提示可能延遲出現 ㊙️ - ㊂ 需不需要馬上更新?
需要,而且優先程度排在今天所有代辦事項的第一位。直接在同一個畫面點「立即更新(Update Now)」,等它跑完,重新整理頁面確認版本號已經變成 6.17.4.1 以上即可 💪 - ㊃ 更新會不會讓網站版面跑掉?
這次的修補屬於安全性微幅更新,並沒有變更前台樣板架構,造成版面錯位的機率很低。但保險起見,動手前建議先透過主機控制台或備份外掛,把「資料庫」與「網站檔案」都存一份,這永遠是好習慣,不是多此一舉 💾 - ㊄ 如果因為相容性問題暫時真的沒辦法更新,該怎麼辦?
既然這個漏洞一定要靠「活動頁開放留言」才能被觸發,只要你先前往「設定」👉「討論」,把留言功能取消勾選,或是到個別活動的編輯頁面把「允許留言」關掉,攻擊者佈下的陷阱就完全沒有機會被引爆。等未來確認相容性沒問題後,再評估是否更新後重新開放留言 🤔 - ㊅ 如果我在後台完全找不到更新按鈕,或不熟悉這整套流程呢?
請立刻聯絡你的網站建置商、維護團隊或代管主機客服。跟他們溝通時,直接提供「CVE-2026-78006」這個編號,專業技術人員就能立刻理解問題的嚴重性並協助處置。在沒有完整備份的情況下,請不要自己貿然進入主機底層刪除任何檔案 ❗
🫂 新手求助:完全看不懂「外掛」「後台」這些詞怎麼辦?
完全沒關係,這篇文章本來就不是要你變成資安專家。如果你連「已安裝外掛」這個選單都找不到,最快的方式就是把這篇文章網址直接轉傳給你的主機商客服或網站外包商,跟他們說「我這個活動行事曆外掛有嚴重漏洞,麻煩幫我更新並檢查」,多數台灣主機商都有提供這類技術支援管道,這件事本來就不該是你一個人扛 🤗
🧑🔧 第二部分:技術深潛篇
接下來這段是給想知道「底層到底哪裡壞掉」的人看的——如果你只想知道怎麼補洞,上面那段已經夠用。但如果你也好奇這種等級的漏洞長什麼樣子,我們繼續往下拆 ⚔️
🧩 漏洞小檔案與底層原理拆解
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | The Events Calendar |
| WordPress Slug | the-events-calendar |
| 開發廠商 | StellarWP(原由 Modern Tribe 開發) |
| CVE 編號 | CVE-2026-78006 |
| 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 分類 | CWE-502(不可信資料的反序列化) |
| 受影響版本 | ≤ 6.17.4 |
| 安全版本 | ≥ 6.17.4.1 |
| 是否需要登入 | ❌ 不需要(未經認證) |
| 是否已列入 CISA KEV | 截至查證當下:否 |
| 研究人員/指派單位 | Chloe Chamberland、Wordfence Argus 團隊;由 Wordfence 擔任 CNA 指派 CVE |
核心問題出在外掛裡一個叫 is_safe_widget_instance() 的驗貨函式。它原本的任務,是在把「複製過來的舊版小工具資料」丟進 unserialize() 之前,先確認裡面沒有夾帶危險的物件結構。聽起來滴水不漏,但這裡的破口在於兩件事疊在一起:
- ⭐ PHP 的魔術方法觸發時機,比驗證邏輯本身還要早。 當資料在「預解析」階段被處理時,某些魔術方法可能已經被觸發執行,這代表安全檢查完成之前,惡意邏輯就有機可乘。
- ⭐ 負責重新渲染小工具的 enable_rendering_widget_copied() 函式,會在資料抵達 unserialize() 之前,順手用 WordPress 核心的 wp_hash() 幫這筆資料算出一個「看起來合法」的完整性簽章。 換句話說,安檢流程裡負責蓋合格章的那一關,被誘導著替一個原本不該過關的包裹蓋了章。
🧑🔧 DevOps 小知識:這不是「PHP 有 unserialize() 就一定會出事」
真正的風險核心,不是這個函式本身危險,而是「安全檢查所依據的資料」跟「最後真正被拿去使用的資料」,並沒有被綁定在同一份牢靠的信任邊界上。你可以檢查完 A,卻拿 A 的檢查結果去放行了長得很像 A 但其實已經被動過手腳的 B,這才是這類漏洞真正難防的地方 🏋️
🔬 6.17.4.1 到底怎麼補的?
從官方原始碼的修補差異可以看出蠻紮實的修補思路。修補前的邏輯大致是:解碼 → 反序列化(限制 allowed_classes=false)→ 檢查有沒有物件 → 沒有就判定安全 → 拿原始 bytes 重新算雜湊。修補後的 get_safe_widget_instance() 則多繞了幾道關卡:
- ⭐ 先確認輸入真的是字串,不是其他資料型態。
- ⭐ 反序列化並限制 allowed_classes=false,結果必須是陣列,不能是物件。
- ⭐ 遞迴檢查陣列裡的每一層,確保深處也沒有夾帶物件。
- ⭐ 通過檢查後,不是繼續信任原始 bytes,而是把這份「已驗證過的資料」重新序列化一次,變成一份全新的、乾淨的版本。
- ⭐ 後續的雜湊簽章,一律針對「重新序列化後的乾淨版本」計算,而不是針對最初那份未經處理的原始資料。
另外,負責偵測物件的 contains_object() 函式也加了一道遞迴深度上限——超過 20 層就直接視為不可信。這其實蠻聰明的,等於是告訴系統「如果資料結構故意疊得又深又複雜到我懶得繼續往下查,那我乾脆一律當作有問題」,堵死了刻意用深層巢狀結構混淆檢查的路 🛣️
🫡 碎碎念:這種修補思路,其實是把「檢查輸入」升級成「檢查後重建、再對重建結果簽章」
這個概念換個場景講你可能更有感:與其檢查一份文件影本上有沒有動過手腳(原始資料),不如乾脆把內容重新謄寫一遍成一份新文件,再對這份自己謄寫出來、確定乾淨的新文件蓋章,而不是對別人交給你的那份原件蓋章。這樣一來,就算原件裡藏了什麼看不出來的機關,也不會被帶進最終被信任的那份文件裡 📜
🎯 攻擊鏈路長什麼樣(純概念層級,不含可操作細節)
把整條路徑串起來看,其實邏輯相當工整,一步一步拆給你看 🕵️♀️
- 🏮 第一步:無驗證投遞。 攻擊者以匿名訪客身分,在某個開放留言的活動頁面送出一則包含特製區塊標記的留言,這則留言此刻還處於「待審核」狀態。
- 🏮 第二步:搶先預覽。 WordPress 核心會自動發給留言提交者一組帶著雜湊驗證的「moderation-hash」預覽網址,讓對方能在管理員審核之前,就先看到自己那則留言的呈現效果。
- 🏮 第三步:區塊解析引爆。 The Events Calendar 的 V2 單一活動頁樣板,在產生輸出畫面時,會對包含這則留言的 HTML 內容執行區塊解析函式 do_blocks(),藉此支援 Gutenberg 區塊編輯器的呈現。
- 🏮 第四步:驗證繞過。 惡意的區塊資料在解析過程中被送進 is_safe_widget_instance(),靠著前面提到的雜湊偽造與魔術方法時機差,成功繞過驗證。
- 🏮 第五步:物件注入成真。 資料進入 unserialize(),達成 PHP 物件注入,並可能透過外掛內建的既有類別串聯成可執行的攻擊鏈,最終達成遠端程式碼執行。
| CVSS 面向 | 本案評估 |
|---|---|
| 攻擊向量(AV) | Network(可遠端觸發) |
| 攻擊複雜度(AC) | Low(沒有特殊前提條件) |
| 所需權限(PR) | None(不需要任何帳號) |
| 使用者互動(UI) | None(不需要誘騙任何人點擊) |
| 機密性 / 完整性 / 可用性 | 全部 High |
是否已經有遭利用的證據?這裡要分層講清楚,不能混在一起講:
- ✅ 已確認:漏洞公開後,多家資安防護系統都觀測到針對這個漏洞的攻擊嘗試。 不同來源回報的 24 小時攔截次數不完全一致(前面提過這個現象),但共同傳遞的訊號很一致——這顆洞已經在被主動掃描。
- ✅ 已確認:截至查證當下,這顆 CVE 並未出現在 CISA KEV(已知遭利用漏洞)清單裡。
- 🟡 目前沒有足夠可靠的資料,能證明「已經有網站被大規模成功入侵」。 「有攻擊嘗試」跟「已經確認遭野外成功利用並列入 KEV」,是兩件差很多的事,不能混為一談。
🔍 資安排查與自我檢查 Checklist
🤖 重要提醒:以下指令與排查流程由 Gemini 產生、Claude 複查
雖然已盡力對照常見實務經驗與 WP-CLI 官方文件,但仍可能因伺服器環境、權限設定或外掛版本差異而產生疏漏。正式環境(尤其是正在營業中的電商站)的高風險操作——包含更新、停用、刪除檔案、變更權限或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter 🧙
如果你是自己架 VPS、同時管理多個 WordPress,這一節最重要的不是背一條萬用指令,而是先搞清楚自己到底是哪一種架構。搞錯架構,後面的批次指令就算語法完全正確,也可能處理錯對象。這一節依序走過環境判斷 👉 盤點(版本比對)👉 Log 日誌排查 👉 後門檢查點 👉 加固檢查五個關卡,每一關都拆成「決策原理 👉 具體指令 👉 預期輸出 👉 分支判斷邏輯」四段,並分別涵蓋單一網站、獨立多站、Multisite 三種環境 📜
🧑🔧 DevOps 小常識:先判斷地圖,再決定走哪條路
「一台伺服器上有 10 個 WordPress」跟「一個 Multisite 裡面有 10 個 Site」外觀很像,實際上是兩種完全不同的架構。前者要逐一找到各網站自己的 wp-config.php;後者才會用 wp site list。搞混的話,資料庫匯出、外掛更新這些指令很容易對錯地方下手 🫠
🧭 ㊀ 環境判斷:你在管一個 WordPress,還是一整台 WordPress 伺服器?
決策原理:環境判斷錯誤會直接讓後續盤點、備份、更新全部跑偏。單一站、獨立多站、Multisite 三者的 WP-CLI 參數與資料範圍完全不同,必須先用官方指令確認,不能憑印象猜 🤔
| 環境 | 典型結構 | 常用定位方式 |
|---|---|---|
| 💠 單一網站 | 一個 WordPress 安裝 | –path=”$WP_PATH” |
| ❇️ 獨立多站 | 一台 VPS 上有多個完全獨立的 WordPress | 逐一尋找各自的 wp-config.php |
| ✴️ Multisite | 一套 WordPress Network,底下有多個 Site | wp site list / –url=”$url” |
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
if wp --path="$WP_PATH" core is-installed; then
echo "OK: 已偵測到 WordPress 安裝"
else
echo "ERROR: 這個路徑下沒有偵測到 WordPress"
fi
grep -E "define\s*\(\s*['\"]MULTISITE['\"]" "$WP_PATH/wp-config.php" 2>/dev/null \
&& echo "此站有定義 MULTISITE 常數" \
|| echo "此站未定義 MULTISITE 常數"
預期輸出範例:
OK: 已偵測到 WordPress 安裝 此站未定義 MULTISITE 常數
分支判斷邏輯:
- ⭕ 顯示 OK 且未定義 MULTISITE 👉 屬於單一網站,可直接進入盤點階段
- 🟠 顯示 ERROR 👉 先停下來,不要把錯誤目錄當成網站處理
- 🟡 網站路徑含空格或特殊字元 👉 保留 “$WP_PATH” 的雙引號,不要自行拿掉
❇️ 獨立多站
決策原理:「同一台伺服器有很多 WordPress」不代表 Multisite。這時應該從各站的 wp-config.php 建立盤點清單,並取得檔案實際擁有者,避免用單一 Linux 帳號跑遍所有網站造成權限錯亂 ⚙️
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
if sudo -u "$owner" -- wp --path="$path" core is-installed 2>/dev/null; then
printf 'WordPress: %s (owner: %s)\n' "$path" "$owner"
fi
done
預期輸出範例:
WordPress: /var/www/site-a/public_html (owner: site-a) WordPress: /var/www/site-b/public_html (owner: site-b)
分支判斷邏輯:
- ⭕ 每個路徑都通過 👉 可以建立獨立多站盤點清單
- 🟠 找到 wp-config.php 卻無法載入 WordPress 👉 先確認 PHP、檔案權限或路徑,不要直接更新
- 🟡 同一個網站被找到多個 wp-config.php 👉 先人工確認哪一個才是正式環境,避免誤更新到備份或測試副本
✴️ Multisite
決策原理:wp site list 是 Multisite 專用指令,不能拿來掃描一台 VPS 上互不相干的獨立多站 📔
WP_PATH="/var/www/network/public_html"
if wp --path="$WP_PATH" core is-installed; then
wp --path="$WP_PATH" site list \
--fields=blog_id,url \
--format=table
else
echo "ERROR: 這個路徑下沒有偵測到 WordPress"
fi
grep -E "MULTISITE|SUBDOMAIN_INSTALL|DOMAIN_CURRENT_SITE|PATH_CURRENT_SITE" "$WP_PATH/wp-config.php"
預期輸出範例:
blog_id url 1 https://example.com/ 2 https://tickets.example.com/
分支判斷邏輯:能列出多個 Site 👉 進入 Multisite 流程;只能列出單一網站不代表一定是獨立多站,還要搭配 wp-config.php 裡是否存在 MULTISITE 常數一起判斷 🧭
🔎 ㊁ 盤點(版本比對):先回答「哪些站裝了這款外掛、現在到底是哪一版?」
決策原理:不建議把版本號寫死在腳本裡當成比較基準,而是直接讓 WP-CLI 回報 version 與 update 欄位,讓官方 metadata 系統告訴你「這個版本是不是最新的」,比自己土法煉鋼比對字串可靠得多 📊
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
SAFE_VERSION="6.17.4.1"
CURRENT_VERSION="$(wp --path="$WP_PATH" plugin get the-events-calendar --field=version)"
UPDATE_STATUS="$(wp --path="$WP_PATH" plugin get the-events-calendar --field=update)"
printf '目前版本:%s\n' "$CURRENT_VERSION"
printf '是否有可用更新:%s\n' "$UPDATE_STATUS"
if [ "$(printf '%s\n%s\n' "$SAFE_VERSION" "$CURRENT_VERSION" | sort -V | head -n1)" = "$SAFE_VERSION" ]; then
echo "✅ 版本已達到或超過安全版本 $SAFE_VERSION"
else
echo "🔴 版本低於安全版本 $SAFE_VERSION,屬於受影響範圍"
fi
預期輸出範例:
目前版本:6.17.4 是否有可用更新:available 🔴 版本低於安全版本 6.17.4.1,屬於受影響範圍
分支判斷邏輯:
- 🔴 版本低於 6.17.4.1 👉 進入第 4 節備份/更新流程
- 🟢 版本已達 6.17.4.1 或更新 👉 已跨過這顆 CVE 的修補門檻,但如果這個外掛曾經對外公開暴露過一段時間,仍建議繼續往下做後門排查
- 🟠 找不到外掛 👉 確認是否已被移除、資料夾名稱是否被改過,或是查錯了網站路徑
🧑🔧 DevOps 小知識:如果網站還裝了 Events Calendar Pro
The Events Calendar 的免費核心版本掛在 WordPress.org 的 SVN 上,可以用 wp plugin verify-checksums 驗證。但如果你的網站另外購買了商業付費的 Events Calendar Pro 擴充套件,這款屬於閉源商業外掛,沒有公開的 SVN 儲存庫,verify-checksums 對它完全無效,這點請務必老實跟團隊說清楚,改用比對官方下載 ZIP 的雜湊值等替代方式驗證完整性 🔐
❇️ 獨立多站
決策原理:每個獨立 WordPress 都有各自的外掛安裝狀態,因此要逐一使用 –path,並保留 owner 資訊,方便後續備份與更新沿用同一組憑證 🗂️
SEARCH_ROOT="/var/www"
SAFE_VERSION="6.17.4.1"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
if ! sudo -u "$owner" -- wp --path="$path" core is-installed 2>/dev/null; then
continue
fi
version="$(sudo -u "$owner" -- wp --path="$path" \
plugin get "the-events-calendar" \
--field=version 2>/dev/null || true)"
if [ -z "$version" ]; then
continue
fi
if [ "$(printf '%s\n%s\n' "$SAFE_VERSION" "$version" | sort -V | head -n1)" = "$SAFE_VERSION" ]; then
status="✅ 安全"
else
status="🔴 受影響"
fi
printf 'Site: %s | Owner: %s | Version: %s | %s\n' "$path" "$owner" "$version" "$status"
done
預期輸出範例:
Site: /var/www/site-a/public_html | Owner: site-a | Version: 6.17.4.1 | ✅ 安全 Site: /var/www/site-b/public_html | Owner: site-b | Version: 6.17.2 | 🔴 受影響
分支判斷邏輯:這份結果就是你的第一張「風險地圖」——標示 🔴 的站優先列入正式修補名單,標示 ✅ 的站可以繼續往下做歷史排查,確認舊版暴露期間有沒有留下痕跡 🗺️
✴️ Multisite
決策原理:Multisite 共用同一套外掛檔案,因此不需要對每個 Site 各查一次版本,只要確認 Network 層級的啟用狀態即可 🧩
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" plugin get "the-events-calendar" \
--fields=name,status,version,update,update_version
wp --path="$WP_PATH" plugin is-active "the-events-calendar" --network
echo "Network 啟用狀態的 exit code:$?"
預期輸出範例:
name: the-events-calendar status: active version: 6.17.4 update: available update_version: 6.17.4.1 Network 啟用狀態的 exit code:0
分支判斷邏輯:Exit code 為 0 代表整個 Network 共用啟用,處理範圍要以整個 Network 為單位;若為部分 Site 各自啟用,再用 –url=”$url” 逐一確認各站狀態 🔀
📊 ㊂ Log 日誌排查特徵:找「留言 + 預覽連結 + 時間點」的組合
決策原理:這條攻擊鏈完全借用 WordPress 內建、合法的留言與區塊解析通道,特徵比對型防火牆很難第一時間攔截。以下屬於依攻擊機制推導出的通用排查特徵,官方尚未公開唯一確切的攻擊訊號,因此標為 🟡
💠 單一網站
ACCESS_LOG="/var/log/nginx/access.log"
ERROR_LOG="/var/log/nginx/error.log"
if [ -f "$ACCESS_LOG" ]; then
echo "== 排查活動頁留言 POST 與預覽連結存取 =="
grep -Ei "wp-comments-post\.php|/event(s)?/.*comment" "$ACCESS_LOG" | tail -n 100
grep -Ei "unapproved=[a-f0-9]{32}|moderation-hash" "$ACCESS_LOG" | tail -n 100
else
echo "LOG NOT FOUND: $ACCESS_LOG"
fi
if [ -f "$ERROR_LOG" ]; then
echo "== 排查 PHP 反序列化相關錯誤 =="
grep -Ei "unserialize|is_safe_widget_instance|Tribe__Utils__Callback" "$ERROR_LOG" | tail -n 100
fi
預期輸出範例:
203.0.113.10 - - [12/Sep/2026:14:22:10 +0800] "POST /event/sample-event/ HTTP/1.1" 302 0 203.0.113.10 - - [12/Sep/2026:14:22:11 +0800] "GET /event/sample-event/?unapproved=abc123 HTTP/1.1" 200 45210
分支判斷邏輯:
- 🟢 找不到異常 👉 目前沒有明顯跡象,繼續往下做後門排查
- 🟠 同一個 IP 在極短時間內出現「留言 POST → 帶 unapproved 或 moderation-hash 的 GET」這種組合 👉 提高優先級,保留這段 log 並繼續調查
- 🔴 錯誤日誌裡出現反序列化相關的 PHP 致命錯誤 👉 不要急著刪檔,先進入「已中招/疑似中招」流程
❇️ 獨立多站
LOG_ROOT="/var/log"
find "$LOG_ROOT" -type f \
\( -name "*access*.log" -o -name "*access*.log.*" \) \
-print0 |
while IFS= read -r -d '' log; do
matches="$(grep -Ei "wp-comments-post\.php|unapproved=[a-f0-9]{32}|moderation-hash" "$log" 2>/dev/null || true)"
if [ -n "$matches" ]; then
printf '\n===== %s =====\n' "$log"
printf '%s\n' "$matches" | tail -n 50
fi
done
分支判斷邏輯:某個 log 出現命中 👉 依檔名或 VirtualHost 設定回推是哪一站,標記為需要進一步鑑識;全部沒有命中 👉 風險降低,但不能證明「絕對沒被摸過」,因為 log 可能已輪替或遺失 ♻️
✴️ Multisite
ACCESS_LOG="/var/log/nginx/access.log"
WP_PATH="/var/www/network/public_html"
if [ -f "$ACCESS_LOG" ]; then
grep -Ei "wp-comments-post\.php|unapproved=[a-f0-9]{32}|moderation-hash" "$ACCESS_LOG" | tail -n 100
fi
wp --path="$WP_PATH" site list --field=url
分支判斷邏輯:找到可疑時間點後,比對 wp site list 的 Host/URL 清單,判斷哪個 Site 受到影響,不要只看「外掛是不是啟用」就結案 🔗
🕳️ ㊃ 後門檢查點:檔案完整性不能只看外表
🧊 冷知識:為什麼 mu-plugins 常被拿來當後門藏身處?
wp-content/mu-plugins(Must-Use)目錄裡的 PHP 檔案不需要在後台「啟用」,也不會出現在一般外掛清單裡,只要檔案存在,下一個進站的請求就會自動載入執行。很多清理教學會漏掉這裡,導致清完後門又悄悄復活,排查時一定要把它當成第一級檢查點 🗝️
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
MU_PLUGINS="$WP_PATH/wp-content/mu-plugins"
if [ -d "$MU_PLUGINS" ]; then
find "$MU_PLUGINS" -maxdepth 2 -type f -iname "*.php" -print
else
echo "MU-PLUGINS DIRECTORY NOT FOUND: $MU_PLUGINS"
fi
echo "== 近 7 天內被修改的 PHP 檔案 =="
find "$WP_PATH/wp-content" -type f -name "*.php" -mtime -7 -not -path "*/wp-content/cache/*" -print
echo "== uploads 目錄中不應存在的 PHP 檔案 =="
find "$WP_PATH/wp-content/uploads" -type f -name "*.php" -print
資料庫留言與異常管理員帳號排查(用 heredoc 把 SQL 寫成獨立暫存檔,避免多層跳脫符號互相干擾):
WP_PATH="/var/www/example.com/public_html"
DB_PREFIX="$(wp --path="$WP_PATH" db prefix)"
QUERY_FILE="$(mktemp)"
cat > "$QUERY_FILE" <<EOF
SELECT comment_ID, comment_post_ID, comment_author, comment_author_IP, comment_date,
LEFT(comment_content, 100) AS preview
FROM ${DB_PREFIX}comments
WHERE comment_content LIKE '%wp:legacy-widget%'
OR comment_content LIKE '%O:%'
ORDER BY comment_date DESC
LIMIT 50;
EOF
wp --path="$WP_PATH" db query < "$QUERY_FILE"
rm -f -- "$QUERY_FILE"
wp --path="$WP_PATH" user list \
--role=administrator \
--fields=ID,user_login,user_email,display_name,user_registered \
--orderby=user_registered \
--order=desc \
--format=table
預期輸出範例:
+------------+----------------+---------------------+-------------------------+ | comment_ID | comment_author | comment_date | preview | +------------+----------------+---------------------+-------------------------+ | 9182 | - | 2026-09-12 03:14:02 | ...wp:legacy-widget... | +------------+----------------+---------------------+-------------------------+ ID user_login user_email display_name user_registered 1 admin admin@example.com Site Admin 2023-01-15 08:30:00 ```
分支判斷邏輯:
- 🔴 mu-plugins 出現非部署流程建立的 PHP 檔案,尤其檔名是亂數字串 👉 不要直接刪除,先保留檔案時間、權限、雜湊值供鑑識,並進入「已中招」流程
- 🟡 留言內容出現大量 wp:legacy-widget 或序列化保留字 👉 標記這幾筆留言 ID,之後清理時只針對這幾筆處理,不要整個留言資料表清空
- 🟢 找不到可疑檔案、留言內容也正常 👉 這一項沒有異常,但任何不認識的管理員帳號仍值得進一步確認,「陌生」不等於「惡意」,也可能是維運商既有帳號
❇️ 獨立多站
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
mu_plugins="$path/wp-content/mu-plugins"
if [ ! -d "$mu_plugins" ]; then
continue
fi
hits="$(find "$mu_plugins" -maxdepth 2 -type f -iname "*.php" -print)"
if [ -n "$hits" ]; then
printf '\n===== SUSPICIOUS MU-PLUGINS: %s (owner: %s) =====\n' "$path" "$owner"
printf '%s\n' "$hits"
fi
done
分支判斷邏輯:每個站獨立判斷,不要因為 A 站乾淨就把 B、C 站一起視為乾淨;找到命中的站優先進入事件調查,其餘站繼續走一般盤點流程 🧭
✴️ Multisite
WP_PATH="/var/www/network/public_html"
MU_PLUGINS="$WP_PATH/wp-content/mu-plugins"
if [ -d "$MU_PLUGINS" ]; then
find "$MU_PLUGINS" -maxdepth 2 -type f -iname "*.php" -print
fi
wp --path="$WP_PATH" user list \
--fields=ID,user_login,user_email,display_name,user_registered \
--format=table
分支判斷邏輯:發現可疑 mu-plugins 檔案 👉 把整個 Network 都納入事件調查範圍,因為 mu-plugins 是 Network 共用資源;發現未知帳號後,再確認它屬於哪個 Network/Site 層級的權限,不要看到帳號存在就直接刪除 🧾
🔧 ㊄ 加固檢查:確認第二道門也鎖好了
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
echo "== 檢查 disable_functions 設定 =="
php -i 2>/dev/null | grep -E "disable_functions|open_basedir" \
|| echo "請直接檢查 php.ini 設定檔"
echo "== 檢查關鍵檔案權限 =="
stat -c '%a %n' "$WP_PATH/wp-config.php"
find "$WP_PATH" -type d -exec chmod 755 {} \;
find "$WP_PATH" -type f -exec chmod 644 {} \;
chmod 600 "$WP_PATH/wp-config.php"
echo "檔案權限已收斂"
預期輸出範例:
disable_functions => exec,passthru,shell_exec,system,proc_open,popen => exec,passthru,shell_exec,system,proc_open,popen open_basedir => /var/www/example.com/public_html:/tmp => /var/www/example.com/public_html:/tmp 600 /var/www/example.com/public_html/wp-config.php
分支判斷邏輯:
- 🟠 disable_functions 未包含 exec、system、shell_exec、passthru、proc_open、popen 👉 建議在可控範圍內加入,能大幅降低 RCE 真正落地成 Shell 的成功率
- 🟠 open_basedir 未設定 👉 建議限制 PHP 只能存取網站目錄與 /tmp
- ✅ 檔案權限已調整為 755/644,wp-config.php 為 600 👉 符合官方建議基準
❇️ 獨立多站
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
echo "== 加固 $path (owner: $owner) =="
sudo -u "$owner" -- find "$path" -type d -exec chmod 755 {} \;
sudo -u "$owner" -- find "$path" -type f -exec chmod 644 {} \;
chmod 600 "$config"
done
✴️ Multisite
WP_PATH="/var/www/network/public_html"
find "$WP_PATH" -type d -exec chmod 755 {} \;
find "$WP_PATH" -type f -exec chmod 644 {} \;
chmod 600 "$WP_PATH/wp-config.php"
分支判斷邏輯(三種架構共通):權限已收斂到 755/644/600 👉 進入下一節的正式修補流程;若更新後外掛出現寫入相關的錯誤 👉 檢查是否因為權限收得太緊,導致外掛自動更新機制無法正常寫入檔案,必要時暫時放寬再重新收斂 ⚠️
🛠️ 修不了先擋、能修就趕快修:應急與正式修補三部曲
不管你屬於哪一種情境,強制流程都是同一套:判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證,每個階段都要走完才能進下一步,正式環境升級前務必先在測試環境驗證 🧹
🚨 A. 已中招/疑似中招:先止血再修補
決策原理:如果第 3 節排查出現明確跡象(mu-plugins 可疑檔案、留言資料異常、log 出現反序列化錯誤),優先目標是「切斷攻擊鏈+保留證據+隔離後門」,更新只是後續步驟。最大的敵人不是漏洞本身,而是在沒有留下證據的情況下直接清理,反而把最有價值的線索一起刪掉 🧯
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
INCIDENT_DIR="$HOME/tec-incident-$(date +%Y%m%d_%H%M%S)"
mkdir -p "$INCIDENT_DIR"
## ❶ 保留事件證據(外掛版本、核心版本、待審留言)
cat > "$INCIDENT_DIR/summary.txt" <<EOF
=== Incident collection ===
$(date -Is)
WP_PATH=$WP_PATH
$(wp --path="$WP_PATH" core version)
$(wp --path="$WP_PATH" plugin get "the-events-calendar" \
--fields=name,status,version,update,update_version)
EOF
wp --path="$WP_PATH" comment list --status=hold --number=200 --format=json \
> "$INCIDENT_DIR/pending-comments.json"
wp --path="$WP_PATH" user list \
--role=administrator \
--fields=ID,user_login,user_email,user_registered \
--format=csv \
> "$INCIDENT_DIR/administrators.csv"
find "$WP_PATH/wp-content/mu-plugins" -type f \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %u:%g %m %p\n' \
2>/dev/null \
> "$INCIDENT_DIR/mu-plugins-inventory.txt"
## ❷ 立即隔離:先停用外掛,保留檔案供鑑識(不要直接刪除)
wp --path="$WP_PATH" plugin deactivate "the-events-calendar"
## ❸ 完整備份 wp-content、wp-config.php 與資料庫,做成鑑識用快照
tar -czf "$INCIDENT_DIR/wp-content-snapshot.tar.gz" \
-C "$WP_PATH" \
"wp-content"
cp -- "$WP_PATH/wp-config.php" "$INCIDENT_DIR/wp-config.php.snapshot"
wp --path="$WP_PATH" db export "$INCIDENT_DIR/database-snapshot.sql"
## ❹ 隔離可疑 mu-plugins 檔案(先搬走,不要直接刪除)
find "$WP_PATH/wp-content/mu-plugins" -type f -iname "*.php" -mtime -30 -print0 |
while IFS= read -r -d '' file; do
printf '隔離候選檔案:%s\n' "$file"
chmod 000 -- "$file"
mv -- "$file" "$INCIDENT_DIR/"
done
## ❺ 強制刷新 wp-config.php 裡的 Salt 鹽值,讓所有既有登入 Cookie 全部失效
wp --path="$WP_PATH" config shuffle-salts
## ❻ 銷毀所有使用者的登入 Session,並重設所有管理員密碼
wp --path="$WP_PATH" user list --field=ID |
xargs -I{} wp --path="$WP_PATH" user session destroy {} --all
wp --path="$WP_PATH" user list --role=administrator --field=user_login |
while IFS= read -r login; do
printf '重設管理員密碼:%s\n' "$login"
wp --path="$WP_PATH" user reset-password "$login" --skip-email
done
預期輸出範例:
Plugin 'the-events-calendar' deactivated. Success: Exported to '/home/admin/tec-incident-20260913_120000/database-snapshot.sql'. Success: Shuffled the salt keys. 重設管理員密碼:admin
🧑🔧 DevOps 小知識:為什麼這裡是刷新鹽值,不是手動改 wp-config.php?
wp config shuffle-salts 會直接由 WP-CLI 自動向官方金鑰產生服務取得全新的 8 組隨機字串並寫回設定檔,比自己寫正則表達式去抓取、刪除、再插入這 8 個常數安全得多——手動處理很容易因為格式差一點點就整個設定檔壞掉,導致網站直接打不開。既然懷疑已經遭到 RCE,攻擊者有機會讀到這些鹽值,全部刷新才能真正讓舊的登入憑證失效 🔐
分支判斷邏輯:證據保存、隔離與重置動作全部成功 👉 接續下方「C. 正式修補」流程;任一步驟失敗(例如檔案權限不足導致無法搬移可疑檔案)👉 先解決權限問題,不要跳過這步直接升級外掛版本,否則同一個入侵路徑可能再次被利用 🔁
❇️ 獨立多站:只隔離有證據的站,避免整台 VPS 一起誤傷
INCIDENT_PATH="/var/www/site-b/public_html"
INCIDENT_OWNER="$(stat -c '%U' -- "$INCIDENT_PATH/wp-config.php")"
INCIDENT_DIR="$HOME/tec-incident-site-b-$(date +%Y%m%d_%H%M%S)"
mkdir -p "$INCIDENT_DIR"
if sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" core is-installed; then
echo "確認事件站台:$INCIDENT_PATH(owner: $INCIDENT_OWNER)"
else
echo "ERROR: 這個路徑不是有效的 WordPress 安裝"
fi
sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" plugin deactivate "the-events-calendar"
sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" db export "$INCIDENT_DIR/database-snapshot.sql"
cp -- "$INCIDENT_PATH/wp-config.php" "$INCIDENT_DIR/wp-config.php.snapshot"
tar -czf "$INCIDENT_DIR/wp-content-snapshot.tar.gz" -C "$INCIDENT_PATH" "wp-content"
sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" config shuffle-salts
sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" user list --field=ID |
xargs -I{} sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" user session destroy {} --all
分支判斷邏輯:確認路徑與擁有者正確 👉 對這一站套用單一網站流程的證據保存與隔離指令;不要把整個 /var/www 當成單一網站處理,也不要因為同一台 VPS 其他站看起來正常,就跳過對它們的第 3 節排查 🧭
✴️ Multisite:把事件視為 Network 級問題
WP_PATH="/var/www/network/public_html"
INCIDENT_DIR="$HOME/tec-incident-network-$(date +%Y%m%d_%H%M%S)"
mkdir -p "$INCIDENT_DIR"
wp --path="$WP_PATH" site list --fields=blog_id,url --format=table
wp --path="$WP_PATH" plugin deactivate "the-events-calendar" --network
wp --path="$WP_PATH" db export "$INCIDENT_DIR/network-database-snapshot.sql"
cp -- "$WP_PATH/wp-config.php" "$INCIDENT_DIR/wp-config.php.snapshot"
tar -czf "$INCIDENT_DIR/network-wp-content-snapshot.tar.gz" -C "$WP_PATH" "wp-content"
wp --path="$WP_PATH" config shuffle-salts
wp --path="$WP_PATH" user list --field=ID |
xargs -I{} wp --path="$WP_PATH" user session destroy {} --all
分支判斷邏輯:任一 Site 出現明確入侵跡象 👉 對整個 Network 執行證據保存、mu-plugins 隔離與鹽值刷新;接著再逐一檢查各 Site 的管理員帳號是否有異常,不要遺漏權限較低但仍具備發文能力的帳號 🔍
🧯 B. 短期應急:尚未確認中招,但現在還不能立刻更新
🚨 短期應急 ≠ 正式修補。停用外掛、關閉留言、伺服器層防護規則都屬於緩衝措施,目標是縮短攻擊面暴露的時間,真正的修補仍然是升級到 6.17.4.1 或更新版本 🧱
💠 單一網站:關閉活動留言(最直接、最快的止血動作)
WP_PATH="/var/www/example.com/public_html"
DB_PREFIX="$(wp --path="$WP_PATH" db prefix)"
QUERY_FILE="$(mktemp)"
cat > "$QUERY_FILE" <<EOF
UPDATE ${DB_PREFIX}posts
SET comment_status = 'closed', ping_status = 'closed'
WHERE post_type = 'tribe_events';
EOF
wp --path="$WP_PATH" db query < "$QUERY_FILE"
rm -f -- "$QUERY_FILE"
echo "活動頁留言已全數關閉。"
預期輸出範例:
Success: Ran 1 query on 1 host. 活動頁留言已全數關閉。
分支判斷邏輯:
- 🟢 可以安全關閉留言 👉 保留關閉狀態,盡快排入正式修補窗口
- 🟠 業務上確實需要活動留言互動 👉 改用下方伺服器層防護規則作為過渡,並儘速安排升級
❇️ 獨立多站:逐站關閉留言,不要一次影響整台 VPS
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
db_prefix="$(sudo -u "$owner" -- wp --path="$path" db prefix 2>/dev/null || true)"
if [ -z "$db_prefix" ]; then
continue
fi
query_file="$(mktemp)"
cat > "$query_file" <<EOF
UPDATE ${db_prefix}posts
SET comment_status = 'closed', ping_status = 'closed'
WHERE post_type = 'tribe_events';
EOF
sudo -u "$owner" -- wp --path="$path" db query < "$query_file"
rm -f -- "$query_file"
printf '%s 的活動留言已關閉\n' "$path"
sleep 2
done
✴️ Multisite:逐 Site 關閉留言
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" site list --field=url |
while IFS= read -r url; do
db_prefix="$(wp --path="$WP_PATH" --url="$url" db prefix)"
query_file="$(mktemp)"
cat > "$query_file" <<EOF
UPDATE ${db_prefix}posts
SET comment_status = 'closed', ping_status = 'closed'
WHERE post_type = 'tribe_events';
EOF
wp --path="$WP_PATH" --url="$url" db query < "$query_file"
rm -f -- "$query_file"
printf 'Site %s 的活動留言已關閉\n' "$url"
done
伺服器層防護規則(Nginx 範例,套用前請先在 Staging 驗證,僅供概念示意,實際上路前務必依環境調整):
location ~* "/event(s)?/.*/comment" {
if ($request_method = POST) {
return 403;
}
}
✅ 為什麼關閉留言就能擋?
整條攻擊鏈嚴重依賴「活動頁開放留言+留言在前台可見」這個前提,關掉留言等於百分之百截斷這條特定觸發鏈路,是目前最乾淨、風險最低的短期緩解手段,但不能取代正式升級 🙅
㊂ C. 正式修補:強制流程「判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證」
① 備份
決策原理:外掛更新主要改的是外掛檔案,但網站內容、設定值與媒體檔案仍可能需要回復,因此至少要有資料庫備份與 wp-content 備份。很多教學會漏掉一件事——wp-config.php 放在網站根目錄,不在 wp-content 底下,如果只備份 wp-content,一旦要整站復原,資料庫連線設定跟剛剛才刷新的鹽值全部都會不見,這裡務必額外備份 wp-config.php 💾
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
## ❶ 資料庫備份
wp --path="$WP_PATH" db export \
"$BACKUP_DIR/example-com_pre_update_${STAMP}.sql"
## ❷ wp-content 備份
tar -czf \
"$BACKUP_DIR/example-com_wp-content_pre_update_${STAMP}.tar.gz" \
-C "$WP_PATH" \
"wp-content"
## ❸ wp-config.php 單獨備份(它在網站根目錄,不屬於 wp-content,很容易被忘記)
cp -- "$WP_PATH/wp-config.php" \
"$BACKUP_DIR/example-com_wp-config_pre_update_${STAMP}.php.bak"
printf '備份完成:%s\n' "$BACKUP_DIR"
預期輸出範例:
Success: Exported to '/home/admin/wp-security-backup/example-com_pre_update_20260913_130000.sql'. 備份完成:/home/admin/wp-security-backup
分支判斷邏輯:三份備份都存在 👉 進 Dry-run;任一備份失敗 👉 停止,不進入正式更新,先排除磁碟空間或權限問題 🛑
❇️ 獨立多站
SEARCH_ROOT="/var/www"
BACKUP_ROOT="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_ROOT"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
site_name="$(basename -- "$path")"
if ! sudo -u "$owner" -- wp --path="$path" core is-installed 2>/dev/null; then
continue
fi
echo "=== 備份 $path ==="
sudo -u "$owner" -- wp --path="$path" db export \
"$BACKUP_ROOT/${site_name}_pre_update_${STAMP}.sql"
tar -czf "$BACKUP_ROOT/${site_name}_wp-content_pre_update_${STAMP}.tar.gz" \
-C "$path" "wp-content"
cp -- "$config" \
"$BACKUP_ROOT/${site_name}_wp-config_pre_update_${STAMP}.php.bak"
sleep 3
done
分支判斷邏輯:某一站備份失敗 👉 把該站標記為未完成,不要因為其他站成功就直接進入批次更新 ⛔
✴️ Multisite
WP_PATH="/var/www/network/public_html"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p "$BACKUP_DIR"
wp --path="$WP_PATH" db export \
"$BACKUP_DIR/network_pre_update_${STAMP}.sql"
tar -czf \
"$BACKUP_DIR/network_wp-content_pre_update_${STAMP}.tar.gz" \
-C "$WP_PATH" \
"wp-content"
cp -- "$WP_PATH/wp-config.php" \
"$BACKUP_DIR/network_wp-config_pre_update_${STAMP}.php.bak"
wp --path="$WP_PATH" site list --fields=blog_id,url --format=csv \
> "$BACKUP_DIR/network_sites_${STAMP}.csv"
🚨 不要在 Multisite 的每個 Site URL 上重複執行整個 wp db export,這很可能只是把同一個 Network 資料庫重複匯出好幾次,浪費空間又沒有增加實質備份價值 🗄️
② Dry-run
# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
wp --path="$WP_PATH" plugin update "the-events-calendar" --dry-run
# ❇️ 獨立多站
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
if ! sudo -u "$owner" -- wp --path="$path" core is-installed 2>/dev/null; then
continue
fi
printf '\n===== %s =====\n' "$path"
sudo -u "$owner" -- wp --path="$path" plugin update "the-events-calendar" --dry-run
done
# ✴️ Multisite(外掛檔案共用,只需 Dry-run 一次)
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" plugin update "the-events-calendar" --dry-run
預期輸出範例:
Plugin the-events-calendar 6.17.4 will be updated to 6.17.4.1.
分支判斷邏輯:看到目標版本 👉 可以正式更新;顯示沒有可用更新 👉 回頭確認目前版本是不是已經 ≥ 6.17.4.1,不要因為「No updates」就直接認定網站安全 🔮
③ 更新
# 💠 單一網站:deactivate → update → activate 三部曲,避免更新途中前台仍在跑舊版程式碼
WP_PATH="/var/www/example.com/public_html"
wp --path="$WP_PATH" plugin deactivate "the-events-calendar"
wp --path="$WP_PATH" plugin update "the-events-calendar" --version=6.17.4.1
wp --path="$WP_PATH" plugin activate "the-events-calendar"
# ❇️ 獨立多站:逐站完成「備份→更新→驗證」才進下一站,禁止背景並行一次全部更新
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
path="$(dirname -- "$config")"
owner="$(stat -c '%U' -- "$config")"
if ! sudo -u "$owner" -- wp --path="$path" core is-installed 2>/dev/null; then
continue
fi
echo "=== 更新 $path ==="
sudo -u "$owner" -- wp --path="$path" plugin deactivate "the-events-calendar"
sudo -u "$owner" -- wp --path="$path" plugin update "the-events-calendar" --version=6.17.4.1
sudo -u "$owner" -- wp --path="$path" plugin activate "the-events-calendar"
version="$(sudo -u "$owner" -- wp --path="$path" plugin get "the-events-calendar" --field=version)"
printf '完成:%s(版本:%s)\n' "$path" "$version"
sleep 5
done
# ✴️ Multisite:外掛檔案通常只需要更新一次
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" plugin update "the-events-calendar" --version=6.17.4.1
wp --path="$WP_PATH" plugin activate "the-events-calendar" --network
🚨 千萬別踩雷:不要把獨立多站的更新包成一個「同時背景執行、跑完才驗證」的巨大迴圈。一次對數十個站同時發送更新請求,很容易讓資料庫連線數瞬間爆增、CPU 使用率飆高,甚至導致整台主機服務中斷,而且某一站失敗時完全無法定位是哪一站出的錯。逐站「備份→更新→驗證」再進下一站,雖然慢一點,但某一站真的出問題時,影響範圍會小非常多 🐢
預期輸出範例:
Plugin 'the-events-calendar' deactivated. Downloading installation package from https://downloads.wordpress.org/plugin/the-events-calendar.6.17.4.1.zip... Unpacking the package... Installing the plugin... Plugin updated successfully. Plugin 'the-events-calendar' activated. Success: Updated 1 of 1 plugins.
分支判斷邏輯:更新成功 👉 立即進版本驗證;更新失敗 👉 不要反覆重試,先查看錯誤訊息、確認檔案權限與磁碟空間是否足夠 ⚠️
④ 驗證(更新成功 ≠ 工作完成)
正式更新後至少完成以下四層驗證:
第一層:版本驗證
wp --path="$WP_PATH" plugin get "the-events-calendar" \
--fields=name,version,status,update
第二層:Plugin Checksum 驗證
wp --path="$WP_PATH" plugin verify-checksums "the-events-calendar"
正常會看到:
Success: Verified 1 of 1 plugins.
第三層:WordPress Core Checksum(若曾疑似入侵,建議一併執行)
wp --path="$WP_PATH" core verify-checksums
⚠️ Checksum 的界線:它可以回答「目前檔案是否與 WordPress.org 對應版本一致」,但不能回答「整個網站肯定沒有後門」。因為資料庫可能被動過手腳、自訂程式可能被植入、uploads 可能有惡意檔案、mu-plugins 也不在這個檢查範圍內,如果站上還有前面提過的商業版 Events Calendar Pro,checksum 也完全驗證不到它。所以 checksum 是完整性驗證的一層,不是整站清潔證明 🔍
第四層:功能測試
- ✅ 前台活動列表正常顯示
- ✅ 單一活動頁正常開啟
- ✅ 活動搜尋功能正常
- ✅ 活動 Widget 正常運作
- ✅ Gutenberg/區塊編輯器正常
- ✅ 活動留言流程(若業務需要重新開放)
- ✅ 後台活動新增/編輯正常
- ✅ Event Tickets/Pro 等相依元件正常
curl -s -o /dev/null -w "%{http_code}\n" "https://example.com/events/"
curl -s "https://example.com/events/" | grep -iE "fatal error|uncaught exception" \
|| echo "前台渲染沒有出現致命錯誤字串"
🔥 一句話總結收尾:真正安全的批次維運,是先判斷環境,再盤點、備份、Dry-run,確認無誤後才逐批更新,最後完成四層驗證。環境判斷錯誤比指令寫錯更致命——這套判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證的閉環,換成下一次別的外掛、別的 CVE,一樣可以直接套用 🦸
📋 操作手冊(Runbook)總表
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| 環境判斷 | wp core is-installed | 逐一尋找 wp-config.php | wp site list |
| 盤點(版本) | plugin get | 逐站 –path | Network 外掛檔案 + –network |
| Log 排查 | 單一 access log | 逐 VirtualHost log | 共用 log + URL 比對 |
| 後門檢查 | mu-plugins + 留言 + 帳號 | 逐站 mu-plugins | Network mu-plugins + Site 帳號 |
| 備份 | DB + wp-content + wp-config.php | 逐站獨立命名,含 wp-config.php | Network DB(整庫)+ wp-content + wp-config.php |
| Dry-run | –dry-run | 逐站 –dry-run | 一次即可 |
| 更新 | deactivate → update → activate | 逐站完成才進下一站,禁止並行 | 外掛檔案通常只需更新一次 |
| 驗證 | 版本 + Checksum + 前後台功能 | 逐站獨立驗證 | 版本 + Checksum + 各 Site 前台驗證 |
🧠 舉一反三:這類漏洞的共通防禦心法
以下是這種「反序列化/物件注入」漏洞類型的通用防禦知識,不是這顆 CVE 官方要求的處理步驟,但了解一次,下次遇到別的外掛出這種洞,你會更快抓到重點 🧩
- ⭐ 不要只檢查「輸入裡現在有沒有物件」就結束。 更穩定的做法是把流程走完整套:解析 → 嚴格驗證 → 重新正規化 / 重建 → 對重建後的乾淨版本簽章 → 使用。這次 6.17.4.1 的修補正好就是走這條路線。
- ⭐ allowed_classes=false 不是萬靈丹。 它能限制反序列化還原出的類別,但整體安全性仍取決於輸入是否可信、驗證是否完整、後續是不是又重新使用了原始 bytes,真正的安全邊界要看整條資料流,不能只盯著這一行參數。
- ⭐ 對「公開留言」與「區塊渲染」交會的路徑,多一分警覺。 只要是「公開輸入 → 存進資料庫 → 被解析引擎處理 → 動態渲染」這種路徑,都值得特別留意,因為這正是本次事件的核心攻擊面。
- ⭐ RCE 類漏洞的處理目標,從來不是「外掛更新成功」而已。 完整驗收至少要走過:版本 → Checksum → Log → 功能測試 → 可疑檔案/帳號排查。「修補成功」跟「沒有已知入侵跡象」是兩件不同的事情,缺一不可。
🙋 讀到快喘不過氣?幾個常見焦慮問題先幫你解一下
- 🏮 Q:更新之後,前台版面會不會跑掉?
A:這次的 6.17.4.1 屬於安全性微幅修補,並沒有變更前台樣板架構,造成版面錯位的機率不高。但保險起見,動手前先備份資料庫絕對是不變的真理 💾 - 🏮 Q:外包廠商說「活動早就辦完了,這個外掛沒在用不用管」,我該聽他的嗎?
A:千萬別信這句話。這顆漏洞最狡猾的地方,就在於只要外掛還處於啟用狀態、活動留言功能還開著,就算你完全沒在更新活動內容,攻擊者一樣有機可乘。請堅定地要求廠商協助升級或至少先關留言 🚫 - 🏮 Q:我完全看不懂上面那些指令,是不是就沒救了?
A:完全不會。前面「五分鐘無痛自救指南」那一段從頭到尾不需要碰任何指令,只要在後台點幾下滑鼠就能完成最基本的防護,技術深潛篇是給有 SSH 權限、想深入排查的人看的補充內容 🤗
🏆 事件綜合評估(10 分制,數字越高代表越需要立即處理/應對品質越好)
| 評估維度 | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 9.8 | 不用登入就能觸發 RCE,而且靠合法的留言功能引爆,防禦難度不低 |
| 官方應變速度 | 8 | 從通報到修補版釋出的節奏算快,官方 Changelog 也清楚寫明修補內容 |
| 一般站長自救可行性 | 9 | 點更新按鈕或關閉留言功能就能大幅降低風險,操作門檻不高但急迫度極高 |
| DevOps 技術文件完整度 | 7 | 攻擊鏈路與 CVSS 向量清楚,但部分細節(如公開 PoC 是否存在、攔截次數)各家情資說法不一致,需要交叉查證 |
| 🏆 加權綜合建議 | 立即行動 | 最終建議:【今天內完成更新或先關閉活動留言,不要拖到明天】 |
🔥 這起事件其實給了所有網站主一個提醒:漏洞不見得都躲在複雜的核心系統裡,有時候反而藏在一個「大家覺得再平常不過」的功能背後。留言功能本身並不是問題,問題是當它跟外掛的內部驗證邏輯疊在一起時,出現了一個意想不到的破口。與其等哪天真的出事才後悔,不如現在就花五分鐘,打開後台看一眼版本號碼 🔢
📚 參考資料與延伸資源
- 📌 Wordfence 威脅情資:The Events Calendar 漏洞紀錄 [64]
- 📌 CVE.org 官方紀錄 — CVE-2026-78006 [65]
- 📌 NVD — CVE-2026-78006 詳細資訊 [66]
- 📌 OpenCVE — CVE-2026-78006 [67]
- 📌 IONIX Threat Center 分析 [68]
- 📌 Patchstack 漏洞資料庫紀錄 [69]
- 📌 The Events Calendar — WordPress.org 官方外掛頁面 [1]
- 📌 WP-CLI 官方文件:plugin verify-checksums [70]
- 📌 WP-CLI 官方文件:config shuffle-salts [71]
- 📌 WP-CLI 官方文件:user session destroy [72]










