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

🚨網址隨便加幾個字,網站站長就換人當?🗓️ My Calendar 高危漏洞全解析(CVE-2026-104798/CVSS 9.8)

網址隨便加幾個字,網站站長就換人當?🗓️ My Calendar 超高危險漏洞大解密(CVE-2026-104798)

🔥 懶人包:
超多人用的活動行事曆外掛 My Calendar – Accessible Event Manager(在 WordPress 官方可是有 20,000+ 活躍安裝數喔!)最近被貼上了一張超大張的通緝令:CVE-2026-104798《什麼是CVE❓這就像是通緝犯的專屬身分證號碼:讓全世界的警察(資安人員)都知道現在在抓哪一個壞蛋》。更可怕的是它的 CVSS 分數高達 9.8 嚴重等級《什麼是CVSS❓你可以把它想成颱風警報的威力等級:滿分10分,9.8分就相當於強烈颱風直接登陸,破壞力直接拉滿 🌪️》

簡單來說,駭客不需要有你網站的帳號密碼、也不用騙你點什麼奇怪的釣魚連結,只要在網址後面加上一段特製的「通關密語」,就有極大的機率可以直接把網站第一號管理員(通常就是你!)的密碼給改掉。受影響的版本非常廣泛,目前最新的 3.8 版本線只要是 3.8.7 以下(含)通通中標,官方已經緊急推出 3.8.8 修補包。只要花你五分鐘,趕快確認版本、按個更新,順便檢查一下家裡有沒有被偷塞陌生人 🔒

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

[2]


🟦 🟦 🟦

內容目錄

🪧 第一章:案發現場還原!為什麼會議室門口的預約白板,最後會變成打開保險箱的遙控器?

想像一下這個畫面:你們公司走廊上掛著一塊超方便的「會議室預約白板」,不管是誰路過都可以隨手在備註欄寫字。這時候,總機小姐有一個很熱心(但有點要命)的習慣:只要看到備註欄裡有用中括號 [某某名稱] 框起來的字,她就會當成是「分機快速撥號鍵」直接幫你轉接 ☎️ 平常大家寫「[下午三點] A會議室」,相安無事。直到有一天,有個路過的陌生人,在白板上寫了一串偽裝成快速撥號的「密碼重設指令」,指名要總機去找人事部,把老闆的門禁密碼換掉。結果總機小姐居然連問都沒問「你是誰?」,就直接照辦了 🗓️

📢 大白話:WordPress 的「短代碼 (Shortcode)」就是辦公室總機的快速撥號鍵
在 WordPress 的世界裡,用中括號包起來的 [某某名稱] 被稱為短代碼。它的存在就像是總機電話上的快速鍵:按 3 就接客服,省去背一長串號碼的麻煩。但這套聰明設計的背後,藏著一個天真的假設:「會按按鍵的人,都是自己人」。這次的災難就在於,陌生人居然也能把「要按哪顆鍵」直接寫進網址裡,而外掛還真的乖乖照按了 ☎️

這次的 CVE-2026-104798 漏洞,玩的就是這種「借刀殺人」的把戲。出包的主角 My Calendar 是一款專門幫 WordPress 網站製作活動行事曆的外掛。真正破洞的地方,其實根本不在你看到的漂亮日曆畫面上,而是在網址最後面一個名為 mcs 的小參數裡 🔍

🧊 冷知識:短代碼其實是個「骨灰級」的老古董?
Shortcode API 其實早在 2008 年的 WordPress 2.5 版本就已經加入核心功能了!十幾年來,它簡直就像外掛界的「萬能強力膠」,到處都能黏。但也正因為它無所不在,一旦「誰有資格發送短代碼」這道防線沒守好,引發的災情往往比單一外掛本身還要慘烈 🧩

官方在更新日誌裡只輕描淡寫地寫了一句:「修補了一個嚴重的、免登入的權限提升漏洞《什麼是權限提升❓你可以把它想成拿著一般員工的門禁卡,卻刷開了董事長辦公室的金庫大門:這就是駭客夢寐以求的終極目標!》」。因為官方沒有公佈太多細節,所以以下的攻擊過程,是我從各大資安報告中拼湊出來的輪廓 🧾

  • ㊀ 壞人在網址塞進帶有中括號的 mcs 參數:🟡 根據報告,這個參數的內容會被直接放進網頁裡,而且中括號居然沒有被過濾掉 🔗
  • ㊁ 網頁內容被送進短代碼處理器:🟡 於是,裡面夾帶的 [my_calendar] 短代碼,就被系統當成正常的合法指令開始執行 ⚙️
  • ㊂ 短代碼的 above 屬性被拿去呼叫重要函式:🟡 報告指出,外掛把它拆開後直接交給了 PHP 內建的 call_user_func(),不但沒檢查發送者有沒有登入,甚至沒有限制只能呼叫哪些函式 📞
  • ㊃ 呼叫一路暢通無阻,抵達了修改使用者的核心函式:🟡 帶進去的參數被系統自動轉換成了整數 1。登愣!結果就是 ID 為 1 的帳號(通常是網站的第一個管理員)密碼跟信箱就被改掉了 🔑

🚨 畫重點:我絕對不會給你完整的攻擊字串
雖然有些報告寫得很露骨,但我這篇絕對不會照搬!這篇文章的目的是要幫你當個「聰明的防盜專家」,告訴你鎖在哪裡壞了、該怎麼補,而不是教你怎麼當小偷。你只要記住一個大原則:這個漏洞的攻擊大門是「公開網址」,而且駭客完全不需要擁有你網站的帳號就能動手 🙅

🧊 冷知識:為什麼遭殃的總是「ID 1」?
因為 PHP 語言有個奇妙的特性:當它試圖把一個「非空陣列」硬轉成整數時,得到的結果固定就是 1。如果報告裡描述的機制沒錯,這就完美解釋了為什麼受害的苦主永遠是 ID 1(也就是絕大多數網站剛架設時,建立的第一個創始管理員帳號)。不過,你網站的 ID 1 現在到底還是不是管理員,這點得看你自己的帳號管理習慣,不能一概而論 🪪


🟩 🟩 🟩

⏱️ 炸彈是什麼時候被引爆的?事件時間軸一次看懂

時間點 發生了什麼大事 站長可信度判定
2026-10-15 知名資安機構 Wordfence 首度揭露,揪出漏洞的英雄署名為 @daroo 🟡 多份報告內容吻合;不過 Wordfence 原始頁面有阻擋,無法直接100%核對細節【❓待確認】
2026-10-16 CVE-2026-104798 漏洞紀錄正式被公開在陽光下 🟡 同上【❓待確認】
官方修補版 WordPress 官方外掛區目前最新的穩定版已經來到 3.8.8,更新日誌也承認修補了嚴重的免登入權限提升漏洞 ✅ 站長親自去官方頁面核對無誤!
到底有沒有人受害? 我目前取得的資料裡,還沒有看到明確的大規模災情紀錄;CISA KEV 等國家級警報我暫時無法進一步驗證 【❓待確認】

大家千萬不要有一種鴕鳥心態:「既然沒看到災情,那我是不是可以再等等?」
🙅 錯!這種「免登入、光靠網址就能打」的漏洞,一旦被公開,駭客的自動掃描機器人絕對是 24 小時不眠不休地在網路上瘋狂敲門。所以,時間軸上的空白,絕對不是讓你拿來拖延更新的藉口 ⏳

  • ✅ 已確認事實:目前 WordPress.org 上面 My Calendar 的最新穩定版是 3.8.8,官方也白紙黑字寫明修補了這個嚴重漏洞;另外,新版外掛要求網站環境必須是 WordPress 6.5 以上、PHP 7.4 以上。
  • 🟡 合理推論:資安圈給它的攻擊向量代號是 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H。翻成白話文就是:「只要連上網路就能打、技術門檻有夠低、不需要任何帳號、不需要受害者點擊,而且網站的機密性、資料完整性跟系統可用性,會被一次『滿門抄斬』」。
  • 🔴 假設情境:如果一個壞人成功改掉了 ID 1 的信箱和密碼 👉 他就能大搖大擺登入後台 👉 接著裝上各種惡意後門外掛 👉 最後整碗端走,把你的網站變成他的肉雞。請注意,這是一連串的連續動作,並不是單靠這顆 CVE 就能一鍵完成,所以我特別把它拉出來當作假設情境。

🩵 小資族荷包試算:按個更新到底要花多少錢?
這可是 WordPress 官方的免費外掛!按下安全更新的費用是 NT$ 0 元。你只需要在後台輕輕點一下滑鼠就能解決。但你知道嗎?如果真的「出事了」,你要花錢請資安專家來緊急救援、重建被弄亂的帳號資料,還要承擔網站被 Google 貼上「危險網站」黑名單的流量損失。這些後續的人力與商譽成本,隨便算都遠遠超過一次免費更新啊 💸

[32]


🟪 🟪 🟪

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

看到同一份資安警告信,負責發文的小編、喜歡自己買主機架站的硬核玩家、還有大公司的 IT 部門,該做的事情完全不一樣!先看看自己屬於下面哪一種,再決定你要把文章往下讀到哪裡 🧭

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

別怕,你完全不需要懂什麼是 PHP 程式碼!你要做的只有四個超簡單的動作:看版本、按備份、點更新、最後看一眼管理員名單。第三章我會手把手、全程帶你用滑鼠解決。但請特別注意一件事:如果你現在發現自己「突然登不進後台」了,千萬不要手賤去按「忘記密碼」!原因我會在第三章解釋給你聽 🧑‍💼

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

你需要的是「整台主機一次掃描」的爽快感!直接跳到第五章的工具箱,我準備了批次指令,能幫你找出每一個 WordPress,還會貼心地用各網站擁有者的身分去跑 WP-CLI,絕對不弄髒你的 root 家目錄。請注意,這顆漏洞同時開了五條版本線的副本,盤點的時候一定要逐站對比,千萬別看到「3.8.x」就以為天下太平了 🔧

🧑‍🔧 DevOps 小知識:為什麼這次的漏洞修補要「逐分支」來判斷?
平常我們看漏洞,通常是 低於某個版本號 = 危險。但這次原廠非常有誠意(也很讓人頭痛),他們替過去的五條舊版本線都各自推出了專屬修補包:像是 3.7.17 還是受害者,要到 3.7.18 才安全;而 3.8.7 同樣也是受害者。如果你只傻傻地拿最新的 3.8.8 當作唯一的安全標準,那你可能會把已經乖乖打好補丁的 3.7.18 誤判成高危險群!這就是為什麼我們需要更聰明的判斷腳本 🧱

🏢 我們是企業網站,裡面存了一大堆會員和訂單個資

免費外掛最大的痛點,就是沒有所謂的「SLA 保固條款」,出事了沒人能保證多久會修好。雖然這次廠商很負責地補了五條分支,但翻開過去的黑歷史,這款外掛在權限控管上跌倒已經不是第一次了。如果貴公司的會員個資有可能已經外洩,依照台灣現行的《個人資料保護法》,你們對會員可能會有主動通知的法律義務!這部分請務必盡快交給公司法務判斷【❓待確認】。站長這邊只是幫大家統整技術情報,重大決定還是要請教專業顧問喔 🩺

🚨 冤枉啊!我早就沒在用那個行事曆了,外掛只是懶得刪而已

🚨 高風險場景模擬(🔴 假設情境):「活動早就辦完了,外掛放著長灰塵應該沒差吧?」
某個社團三年前用這個外掛辦了一場超大型的尾牙報名,活動結束後就把報名網頁給刪了,但是外掛卻一直維持在「啟用」的狀態。管理員心想:「反正前台都沒放日曆了,駭客總不可能憑空打進來吧?」
🙅大錯特錯!根據資安報告,這個漏洞的觸發點,是藏在外掛處理「網址參數」的底層流程裡,根本不管你的網頁上有沒有真的擺出行事曆。只要外掛還是「啟用」狀態,你家後門就等於沒上鎖!這就像你家有扇三年沒打開過的後門,你以為很安全,其實鎖頭早就壞了 🆘!

🚥 站長拿螢光筆畫重點!千萬別踩雷:
「更新」跟「停用」,你今天最少最少要選一個做!如果暫時不能更新,就先把它停用。記住:「我沒在用」絕對不是你不處理的藉口,「已經停用」或「徹底刪除」才算是真正把駭客擋在門外 🙅‍♂️


🔴 🔴 🔴

🧮 來對獎囉!你的版本到底安不安全?五條版本線對照表

這張表怎麼看?很簡單:先看看你的版本號是「3.幾」開頭,然後對照表格。只要你的數字小於右邊那欄的「修到這版才安全」,抱歉,你就是受災戶。這些版號數據都是從資安報告和官方日誌交叉比對出來的,雖然 Wordfence 的原始頁面被擋住了有點可惜,但大方向絕對錯不了 🧾

你的主線版本 受影響的危險區間 ✅ 修到這版才安全
3.8 3.8 ~ 3.8.7 3.8.8
3.7 3.7 ~ 3.7.17 3.7.18
3.6 3.6 ~ 3.6.17 3.6.18
3.5 3.5 ~ 3.5.21 3.5.22
3.4 及更早古董 3.4.24 以下(含) 3.4.25;更舊的版本拜託你直接升到最新版啦!
📢 大白話:就像熱水器廠商召回大檢修
想像一下,熱水器大廠突然發現瓦斯閥有瑕疵。廠商佛心來著,沒有強迫大家都買最新型號,而是幫過去五年的老機型都各自出了專屬的「修正包」。如果你家的是 2022 年款,就乖乖裝 2022 年的修正包;硬裝別年的反而會出事。但如果你家是用十年前的老古董,那廠商也只能雙手一攤,請你直接買一台新機器了。上面那張表,就是你的「熱水器型號對應修正包」清單 🔧

順帶一提,這個外掛也是會挑環境的:最新版要求你的主機必須具備 WordPress 6.5 以上,以及 PHP 7.4 以上的規格。如果你的主機環境太老舊,更新可能會卡關,這時候請先跳到第六章採取短期應急措施,然後趕快把主機升級吧 🧰

[33]


🟠 🟠 🟠

🕰️ 考古時間:這外掛以前出過事嗎?黑歷史一次翻給你

我們不隨便抹黑別人,直接讓公開數據說話。下面這四筆紀錄,每一筆都是有拿到正式 CVE 身分證的資安事件,有興趣的可以自己去 Google 查證 🔍

公開曝光時間 受波及版本 出包類型與嚴重度 專屬 CVE 編號
2023-05-22 ≤ 3.4.3 跨站請求偽造(CSRF),已於 3.4.4 修補 CVE-2023-23813
2025-12-09 ≤ 3.6.16 缺少授權檢查(CWE-862),CVSS 4.3,已於 3.6.17 修補 CVE-2025-67592
2026-07-02 ≤ 3.7.14 不安全的物件參照,免登入就能把非公開活動的 iCalendar 給偷匯出來,CVSS 5.3;修補版號不明 CVE-2026-11896
時間成謎【❓待確認】 ≤ 3.7.8 免登入的時間盲注 SQL 注入漏洞(mc_auth 參數惹禍),CVSS 7.5;目前只有單一來源證實 CVE-2026-6854
2026-10-16 ≤ 3.8.7 免登入權限提升(就是我們今天的主角!),CVSS 9.8,已於 3.8.8 修補 CVE-2026-104798

看出來了嗎?近兩年來,這款外掛在「誰有資格做什麼事」的檢查防線上,已經摔跤好幾次了,而這次更是摔得最慘的一次。這不是叫你立刻把外掛刪掉不用,而是提醒你:它絕對不是那種可以讓你「裝了就忘、有空再更新」的佛系外掛 🧯


🟢 🟢 🟢

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

💬 溫馨小叮嚀:更新外掛就像是「把壞掉的鎖換新」,它只能保證從現在開始小偷進不來,但沒辦法幫你倒帶檢查「昨天小偷有沒有已經偷偷跑進來逛過」。所以最後那兩步(查名單、留意登入狀況)千萬不能偷懶跳過;如果你們家網站有存客戶訂單或是會員資料,請務必往上報備給主管,由他們來判斷要不要做更深度的資安健檢 🩺

不管你是公司老闆、小編還是接案仔,這一章拜託你一定要跟著走一遍。每一個步驟我都幫你列出「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只要動滑鼠就好 🤏

  • ㊀ 先查水表,看看有沒有裝:
    登入你的 WordPress 後台,看左邊黑色的選單找到「外掛」→「已安裝的外掛」,在右上角的搜尋框打上 My Calendar。如果看到一個叫「My Calendar – Accessible Event Manager」的東西,恭喜你中獎了;如果搜不到,那就代表沒裝,這篇文章你可以當作聽鬼故事提早下課啦 🔎
  • ㊁ 核對版本號,拿去跟第二章的表格對答案:
    版本號就寫在外掛名稱的正下方。拿去跟第二章的表格對照,只要你的數字比「修到這版才安全」還要小,那就是危險群。如果懶得對表,直接看有沒有出現 3.8.8 或是更新的版本數字,有的話就直接給它升級上去,最省事 🕵️‍♀️
  • ㊂ 動手前,先給自己留條後路(備份):
    去主機商的後台點一下「一鍵備份」,或是用你平常用的備份外掛先存個檔。如果你連「備份」兩個字怎麼寫都不知道,聽我的,直接把這篇文章的網址 Line 給幫你維護網站的工程師,這本來就是他們的工作,大膽地要求他們處理就對了 💾
  • ㊃ 深呼吸,按下「立即更新」:
    更新跑完之後重新整理一下網頁,確認版本號已經跳到安全範圍,然後去你的網站前台逛一下,看看日曆有沒有破圖。注意!如果更新失敗或是畫面變成一片慘白,千萬別像中邪一樣狂按重整,趕快進後台先把外掛「停用」,這樣至少可以先躲過駭客的攻擊 🔄
  • ㊄ 懶人福音:開啟自動更新:
    在外掛清單裡,每個外掛的右邊都有一個「啟用自動更新」的藍色小字,點它一下,以後有安全修補包它就會自己偷偷裝好,省得你整天提心吊膽 ⏰
  • ㊅ 化身名偵探,揪出後台的陌生人:
    點左邊選單的「使用者」,然後點最上面的「管理員」分頁。正常情況:裡面全都是你認識的同事,信箱也都是你們家自己的網域。見鬼的情況:信箱變成了什麼 .ru 結尾的奇怪網域、或是多出一個你根本沒印象的帳號!這時候請把目光鎖定在「最早建立的那個帳號」(通常是網站建置時的第一個管理員),這就是這次漏洞的主要狙擊目標。如果真的看到髒東西,先別急著自己動手刪,截圖下來交給懂資安的專家處理 🔀
  • ㊆ ⚠️ 救命!我突然登不進後台了:
    冷靜!手離開滑鼠!絕對不要按「忘記密碼」!
    這次的漏洞就是專門改掉你的帳號信箱跟密碼,如果你傻傻地按了重設,系統只會把熱騰騰的新密碼寄去給駭客剛改好的信箱,等於你雙手把鑰匙奉上。這時候請直接找你的主機商或維護工程師,請他們從伺服器底層幫你搶回主導權 🚫

🫂 新手求助專區:看到這些專有名詞就頭暈怎麼辦?
沒關係,站長教你一招「複製貼上之術」。直接把下面這段話傳給你的主機客服或外包商:「你好,我的網站可能有裝 My Calendar 外掛,請幫我確認版本是否安全,漏洞編號是 CVE-2026-104798,並請幫我檢查管理員名單有沒有被竄改。」對方只要看到這串英文加數字,就知道該怎麼辦了。切記,在沒有備份的情況下,千萬別自己去亂刪東西喔 🤗

🧊 冷知識:「停用」跟「刪除」到底差在哪裡?
這就像是你家買了一台微波爐。「停用」就是把插頭拔掉,機器還佔著位子,但只要不通電就不會起火(暫時擋住漏洞),哪天想用插頭插回去就好。「刪除」則是直接把整台微波爐丟到資源回收車,連影子都不剩。如果你的網頁上原本有放行事曆,一旦「刪除」,那個區塊就會直接破一個大洞給你看 🧰

[34]


🔴 🔴 🔴

🤿 第四章:技術深潛——陌生人的網址參數,是怎麼一路走到「改帳號」的?

這一章給想知道「為什麼會這樣」的人。只想補洞,前三章已經夠用;想看細節,往下拆。先說清楚資料狀況:官方沒公開程式差異,Wordfence 與作者網站我也無法直接開啟,下面的機制全部來自多份來源報告彼此一致的描述,標 🟡,程式碼層級的細節一律【❓待確認】⚔️

評估指標 具體資訊
外掛名稱 My Calendar – Accessible Event Manager
外掛代號(slug) my-calendar
CVE 編號 CVE-2026-104798
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-269:權限管理不當(免登入權限提升)
受影響與修補版本 五條版本線各自不同,見第二章對照表;3.8 線為 ≤ 3.8.7,3.8.8 修補
發現者 @daroo 與 Wordfence(依 WordPress.org 更新日誌致謝)
觸發入口 公開網址的 mcs 參數(🟡 來源報告)
外掛要求 WordPress 6.5 以上、PHP 7.4 以上(WordPress.org 外掛頁)

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

  • 🏮 環節一:外部輸入被塞進頁面內容。網址參數的值在處理短代碼之前,就先被放進了要渲染的文字裡,而中括號沒被清掉。這是經典的「先加料、後洗菜」順序問題 🥬
  • 🏮 環節二:短代碼屬性直接變成函式呼叫。報告描述 mc_generate_calendar_nav() 會拆解 above 屬性,再丟給 call_user_func()。這個函式的用途是「依名稱呼叫某個函式」,如果名稱來自外部又沒有白名單,攻擊者就等於拿到一個遙控器 🎮
  • 🏮 環節三:沒有任何身分檢查擋在中間。依報告,整條路徑沒有登入檢查,也沒有權限判斷。所以 CVSS 向量裡 PR(所需權限)是 N,UI(使用者互動)也是 N 🚪
📢 大白話:白名單,就是「只接受名單上的分機號碼」
櫃台助理可以替人轉接分機,這沒問題。問題是這次助理不看名單,來電的人報什麼號碼就轉什麼號碼,連「人事部密碼管理」那種內線也轉。白名單的做法很單純:櫃台手上有一張寫了十個分機的紙,不在紙上的一律不轉。外掛少做的就是這張紙 📋

🧑‍🔧 DevOps 小知識:這顆漏洞不是 RCE,為什麼還是 9.8?
它的直接後果是「改寫 ID 1 的憑證」,不是在伺服器上執行任意程式。但 WordPress 管理員能裝外掛、改佈景主題檔案,拿到管理員幾乎等於拿到程式執行能力。所以事後的重點不只是「更新外掛」,還要查「有沒有人以管理員身分裝了東西」。這也是第七章的 disable_functions 只能當第二道防線、擋不住這顆漏洞本身的原因 🛡️

🧊 冷知識:9.8 其實是這個向量的天花板
CVSS 3.1 在 S:U(影響範圍未改變)、而且 C/I/A 三項全高的情況下,最高就是 9.8;想拿到 10.0,影響範圍必須跨越安全邊界(S:C)。所以 9.8 的意思是「在不跨出外掛本身權限範圍的前提下,已經是最嚴重的一檔」,不是「還差一點才滿分」🎯

🔧 3.8.8 到底修了什麼?

官方更新日誌只寫了一句安全修補,沒有公開差異。有來源報告推測是加上了呼叫函式前的白名單與參數清洗,我沒能在官方程式碼核對,所以只當 🟡 合理推論,不當事實寫死【❓待確認】。你唯一確定能做的判斷是:版本落在第二章表格的安全欄,就是官方認定的已修補狀態 🧾

🚨 畫重點:「觸發條件的完整細節」目前查不到【❓待確認】
哪些頁面、哪些設定組合一定能觸發,官方沒公開,各家報告的描述也不完全一致。所以第五章的排查我刻意不依賴某一條特定攻擊字串,而是看兩種結果特徵:網址裡有帶方括號的 mcs 請求,以及 ID 1 帳號有沒有被動過。代價是會有一些雜訊,好處是不會因為猜錯入口而漏掉真正的異常 🧭


🔷 🔷 🔷

🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 Checklist

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

這一章的流程是:工具箱 👉 盤點版本 👉 日誌排查 👉 帳號與檔案稽核 四步,每一步都拆成「為什麼這樣做、指令、預期輸出、看到什麼怎麼辦」。以上全部只讀不寫,放心跑,不會改任何東西。預期輸出只是典型範例,版本不同可能略有差異 📜


🧭 🧭 🧭

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

為什麼這樣做:HestiaCP 的每個網站屬於不同的 Linux 使用者。用 root 直接跑 WP-CLI 會產生 root 擁有的檔案,之後網站自己寫入就會失敗。所以先定義幾個小函式:自動偵測 WP-CLI 的名稱(有的主機叫 wp,有的叫 wp-cli)、以檔案擁有者身分執行、逐站迴圈,以及最重要的 is_patched,專門處理五條版本線各自的修補門檻。所有路徑變數都有用雙引號包起來,路徑含空白也不會斷 🧱

# ㊀ 工具箱:同一個 SSH 連線貼一次就好,連線中斷後要重貼
PLUGIN="my-calendar"
LATEST="3.8.8"

# 找出 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 sudo > /dev/null || echo "⚠️ 找不到 sudo(root 可改用 runuser -u 使用者 -- 指令)"

# 列出主機上每一個 WordPress(HestiaCP 標準位置:/home/使用者/web/網域/public_html)
list_wp_configs() {
  find /home -maxdepth 5 -type f -path '*/web/*/public_html/wp-config.php' -print0 2> /dev/null
}

site_owner()  { stat -c '%U' -- "$1/wp-config.php"; }
site_domain() { basename -- "$(dirname -- "$1")"; }

# 以「網站檔案擁有者」的身分執行 WP-CLI:wpx 網站路徑 wp指令...
wpx() {
  local wp_path="$1" owner
  shift
  owner="$(site_owner "$wp_path")" || return 1
  sudo -u "$owner" -H -- "$WPCLI" --path="$wp_path" "$@"
}

# 逐站執行:任何一站失敗就停下,不會連鎖出事
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 舊(不用字串比大小)
version_lt() {
  [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}

# 這顆 CVE 有「各分支各自的修補版」,所以要先看分支、再比對該分支的修補版
# 回傳 0=已在修補版本線上,1=受影響
is_patched() {
  local v="$1" branch fixed
  if version_lt "$v" "3.4"; then
    return 1
  fi
  branch="$(printf '%s' "$v" | cut -d. -f1,2)"
  case "$branch" in
    3.4) fixed="3.4.25" ;;
    3.5) fixed="3.5.22" ;;
    3.6) fixed="3.6.18" ;;
    3.7) fixed="3.7.18" ;;
    3.8) fixed="3.8.8" ;;
    *)   return 0 ;;
  esac
  ! version_lt "$v" "$fixed"
}

classify_version() {
  local v="$1"
  if [ -z "$v" ]; then
    echo "⚪ 未安裝"
  elif is_patched "$v"; then
    echo "🟢 已在修補版本線上"
  else
    echo "🔴 受影響"
  fi
}

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

驗證(確認 is_patched 判斷正確):貼完後執行下面這行,預期輸出是每個版本一行,3.7.17 與 3.8.7 要是「受影響」,3.7.18 與 3.8.8 要是「已修補」。這行只做判斷、不碰任何網站 🧪

for v in 3.4.24 3.4.25 3.7.17 3.7.18 3.8.7 3.8.8; do
  if is_patched "$v"; then echo "$v 已修補"; else echo "$v 受影響"; fi
done
  • 🟢 結果符合預期 👉 工具箱沒貼錯,往下進行。
  • 🟠 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
  • 🟡 3.4.24 以下一律判為受影響,是刻意的設計:我取得的資料沒有列出更舊版本的專屬修補版,請直接升到最新版。

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

[35]


🔎 🔎 🔎

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

為什麼這樣做:一台主機上有十個獨立 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%s\n' "$wp_path"
    return 0
  fi
  ver="$(wpx "$wp_path" plugin get "$PLUGIN" --field=version 2> /dev/null || true)"
  status="$(wpx "$wp_path" plugin get "$PLUGIN" --field=status 2> /dev/null || true)"
  printf '%s\t%s\t版本:%s\t狀態:%s\t%s\n' \
    "$(classify_version "$ver")" "$kind" "${ver:--}" "${status:--}" "$wp_path"
  return 0
}

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

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

預期輸出範例:

🔴 受影響	💠 一般安裝	版本:3.8.7	狀態:active	/home/admin/web/site-a.com/public_html
🟢 已在修補版本線上	💠 一般安裝	版本:3.7.18	狀態:active	/home/bob/web/shop.example/public_html
⚪ 未安裝	💠 一般安裝	版本:-	狀態:-	/home/bob/web/blog.example/public_html
🟢 已在修補版本線上	✴️ Multisite	版本:3.8.8	狀態:active-network	/home/bob/web/network.example/public_html

分支判斷邏輯:

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

🟥 🟥 🟥

📊 ㊂ 日誌排查:找「mcs 參數帶方括號」的請求

為什麼這樣做:官方沒公開 payload,標準存取日誌也不記錄 POST 內容。能抓的只有一個必然會留下的痕跡:請求網址裡有 mcs 參數,而且值或名稱帶了方括號,不論是字面的 [ 還是編碼過的 %5B。腳本只切出「請求行」那一欄比對,時間戳記裡的方括號不會誤報;用 zcat -f 一併讀取輪替後的 .log.1 與 .log.2.gz。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log。如果你前面還有 CDN,日誌看到的可能是 CDN 的 IP 🎯

# 取出某網域「目前+輪替後(含 .gz)」的 nginx 存取日誌
scan_domain_log() {
  local domain="$1"
  find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.log*" -print0 \
    | xargs -0 -r zcat -f --
}

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

  # 只看「請求行」那一欄(用雙引號切欄),時間戳記裡的方括號不會誤報
  all="$(scan_domain_log "$domain" | awk -F'"' 'tolower($2) ~ /[?&]mcs(=|\[|%5b)/')"
  total="$(printf '%s' "$all" | grep -c . || true)"
  hits="$(printf '%s\n' "$all" | awk -F'"' 'tolower($2) ~ /(\[|%5b|%255b)/' | tail -n 20)"

  echo "--- ❶ 請求行帶有 mcs 參數的總筆數 ---"
  printf '%s\n' "$total"

  echo "--- ❷ 其中 mcs 帶有方括號(含編碼寫法)的請求,最多 20 筆(最值得看)---"
  if [ -n "$hits" ]; then
    printf '%s\n' "$hits"
    echo "--- ❸ 這些請求的來源 IP 排行 ---"
    printf '%s\n' "$hits" | awk '{print $1}' | sort | uniq -c | sort -rn | head -n 10
  else
    echo "(無命中)"
  fi
  return 0
}

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

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

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

===== example.com =====
--- ❶ 請求行帶有 mcs 參數的總筆數 ---
2
--- ❷ 其中 mcs 帶有方括號(含編碼寫法)的請求,最多 20 筆(最值得看)---
203.0.113.42 - - [16/Oct/2026:10:00:00 +0800] "GET /?mcs=%5B... HTTP/1.1" 200 5 "-" "python-requests/2.31.0"
--- ❸ 這些請求的來源 IP 排行 ---
      1 203.0.113.42

分支判斷邏輯:

  • 🔴 ❷ 有命中,且來源是陌生 IP、User-Agent 像腳本工具 👉 視為有人在試探或已經利用,記下 IP 與時間,把 ㊃ 做完整;若 ㊃ 也有異常,直接進第六章 A。
  • 🟡 ❶ 有數字但 ❷ 沒命中 👉 有人用過 mcs 參數但沒帶方括號,可能是正常使用。這個參數平常會不會出現在正常流量裡,我無法確認【❓待確認】,請對照你的網站實際行為。
  • 🟢 都沒命中 👉 只能說「目前沒有直接證據」,不能證明沒被打過:日誌可能已輪替、前面有 CDN,或攻擊根本沒走你查的這個日誌。繼續做 ㊃。

[36]


🟣 🟣 🟣

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

為什麼這樣做:這顆漏洞的直接後果是帳號被改,所以第一個要看的是 ID 1 的資料與登入階段,接著看管理員名單與最近註冊的帳號。管理員一旦被接管,能做的事很多,所以也看檔案那一側:uploads 裡的 PHP、會自動載入的 mu-plugins、揭露日之後被改過的 PHP,以及外掛與核心檔案跟官方版本的比對。資料表前綴(wp_)每站可能不同,所以先用 wp db prefix 取得並檢查格式,再把 SQL 寫進 mktemp 的暫存檔,用單引號包住的 heredoc 避免 Shell 解讀 SQL 裡的特殊字元 🕵️‍♀️

# 掃描起點:Wordfence 揭露日(2026-10-15)。想更保守,可以往前調幾天
SINCE="2026-10-15"

audit_one() {
  local wp_path="$1" prefix qf opt re
  re='^[A-Za-z0-9_]+$'
  printf '\n===== %s(擁有者:%s)=====\n' "$wp_path" "$(site_owner "$wp_path")"

  echo "--- ❶ ID 1 的帳號資料(這顆漏洞鎖定的就是它)---"
  wpx "$wp_path" user get 1 --fields=ID,user_login,user_email,display_name,user_registered,roles --format=table

  echo "--- ❷ ID 1 目前的登入階段(不認識的 IP 或裝置就是警訊)---"
  wpx "$wp_path" user session list 1 --fields=login_time,expiration_time,ip,ua --format=table || true

  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 1
  if ! [[ "$prefix" =~ $re ]]; then
    echo "資料表前綴長得不對勁,略過 SQL 查詢"
  else
    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"
  fi

  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(不用啟用就會自動載入,後台外掛頁看不到)---"
  find "$wp_path/wp-content/mu-plugins" -maxdepth 2 -type f \
    -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 "--- ❽ 外掛與核心檔案跟官方版本比對 ---"
  wpx "$wp_path" plugin verify-checksums --all || true
  wpx "$wp_path" core verify-checksums || true

  echo "--- ❾ 幾個常被動手腳的網站設定 ---"
  for opt in siteurl home admin_email users_can_register default_role; do
    printf '%s = %s\n' "$opt" "$(wpx "$wp_path" option get "$opt")"
  done
  return 0
}

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

# ❇️ 獨立多站:逐站各自檢查
each_site audit_one

# ✴️ Multisite:帳號要多看一層「超級管理員」
wpx "/home/admin/web/network.example.com/public_html" super-admin list

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

--- ❶ ID 1 的帳號資料(這顆漏洞鎖定的就是它)---
+----+------------+-------------------+--------------+---------------------+---------------+
| ID | user_login | user_email        | display_name | user_registered     | roles         |
+----+------------+-------------------+--------------+---------------------+---------------+
| 1  | admin      | admin@example.com | admin        | 2024-01-15 08:00:00 | administrator |
+----+------------+-------------------+--------------+---------------------+---------------+
--- ❺ uploads 底下的 PHP 類檔案(正常網站應該是空的)---
--- ❽ 外掛與核心檔案跟官方版本比對 ---
Success: Verified 12 of 12 plugins.
Success: WordPress installation verifies against checksums.

分支判斷邏輯:

  • 🔴 ❶ 的信箱不是你們的,或 ID 1 的登入階段出現你不認識的 IP 👉 高度可疑,直接進第六章 A。你本人登不進後台,也算這一類。
  • 🔴 ❸❹ 出現註冊時間在 2026-10-15 之後、你不認識的帳號 👉 高度可疑。但「陌生」不等於「惡意」,也可能是外包或維運商留的,先問清楚再處理。
  • 🔴 ❺ 找到 PHP 類檔案,或 ❻ 的 mu-plugins 出現不是你部署的檔案 👉 視為已中招,先別刪,保留證據,進第六章 A。
  • 🟡 ❼ 揭露日之後被改過的 PHP 👉 外掛正常更新也會出現,要對照你自己的維運紀錄,不要看到就當成入侵。
  • 🟡 ❽ 出現 File was added 之類的差異 👉 先確認是不是你自己放的客製檔案;不確定就當成可疑。
  • 🔴 ❾ 的 users_can_register 變成 1、default_role 變成 administrator,或 siteurl/home 被改成陌生網域 👉 網站設定被動過手腳,這類改動很少是正常維運造成的。
  • 🟢 全部乾淨 👉 這一輪沒有發現明確跡象,仍建議做第六章的更新與第七章的加固,並多觀察一週。

📋 📋 📋

📋 ㊄ 三環境速查表:把四步濃縮成一張

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

[37]


🪛 🪛 🪛

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

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


💾 💾 💾

💾 ㊀ 兩個共用零件:備份與刷新鹽值

為什麼這樣做:A 和 C 都需要先備份,A 還需要刷新鹽值,所以先定義成函式。備份檔放在 root 家目錄下、權限 700,不放在網站目錄,避免被網址直接下載。鹽值(Salts)就是 wp-config.php 裡那八組長字串,WordPress 靠它們替登入 Cookie 簽章。這次漏洞的受害目標就是登入憑證,所以我的判斷是:舊版曾對外暴露、又有任何可疑跡象,就用 wp config shuffle-salts 強制刷新,所有人,包含可能已經拿到 Cookie 的攻擊者,立刻被登出。代價是正常使用者包括你自己也要重新登入 🔑

📢 大白話:刷鹽值,是把全公司的通行證版本換新
你只改了一號員工的密碼,但攻擊者手上可能已經有一張還沒過期的通行證,照樣進得來。刷新鹽值等於宣布「舊版通行證今天起全部作廢」,不管誰手上拿著哪一張,都得重新辦。這比單純改密碼徹底得多 🪪
# ㊀ 共用零件一:備份(資料庫+wp-content),任何一步失敗就回傳失敗
BACKUP_DIR="${HOME}/wp-security-backup"

backup_one() {
  local wp_path="$1" domain stamp db_file
  domain="$(site_domain "$wp_path")"
  stamp="$(date +%Y%m%d-%H%M%S)"
  db_file="${BACKUP_DIR}/${domain}_db_${stamp}.sql"

  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 "${BACKUP_DIR}/${domain}_wp-content_${stamp}.tar.gz" \
    -C "$wp_path" wp-content || { echo "❌ 檔案備份失敗,停止"; return 1; }
  chmod 600 -- "${BACKUP_DIR}/${domain}_wp-content_${stamp}.tar.gz"
  ls -lh -- "${BACKUP_DIR}/${domain}_"*"_${stamp}".*
  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
}

預期輸出範例:備份函式會列出兩個檔案與大小;刷新鹽值會印出前後兩段短雜湊。

/dev/vda1  80G  22G  54G  29% /
-rw------- 1 root root 485M Oct 16 10:30 /root/wp-security-backup/example.com_db_20261016-103022.sql
-rw------- 1 root root 2.4G Oct 16 10:31 /root/wp-security-backup/example.com_wp-content_20261016-103022.tar.gz
Success: Shuffled the salt keys.
✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin

驗證與失敗分支:

  • 🟢 備份:兩個檔案都在、大小合理,資料庫檔結尾有 Dump completed 👉 可以進下一步。這個結尾字樣是 mysqldump 與 MariaDB 的慣例輸出,若你的環境用別的工具匯出,字樣可能不同【❓待確認】,腳本只會警告,不會擋你。
  • 🟠 備份失敗或是空檔 👉 函式立刻停下,先查磁碟空間(腳本已印出 df 結果)與資料庫權限,不要在沒備份的情況下往下。
  • 🟢 鹽值:前後兩段雜湊不同 👉 已生效,你會被登出,重新登入即可。
  • 🟠 出現 ❌「鹽值沒有改變」或指令本身失敗 👉 先確認 wp-config.php 的擁有者可寫;這個指令會連線到 WordPress.org 的 API 取得新鹽值,主機若連不出去也會失敗,先查網路,不要急著換別的來源。
  • 🔴 刷完網站出現「建立資料庫連線時發生錯誤」 👉 用備份還原:cp -p “$BACKUP_DIR/網域_wp-config.php.時間” “網站路徑/wp-config.php”,備份檔名在上面的 ls 輸出裡。備份裡有資料庫密碼,確認一切正常後再決定要不要保留,刪除前請再三確認。

🔴 🔴 🔴

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

為什麼這樣做:第五章出現明確跡象時,第一優先不是急著更新,而是保留現場。順序是:資料庫快照與日誌 👉 停用外掛 👉 刷新鹽值 👉 先把 ID 1 的信箱改回本人的 👉 再重設管理員密碼並登出所有階段。這個順序很重要:如果信箱還是攻擊者留下的,任何「寄重設信」的做法都會把新密碼送到對方手上,所以腳本一律用 –skip-email –show-password 把新密碼顯示在螢幕上,並且先處理信箱 🧯

這支腳本會動到帳號與設定,請先確認路徑,而且只對有疑慮的那一站執行,不要整台主機無差別跑。新密碼會留在終端機的捲動紀錄裡,請在私密環境操作,用安全管道交給管理員本人,並請他登入後立刻自行更換,不要貼進聊天視窗或腳本 🔐

# 前提:已貼過「工具箱」與「共用零件」。取證完成前,什麼都不要刪
incident_one() {
  local wp_path="$1" domain stamp evid out uid url new_email re
  re='^[^@[:space:]]+@[^@[:space:]]+$'
  domain="$(site_domain "$wp_path")"
  stamp="$(date +%Y%m%d-%H%M%S)"
  evid="${HOME}/mc-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
  find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.log*" \
    -exec cp -p -- {} "$out/" \;
  wpx "$wp_path" user list --role=administrator \
    --fields=ID,user_login,user_email,user_registered --format=table > "${out}/admins.txt"
  wpx "$wp_path" user get 1 --fields=ID,user_login,user_email,roles --format=table > "${out}/user1.txt"
  wpx "$wp_path" user session list 1 --fields=login_time,expiration_time,ip,ua \
    --format=table > "${out}/user1-sessions.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 "=== ❷ 隔離:停用外掛(Multisite 連全網與各子站一起處理)==="
  if wpx "$wp_path" core is-installed --network 2> /dev/null; then
    wpx "$wp_path" plugin deactivate "$PLUGIN" --network || 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" || return 1
  fi

  echo "=== ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)==="
  rotate_salts_one "$wp_path" || return 1

  echo "=== ❹ 先把 ID 1 的信箱改回「確定是你本人的」,再重設密碼 ==="
  wpx "$wp_path" user get 1 --fields=ID,user_login,user_email --format=table
  read -r -p "ID 1 本人可收信的 Email(目前顯示的就是正確的,直接按 Enter 略過):" new_email
  if [ -n "$new_email" ]; then
    if ! [[ "$new_email" =~ $re ]]; then
      echo "Email 格式不對,停止"
      return 1
    fi
    wpx "$wp_path" user update 1 --user_email="$new_email" || return 1
  fi

  echo "=== ❺ 逐一重設管理員密碼(新密碼會顯示在螢幕上)並登出所有階段 ==="
  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)

  wpx "$wp_path" cache flush || true
  printf '\n✅ %s 止血完成,證據放在 %s\n' "$domain" "$out"
  ls -lh -- "$out"
  return 0
}

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

預期輸出範例:

=== ❷ 隔離:停用外掛(Multisite 連全網與各子站一起處理)===
Plugin 'my-calendar' deactivated.
Success: Deactivated 1 of 1 plugins.
=== ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)===
Success: Shuffled the salt keys.
✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin
=== ❺ 逐一重設管理員密碼(新密碼會顯示在螢幕上)並登出所有階段 ===
(每位管理員各一組隨機新密碼)
✅ example.com 止血完成,證據放在 /root/mc-incident-20261016-103500/example.com

驗證(確認真的止血了):跑完後執行下面三行,預期結果是「status 顯示 inactive」「ID 1 的信箱是你的」「ID 1 的登入階段清單只剩你剛登入的、或是空的」。三項有任何一項不符,就代表那一步沒成功,回頭看對應輸出。這三行只讀不寫 🧪

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

wpx "$WP_PATH" plugin get "$PLUGIN" --field=status
wpx "$WP_PATH" user get 1 --fields=ID,user_login,user_email --format=table
wpx "$WP_PATH" user session list 1 --fields=login_time,ip,ua --format=table
  • ⭕ 全部成功 👉 保留證據資料夾,接著走 C 正式修補;公司或有會員資料的網站,同步通知內部資安或主管。
  • 🟠 資料庫快照失敗 👉 函式立刻中斷,先處理磁碟空間或資料庫權限,沒有快照不要往下。
  • 🟠 停用外掛時出現 PHP Fatal Error 👉 外掛檔案可能被改過,請交給資安專業人員做完整清理,不要自己硬刪。
  • 🔴 發現後門檔案、不認識的管理員,或網站有個資與線上收款且確認曾遭觸發 👉 這已超出更新外掛的層級,請啟動內部資安事件流程,並把 SMTP、API 金鑰等存在資料庫或設定檔的憑證一併換掉。
  • 🔴 更新前的備份可能已經含有後門,不能當成「乾淨的還原來源」;要還原請用事發前、已知乾淨的備份。

[38]


🟡 🟡 🟡

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

🚨 短期應急 ≠ 正式修補。
停用外掛與擋網址都只是縮短暴露時間,真正的修補仍然是更新到第二章表格的安全版本。擋網址有副作用:mcs 參數帶方括號的正常請求也會被擋;停用外掛則是行事曆功能會暫時消失 🧱

選項一(最乾淨):直接停用外掛。Multisite 請加 –network。停用後 plugin get 必須顯示 inactive,否則就是沒成功,回頭看第一行的錯誤訊息 🧯

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

wpx "$WP_PATH" plugin deactivate "$PLUGIN"
wpx "$WP_PATH" plugin get "$PLUGIN" --field=status

選項二:行事曆不能中斷時,用 nginx 擋掉 mcs 參數裡的方括號。HestiaCP 的網站設定檔會在重建網域或續約 SSL 時被樣板覆蓋,所以千萬不要直接改 nginx.conf;官方做法是在 /home/使用者/conf/web/網域/ 放 nginx.conf_任意名稱(HTTP)與 nginx.ssl.conf_任意名稱(HTTPS),樣板會自動載入。下面的函式在 nginx -t 沒過時會自動撤回剛建的兩個檔案,不會讓 nginx 掛掉。規則比對的是未解碼的原始參數,所以同時涵蓋字面的 [、%5B 與雙重編碼的 %255B 🧱

# 用 HestiaCP 認得的自訂檔擋掉 mcs 參數裡的方括號(面板重建設定時不會被蓋掉)
block_mcs() {
  local owner="$1" domain="$2"
  local conf_dir="/home/${owner}/conf/web/${domain}"
  local http_inc="${conf_dir}/nginx.conf_mc_cve_2026_104798"
  local ssl_inc="${conf_dir}/nginx.ssl.conf_mc_cve_2026_104798"

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

  tee "$http_inc" > /dev/null <<'EOF'
# 暫時封鎖 CVE-2026-104798:mcs 參數帶方括號(含編碼寫法)一律回 403
# My Calendar 更新完成後請移除這個檔案
if ($args ~* "(^|&)mcs(=[^&]*)?(\[|%5b|%255b)") {
    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_mcs() {
  local owner="$1" domain="$2"
  local conf_dir="/home/${owner}/conf/web/${domain}"
  rm -f -- "${conf_dir}/nginx.conf_mc_cve_2026_104798" "${conf_dir}/nginx.ssl.conf_mc_cve_2026_104798"
  if nginx -t; then
    systemctl reload nginx
    printf '已解除:%s\n' "$domain"
  else
    printf 'nginx 設定檢查沒過,請先查原因,不要 reload\n'
    return 1
  fi
}

# 驗證:帶方括號的要 403,一般網址不受影響(curl 不會留下任何暫存檔)
check_block() {
  local domain="$1"
  curl -sS -o /dev/null -w '帶方括號的 mcs:HTTP %{http_code}\n' --max-time 20 "https://${domain}/?mcs=%5Btest%5D"
  curl -sS -o /dev/null -w '一般網址:HTTP %{http_code}\n' --max-time 20 "https://${domain}/"
}

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

# 外掛更新完成後,把規則拆掉,並再驗證一次
unblock_mcs "admin" "example.com"
check_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
帶方括號的 mcs:HTTP 403
一般網址:HTTP 200
(更新完成、解除規則後)
已解除:example.com
帶方括號的 mcs:HTTP 200
一般網址:HTTP 200
  • 🟢 套用後帶方括號回 403、一般網址回 200 👉 規則生效。
  • 🟠 兩個都是 403 👉 規則寫太寬或與別的設定衝突,立刻執行 unblock_mcs。⚠️ 這個函式會刪檔,但只刪我們自己建的那兩個。
  • 🟠 nginx -t 報錯 👉 函式已自動撤回,請保留錯誤訊息給維運人員。
  • 🟡 拆除後帶方括號的請求回到 200 或 404 👉 確認已拆乾淨;若還是 403,檢查那兩個檔案是否真的被刪除。

🧑‍🔧 DevOps 小知識:這條規則擋不到什麼?
它只看網址參數,如果有別的入口不經過 mcs,這條規則看不到。依來源報告,目前已知的入口是這個參數,所以方向對,但我沒有實際打過、也沒驗證過「完整擋住」,不能把它當成修補的替代品【❓待確認】。前面若有 CDN 或 WAF,規則要同步放到那一層才有意義 🧩


🚀 🚀 🚀

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

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

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

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

if [ "$#" -ne 2 ]; then
  echo "用法:$0 網站使用者 網域" >&2
  exit 2
fi
SITE_USER="$1"
SITE_DOMAIN="$2"
[[ "$SITE_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo "❌ 使用者名稱格式不對" >&2; exit 2; }
[[ "$SITE_DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "❌ 網域格式不對" >&2; exit 2; }

WP_PATH="/home/${SITE_USER}/web/${SITE_DOMAIN}/public_html"
if [ ! -f "${WP_PATH}/wp-config.php" ]; then
  echo "❌ 找不到 ${WP_PATH}/wp-config.php" >&2
  exit 1
fi

wpx() { sudo -u "$SITE_USER" -H -- "$WPCLI" --path="$WP_PATH" "$@"; }

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

# 0=已在修補版本線上,1=受影響
is_patched() {
  local v="$1" branch fixed
  if version_lt "$v" "3.4"; then
    return 1
  fi
  branch="$(printf '%s' "$v" | cut -d. -f1,2)"
  case "$branch" in
    3.4) fixed="3.4.25" ;;
    3.5) fixed="3.5.22" ;;
    3.6) fixed="3.6.18" ;;
    3.7) fixed="3.7.18" ;;
    3.8) fixed="3.8.8" ;;
    *)   return 0 ;;
  esac
  ! version_lt "$v" "$fixed"
}

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

if is_patched "$ver"; then
  echo "🟢 ${SITE_DOMAIN} 目前 ${ver},已在修補版本線上,略過更新,只做驗證"
else
  echo "🔴 ${SITE_DOMAIN} 目前 ${ver},受影響,開始處理"

  BACKUP_DIR="${HOME}/wp-security-backup"
  STAMP="$(date +%Y%m%d-%H%M%S)"
  DB_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_db_${STAMP}.sql"
  install -d -m 700 -- "$BACKUP_DIR"

  ( umask 077; wpx db export - --single-transaction > "$DB_FILE" )
  if [ ! -s "$DB_FILE" ]; then
    echo "❌ 資料庫備份是空檔,停止,沒有進行任何更新" >&2
    exit 1
  fi
  if ! tail -n 1 -- "$DB_FILE" | grep -q 'Dump completed'; then
    echo "⚠️ 備份檔結尾沒有 Dump completed 字樣,請人工確認後再繼續"
  fi
  echo "✅ 資料庫已備份:${DB_FILE}"

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

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

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

echo "--- 驗證 ---"
wpx plugin verify-checksums "$PLUGIN"
wpx cache flush || true
home_url="$(wpx option get home)"
code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/" || true)"
case "$code" in
  2*|3*) echo "✅ ${SITE_DOMAIN}:版本 ${ver}、首頁 HTTP ${code}" ;;
  *)     echo "❌ ${SITE_DOMAIN}:首頁 HTTP ${code:-無回應},請立刻檢查" >&2; exit 1 ;;
esac
MC_PATCH_EOF

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

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

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

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

預期輸出範例:

🔴 example.com 目前 3.8.7,受影響,開始處理
✅ 資料庫已備份:/root/wp-security-backup/example.com_db_20261016-111500.sql
--- 預覽(不會真的更新)---
+-------------+-------------+-------------+-----------+
| name        | old_version | new_version | status    |
+-------------+-------------+-------------+-----------+
| my-calendar | 3.8.7       | 3.8.8       | available |
+-------------+-------------+-------------+-----------+
確認要更新 example.com 嗎?(y/N):y
Success: Updated 1 of 1 plugins.
--- 驗證 ---
Success: Verified 1 of 1 plugins.
✅ example.com:版本 3.8.8、首頁 HTTP 200
  • 🟢 看到最後的 ✅ 👉 這一站完成。接著手動清一次網站快取與 CDN 快取,並實際開一個行事曆頁面看一眼。
  • 🟠 備份失敗 👉 腳本直接停下,沒有進行任何更新,先查磁碟空間與資料庫權限。
  • 🟠 更新後版本仍不在修補版本線上 👉 更新來源沒生效,把輸出交給維運人員,不要反覆硬跑。
  • 🟠 verify-checksums 出現 File was added 👉 外掛資料夾裡多了官方沒有的檔案,逐一確認,不確定就視為可疑,回到 A。
  • 🔴 更新後網站白畫面 👉 立刻停用外掛,再用備份還原。
  • 🟡 預覽時沒有列出可用更新 👉 不代表已是最新,以最後的版本判定為準;也要確認主機能連到 WordPress.org,以及環境是否符合 WordPress 6.5、PHP 7.4 的最低要求。

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

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

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

[39]


🔒 🔒 🔒

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

更新是根本,加固是「萬一下一顆漏洞又來,讓損害小一點」。要先把話說在前面:下面這招 disable_functions 擋不住這顆 CVE 本身,這顆漏洞改的是帳號資料,不需要執行系統指令。它擋的是下一步:攻擊者拿到管理員之後,想靠 PHP 呼叫系統指令的路。屬於通用加固,不是官方要求 🛡️

為什麼這樣做:清單刻意不含 curl_exec,因為 WordPress 的對外連線要靠它,停掉會讓外掛更新與排程出問題;也不要寫 eval,它是 PHP 的語言結構,不是函式,寫進去沒有效果。備份、圖片處理類外掛有時會用到 exec,改完請實際測前後台。腳本先備份 php.ini,用 sed -i -E 替換設定行,找不到那一行才用 tee -a 補上,所以跑幾次都不會疊加;設定檢查通過才重啟,沒過就自動還原 ⚠️

# 對主機上每一個 PHP-FPM 版本做「可重複執行」的替換(跑幾次都不會疊加)
harden_php_ini() {
  local ini ver bak line
  line='disable_functions = exec,passthru,shell_exec,system,proc_open,popen'

  while IFS= read -r -d '' ini <&3; do
    ver="$(basename -- "$(dirname -- "$(dirname -- "$ini")")")"
    bak="${ini}.BAK.$(date +%F_%H%M%S)"

    cp -- "$ini" "$bak"
    sed -i -E "s/^[;#]*[[:space:]]*disable_functions[[:space:]]*=.*/${line}/" "$ini"

    # 檔案裡本來就沒有這一行的話,補在最後
    if ! grep -q '^disable_functions' "$ini"; then
      printf '\n%s\n' "$line" | tee -a "$ini" > /dev/null
    fi

    # 設定檢查通過才重啟;沒過就還原備份
    if "php-fpm${ver}" -t > /dev/null 2>&1; then
      systemctl restart "php${ver}-fpm"
      printf 'PHP %s:已套用並重新啟動\n' "$ver"
    else
      cp -- "$bak" "$ini"
      printf 'PHP %s:設定檢查沒過,已還原備份:%s\n' "$ver" "$bak"
    fi
    grep -H '^disable_functions' "$ini"
  done 3< <(find /etc/php -maxdepth 3 -type f -path '*/fpm/php.ini' -print0)
}

harden_php_ini

# 檢查個別網域的 pool 設定有沒有另外覆蓋(沒輸出代表沒覆蓋)
grep -H 'disable_functions' /etc/php/*/fpm/pool.d/*.conf

預期輸出範例:

PHP 8.4:已套用並重新啟動
/etc/php/8.4/fpm/php.ini:disable_functions = exec,passthru,shell_exec,system,proc_open,popen

最後那行 grep … pool.d/*.conf 沒有輸出,代表沒有個別網域的 pool 設定在覆蓋它,這是好事。出現「設定檢查沒過,已還原備份」代表設定沒套用、網站不受影響,把輸出貼給維運人員;要復原就把 .BAK.時間 備份檔蓋回 php.ini 再重啟 PHP-FPM。

驗證加固真的有效:光看設定檔不夠,要實際從外面打一次。下面的探針用隨機檔名,內容只回報 exec 能不能用,測完立刻刪除。預期回應是 EXEC-DISABLED;若是 EXEC-AVAILABLE,代表這個網站實際用的 PHP-FPM 版本不是你剛改的那一個,或被別處覆蓋。探針只存在幾秒,請確認最後印出「探針已移除」🧪

# 驗證 disable_functions 真的生效:放一個「隨機檔名」的無害探針,從外面打一次,馬上刪掉
probe_disabled() {
  local wp_path="$1" domain name probe out
  domain="$(site_domain "$wp_path")"
  name="__probe_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
  probe="${wp_path}/${name}"

  cat > "$probe" <<'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 設定有沒有覆蓋。
  • 🟠 印出「探針檔案還在」 👉 手動刪掉那個檔案,別留在網站根目錄。
  • 🟡 沒有回應 👉 可能是憑證、防火牆或網域解析問題,先確認網站本身連得上。

🧑‍🔧 DevOps 小知識:open_basedir 不用你再全域設定
有些教學建議在 php.ini 全域寫死 open_basedir,但寫死某個網站的路徑,其他網站就全壞。HestiaCP 的 PHP-FPM 樣板本來就會為每個網域設好,用 grep -H open_basedir /etc/php/*/fpm/pool.d/*.conf 確認即可。它同樣擋不住這顆 CVE,只是縮小事後的傷害範圍 🔒

  • ⭐ 開自動更新:這顆漏洞公開到被掃描的時間窗口很短,自動更新能把「有空再更新」的空窗壓到最小。代價是偶爾要處理更新後的相容性問題,所以重要網站建議搭配每日備份。
  • ⭐ 不用的外掛直接刪:停用只是暫時離開攻擊面,刪除才是永久離開。
  • ⭐ 管理員變動要有人知道:這次的受害目標是管理員帳號,所以「管理員信箱或密碼被改」這件事,最好有通知機制,而不是等你登不進去才發現。常見資安外掛有提供這類通知,可以評估是否啟用【❓待確認:依你使用的外掛而定】。

🩵 荷包試算:要不要花錢買 WAF 或虛擬修補?
付費防火牆可以在你更新前先擋一層,但這顆漏洞在後台點一下「更新」就解決,今天能更新,通常比月付方案划算。真的卡在相容性、短期無法更新時,再把付費防護當過渡手段,別反過來拿它當不更新的理由 💰


🟢 🟢 🟢

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

  • 🌠 Q:我已經是 3.8.8 了,就安全了嗎?
    A:就這顆 CVE 來說,是的,那是官方修補版。但如果舊版曾對外暴露,更新只能擋住下一次,擋不了已經發生的事,所以還是建議做完第五章的帳號與檔案稽核 📦
  • 🌟 Q:我用的是 3.7.x,要不要直接升到 3.8?
    A:不一定要。3.7.18 是官方給 3.7 線的修補版,裝到它就脫離這顆漏洞。要不要跨線升級,取決於你的相容性測試,我無法替你的環境保證 🧭
  • ✨ Q:我沒有行事曆頁面,還需要處理嗎?
    A:需要。外掛啟用就有風險,不用的外掛請直接刪除 🙅
  • 🌠 Q:看不懂那些指令,是不是沒救了?
    A:不會。第三章全程用滑鼠就能完成最基本的處理,第四章以後是給有 SSH 權限的人看的補充 🤗
  • ✨ Q:被改的只有 ID 1,其他管理員帳號就沒事嗎?
    A:依來源報告,直接受害的是 ID 1。但若 ID 1 是管理員且已被接管,攻擊者就有能力動別的帳號,所以管理員名單要整份看,不是只看第一個 🔍

[40]


🧠 🧠 🧠

🧩 舉一反三:「把外部字串當函式名稱」這一類漏洞怎麼防

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

  • ⭐ 函式名稱不能由外部決定。只要程式會依字串去呼叫函式,就該先比對一張寫死的白名單,名單外的一律拒絕。
  • ⭐ 網址參數也是外面來的資料。很多人只防表單,卻忘了網址列、Header、Cookie 都能被任意填寫。
  • ⭐ 敏感操作要有身分檢查,而且放在最靠近動作的地方。就算前面有一層過濾,真正改帳號的那一步也應該再問一次「你是誰、你有沒有資格」。
  • ⭐ 修補與事件應變是兩件事。舊版只要曾暴露在網路上,更新完還要查日誌、查帳號、查檔案,缺一不可。


🏁 🏁 🏁

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

評估維度(10 分制,數字越高代表風險或應對品質越正向) 分數 一句話評語
漏洞嚴重程度(越高代表風險越大) 9.8 免登入、免互動、網址就能觸發,可能直接奪走第一位管理員的帳號,是最危險的那一檔
官方應變速度 8 五條版本線都補了版,是加分項;確切上架日期我沒能核對,且近兩年同類授權問題反覆出現,略扣分 🟡
一般站長自救可行性 8 後台點「更新」就能修補,門檻低;扣分在於要多做「看管理員名單、登不進去別按忘記密碼」這些事後檢查
DevOps 技術文件完整度 5 官方沒公開程式差異,觸發條件也不完整,排查只能靠「mcs 帶方括號」與「ID 1 有沒有被動過」這類結果特徵【❓待確認】
🏆 加權綜合建議 立即更新 最終建議:【今天內更新到對應版本線的修補版;舊版曾暴露就做第五章稽核,疑似中招走第六章 A】

🔥 這次提醒我們:漏洞最危險的時候,不是被發現的當天,而是「已經公開、你還沒更新」的那段空窗。攻擊字串沒公開,不代表安全,因為真正有差別的是,你有沒有在別人摸清楚之前把門關好。與其等到後台登不進去才發現不對,不如現在花五分鐘,打開外掛頁看一眼版本號 🔢

[41]


📎 📎 📎

📚 參考資料與延伸資源

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