🚨全能裝潢外掛,怎麼變成資料庫的開放櫃台?Unlimited Elements For Elementor 高危漏洞全解析(CVE-2026-103355/CVSS 9.3)
🧩 全能裝潢外掛,怎麼變成資料庫的開放櫃台?Unlimited Elements For Elementor 高危漏洞全解析(CVE-2026-103355/CVSS 9.3)
🔥 懶人包:
活躍安裝數約 30 萬 的 Elementor 附加元件包 Unlimited Elements For Elementor,被登記了一個「不用登入、不用互動」就能發動的盲注式 SQL 注入漏洞(CVE-2026-103355,CNA 評分 CVSS 9.3 嚴重等級)。受影響的是 2.0.20 以下,2.0.21 起已修補。截至 2026 年 10 月 5 日,VulDB 仍標示「沒有可用的攻擊程式」,但官方幾乎沒公開技術細節,而且同一個版本還同時修了好幾筆 SQL 注入,四份來源報告對「漏洞到底出在哪個參數」的說法也互相對不上。如果你的網站有裝這個外掛,今天就花五分鐘確認版本、更新,再順手看一下管理員名單 🔒
內容目錄
🧐 第一章:「裝潢設計師」居然不看紙條就去開倉庫?先搞懂這個盲注漏洞
先講個畫面:你開了一間飯店,請了一位全能裝潢設計師,專門幫大廳換各種漂亮的燈飾、輪播看板和篩選選單。這位設計師手藝很好,只有一個壞習慣——有人遞紙條來問「請幫我查一下某分類底下的東西」,他不檢查紙條上寫的是不是單純的編號,就直接拿去倉庫櫃台問。於是有人在紙條裡夾帶了別的問題,倉庫櫃台也照單全收 🏨
一般的 SQL 注入像是小偷衝進倉庫,看到什麼就搬什麼。「盲注」則像在玩二十個問題:櫃台不會把帳冊念給你聽,但你可以一直問「管理員密碼的第一個字是不是 a?」,答案對的時候網頁多停頓幾秒(或畫面有微小差異),你就知道「是」。問久了,整本帳冊就被一個字一個字拼出來了,就像守口如瓶的警衛,雖然不開口,卻會對著你微微點頭 🤫
這次的主角叫 Unlimited Elements For Elementor,是 Elementor 頁面編輯器的熱門擴充,提供一整包現成的小工具、範本和動態清單。目前公開的資訊指出:它在 ≤2.0.20 版本存在未授權的盲注式 SQL 注入(CWE-89),CNA(CVE 編號授權單位)是 Patchstack,評分向量為 AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:L。翻成人話:從網路上就能打、不難、不用帳號、不用你點任何東西,主要風險是「資料被讀走」,而不是「被改掉」。VulDB 的條目也標示利用門檻「容易」、「不需要任何驗證」,同時註明「技術細節未公開、沒有可用的攻擊程式」 🕳️
🧊 冷知識:「盲」到底要問幾次?
用二分法來猜,一個英數字元(128 種可能)大約只要 7 次是非題就能定案。換句話說,如果拿到的是 34 字元長的舊式密碼雜湊,粗估大約 240 次請求就能拼完——這是我用 7 × 34 簡單估算的數字,真實情況會因攻擊工具而不同,但已經能說明為什麼自動化工具做這件事一點都不累 🧮
⏱️ 時間軸怎麼走?日期有點亂,我先幫你對過一遍
我把四份來源報告與公開資料庫(VulDB、Offseq、Patchstack、WPScan、Vulners)交叉比對後,整理出下面這張表。有兩個日期各家說法對不上,我直接標【❓待確認】,不硬挑一個 📅
| 日期 | 事件節點 | 可信度與備註 |
|---|---|---|
| 2026-09-30 | CVE-2026-103355 編號保留,Patchstack 擔任 CNA | ✅ VulDB、Offseq 一致 |
| 9/30 或 10/2 | 外掛釋出修補版 2.0.21 | 【❓待確認】Gemini、Grok 寫 9/30,Meta AI 寫 10/2,我沒能在官方更新日誌核對到這個版本的日期 |
| 2026-10-01 | Patchstack 公開同版本範圍(≤2.0.20)的另一筆 SQL 注入紀錄(CVE-2026-103338);WPScan 公開 ‘ucs’ 參數那一筆(CVE-2026-85568) | ✅ 兩邊頁面都載明 10 月 1 日;注意這兩筆編號跟本文主角不同 |
| 2026-10-03 | 第三筆(CVE-2026-92923,需訂閱者以上登入)與 CVE-2026-85568 進入 CVE 資料庫 | ✅ Vulners 彙整頁顯示 10 月 3 日 |
| 2026-10-04 | CVE-2026-103355 正式發布(08:00 UTC) | ✅ VulDB、Offseq 一致。這天剛好是週日,不少維運團隊要到週一上班才會注意到 |
📢 碎碎念:同一個 2.0.21,一口氣修了好幾筆 SQL 注入
你如果自己去搜尋,會發現這個外掛在 10 月初同時冒出好幾個編號:CVE-2026-85568、CVE-2026-92923、CVE-2026-103338、CVE-2026-103355,修補版本全是 2.0.21。它們是不是同一個破口、被不同單位各登記一次,還是真的各是一個,目前公開資料看不出來【❓待確認】。這也解釋了為什麼網路上的文章細節常常互相打架——有人把 A 筆的描述貼到 B 筆去了 🧩
⚠️ 講白了,最壞會怎樣?先分清楚哪些是確定的
🩵 荷包試算:更新要花錢嗎?
免費版外掛的安全更新是 NT$0,直接從 WordPress 後台或 WP-CLI 取得。真正花錢的是事後:請人做鑑識、通知客戶、重建信譽。另外,如果你同時有買 Pro 版授權,Pro 是否需要同步更新、授權是否牽動版本取得,我沒有查到公開資料【❓待確認】,請看官方更新日誌或洽原廠 💸
先講目前已經證實的,再往下推一層,最後才是「理論上可能」的情境,三層不要混在一起看 👇
- ✅ 已確認事實:不用登入、不需使用者互動就能從網路發動;機密性影響高(C:H)、可用性影響低(A:L)、完整性影響為無(I:N)——也就是說這個漏洞本身是「讀」而不是「寫」。
- 🟡 合理推論:攻擊者可能讀到使用者名稱、Email、密碼雜湊、網站設定(有些網站的 API 金鑰、SMTP 密碼會存在這裡),裝了 WooCommerce 的話還包含訂單與顧客資料。拿到雜湊之後能不能破解,取決於密碼本身強不強。
- 🔴 假設情境:攻擊者破解了管理員密碼 → 登入後台 → 上傳惡意外掛 → 接管整站。這條路每一步都需要「額外條件」,不是這顆 CVE 單獨能做到的事,我刻意跟前面兩層分開寫。
PoC(概念驗證程式碼)你可以把它想成「作案教學影片」:原本要摸出這個破口得靠技術與時間,一旦有人把完整步驟拍成影片公開,懂一點點技術的人就能照著做。關於「影片有沒有外流」,四份報告說法不一:DeepSeek 說沒有公開 PoC;Gemini 說已有 PoC 與掃描工具的追蹤紀錄(出處是一個 Telegram 頻道,我無法驗證);Grok 說有研究證明可重現;VulDB 則寫「沒有可用的攻擊程式」。我的處理方式是【❓待確認】,但行動上假設「有人正在試著轉門把」,因為 CVE 公開後的幾天,本來就是自動化掃描最忙的時候 🎬
🎭 第二章:先別慌,看看你是哪一種網站主
漏洞公開的當天,每個人該做的事其實差很多。先對號入座,再決定要往下讀到哪裡 🧭
🙋 我只是負責發文、顧後台的小編或老闆
你不需要看懂 SQL 是什麼。你要做的只有三件事:確認版本、更新、看一眼管理員名單。第三章已經把每一步要點哪裡、看什麼、怎麼判斷正常與否都拆開了,照著做就好,做不來也有退場方式。免費更新不會多花你一毛錢 ✅
👨💻 我自己架站,HestiaCP 底下還掛著好幾個客戶網站
你要的是「一次掃完整台主機」。第五章的工具箱會用 find 找出主機上每一個 WordPress,再用檔案擁有者的身分逐站執行 WP-CLI,不會在你的 root 家目錄裡留下一堆屬於 root 的檔案。Elementor 站更新後,記得清一次快取外掛與 CDN 的快取,不然你會看到「明明更新了,版面還是舊的」 🔧
🧑🔧 DevOps 小知識:HestiaCP 預設一個網站使用者就有一個獨立的 PHP-FPM pool
官方預設範本可以看到每個網域的 pool 都設定了 user 與 open_basedir,所以一個網站被打穿,通常碰不到別的使用者家目錄。但這個隔離的前提是「一個客戶一個使用者」;如果你把好幾個客戶塞在同一個 Hestia 使用者底下,隔離就形同虛設 🧱
🏢 我們是公司,網站有會員或訂單資料
對企業來說,免費外掛沒有 SLA(服務等級協議),出事也沒有人保證幾小時內修好。這次官方是先出修補版、CVE 後公開,節奏算正常,但同一個元件在 2026 年反覆出包(下一節會列給你看),就變成「採購與維運」層級的問題了。如果會員個資可能被讀走,台灣《個人資料保護法》可能涉及對當事人的事故通知義務,細節請交給法務判斷【❓待確認】。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺
🩵 荷包試算:「虛擬修補」要不要買?
有些資安廠商提供付費的 WAF/虛擬修補服務,可以在你更新前先擋一層。這次公開資料沒有顯示哪一家已經針對 CVE-2026-103355 上線規則,價格也因方案而異【❓待確認】。如果你能在今天內完成更新,通常比額外付費更划算;真的更新不了,再考慮它 💰
🚨 這個外掛今年到底出過幾次事?說三個我查得到的案例
與其空口說「這個外掛常出事」,不如直接看紀錄。下面這幾筆都是我在公開資料庫查得到、有 CVE 編號的案例,你也可以自己對照 🔍
| 公開時間 | 受影響版本 | 類型與門檻 | CVE 編號 |
|---|---|---|---|
| 2026-05(13~14 日) | ≤2.0.7 | SQL 注入,需投稿者以上登入 | CVE-2026-5486 |
| 2026-06-04 | ≤2.0.8 | 盲注,需低權限登入(CVSS 8.5) | CVE-2026-48837 |
| 2026-09-10 | ≤2.0.16 | 未授權 SQL 注入(addontype 參數),2.0.17 修補 | CVE-2026-18561 |
| 2026-10-04 | ≤2.0.20 | 未授權盲注(本文主角),2.0.21 修補 | CVE-2026-103355 |
讀到這裡你應該猜到了:從 5 月到 10 月,光是我查得到的 SQL 注入編號就有 7 個(含前面提到的同期其他幾筆),而且其中兩筆已經是「不用登入」等級。Patchstack 資料庫在我查詢時顯示這個外掛累計 45 筆已修補的漏洞、19 條緩解規則。這不代表它不能用,但代表你不應該讓它長期停在「手動、有空再更新」的狀態 🧯
關於「使用者端的真實痛點」,我這次只查到上面這種資料庫層級的紀錄,沒有找到可以引用的論壇抱怨串(例如更新後版面跑掉的具體案例),所以不替你編故事【❓待確認】 🙅
某工作室三年前用這個外掛做了首頁輪播,後來改版換了版型,輪播早就不見了,但外掛一直是「啟用」狀態。老闆認為「既然沒在用,漏洞應該碰不到我們」。
這個想法的盲點在於:官方至今沒公布觸發條件。只要外掛還在啟用清單裡,你就不知道門有沒有開著。就像家裡三年沒用的後門,你以為它沒事,其實鎖是不是生鏽了,你根本沒檢查過 🆘
🚥 畫重點!千萬別踩雷:
更新與停用,至少要選一個今天做。更新暫時做不到的話,退而求其次是先停用;「沒用到」不是不處理的理由,「已停用」才算不在攻擊面裡 🙅♂️
🛟 第三章:五分鐘無痛自救指南(電腦小白也能跟著做)
不管你屬於哪一種網站主,這一段都建議先跑一遍。不用打任何指令,只要會用滑鼠,每一步我都寫了「點哪裡、看到什麼算正常、不對勁怎麼辦」 🤏
- ㊀ 先確認你有沒有裝:
登入 WordPress 後台,左側選單點「外掛」→「已安裝的外掛」。在右上角搜尋框輸入 Unlimited Elements,如果清單裡出現名稱類似「Unlimited Elements For Elementor (Free Widgets, Addons, Templates)」的項目,就是它;搜尋不到就代表這站沒裝,這篇可以先放下 🔎 - ㊁ 看版本號,判斷安不安全:
名稱下方會寫「版本 2.0.xx」。2.0.20 或更舊=受影響,請繼續往下;2.0.21 或更新=已經跨過這顆漏洞的修補門檻,你可以先鬆一口氣,但最後兩步(看管理員名單)還是建議做 🕵️♀️ - ㊂ 更新前先備份:
最簡單的做法,是用你主機商控制台的一鍵備份,或你本來就在用的備份外掛,先備份一份再說。如果你連備份在哪裡都不知道,請直接把這篇文章的網址轉給維護你網站的工程師或主機商客服,這正是他們的工作,不需要不好意思 💾 - ㊃ 按下「立即更新」:
回到「已安裝的外掛」,在該外掛下方點「立即更新」,等轉圈圈結束,重新整理頁面,確認版本變成 2.0.21 以上。接著隨便開兩三個用到 Elementor 的頁面看看版面有沒有跑掉。如果更新失敗或畫面變白,別一直重複按,截圖後找人幫忙,並且先把外掛「停用」,這樣至少漏洞碰不到你 🔍 - ㊄ 順手開自動更新:
同一個外掛列表裡,每個外掛的右側欄位有「啟用自動更新」的連結,點一下就行。以後類似的修補版本會自己裝,你不用每次都記得來看 ⏰ - ㊅ 最後看一眼管理員名單:
左側選單點「使用者」,清單上方會有「管理員」的篩選連結,點進去。正常的樣子:全部都是你認得的人、用的是公司或本人的信箱。可疑的樣子:名稱像亂碼(例如一串隨機英文字母)、信箱是免洗信箱、你完全沒印象的人。看到不認得的,先別自己刪,截圖後交給懂技術的人判斷;為了保險,順便把所有管理員密碼換掉 🔀
🫂 新手求助:後台找不到更新、也看不懂這些選單怎麼辦?
把這句話原封不動傳給你的主機商或網站維護人員:「我的網站可能有裝 Unlimited Elements For Elementor,請幫我確認版本是否在 2.0.21 以上,漏洞編號是 CVE-2026-103355。」對方看到編號就知道要處理什麼。在沒有備份的情況下,請不要自己進主機底層刪除任何檔案 🤗
🧊 冷知識:「停用」跟「刪除」差在哪?
停用的外掛不會被 WordPress 載入,裡面的功能也就不會在網站上啟動,所以對這類「需要外掛執行才會被觸發」的漏洞來說,停用就足以暫時擋住。但外掛檔案還留在主機上,之後想用隨時能重新啟用;刪除則是連檔案一起拿掉,萬一你有舊頁面用到它的小工具,版面就會缺一塊 🧰
💬 溫馨提醒:更新只是「從現在開始把門關好」,沒辦法倒帶確認「更新之前有沒有人進來過」。這就是為什麼最後一步要看管理員名單,如果你的網站有會員或訂單資料,也請告知負責人,由他們判斷是否需要進一步檢查 🩺
🤿 第四章:技術深潛篇——漏洞底層到底哪裡出包?(以及為什麼四份報告說法對不上)
以下是給想知道「為什麼會這樣」的人看的。如果你只想補洞,前面三章已經夠用;想知道這個漏洞的底層長什麼樣,繼續往下拆 ⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | Unlimited Elements For Elementor (Free Widgets, Addons, Templates) |
| 外掛 slug | unlimited-elements-for-elementor |
| CVE 編號 | CVE-2026-103355 |
| CVSS 3.1(CNA:Patchstack) | 9.3(Critical) |
| CVSS 向量 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:N/A:L |
| 其他評分 | VulDB 自評 7.3、綜合 8.3。不同單位算出來的分數本來就會有落差,以向量內容為準 |
| 受影響版本 | ≤ 2.0.20 |
| 安全版本 | ≥ 2.0.21 |
| 漏洞類型 | 未經驗證的盲注式 SQL 注入(CWE-89) |
| 發現者 | 🟡 多份來源報告指向研究員 b1ue0cean(透過 Patchstack 獎金計畫),我沒能在官方頁面直接核對 |
🧩 四份報告對「漏洞點」的說法,我逐一對過了
這是整份資料裡最需要小心的地方。官方沒有公布受影響的端點、參數與修補差異,所以各家報告都在「推測」,推測的內容還彼此矛盾。我的判斷如下 👇
| 來源報告 | 說法 | 我的判斷 |
|---|---|---|
| Gemini | 某個 AJAX 查詢參數沒有經過 prepare 就拼進 SQL | 🟡 方向合理,但沒有可核對的出處 |
| Grok | 「分類清單」小工具的「選定分類的直屬子項」模式,把分類 ID 清單直接拼進 IN(),2.0.21 加入只允許數字與逗號的檢查 | 🟡 描述很具體,但我找不到公開的程式差異可以驗證,不能當成事實 |
| Meta AI | 問題出在 ucs 參數 | ❌ 這其實是 WPScan 另一筆編號(CVE-2026-85568)的標題與描述,被混用了 |
| DeepSeek | 官方未揭露確切端點 | ✅ 與 VulDB「技術細節未公開」一致 |
🚨 畫重點:漏洞出在哪個參數【❓待確認】
所以接下來的排查指令,我刻意不依賴某個特定參數名稱,而是用「盲注通用特徵」去找。這樣做的代價是比較容易有雜訊,好處是不會因為猜錯參數而漏掉真正的攻擊 🧭
🧑🔧 DevOps 小知識:CVSS 向量裡的 S:C 與 I:N 在說什麼?
S:C(範圍已變更)表示漏洞造成的影響跨出了外掛本身,連到整個資料庫;I:N 表示這個漏洞本身不能修改資料。這兩個值合起來,等於告訴你:事後的重點是「資料被讀走了嗎?憑證要不要換?」,而不是「找硬碟裡的後門」。後門(如果有)通常是攻擊者拿到管理員帳號之後才會留下的,兩件事要分開處理 🛡️
🦹 攻擊者在概念上做了什麼(僅防禦用途的概念層級)
- 🏮 第一步:偵察。確認目標網站有裝這個外掛,並大致判斷版本是否落在 2.0.20 以下。外掛的靜態資源路徑通常是公開的,這一步不費力。
- 🏮 第二步:送出帶有惡意條件的請求。透過外掛提供的前台功能(具體入口官方未公布),在原本應該只收數字的欄位塞進額外的 SQL 條件。
- 🏮 第三步:逐位元讀取。每次請求只回答「是或不是」,用回應時間或頁面差異判斷,反覆多次就能拼出資料。
- 🏮 第四步:拿讀到的東西做別的事。例如嘗試破解管理員密碼雜湊、用外洩的 API 金鑰存取其他服務。這一步已經超出這顆 CVE 本身,屬於後續攻擊鏈。
😦 碎碎念:日誌找不到,不代表沒被打
網站日誌(access log)預設只記錄請求的網址與查詢字串,不會記錄 POST 的內容。如果攻擊是用 POST 送的,日誌裡幾乎看不到任何特徵;用時間差判斷的盲注,在日誌預設格式裡也看不到回應花了多久。所以第五章的日誌排查,我只把它當成「找得到就是證據、找不到不能當作沒事」的輔助工具 🪞
🩵 荷包試算:想看得更清楚要多花多少?
想補強偵測,可以在 nginx 的日誌格式裡加上請求耗時($request_time),不用花錢,改完重新載入就能用;缺點是只對「之後發生的事」有效,已經過去的請求補不回來 💸
🕵️ 第五章:資安排查 Checklist——怎麼確認你家有沒有被摸過
🤖 重要提醒:以下指令與排查流程由 DeepSeek、Gemini、Meta AI、Grok 產生,Claude 複查修正
指令已通過 bash 語法檢查,並在「模擬的 HestiaCP 目錄結構+假的 wp/sudo」環境逐段跑過,確認迴圈、變數與失敗中斷邏輯正確;正式環境(尤其是正在營業中的網站)的高風險操作——包含更新、停用、刪除檔案、變更權限或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter 🧙
🧑🔧 DevOps 小知識:我替來源報告的指令改掉了這些地方
- 網站日誌在 /var/log/nginx/domains/網域.log,來源報告寫的 /var/log/hestia/nginx 是 HestiaCP 面板自己的日誌,找不到網站的紀錄。
- wp plugin update 與 wp plugin get 沒有 –network 參數(官方文件查無),已移除。
- 讓網站使用者用 sudo -u 把備份寫進 root 的家目錄會被拒絕,改成 db export – 輸出到螢幕,再由 root 自己導向存檔。
- 多份來源用 while read -r UID,但 UID 是 bash 的唯讀變數,會直接報錯,已改成小寫變數。
- wp config shuffle-salts 官方文件只有 –force 等參數,沒有 –dry-run;也不要用 curl 加 sed 手動刪改 wp-config.php,改壞檔案會讓整站白畫面。
- 檢查 PHP 設定要看 /etc/php/*/fpm/php.ini,用 php -i 看到的是命令列(CLI)的設定,不是網站實際使用的 PHP-FPM。
- 有來源建議在 nginx 直接封鎖整個外掛目錄,這會連帶擋掉所有小工具的 CSS、JS,整站版面會壞掉,不採用。
- (我自己的設計取捨)nginx 在 server 層級的 if 沒辦法可靠比對 $request_body,所以臨時規則只擋 GET 查詢字串,並在文中老實標示限制。
這一章的流程是:環境判斷 👉 盤點(版本比對)👉 日誌排查 👉 後門與帳號檢查 👉 加固五個關卡,每一關都拆成「決策原理、指令、預期輸出、分支判斷」。預設環境是 Debian 12 + HestiaCP,以 root 身分操作 📜
🧰 先貼一次工具箱
決策原理:HestiaCP 的每個網站都屬於不同的 Linux 使用者,網站檔案放在 /home/使用者/web/網域/public_html。如果你用 root 直接跑 WP-CLI,會產生 root 擁有的檔案,之後網站自己寫入就會失敗。所以我們先定義三個小函式:找出所有網站、以檔案擁有者身分執行 WP-CLI、逐站執行並在失敗時停下。逐站迴圈刻意使用檔案描述符 3 讀取清單,避免迴圈裡的指令不小心把後面的路徑吃掉 🧱
# ㊀ 先貼這一段(同一個 SSH 工作階段只要貼一次,後面的指令都會用到)
PLUGIN="unlimited-elements-for-elementor"
FIXED="2.0.21"
# 找出本機所有 WordPress(HestiaCP 預設在 /home/使用者/web/網域/public_html)
list_wp_sites() {
find /home -maxdepth 7 -type f -name "wp-config.php" \
-not -path "*/wp-content/*" -print0 2>/dev/null
}
# 以「網站檔案擁有者」的身分執行 WP-CLI,避免留下 root 擁有的檔案
wpx() {
local wp_path="$1" owner
shift
owner="$(stat -c '%U' -- "$wp_path/wp-config.php")" || return 1
sudo -u "$owner" -- wp --path="$wp_path" "$@"
}
# 逐站執行:each_site 函式名稱(任何一站失敗就停下,避免連鎖出事)
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)
}
# 防呆:確認 WP-CLI 與 sudo 都在
command -v wp >/dev/null || echo "⚠️ 找不到 wp 指令,請先安裝 WP-CLI"
command -v sudo >/dev/null || echo "⚠️ 找不到 sudo(root 可改用 runuser -u 使用者 -- 指令)"
預期輸出範例:貼完沒有任何輸出就是正常的;如果看到 ⚠️ 警告,代表缺了 WP-CLI 或 sudo,請先補上再往下。
分支判斷邏輯:
- 🟢 沒有警告 👉 往下進入環境判斷。
- 🟠 找不到 wp 👉 先安裝 WP-CLI(可用 wp –info 確認),不要硬跑下面的指令。
- 🟡 如果你是 root 且沒有 sudo 👉 把函式裡的 sudo -u “$owner” — 換成 runuser -u “$owner” —。
🧭 ㊀ 環境判斷:你在管一個 WordPress,還是一整台 WordPress 伺服器?
決策原理:單一站、獨立多站、Multisite 三者的處理範圍完全不同。判斷錯誤,後面的備份與更新都會跑偏,所以先讓指令自己回答,而不是靠印象。判斷 Multisite 用的是官方的 core is-installed –network,比去 grep wp-config.php 找 MULTISITE 字樣可靠,因為註解裡的字樣也會被 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) 👉 人工確認哪個才是正式環境。
🔎 ㊁ 盤點(版本比對):哪些站裝了它、現在是哪一版?
決策原理:版本比對用 Debian 內建的 dpkg –compare-versions,不要用字串比大小,否則 2.0.9 會被當成比 2.0.20 大。判斷條件是「低於 2.0.21 就是受影響」,比單純寫「≤2.0.20」更保守,萬一出現 2.0.20.1 這類小版號也不會被漏掉 🔢
inventory_one() {
local wp_path="$1" ver status risk
if ! ver="$(wpx "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null)"; then
printf '⚪ 未安裝\t-\t-\t%s\n' "$wp_path"
return 0
fi
status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status 2>/dev/null)"
if dpkg --compare-versions "$ver" lt "$FIXED"; then
risk="🔴 受影響"
else
risk="🟢 已修補"
fi
printf '%s\t%s\t%s\t%s\n' "$risk" "$ver" "$status" "$wp_path"
return 0
}
# 💠 單一網站(路徑換成你的)
WP_PATH="/home/user1/web/example.com/public_html"
inventory_one "$WP_PATH"
# ❇️ 獨立多站:整台主機一次盤點
each_site inventory_one
# ✴️ Multisite:外掛檔案全網共用,查主站一次即可(status 顯示 active-network 代表全網啟用)
inventory_one "/home/user2/web/network.example.com/public_html"
預期輸出範例:
🔴 受影響 2.0.20 active /home/user1/web/example.com/public_html 🟢 已修補 2.0.21 active-network /home/user2/web/network.example.com/public_html ⚪ 未安裝 - - /home/user3/web/blog.example.net/public_html
分支判斷邏輯:
- 🔴 受影響 👉 排入第六章的備份、預覽、更新流程;已經公開多日的站,同時繼續做日誌與帳號檢查。
- 🟢 已修補 👉 版本條件已解除,但如果舊版曾暴露一段時間,仍建議做 ㊂、㊃。
- ⚪ 未安裝 👉 這個站不受影響。
- 🟡 status 欄位顯示 inactive 👉 外掛已停用,攻擊面暫時不在,但仍建議找時間更新或刪除。
📊 ㊂ 日誌排查:找「通用盲注特徵+來源 IP」的組合
決策原理:因為官方沒公布確切的參數(第四章的【❓待確認】),這裡搜尋的是盲注常見的函式特徵:sleep(、benchmark(、union select、information_schema 等,同時考慮網址編碼(例如 %28 就是左括號)。用 zgrep 可以一併掃描輪替後的 .log.1、.log.2.gz。nginx 在最前面,日誌裡的來源 IP 才是真正的訪客;如果網站前面還有 Cloudflare,看到的可能是 Cloudflare 的 IP,需要另外對照 🎯
log_scan_one() {
local wp_path="$1" domain
local -a logs
local pattern='(sleep|benchmark)(\(|%28)|union(\+|%20|[[:space:]])+(all(\+|%20)+)?select|information_schema|extractvalue|updatexml'
# HestiaCP 的網域名稱就是 public_html 上一層的資料夾名稱
domain="$(basename -- "$(dirname -- "$wp_path")")"
printf '\n===== %s =====\n' "$domain"
# nginx 在最前面,日誌裡的來源 IP 才是真正的訪客;輪替過的 .log.1、.log.2.gz 一起掃
logs=( "/var/log/nginx/domains/${domain}.log"* )
if [ ! -e "${logs[0]}" ]; then
echo "(找不到 nginx 日誌,請確認網域資料夾名稱)"
return 0
fi
echo "--- ❶ 帶有盲注特徵的請求(最多 20 筆)---"
zgrep -aihE -- "$pattern" "${logs[@]}" | head -n 20
echo "--- ❷ 打 admin-ajax.php 最兇的前 5 個來源 IP ---"
zgrep -aih -- 'admin-ajax\.php' "${logs[@]}" | awk '{print $1}' | sort | uniq -c | sort -rn | head -n 5
return 0
}
# 💠 單一網站
log_scan_one "/home/user1/web/example.com/public_html"
# ❇️ 獨立多站(每個網域各看各的日誌)
each_site log_scan_one
# ✴️ Multisite:所有子站共用同一份日誌,掃主站網域即可,再依日誌裡的 Host/網址回推是哪個子站
log_scan_one "/home/user2/web/network.example.com/public_html"
預期輸出範例:
===== example.com =====
--- ❶ 帶有盲注特徵的請求(最多 20 筆)---
1.2.3.4 - - [04/Oct/2026:10:00:00 +0800] "GET /wp-admin/admin-ajax.php?action=x&a=1%20AND%20SLEEP%285%29 HTTP/1.1" 200 12
--- ❷ 打 admin-ajax.php 最兇的前 5 個來源 IP ---
2 1.2.3.4
分支判斷邏輯:
- 🟢 兩項都沒有輸出 👉 沒有直接證據,但別忘了第四章的限制:POST 內容與回應時間不在預設日誌裡,仍建議繼續做 ㊃。
- 🟡 有命中,但來源 IP 是你自己的掃描工具或公司的資安掃描 👉 先確認,不要直接當成攻擊。
- 🔴 命中集中在漏洞公開前後、來源是陌生 IP 且重複出現 👉 視為疑似遭觸發,進入第六章「A. 疑似中招」。
- 🟡 找不到日誌 👉 確認網域資料夾名稱是否與 Hestia 內的網域一致;子目錄安裝的網站,網域名稱需要手動指定。
🕳️ ㊃ 後門與帳號檢查:重點是「有沒有人拿著讀到的東西進來過」
決策原理:回想第四章:這個漏洞本身是「讀」,所以最優先要看的是帳號,其次才是檔案。mu-plugins 目錄裡的檔案不用在後台啟用就會自動載入,也是攻擊者常用的藏身處。資料表前綴(wp_)每個站可能不同,所以不能寫死,這裡先用 wp db prefix 取得,再把 SQL 寫進用 mktemp 建立的暫存檔,並用單引號包住的 heredoc 避免 Shell 去解讀 SQL 裡的特殊字元 🕵️♀️
audit_one() {
local wp_path="$1" prefix qf
printf '\n===== %s =====\n' "$wp_path"
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' -ls 2>/dev/null
echo "--- ❸ 近 7 天被改過的 PHP(排除快取)---"
find "$wp_path" -type f -name '*.php' -mtime -7 \
-not -path '*/wp-content/cache/*' -ls 2>/dev/null | head -n 50
echo "--- ❹ 管理員帳號 ---"
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table
echo "--- ❺ 最近 10 筆註冊的帳號(任何角色)---"
prefix="$(wpx "$wp_path" db prefix)" || return 0
qf="$(mktemp)"
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 < "$qf"
rm -f -- "$qf"
return 0
}
# 💠 單一網站
audit_one "/home/user1/web/example.com/public_html"
# ❇️ 獨立多站
each_site audit_one
# ✴️ Multisite:mu-plugins 屬於整個網路,帳號要多看一層「超級管理員」
audit_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 ===== --- ❶ mu-plugins(會自動載入、後台外掛頁看不到)--- --- ❷ uploads 底下出現 PHP 檔,一律視為可疑 --- --- ❹ 管理員帳號 --- +----+------------+-------------------+---------------------+ | ID | user_login | user_email | user_registered | +----+------------+-------------------+---------------------+ | 1 | admin | admin@example.com | 2024-01-15 08:00:00 | +----+------------+-------------------+---------------------+
分支判斷邏輯:
- 🔴 mu-plugins 或 uploads 出現不是你部署的 PHP 檔,尤其檔名是亂數字串 👉 先不要刪,保留時間與副本做鑑識,進入第六章「A」。
- 🔴 管理員名單出現註冊時間在 2026 年 10 月前後、你不認識的帳號 👉 視為高度可疑,進入「A」。
- 🟡 近 7 天有被改過的 PHP 檔 👉 對照你自己的更新時間,不是你改的再往下查內容;剛更新過外掛,這裡本來就會有很多檔案。
- 🟢 一切正常 👉 這一輪沒有發現異常。「陌生」不等於「惡意」,也可能是外包或維運商的帳號,先問過再判斷。
🔧 ㊄ 加固檢查:就算補好了,也把第二道門鎖起來
決策原理:更新外掛是根本,加固則是「萬一下次又出事,把破壞範圍縮小」。這一關做兩件事:把 wp-config.php(裡面有資料庫密碼)權限收緊;以及停用 PHP-FPM 裡的高風險函式,降低攻擊者拿到寫檔能力之後直接執行系統指令的機會。HestiaCP 可能同時有好幾個 PHP 版本,所以逐版本處理,改之前先備份、改完先用 php-fpm版本 -t 檢查,檢查通過才重新啟動 🔒
# ❶ wp-config.php 權限:收緊到「擁有者可讀寫、同群組唯讀、其他人不可讀」
harden_one() {
local wp_path="$1" cfg="$1/wp-config.php"
stat -c '調整前:%a %U:%G %n' -- "$cfg"
chmod 640 -- "$cfg"
stat -c '調整後:%a %U:%G %n' -- "$cfg"
return 0
}
each_site harden_one
# ❷ PHP-FPM 停用高風險函式:HestiaCP 可能同時裝了好幾個 PHP 版本,所以逐版本處理
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
預期輸出範例:
調整前:644 user1:user1 /home/user1/web/example.com/public_html/wp-config.php 調整後:640 user1:user1 /home/user1/web/example.com/public_html/wp-config.php PHP 8.2:已套用並重新啟動 disable_functions = exec,passthru,shell_exec,system,proc_open,popen
分支判斷邏輯:
- 🟢 看到「已套用並重新啟動」且最後那行與預期一致 👉 完成。驗證時請看 FPM 的 php.ini,不要用 php -i。
- 🟠 出現「設定檢查沒過」 👉 不要重啟,用 .BAK 備份還原後再查原因。
- 🟡 有些備份外掛、圖片處理外掛會用到 exec 之類的函式 👉 套用後請實際測試,必要時改成逐站在 pool 設定裡處理,而不是全域停用。
- 🟡 如果某個網域的 pool 設定檔(/etc/php/版本/fpm/pool.d/網域.conf)自己設了 disable_functions,會優先於全域設定,需另外檢查。
🧑🔧 DevOps 小知識:640 會不會讓網站讀不到設定檔?
HestiaCP 預設是讓 PHP-FPM 以網站使用者身分執行,所以擁有者本來就能讀寫,640 不影響。如果你改過架構(例如另外裝了以 www-data 執行的 mod_php),請先在測試站驗證再套用 🧪
📋 ㊅ 三環境速查表
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 環境判斷 | each_site detect_one 只有一行 | 多行 💠,路徑各自不同 | 出現 ✴️ |
| ② 盤點版本 | inventory_one 路徑 | each_site inventory_one | 查主站一次,status 看 active-network |
| ③ 日誌排查 | 單一網域日誌 | 逐網域各看各的 | 共用日誌,再依 Host 回推子站 |
| ④ 後門與帳號 | mu-plugins+管理員+近期註冊 | 逐站各自檢查 | mu-plugins 全網共用,另加 super-admin |
| ⑤ 加固 | wp-config 權限+php.ini | 逐站權限,php.ini 逐版本 | php.ini 屬伺服器層級,一次到位 |
🔥 核心原則:「批次」不等於「把所有東西塞進一條迴圈」。真正安全的批次排查,是先判斷環境、再逐層縮小範圍、一次處理一站,而且每一步失敗就停下 🎯
🛠️ 第六章:修不了就先擋——止血、補洞、驗收三部曲
前一章解決的是「怎麼判斷」,這一章是實際動手的流程。無論哪一種情境,順序都是同一套:備份 👉 預覽(Dry-run)👉 更新 👉 驗證,只是如果你已經有中招跡象,要先插入「保留證據與止血」。以下都沿用第五章貼過的工具箱函式,沒貼的話請先回去貼一次 🧹
🚨 ㊀ A. 疑似中招:先留證據,再止血,最後才修補
決策原理:只要第五章出現明確跡象(陌生管理員、mu-plugins 可疑檔、日誌有大量盲注特徵),第一優先不是急著更新,而是「先保留現場」。順序是:資料庫快照與日誌 👉 停用外掛 👉 強制重置 wp-config.php 裡的金鑰與鹽值(讓所有人包含攻擊者手上的 Cookie 立刻失效)👉 重設管理員密碼。重置鹽值用官方的 wp config shuffle-salts,不要自己用 curl 加 sed 去改檔案。證據資料夾裡有 wp-config.php 的備份(含資料庫密碼),權限設成 700,請別外傳 🧯
STAMP="$(date +%Y%m%d-%H%M%S)"
EVIDENCE_DIR="${HOME}/wp-incident-${STAMP}"
mkdir -p -- "$EVIDENCE_DIR" && chmod 700 -- "$EVIDENCE_DIR"
incident_one() {
local wp_path="$1" domain out uid url
local -a admin_ids urls
domain="$(basename -- "$(dirname -- "$wp_path")")"
out="${EVIDENCE_DIR}/${domain}"
mkdir -p -- "$out"
# ❶ 先留證據:資料庫快照、日誌、mu-plugins、wp-config.php(含資料庫密碼,別外傳)
wpx "$wp_path" db export - --single-transaction > "$out/db_${STAMP}.sql" || return 1
cp -a -- "/var/log/nginx/domains/${domain}.log"* "$out/" 2>/dev/null
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
cp -p -- "$wp_path/wp-config.php" "$out/wp-config.php.BAK.${STAMP}"
chmod 600 -- "$out/wp-config.php.BAK.${STAMP}"
# ❷ 隔離:停用外掛(Multisite 連全網與各子站一起處理)
if wpx "$wp_path" core is-installed --network 2>/dev/null; then
wpx "$wp_path" plugin deactivate "$PLUGIN" --network
mapfile -t urls < <(wpx "$wp_path" site list --field=url)
for url in "${urls[@]}"; do
wpx "$wp_path" --url="$url" plugin deactivate "$PLUGIN"
done
else
wpx "$wp_path" plugin deactivate "$PLUGIN"
fi
# ❸ 強制重置 wp-config.php 的金鑰與鹽值,所有人(包含攻擊者手上的 Cookie)立刻被登出
wpx "$wp_path" config shuffle-salts || return 1
# ❹ 逐一重設管理員密碼,新密碼直接顯示在螢幕上,請在私密環境操作
mapfile -t admin_ids < <(wpx "$wp_path" user list --role=administrator --field=ID)
for uid in "${admin_ids[@]}"; do
wpx "$wp_path" user reset-password "$uid" --skip-email --show-password
done
printf '✅ %s 止血完成,證據放在 %s\n' "$domain" "$out"
return 0
}
# 只對「有疑慮的那一站」執行,不要整台主機一起跑
incident_one "/home/user1/web/example.com/public_html"
預期輸出範例:
Plugin 'unlimited-elements-for-elementor' deactivated. Success: Shuffled the salt keys. Password: (隨機產生的新密碼,每位管理員各一組) ✅ example.com 止血完成,證據放在 /root/wp-incident-20261005-143022/example.com
分支判斷邏輯:
- ⭕ 全部成功 👉 保留證據資料夾,接著走「C. 正式修補」;企業或會員制網站請同步通知內部資安或維運負責人。
- 🟠 db export 失敗 👉 函式會立刻中斷,先處理磁碟空間或資料庫權限,不要在沒有快照的情況下繼續。
- 🟠 確認有後門檔案 👉 先隔離與保留,不要直接刪除,把證據交給資安事件應變人員。
- 🔴 網站有會員個資或線上收款,且確認曾遭觸發 👉 這已經不只是更新外掛的層級,請啟動內部資安事件流程,並把 SMTP、API 金鑰等存在資料庫或設定檔裡的憑證一併換掉。
- 🟡 Multisite 👉 函式已處理全網與各子站的停用;另外請用 wp super-admin list 檢查超級管理員,重設密碼指令對超級管理員需另外處理。
🚨 畫重點:新密碼會直接顯示在螢幕上,並可能留在終端機的捲動紀錄裡。請在私密環境操作,用安全管道交給管理員本人,並請他們登入後立刻自行更換 🔐
🧯 ㊁ B. 短期應急:還沒確認中招,但現在還不能更新
🚨 短期應急 ≠ 正式修補。
停用外掛與伺服器層規則都只是緩衝,目的是縮短暴露時間,真正的修補仍然是更新到 2.0.21 以上 🧱
決策原理:最乾淨的做法是直接停用外掛(Multisite 加上 –network)。如果網站版面離不開它,才用 HestiaCP 認得的自訂 include 加一道臨時規則:把檔案放在 /home/使用者/conf/web/網域/ 底下,檔名以 nginx.conf_、nginx.ssl.conf_ 開頭,面板重建設定時不會被蓋掉。這道規則只擋 GET 查詢字串裡的常見盲注函式,擋不到 POST,所以只能當輔助,不能取代停用 🔒
# ❶ 最乾淨的做法:直接停用外掛(Multisite 請加 --network)
wpx "/home/user1/web/example.com/public_html" plugin deactivate "$PLUGIN"
# ❷ 非得讓外掛繼續跑時:用 HestiaCP 認得的自訂 include,加一道「只擋 GET」的臨時規則
apply_temp_block() {
local wp_path="$1" huser domain conf_dir rule="ue-cve-2026-103355"
huser="$(stat -c '%U' -- "$wp_path/wp-config.php")" || return 1
domain="$(basename -- "$(dirname -- "$wp_path")")"
conf_dir="/home/${huser}/conf/web/${domain}"
[ -d "$conf_dir" ] || { printf '❌ 找不到 %s\n' "$conf_dir"; return 1; }
cat > "${conf_dir}/nginx.conf_${rule}" <<'EOF'
# 暫時緩解 CVE-2026-103355:只擋 GET 查詢字串裡常見的盲注函式,POST 內容擋不到
if ($args ~* "(sleep|benchmark)(\(|%28)|union(\+|%20)+(all(\+|%20)+)?select|information_schema") {
return 403;
}
EOF
ln -sfn -- "nginx.conf_${rule}" "${conf_dir}/nginx.ssl.conf_${rule}"
if nginx -t; then
systemctl reload nginx && printf '✅ 已套用 %s 的臨時規則\n' "$domain"
else
rm -f -- "${conf_dir}/nginx.conf_${rule}" "${conf_dir}/nginx.ssl.conf_${rule}"
printf '❌ nginx 設定檢查沒過,已自動撤回規則\n'
return 1
fi
}
# ❸ 正式更新完成後,記得把臨時規則拆掉
remove_temp_block() {
local wp_path="$1" huser domain conf_dir rule="ue-cve-2026-103355"
huser="$(stat -c '%U' -- "$wp_path/wp-config.php")" || return 1
domain="$(basename -- "$(dirname -- "$wp_path")")"
conf_dir="/home/${huser}/conf/web/${domain}"
rm -f -- "${conf_dir}/nginx.conf_${rule}" "${conf_dir}/nginx.ssl.conf_${rule}"
nginx -t && systemctl reload nginx
}
apply_temp_block "/home/user1/web/example.com/public_html"
預期輸出範例:
nginx: configuration file /etc/nginx/nginx.conf test is successful ✅ 已套用 example.com 的臨時規則
分支判斷邏輯:
- 🟢 網站不依賴這個外掛的版面 👉 直接停用,最乾淨。
- 🟠 網站離不開它 👉 套用臨時規則並盡快安排更新;規則只對 GET 有效,請持續看日誌。
- 🟠 nginx -t 沒過 👉 函式會自動撤回剛建立的兩個檔案,確認路徑與使用者名稱後再試。
- 🟡 正常網址被誤擋(回 403) 👉 先用 remove_temp_block 拆掉,改用停用外掛的做法。
- 🟡 更新完成後,一定要執行 remove_temp_block 把臨時規則拆掉,不要讓它永遠留在設定裡。
㊂ C. 正式修補:備份 👉 預覽 👉 更新 👉 驗證
❶ 備份:遵循 3-2-1 原則
決策原理:至少 3 份副本、2 種儲存媒介、1 份異地。這裡先做第一層:資料庫與 wp-content。如果你習慣用 HestiaCP 面板內建的備份功能,也可以先在那邊備一份當第二層,備份檔存放位置請不要放在網站根目錄,避免被外部直接下載 💾
BACKUP_DIR="${HOME}/wp-security-backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
mkdir -p -- "$BACKUP_DIR" && chmod 700 -- "$BACKUP_DIR"
backup_one() {
local wp_path="$1" domain
domain="$(basename -- "$(dirname -- "$wp_path")")"
# db export 用「-」輸出到螢幕,再由 root 自己導向存檔;
# 網站使用者沒有權限寫進 root 的家目錄,直接指定檔名會失敗
wpx "$wp_path" db export - --single-transaction \
> "$BACKUP_DIR/${domain}_db_${STAMP}.sql" || 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}"*
return 0
}
# 💠 單一網站
backup_one "/home/user1/web/example.com/public_html"
# ✴️ Multisite:資料庫是整個網路共用的一顆,只匯出一次
backup_one "/home/user2/web/network.example.com/public_html"
預期輸出範例:
-rw-r--r-- 1 root root 18M Oct 5 14:30 /root/wp-security-backup/example.com_db_20261005-143000.sql -rw-r--r-- 1 root root 412M Oct 5 14:31 /root/wp-security-backup/example.com_wp-content_20261005-143000.tar.gz
分支判斷邏輯:兩個檔案都在、大小合理 👉 進入預覽;任何一個失敗 👉 函式會中斷,先排除磁碟空間或權限問題,不要進入更新 🛑
🧊 冷知識:為什麼備份要寫 root,不寫網站使用者?
萬一網站被入侵,攻擊者通常拿到的是「網站使用者」的權限。如果備份放在他寫得到的位置,備份也可能一起被動手腳。把備份放在 root 才讀寫得到的資料夾,是最簡單的一層保護 🗄️
❷ 預覽(Dry-run):先看更新會做什麼,不真的動手
dryrun_one() {
local wp_path="$1"
printf '▶ %s\n' "$wp_path"
wpx "$wp_path" plugin update "$PLUGIN" --dry-run
}
# 💠 單一網站
dryrun_one "/home/user1/web/example.com/public_html"
# ❇️ 獨立多站(只預覽,不會真的改任何東西)
each_site dryrun_one
# ✴️ Multisite:外掛檔案只有一份,預覽一次即可(plugin update 沒有 --network 參數)
dryrun_one "/home/user2/web/network.example.com/public_html"
預期輸出範例:
▶ /home/user1/web/example.com/public_html +----------------------------------+-------------+-------------+-----------+ | name | old_version | new_version | status | +----------------------------------+-------------+-------------+-----------+ | unlimited-elements-for-elementor | 2.0.20 | 2.0.21 | available | +----------------------------------+-------------+-------------+-----------+
分支判斷邏輯:表格裡的 status 文字會因 WP-CLI 版本而略有不同,以 old_version → new_version 的變化為準。看到版本會往上跳 👉 可以正式更新;沒有可用更新 👉 先確認目前是否已是最新版,或更新來源有沒有連線問題,不要因為沒看到更新就直接認定安全 🔮
❸ 更新:只對原本啟用的外掛做停用再啟用
決策原理:更新前先把狀態記下來,原本啟用的才會停用再啟用,原本停用的維持原樣,Multisite 的全網啟用也會被原樣還原。這樣可以避免「更新完卻把本來不啟用的外掛打開」這種意外。更新失敗時,外掛會停留在停用狀態——漏洞碰不到你,只是前台那些小工具會暫時缺席,這是刻意設計成「失敗時偏向安全」的行為 🧰
update_one() {
local wp_path="$1" status
if ! status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status 2>/dev/null)"; then
printf '⚪ %s 沒裝這個外掛,略過\n' "$wp_path"
return 0
fi
# 只有「原本就啟用」的才會在更新前後先停用、再啟用,原本停用的維持原狀
case "$status" in
active) wpx "$wp_path" plugin deactivate "$PLUGIN" || return 1 ;;
active-network) wpx "$wp_path" plugin deactivate "$PLUGIN" --network || return 1 ;;
esac
wpx "$wp_path" plugin update "$PLUGIN" || return 1
case "$status" in
active) wpx "$wp_path" plugin activate "$PLUGIN" ;;
active-network) wpx "$wp_path" plugin activate "$PLUGIN" --network ;;
esac
}
# 💠 單一網站
update_one "/home/user1/web/example.com/public_html"
# ✴️ Multisite:外掛檔案只更新一次,全網啟用狀態會被原樣還原
update_one "/home/user2/web/network.example.com/public_html"
預期輸出範例:
Plugin 'unlimited-elements-for-elementor' deactivated. Success: Updated 1 of 1 plugins. Plugin 'unlimited-elements-for-elementor' activated.
分支判斷邏輯:更新成功 👉 立即進入驗證;更新失敗 👉 不要反覆重跑,先看錯誤訊息,確認檔案權限、磁碟空間與主機是否能連到 WordPress.org 💾
🧑🔧 多站環境的實務原則:一次只處理一站,至少在第一批時如此。先完成一站的「備份 👉 預覽 👉 更新 👉 驗證」,確認沒有 PHP Fatal Error、白畫面或前台版面異常,再處理下一站,比一條迴圈從頭跑到尾更容易控制風險 🎛️
❹ 驗證:更新成功 ≠ 工作完成
決策原理:至少確認三層:版本、檔案完整性、前台功能。檔案完整性用 WP-CLI 對照 WordPress.org 的官方檢查碼,核心檔案若有你自己放的檔案,只會出現警告而不會讓流程失敗;前台功能用 curl 確認首頁回應是 2xx 或 3xx。Multisite 另外逐一檢查每個子站 🔍
verify_one() {
local wp_path="$1" ver home_url code
ver="$(wpx "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null)" || {
printf '⚪ %s 沒裝這個外掛,略過\n' "$wp_path"; return 0; }
# ❶ 版本層
if dpkg --compare-versions "$ver" lt "$FIXED"; then
printf '❌ %s 版本仍是 %s,還沒修補\n' "$wp_path" "$ver"
return 1
fi
# ❷ 檔案完整性層:外掛檔案要跟 WordPress.org 的發布版一致
wpx "$wp_path" plugin verify-checksums "$PLUGIN" || return 1
wpx "$wp_path" core verify-checksums || echo "⚠️ 核心檔案有差異,請人工確認是不是你自己放的檔案"
# ❸ 功能層:首頁要正常回應(2xx 或 3xx 都算正常)
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
}
# ✴️ Multisite:額外逐一確認每個子站的前台
verify_network() {
local wp_path="$1" url code
local -a urls
verify_one "$wp_path" || return 1
mapfile -t urls < <(wpx "$wp_path" site list --field=url)
for url in "${urls[@]}"; do
code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${url%/}/")"
printf '子站 %s → HTTP %s\n' "$url" "$code"
done
}
預期輸出範例:
Success: Verified 1 of 1 plugins. ✅ /home/user1/web/example.com/public_html → 版本 2.0.21、首頁 HTTP 200
🚨 Checksum 驗證的侷限:它只能證明外掛目錄裡的檔案跟官方發布版一致,檢查不到 mu-plugins、資料庫裡的異常帳號,也看不出別的外掛或主題有沒有被動過。所以請把它當成完整性檢查的其中一環,搭配第五章 ㊃ 的帳號與後門檢查,不是唯一的安全保證 🔐
分支判斷邏輯:三層都通過 👉 修補完成;版本仍低於 2.0.21 👉 回頭看更新步驟有沒有被中斷;前台 HTTP 異常 👉 先還原備份,在測試環境查相容性;更新後仍持續出現可疑請求,或 mu-plugins 出現新檔案 👉 回到「A」重新處理 🔁
❺ 整合:把四步綁成一站一個完整閉環
# 把「備份 → 預覽 → 更新 → 驗證」綁成一站一個完整閉環,任何一步失敗就停
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; }
sleep 3 # 給伺服器喘口氣,也讓你有機會按 Ctrl+C
}
# 💠 單一網站
patch_one "/home/user1/web/example.com/public_html"
# ❇️ 獨立多站:一次一站,遇到失敗就停(each_site 會自動中斷)
each_site patch_one
分支判斷邏輯:任何一站在任何一步失敗,函式都會印出❌並停下,each_site 也不會繼續往下一站。確認問題處理完再重跑即可,因為前面的步驟是可重複執行的。全部完成後,別忘了清一次網站快取,並(如果有套用過 B 段規則)執行 remove_temp_block 🧼
🔥 一句話總結:先判斷環境,再盤點、備份、預覽,確認無誤才逐站更新,最後驗證。環境判斷錯誤比指令寫錯更致命,這套閉環換成下一個外掛、下一個 CVE,一樣可以直接套用 🦸
🤔 讀到快喘不過氣?三個最常見的焦慮,一次解掉
- 🏮 Q:更新後版面會不會跑掉?
A:這是第三方外掛,更新內容官方沒有公布完整細節,我無法保證版面完全不變【❓待確認】。所以更新前先備份、更新後打開幾個用到 Elementor 的頁面檢查,是最穩的做法 💾 - 🏮 Q:外包說「網站早就做好沒在動,不用管」,我該聽嗎?
A:別聽。這個漏洞跟你有沒有在改版無關,只跟外掛有沒有啟用、版本是不是 2.0.21 以上有關。請對方給你看版本截圖 💣 - 🏮 Q:看不懂技術深潛篇,是不是就沒救了?
A:完全不會。第三章從頭到尾不用打指令,點幾下滑鼠就能做完最基本的防護;第四到第六章是給有 SSH 權限、想深入排查的人看的補充 🤗
🧩 舉一反三:這一類漏洞的通用防禦心法
以下是 SQL 注入(CWE-89)這個類型的通用防禦知識,不是本 CVE 的官方要求,但下次遇到同類問題時,用得上 🧠
- ⭐ 參數化查詢是根本。WordPress 開發者應使用 $wpdb->prepare() 把使用者輸入「當成資料」而不是「當成 SQL 的一部分」。你自己選外掛時,也可以優先看更新頻率與漏洞修補速度。
- ⭐ 最小權限原則。網站的資料庫帳號只該擁有自己那顆資料庫的權限。一個網站一組資料庫帳號、只授權自己那顆資料庫,是縮小破壞範圍的基本功,不要為了方便讓好幾個網站共用同一組帳號(HestiaCP 實際授權範圍請以你的設定為準【❓待確認】)。
- ⭐ 縮小攻擊面。外掛不用就停用、不要長期擱置。這次的漏洞需要外掛「啟用」才會被碰到,停用就是最便宜的防禦。
🏆 第七章:這次到底該多緊張?事件綜合評估
| 評估維度(10 分制,數字越高代表風險或應對品質越正向) | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 9.3 | 不用登入、不用互動,但目前沒有確認的公開攻擊程式,實際風險跟「何時有人公開利用」高度相關 |
| 官方應變速度 | 7 | 修補版先於 CVE 公開上線,節奏正常;但同一元件今年反覆出現 SQL 注入,扣分 |
| 一般站長自救可行性 | 9 | 後台點「更新」就能解決,門檻很低,要多做的只有看管理員名單 |
| DevOps 技術文件完整度 | 5 | 官方沒公布漏洞端點與修補差異,四份來源報告彼此矛盾,排查只能靠通用特徵 |
| 🏆 加權綜合建議 | 盡快更新 | 最終建議:【今天排進行程,最慢本週內完成更新;Elementor 站與有會員資料的站優先】 |
🔥 這起事件提醒我們:漏洞不一定躲在複雜的核心系統,有時候藏在你以為「只是幫忙排版」的裝潢外掛裡。細節少、攻擊程式不明,不等於可以放著不管,因為真正有差別的,是你在「別人公開攻擊方法之前」有沒有把門關好。與其等到登不進後台才發現不對勁,不如現在花五分鐘,打開後台看一眼版本號碼 🔢
📚 參考資料與延伸資源
- 📌 VulDB|CVE-2026-103355 漏洞條目
- 📌 Offseq Threat Radar|CVE-2026-103355
- 📌 Strix|CVE-2026-103355 彙整頁
- 📌 Patchstack|Unlimited Elements ≤2.0.20 SQL 注入紀錄(CVE-2026-103338)
- 📌 Patchstack|Unlimited Elements 歷史漏洞列表
- 📌 WPScan|’ucs’ 參數 SQL 注入(CVE-2026-85568)
- 📌 Strix|CVE-2026-92923
- 📌 Vulners|CVE-2026-18561(≤2.0.16 未授權 SQL 注入)
- 📌 Mondoo|CVE-2026-48837
- 📌 Securitricks|CVE-2026-5486
- 📌 WP-CLI 官方文件|wp config shuffle-salts
- 📌 WP-CLI 官方文件|wp plugin update
- 📌 HestiaCP 論壇|自訂 nginx 設定檔的 include 作法











