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

🚨食譜頁底下的一則留言,怎麼變成偷看網站內部資料的窗口?WP Recipe Maker 高危漏洞全解析(CVE-2026-89274/CVSS 9.1)

🍳 食譜頁底下的一則留言,怎麼變成偷看網站內部資料的窗口?WP Recipe Maker 高危漏洞全解析(CVE-2026-89274/CVSS 9.1)

🔥 懶人包:
擁有 5 萬以上活躍安裝的食譜外掛 WP Recipe Maker,被通報存在「不用登入,就能讓伺服器執行任意短代碼」的漏洞(CVE-2026-89274,CVSS 9.1 嚴重等級)。攻擊者只要在食譜頁留下一則夾著暗號的評分留言,等這則留言通過審核(不管是系統自動核准,還是站長手動點頭),外掛在替 Google 準備結構化資料時,就會把暗號當成指令執行,執行結果還會被印進網頁原始碼,讓每一位訪客與搜尋引擎爬蟲都看得到。受影響的是 10.8.1 以下的所有版本,官方在 10.8.2 修好了,我們查證當下官方頁面的最新版已經是 10.8.4。目前沒有看到它被 CISA 列進「已遭利用」清單,但細節已經公開,建議這週就處理;把留言設成「自動核准」的站,今天就動手 🍽️

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

[2]


🟦 🟦 🟦

內容目錄

🧁 第一章:一則食譜留言,為什麼能叫伺服器「照著念」?

先問你一個小問題:在食譜網站底下留過「超好吃,給你五顆星!」這種評語的人,應該不少吧?這種平凡到不能再平凡的留言,這次卻成了漏洞的入口 🍰

💡 白話文教室:把 WP Recipe Maker 想成餐廳裡的「名片代寫員」
你的食譜網站像一間餐廳,除了把菜單印給客人看,這位代寫員還要替 Google 準備一張「背面條碼」。這張條碼的正式名稱叫 JSON-LD,是一段藏在網頁原始碼裡、人眼看不到、機器卻讀得飛快的結構化資料,就像商品背面那排條碼,人看不懂,結帳機一刷就知道是什麼。條碼上有一欄叫「顧客評語」(reviewBody),代寫員會去翻留言簿,把評語抄上去。
壞習慣出在這一步:代寫員抄寫之前,會先把評語裡的「暗號」當成站長下的指令執行一遍。站長平常對廚房喊一聲

[gallery]

廚房就會端出一整本相簿,這種暗號叫短代碼(Shortcode),本來只有內部人員才該用。現在客人隨手寫在留言裡,代寫員也照做不誤,還把執行的結果印在公開的條碼上 🎭

那壞人到底偷得到什麼?這要看你的網站裝了哪些外掛。官方的漏洞描述舉了幾個例子:附件的說明文字、文章的私人欄位,或是其他外掛的短代碼會吐出來的資料。換句話說,WP Recipe Maker 自己沒有多寶貴的東西,但它讓壞人「借用」了你網站裡所有已經註冊過的短代碼 🗝️

🧊 冷知識:短代碼是 2008 年送給「不會寫程式的人」的貼心禮物
WordPress 在 2008 年的 2.5 版加入短代碼機制,初衷是讓部落客不用碰 HTML,只要打一個

[gallery]

就能插入相簿。可是換個角度看,短代碼本質上就是一顆「幫你呼叫 PHP 功能的簡化按鈕」,只要這顆按鈕被放到不受信任的文字上,任何人都能替你按。這也是為什麼資安圈看到 do_shortcode() 這個函式名稱出現在使用者輸入附近,眉頭都會先皺一下 🧋

更麻煩的是順序。外掛的確有做清理,後面接著呼叫了 wp_strip_all_tags()strip_shortcodes() 這類過濾函式,可是它們排在「執行」的後面,只能處理已經吐出來的文字,攔不住已經發生的執行 📜

💡 白話文教室:這叫「先放行、後驗證件」
想像社區大樓的保全,訪客一到就先請進大廳、按好電梯,人都已經上到 12 樓了,才在對講機裡說:「不好意思,麻煩讓我看一下證件。」證件查得再仔細也來不及,因為人早就進門了。這次的漏洞就是這個順序:短代碼先執行,過濾才登場 🚪

另一個關鍵條件是「留言要先過審」。官方描述特別交代,攻擊者的評分留言必須通過網站的審核門檻,可能是自動核准,也可能是管理員手動放行,之後每次有人載入那一頁食譜,這段短代碼就會在伺服器端再執行一次。對很多食譜部落客來說,為了讓留言區熱鬧一點,「自動核准」幾乎是預設習慣,這就讓門檻變得比想像中低一些 😰


🟢 🟢 🟢

⏱️ 這幾天到底發生了什麼事?時間軸攤開來看

我們手上有三份分析報告,日期上有些出入,所以下面這張表,凡是能跟官方頁面對上的標 ✅,只有單一來源、或各家說法不一致的,一律標上【❓待確認】,不硬湊 📔

日期 事件節點 說明與可信度
2026-09-09 同一外掛的另一顆漏洞公開 ✅ CVE-2026-75905(≦ 10.8.0),寫手(又稱投稿者)以上權限可接管他人食譜、把食譜改成未發布,與本次漏洞是兩件事
2026-09-11 通報開發商 只有一份報告提到這個日期【❓待確認】
9 月中旬 10.8.2 釋出 ✅ 官方更新日誌明列此修正,並致謝 Jakub Herman 與 NomanProdhan;確切日期只有一份報告寫 09-17【❓待確認】
2026-09-18 Wordfence、Patchstack 公開情資 兩份報告寫 09-18,另一份寫 Wordfence 完整公告在 09-19【❓待確認】
2026-09-19 NVD、CVE.org 收錄 ✅ NVD 發布時間為 09-19 凌晨(UTC 03:17)
2026-09-19 官方頁面最新版本 ✅ 查證當下為 10.8.4,更新日誌寫的是「修正內嵌食材在中繼資料的顯示」,與本漏洞無關

🧊 冷知識:KEV 是「通緝名單」,EPSS 是「降雨機率」
看資安公告時常會撞見這兩個縮寫。KEV 是美國 CISA 維護的「已知遭利用漏洞」清單,可以想成警方公布的「正在犯案通緝名單」,上榜代表真的有人在拿它作案;EPSS 則是預測「未來 30 天內被利用的機率」,很像天氣預報的降雨機率,下雨機率低不代表你不需要傘。這次的漏洞,KEV 未收錄,EPSS 也還沒有評分,所以目前只能說「沒看到有人在用」,不能說「沒有人會用」 🌦️


🟠 🟠 🟠

⚠️ 講白了,最壞會發生什麼事?

先把「已經證實」跟「還在推論」分開講,這樣你比較知道該多緊張。官方分數是 CVSS 3.1 的 9.1 分,向量寫成 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N,意思是:從網路遠端發動、不難、不用登入、不用受害者配合,機密性與完整性影響都被評為高 📊

  • ✅ 已確認事實:不用登入就能送出含短代碼的評分留言;留言一旦通過審核,每次食譜頁載入,短代碼都會在伺服器端執行,輸出結果會被寫進 JSON-LD 的 reviewBody,對所有訪客公開 💯
  • ✅ 已確認事實:修補版本是 10.8.2,官方更新日誌明寫「防止在食譜中繼資料裡執行留言文字的短代碼」;同一版還修了提交食譜與詞彙表輸出裡類似的問題 ✅
  • 🟡 合理推論:如果攻擊者塞進去的是很吃資料庫資源的短代碼,每次有人(或搜尋引擎爬蟲)看那一頁就會重跑一次,網站有機會被拖慢;另外,向量裡的「完整性影響高」,比較可能是評分時把「內容被塞進網頁中繼資料」也算進去,實際效果仍集中在 JSON-LD 這個區塊 🍍
  • 🔴 假設情境:如果你的網站剛好同時裝了一個「能讀檔案、能寫資料」的短代碼外掛,理論上這個漏洞可以當跳板,串成更嚴重的攻擊。這需要額外條件,目前沒有公開案例證實,所以請當成「值得預防的最壞劇本」,別當成已經發生的事 🛑

🔷 🔷 🔷

🧵 順帶一提:這個外掛的「留言評分」,一直都是麻煩製造機

這次的入口是留言評分,而它過去就不太平靜。翻一下官方公告與更新日誌,至少能挑出幾個大家點頭說「對,我也遇過」的例子 🧾

  • 2024 年 4 月的「評分必須附留言」風波:9.4.0 版讓所有評分都要附上留言、姓名與 Email,理由是 Google 不再信任匿名評分;官方部落格隨後在 4 月 30 日補上更新說明,承認自己「太急了」,9.4.1 就改成可以關掉這些要求。同一個 9.4.0 版本,也把留言首度納入食譜中繼資料的「Reviews」欄位,也就是這次漏洞出事的那條路徑 📜
  • Jetpack 留言會讓星星消失:官方說明文件白紙黑字寫著,使用 Jetpack 留言時評分功能不會出現,要先關掉 Jetpack 的留言功能(不用停用整個 Jetpack)才行。留言相關的設定本來就會互相牽動,動手之前先看一眼官方說明,比憑感覺亂點保險得多 🧋
  • 一週內的第二顆 CVE:2026-09-09 才剛公開 CVE-2026-75905,十天後又輪到 CVE-2026-89274。10.8.2 的更新日誌一口氣列了至少六項與權限、短代碼、XSS 相關的修正,這也是為什麼「先不更新,觀望一下」在這個外掛上特別划不來 🛑

🧊 冷知識:留言進 JSON-LD,其實是 2024 年才有的功能
官方更新日誌顯示,「把留言納入食譜中繼資料」是 9.4.0 才出現的新功能,後來 9.6.0 又加了「精選/排除」開關,讓站長挑哪些留言可以進中繼資料。所以合理推論,這條有問題的路徑大概是從 9.4.0 之後才存在的;不過官方 CVE 公告寫的是「10.8.1 以下所有版本」,我們照公告轉述,這一點也標上【❓待確認】,最穩的做法還是「不管你現在幾版,都更新到最新」 🔎


🟪 🟪 🟪

🎭 第二章:先別急著慌,看看你屬於哪一種站長?

同樣是「網站有裝這個外掛」,有人只是週末更新一篇布丁食譜,有人手上管著十幾個客戶的食譜站,還有人是靠 Google 星等吃飯的品牌內容團隊。該做的事差很多,我們照四種常見情境,一個一個聊 🍮

🙋 我只是寫食譜、回留言的小編或部落客

好消息是,這次不需要你放棄這個外掛,也不用學任何指令。你要做的事很單純:確認版本、按下更新、順手看一下留言區有沒有長得怪怪的內容。這幾步在下一章會用「點哪裡、看什麼」的方式拆開講,跳過中間的技術章節也完全沒問題。至於會不會花錢,免費版的更新是免費的,官方外掛頁上這個外掛的「活躍安裝數」是 5 萬以上,代表你並不孤單,很多人跟你走同一條路 🧧

👨‍💻 我自己架站,手上還顧著幾個朋友或客戶的食譜站

你的重點不是「怎麼點更新」,而是「怎麼一次看完所有站、又不會誤傷」。這種情況建議直接跳到第五、第六章,我們把單一網站、獨立多站、Multisite 三種架構分開處理,還多加了一個很實用的動作:列出網站上所有已註冊的短代碼,因為這個漏洞能偷到什麼,取決於你裝了哪些「會吐資料」的短代碼,先盤點清楚,才知道風險有多大 🧰

🏢 食品品牌、媒體或電商的內容站

對這類團隊來說,食譜的星星評分與結構化資料,本身就是流量與營收的資產,所以評估重點不只是「有沒有更新」。留言與評分要不要維持自動核准、更新流程有沒有先過 Staging、有沒有 WAF 在前面擋、稽核紀錄留得夠不夠久,這些才是導入外掛之後真正的維運成本。另外,如果洩漏出去的內容碰巧包含個人資料,就可能牽涉通報責任,這一塊請直接找法務或資安窗口確認,這篇文章不做法律判斷。官方外掛頁的 FAQ 自稱多數支援問題會在 24 小時內回覆,這是官方的自我描述,實際情況你可以在採購或續約時自己驗證 📃

🚨 高風險誤用場景:把留言設成「自動核准」的熱鬧食譜站

🔴 假設情境(以下是為了說明而編的故事,不是真實案例):小美經營一個很受歡迎的食譜部落格,為了讓留言區看起來熱鬧,她把留言設成「自動核准」,同時為了做會員專屬內容,裝了一個會顯示私人欄位的短代碼外掛。外掛已經好幾個月沒更新,她心想:「食譜卡片一直都很正常,不用動它吧。」
某天,有人在她的一篇食譜底下留了「很好吃!」,後面偷偷多接了一串暗號。留言自動核准,之後每個看這篇食譜的訪客,網頁原始碼裡都多了一段不該公開的內容。小美完全沒發現,因為畫面上一切正常,只有原始碼裡的「隱形條碼」變了樣 🪤

🚨 畫重點!千萬別踩雷:「畫面沒有異常」不等於「沒有事」。這個漏洞最狡猾的地方,就是洩漏的地方在 JSON-LD 這個人眼看不到的區塊;只要外掛還啟用著、版本又落在 10.8.1 以下,風險就一直存在,跟你上次登入後台是什麼時候沒有關係 🙅‍♂️

🧑‍🔧 DevOps 小知識:更新是「以後不會再執行」,不是「已經印出去的東西收得回來」
更新修掉的是執行邏輯,收不回已經被寫進頁面的內容。官方更新日誌顯示,WP Recipe Maker 從 10.7.0 開始會快取食譜中繼資料,頁面快取外掛也可能存著舊版 HTML,搜尋引擎更可能早就抓過一份。所以更新之後,清掉頁面快取、到 Google Search Console 對可疑頁面要求重新索引,都值得做;至於更新是否會自動重建 WPRM 自己的中繼資料快取,官方更新日誌沒有明講【❓待確認】,保守做法是更新後直接去看網頁原始碼,確認 reviewBody 的內容 🧯

[41]


🟢 🟢 🟢

🛟 第三章:不用懂程式,也能在五分鐘內做完的自救流程

不管你屬於上面哪一種站長,這一段都值得先跑一遍。每一步都寫成「去哪裡點、看什麼、怎麼判斷、判斷不出來怎麼辦」,你不需要先知道什麼叫「正常」,照著畫面對就好 🤏

  • 先確認你到底有沒有裝這個外掛
    去哪裡點:登入 WordPress 後台,左側選單點「外掛」,再點「已安裝的外掛」,接著按鍵盤 Ctrl+F(Mac 是 Cmd+F),輸入「Recipe」搜尋。
    看什麼:找一個名叫 WP Recipe Maker 的項目,作者欄位通常會寫 Bootstrapped Ventures 或 Brecht 之類的名字。
    怎麼判斷:找不到,這次不受影響;找到了,就繼續看下一步。如果旁邊還有一個「WP Recipe Maker Premium」,它是附加元件,也要一起檢查更新提示。
    判斷不出來:清單看不懂沒關係,截圖傳給幫你架站的人,這不是你的問題,找人幫忙很正常 🫂
  • 看一下版本號碼
    去哪裡點:還是同一個畫面,外掛名稱下方會有一行灰色小字「版本 10.x.x」。
    看什麼:只看最前面的三段數字,例如 10.8.1。
    怎麼判斷:10.8.1 或更小,就是受影響的版本;10.8.2 以上,就是已經補好了,我們查證當下官方最新版是 10.8.4。
    判斷不出來:數字看不清楚時,把整行截圖給懂的人看就好,不用自己猜 🔢
  • 備份,然後按下「立即更新」
    去哪裡點:先到你的主機商控制台,找「備份」功能手動備一份,或是用你平常在用的備份外掛;備好之後,回到外掛清單,點 WP Recipe Maker 旁邊的「立即更新」。
    看什麼:轉圈圈跑完後重新整理頁面,版本號應該變成 10.8.2 以上。
    怎麼判斷:版本有跳上去就是成功。如果有裝快取外掛(例如 WP Rocket、LiteSpeed Cache 這類),更新完順手按一次「清除全部快取」。想再確認一次,可以打開任一篇食譜,按右鍵選「檢視網頁原始碼」,按 Ctrl+F 搜尋 reviewBody,看後面接的文字是不是留言原文,而不是你不認得的東西。
    判斷不出來:更新按鈕沒出現、卡住不動,多半是授權、檔案權限或連線問題,交給主機商客服或維護的人處理,這也不是你操作不當 🧋
  • 翻一下「已核准」的留言,看有沒有長得像暗號的內容
    去哪裡點:後台左側點「留言」,上方分頁選「已核准」,在右上角的搜尋框輸入一個左中括號 [ 再按 Enter,WordPress 會幫你把留言內文裡有這個符號的留言撈出來。
    看什麼:留言內容裡有沒有「中括號包著英文字」的樣子。
    怎麼判斷:一般人寫「[大推]」「[五顆星]」這種,括號裡是中文或表情符號,屬於正常;可疑的長得像「[英文單字 空格 英文="數字或英文"]」,也就是英文字母、等號、雙引號混在一起,而且出現在食譜評分留言裡。
    怎麼處理:可疑的留言,把滑鼠移上去按「退回待審」就好,先不要刪,因為它們也是日後查證的線索。
    判斷不出來:把留言編號跟截圖丟給懂技術的人,寧可多問一次,也不要憑感覺整批刪除 🔍
  • 暫時把留言入口收緊一點(選做)
    去哪裡點:後台左側點「設定」→「討論」,勾選「留言必須經人工核准」與「留言作者必須有先前已核准的留言」(實際字樣依語系與版本可能略有差異)。
    另一個選項:WP Recipe Maker → Settings → Star Ratings → Comment Ratings,可以暫時關掉評分留言。這個動作比較有代價,關掉之後星星不會顯示,Google 搜尋結果裡的星等也可能受影響,屬於🟡合理推論,不確定就先別關。
    怎麼判斷:改成人工核准之後,每次看到留言,先掃一眼有沒有前面說的那種括號暗號,長得像的就別放行。
    判斷不出來:更新完成、確認版本沒問題後,這些設定都可以再調回去,不需要一直維持 🛑
  • 不敢自己動手,或是完全看不懂這些選單怎麼辦?
    去哪裡找人:你的架站廠商、網站維護的外包,或是主機商的客服。
    怎麼說:把這篇文章的網址,加上一句「我的 WP Recipe Maker 是 10.8.1 以下,有 CVE-2026-89274 的漏洞,請幫我更新到 10.8.2 以上,順便檢查留言」,對方就知道該做什麼了。
    判斷不出來:完全沒有人可以問的話,先照㊄把留言改成人工核准,再回頭找人處理,多爭取一點時間 📔

🩵 荷包試算:這一整套流程要花多少錢?
免費版外掛的更新、翻留言、調整留言設定,都是 0 元的操作,只需要你花一杯珍奶的時間。如果你另外買了 Premium 附加元件,能不能拿到最新版要看你的授權狀態,這一點請以官方帳號頁面為準。真正會花到錢的,是事後才處理的成本,例如請人做鑑識、清理,或是頁面被搜尋引擎抓走敏感內容之後的補救,這筆帳怎麼算都是現在先動手比較划算 💸

🫂 新手求助:卡關的時候,可以去這些地方問
第一站是 WordPress.org 的官方外掛支援論壇,開發者會在那裡回覆問題,多久回覆得到,官方 FAQ 自稱多半在 24 小時內;第二站是 官方說明文件與更新日誌,可以直接查每一版修了什麼;第三站才是找架站廠商或主機商客服。出發前先把版本號、你做了什麼、畫面上出現什麼字,用截圖整理好,對方比較容易一次幫你看懂 📚


🔴 🔴 🔴

🤿 第四章:技術深潛篇,短代碼是怎麼從一則留言走進 reviewBody 的?

接下來這段是給想知道「為什麼會這樣」的人看的。如果你只想知道怎麼補洞,前面三章已經夠用;如果你也好奇這種等級的漏洞底層長什麼樣子,我們就繼續往下拆 ⚔️

評估指標 具體資訊
外掛名稱 WP Recipe Maker(開發者 Brecht,Bootstrapped Ventures)
CVE 編號 CVE-2026-89274
CVSS 3.1 評分 9.1(Critical)
CVSS 向量 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N
弱點分類 CWE-94:程式碼生成控制不當(Code Injection)
受影響版本 ≦ 10.8.1
安全版本 ≧ 10.8.2(查證當下官方最新版為 10.8.4)
漏洞類型 未經身分驗證的任意短代碼執行(Unauthenticated Arbitrary Shortcode Execution)
致謝對象 Md. Moniruzzaman Prodhan(NomanProdhan)、Jakub Herman(官方更新日誌明列)
活躍安裝數 50,000+(WordPress.org 外掛頁)
KEV/EPSS KEV 未收錄;EPSS 尚未評分
公開攻擊程式/在野利用 各來源說法不完全一致,未見明確的在野利用紀錄【❓待確認】

🚨 畫重點!千萬別踩雷:三份分析報告對修補版本的說法一致,都是 10.8.2,但這不是終點。官方頁面在我們查證時已經出現 10.8.4,所以「更新到後台顯示的最新版」永遠比死記某個版本號更保險,10.8.2 只是「這個洞被補上的最低門檻」 🛑

漏洞的成因,官方 CVE 描述講得很清楚:函式 WPRM_Metadata::sanitize_metadata() 會對食譜中繼資料陣列裡的「每一個純量欄位」遞迴呼叫 do_shortcode(),其中包含 reviewBody;這個欄位的值原封不動取自已核准、帶有評分的留言(wprm-comment-rating)的 comment_content,在執行前沒有做任何短代碼剝離 📜

🧑‍🔧 DevOps 小知識:CVSS 裡的 PR:N 與 UI:N,跟「要過審核」矛盾嗎?
向量裡的 PR:N 是「不需要任何權限」,UI:N 是「不需要受害者做任何事」,這兩格官方都給了 N。但同一份描述又說留言必須通過審核,其中一份第三方解讀甚至把「管理員審核」視為一種人為介入。這種落差在 CVSS 評分裡並不少見:評分者傾向把「站長的審核設定」當成環境條件,而不是攻擊者需要的權限。我們照官方數字轉述,實務上的難度,仍然要看你的留言到底是自動核准還是人工把關 🎛️

至於這段程式碼放在哪個檔案,有兩份報告指向 includes/public/class-wprm-metadata.php,但我們沒辦法在查證期間直接核對外掛原始碼,所以檔案路徑這一項標【❓待確認】,以官方原始碼為準 🔎

🧬 一則留言從進門到洩漏,走了哪幾步?

下面是概念層級的流程,只用來理解風險,不含任何可以直接拿去用的內容。對防禦者來說,看懂它比背指令更重要,因為每一步都對應一個可以切斷的地方 ✂️

  • 留言與評分入庫:訪客在食譜頁的留言區送出帶評分的留言,WordPress 把它存進留言資料表,狀態視站台設定為「已核准」或「待審核」。可以切斷的地方:把留言改成人工核准 🚪
  • 留言通過審核:官方描述特別交代,攻擊要成立,這則評分留言必須過審,自動核准與管理員手動核准都算。可以切斷的地方:審核時多看一眼括號長相 👀
  • 頁面渲染,組裝 JSON-LD:有人(或爬蟲)載入食譜頁,外掛開始組裝要給搜尋引擎看的結構化資料,把已核准留言放進 reviewBody。可以切斷的地方:關閉評分留言功能,或用 WPRM 的「排除」機制把可疑留言排除在中繼資料之外 🟡
  • 先執行、後過濾:sanitize_metadata() 遞迴呼叫 do_shortcode(),留言裡的短代碼在伺服器端被解析,輸出結果嵌進 JSON-LD。可以切斷的地方:升級到修補版 🛑
  • 洩漏對外公開:輸出內容出現在網頁原始碼裡,所有訪客與搜尋引擎爬蟲都能看到,快取還可能把它留住。可以切斷的地方:清快取、重新索引 🧹

🧊 冷知識:名叫 sanitize(消毒)的函式,做的卻是「執行」
在 WordPress 的世界裡,開頭是 sanitize 的函式,慣例上是負責清理與過濾的,讀程式碼的人看到這個名字,多半會預設「這裡很安全」。偏偏這次的 sanitize_metadata() 在清理之前,先做了一件跟清理完全相反的事。這也是資安圈常說的:函式名稱是作者的期待,不是程式的保證,看的還是它實際做了什麼 🥟


🟧 🟧 🟧

🧩 這一版更新,其實一口氣補了六件事

只看標題會以為 10.8.2 只修了一個洞,但翻一下官方更新日誌,會發現同一版的安全相關修正還不少,白話整理如下(以下是照更新日誌改寫,具體對應到哪些 CVE,官方沒有逐條說明)📔

  • 防止沒登入的人關掉後台的管理通知(致謝 Isuka Sanuj)
  • 修正非公開清單的搜尋授權問題(致謝 Abdullah Kareem)
  • 防止已登入的使用者看到未發布的食譜(致謝 Abdullah Kareem)
  • 本篇主角:防止在食譜中繼資料裡執行留言文字的短代碼
  • 防止「提交食譜」與「詞彙表輸出」裡非預期的短代碼執行
  • 防止 Contributor 透過食譜預覽觸發儲存型 XSS(致謝 Saulo Rafael)

這也回答了一個常見的疑問:「我只是沒開放註冊、沒有外人投稿,可以晚點再更新嗎?」從這份清單看,至少有好幾項是「登入後才能觸發」或「跟權限有關」的修正,對多作者、多投稿者的站更是如此,這一版的價值不只在補洞這一個 🛸

[42]


🔷 🔷 🔷

🦸 第五章:資安排查 Checklist,怎麼確認自己有沒有已經被摸過?

🤖 重要提醒:以下指令與排查流程由 DeepSeek、Gemini、Meta AI 產生、Claude 複查修正
雖然已盡力對照 WP-CLI 官方文件與常見實務,統一改寫成路徑雙引號包覆、find -print0 安全迴圈、mktemp 加單引號 heredoc 等寫法,仍可能因為伺服器環境、權限設定或外掛版本差異而產生疏漏。正式環境(尤其是正在營業的網站)的高風險操作,包含更新、停用、刪除、變更權限、批次腳本,都請由熟悉該站架構的人做最後確認,切勿複製貼上就直接按 Enter 🧙

這一章的重點不是背指令,而是先搞清楚「你到底在管一個 WordPress,還是一整台裝滿 WordPress 的伺服器」。搞錯架構,後面的指令就算語法完全正確,也可能處理到錯的對象。我們照著「環境判斷 👉 盤點 👉 Log 排查 👉 留言與後門檢查 👉 加固」五個關卡走,每一關都拆成「決策原理、具體指令、預期輸出、分支判斷」四個段落,並且同時涵蓋 💠 單一網站、❇️ 獨立多站、✴️ Multisite 三種環境 🧭

🧑‍🔧 DevOps 小知識:複查時特別挑出來修掉的幾個常見地雷
網路上的排查教學常有這幾個坑,這篇的指令都已經避開了,順便記下來,下次看別人的腳本也能一眼認出來 🔧

  • 在 access.log 裡搜中括號:預設的存取日誌只記網址與狀態碼,不記 POST 的內文,留言內容根本不會出現在裡面,所以日誌只能拿來看「誰在頻繁對留言入口送出請求」,留言內容要回資料庫查 🔍
  • shuffle-salts 加 –hard:官方文件列出的選項只有 –force–config-file–insecure,沒有 –hard,直接下 wp config shuffle-salts 就對了 ✅
  • 用 php -i 驗證 FPM 設定:php -i 讀的是命令列版的設定檔,網站實際用的是 FPM 那份,要用 php-fpm8.4 -i 這類指令才看得到真正生效的值 📜
  • 把 wp-config.php 備份放在網站根目錄:備份檔的副檔名不是 .php,可能被當成純文字直接下載,裡面有資料庫密碼;備份一律放到家目錄,並且把權限收到 700 🧯
  • 一句 DELETE 大批刪除含括號的留言:正常留言也可能有括號,而且一刪就沒有證據了,正確順序是先退回待審,確認之後再刪 🙅‍♂️
  • 把 location 與 if 直接丟進 conf.d:那個位置是 http 層級,location 不能放在這裡;用 $request_body 判斷留言內文,在很多設定下也讀不到內容,這類擋法不可靠 🪤
  • 迴圈裡的 wp 指令沒有隔離標準輸入:wp 本身多半不讀標準輸入,但加上 < /dev/null 的成本幾乎是零,可以避免指令意外吃掉迴圈的輸入 🧋

🧭 🧭 🧭

🧭 ㊀ 環境判斷:你管的是一個站、一群站,還是一張網?

決策原理:單一網站、獨立多站(同一台伺服器上有好幾個互不相干的 WordPress)、Multisite(一個 WordPress 底下長出很多子站)的 WP-CLI 操作對象與資料範圍完全不同。判斷錯誤,備份、更新、驗證的範圍都會跟著歪掉;尤其 Multisite 的外掛檔案是全網共用,一次更新就影響所有子站 🤔

WP_PATH="/var/www/example.com/public_html"

# ❶ 先確認 WP-CLI 真的站在 WordPress 裡(路徑錯了,後面的判斷全部會歪)
if ! wp --path="$WP_PATH" core is-installed 2>/dev/null; then
    echo "ERROR: 這個路徑底下找不到 WordPress,請先修正 WP_PATH"
fi

# ❷ 是不是 Multisite:靠結束碼判斷(0 代表是)
if wp --path="$WP_PATH" core is-installed --network 2>/dev/null; then
    echo "這是 Multisite 網路"
else
    echo "這不是 Multisite 網路"
fi

# ❸ 用設定檔再交叉確認一次
grep -nE "MULTISITE|SUBDOMAIN_INSTALL" "$WP_PATH/wp-config.php" || echo "未定義 MULTISITE"

# ❹ 這台伺服器上有幾個獨立的 WordPress?
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 2>/dev/null |
while IFS= read -r -d '' CONFIG; do
    printf '找到網站設定檔:%s\n' "$CONFIG"
done

預期輸出範例:

# 一般單站
這不是 Multisite 網路
未定義 MULTISITE
找到網站設定檔:/var/www/example.com/public_html/wp-config.php

# 獨立多站會列出多個路徑
找到網站設定檔:/var/www/site-a/public_html/wp-config.php
找到網站設定檔:/var/www/site-b/public_html/wp-config.php

分支判斷邏輯:

  • ❶ 出現 ERROR 👉 先停手,不要把錯誤的目錄當成網站處理,也不要繼續往下跑 🛑
  • ❷ 顯示「這是 Multisite 網路」👉 走 ✴️ Multisite 流程,外掛只需要在網路層級處理一次 ✅
  • ❹ 找到多個彼此獨立的設定檔,而且 ❷ 顯示「不是 Multisite」👉 走 ❇️ 獨立多站流程,每一站都要各自處理,不能混在一起跑 🧭
  • 只找到一個設定檔 👉 走 💠 單一網站流程 ⭕

[43]


🔎 🔎 🔎

🔎 ㊁ 盤點(版本比對):哪些站裝了它?現在是幾版?

決策原理:版本號讓 WP-CLI 動態回報,不在指令裡寫死「最新版是幾版」;只有「安全門檻」(10.8.2)用一個變數放在最上面,將來門檻變了只要改一處。版本比較不靠肉眼,改用 sort -V 做「版本感知」的排序,避免出現「10.10 比 10.8 小」這種字串比較的老問題。獨立多站要動態抓每個站的檔案擁有者,用對應的身分執行 WP-CLI,避免權限錯亂 💪

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
wp --path="$WP_PATH" plugin get wp-recipe-maker --fields=name,status,version,update,update_version

# ❇️ 獨立多站:逐站盤點,並自動判定「受影響/已達安全版本」
MIN_SAFE="10.8.2"
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" core is-installed </dev/null 2>/dev/null; then
        continue
    fi

    VERSION="$(sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin get wp-recipe-maker --field=version </dev/null 2>/dev/null || true)"

    if [ -z "$VERSION" ]; then
        printf '⚪ %s:未安裝\n' "$SITE_PATH"
    elif [ "$(printf '%s\n' "$MIN_SAFE" "$VERSION" | sort -V | head -n 1)" = "$MIN_SAFE" ]; then
        printf '🟢 %s:%s(已達安全版本)\n' "$SITE_PATH" "$VERSION"
    else
        printf '🔴 %s:%s(受影響,需處理)\n' "$SITE_PATH" "$VERSION"
    fi
done

# ✴️ Multisite:外掛檔案全網共用,查一次即可,再逐站確認啟用狀態
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" plugin get wp-recipe-maker --fields=name,status,version,update,update_version

if wp --path="$WP_PATH" plugin is-active wp-recipe-maker --network; then
    echo "已網路啟用(Network Activated)"
else
    echo "未網路啟用,可能只在部分子站啟用"
fi

wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
    if wp --path="$WP_PATH" --url="$SITE_URL" plugin is-active wp-recipe-maker </dev/null; then
        printf '啟用中:%s\n' "$SITE_URL"
    fi
done

預期輸出範例:

# 💠 單一網站
name: wp-recipe-maker
status: active
version: 10.8.1
update: available
update_version: 10.8.4

# ❇️ 獨立多站
🔴 /var/www/site-a/public_html:10.8.1(受影響,需處理)
🟢 /var/www/site-b/public_html:10.8.4(已達安全版本)
⚪ /var/www/site-c/public_html:未安裝

分支判斷邏輯:

  • 🔴 版本 10.8.1 或更舊 👉 視為受影響,進入第六章的備份、應急與修補流程 🚨
  • 🟢 版本 10.8.2 以上 👉 版本條件已解除,但如果舊版曾對外暴露一段時間,仍要繼續做 Log 與留言排查,因為攻擊有可能發生在更新之前 ✅
  • ⚪ 顯示未安裝 👉 這個站不受此漏洞影響,不需要進一步處理 🧋

🟥 🟥 🟥

📊 ㊂ Log 日誌排查:日誌看不到留言內容,那它能看什麼?

決策原理:這個漏洞的入口是「留言」,而留言內文走的是 POST,預設的 Web Server 存取日誌不會記錄 POST 內文,所以在日誌裡搜尋中括號,幾乎必然一無所獲,還會讓你誤以為沒事。日誌真正能提供的是「頻率與來源」:哪些 IP 在短時間內對留言入口送出大量請求?這些請求集中在哪個時間?這些線索屬於🟡合理推論,並非官方證實的攻擊特徵,主要用來決定「要不要進一步查資料庫」。除了傳統的留言入口,也一併統計 WordPress 的 REST 留言端點 🎯

# 💠 單一網站:統計對留言入口送出 POST 的來源 IP(含已輪替、已壓縮的舊日誌)
LOG_DIR="/var/log/nginx"      # Apache 環境常見為 /var/log/apache2
PATTERN='"POST /(wp-comments-post\.php|wp-json/wp/v2/comments|\?rest_route=/wp/v2/comments)'

sudo find "$LOG_DIR" -maxdepth 1 -type f -name 'access.log*' -print0 |
    sudo xargs -0 -r zgrep -hE -e "$PATTERN" |
    awk '{print $1}' | sort | uniq -c | sort -rn | head -n 20

# 抽樣看 30 筆原始紀錄(時間、狀態碼、User-Agent)
sudo find "$LOG_DIR" -maxdepth 1 -type f -name 'access.log*' -print0 |
    sudo xargs -0 -r zgrep -hE -e "$PATTERN" | tail -n 30

# ❇️ 獨立多站:每個網站可能各自有日誌,逐檔統計命中筆數
LOG_ROOT="/var/log"

sudo find "$LOG_ROOT" -type f \( -name "*access*.log" -o -name "*access*.log.*" \) -print0 |
while IFS= read -r -d '' LOG; do
    COUNT="$(sudo zgrep -hcE -e "$PATTERN" "$LOG" </dev/null 2>/dev/null || true)"
    if [ "${COUNT:-0}" -gt 0 ] 2>/dev/null; then
        printf '%s 筆|%s\n' "$COUNT" "$LOG"
    fi
done

# ✴️ Multisite:通常共用同一份日誌,指令與單一網站相同;
#    再用 wp site list --field=url 取得子站網域,對照請求的 Host 或網址回推是哪個子站

預期輸出範例:

# 來源 IP 統計(左邊是命中次數)
    412 203.0.113.45
     37 198.51.100.7
      5 192.0.2.88

# 獨立多站逐檔統計
412 筆|/var/log/nginx/access.log
 37 筆|/var/log/nginx/access.log.1

分支判斷邏輯:

  • 🟢 沒有任何命中 👉 不代表安全,可能日誌已被輪替清掉,或是請求經過 CDN、快取而沒有落在這台機器的日誌,仍要繼續查資料庫 🙅‍♂️
  • 🟡 某個 IP 在短時間內送出大量留言請求 👉 多半是垃圾留言機器人,不一定是這個漏洞;但它值得把優先度往上調,接著到下一關查該時段前後的已核准留言 🔍
  • 🟡 如果網站前面有 CDN 或反向代理(例如 Cloudflare),第一欄看到的可能是代理的 IP,不是真正的訪客,需要改看真實 IP 的標頭或代理端的日誌 🧭
  • 🔴 命中集中在漏洞公開前後,且時間點與可疑留言吻合 👉 高度懷疑已被觸發,進入第六章「A. 已中招/疑似中招」🚨

[44]


🟣 🟣 🟣

🕳️ ㊃ 留言、短代碼與後門:版本正確,不代表整站乾淨

決策原理:這一關要回答三個問題。第一,資料庫裡有沒有「已核准、又帶著括號」的可疑留言(這是攻擊要成立的必要條件);第二,你的網站註冊了哪些短代碼,也就是這個漏洞「最多能偷到什麼」;第三,如果對方真的串了別的弱點,有沒有留下後門。留言查詢用 mktemp 搭配單引號 heredoc 寫 SQL,避免 SQL 裡的 %、引號與 Shell 自己的跳脫規則打架;資料表前綴(不一定是 wp_)交給 wp db prefix 動態取得,Multisite 各子站的前綴不同,也會自動跟著走 🧰

🧾 第一小步:撈出已核准、內文含中括號的留言

# 先建立一份共用的 SQL 範本(__PREFIX__ 之後會被換成真正的資料表前綴)
TEMPLATE="$(mktemp)"

cat > "$TEMPLATE" <<'EOF'
SELECT comment_ID, comment_post_ID, comment_author, comment_author_email, comment_date,
       LEFT(comment_content, 120) AS preview
FROM __PREFIX__comments
WHERE comment_approved = '1'
  AND comment_content LIKE '%[%]%'
ORDER BY comment_date DESC
LIMIT 50;
EOF

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
DB_PREFIX="$(wp --path="$WP_PATH" db prefix)"
SITE_QUERY="$(mktemp)"
sed "s/__PREFIX__/${DB_PREFIX}/g" "$TEMPLATE" > "$SITE_QUERY"
wp --path="$WP_PATH" db query < "$SITE_QUERY"
rm -f -- "$SITE_QUERY"

# ❇️ 獨立多站:逐站取前綴、逐站查詢
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" core is-installed </dev/null 2>/dev/null; then
        continue
    fi

    DB_PREFIX="$(sudo -u "$OWNER" -- wp --path="$SITE_PATH" db prefix </dev/null)"
    SITE_QUERY="$(mktemp)"
    sed "s/__PREFIX__/${DB_PREFIX}/g" "$TEMPLATE" > "$SITE_QUERY"

    printf '\n===== %s =====\n' "$SITE_PATH"
    sudo -u "$OWNER" -- wp --path="$SITE_PATH" db query < "$SITE_QUERY"
    rm -f -- "$SITE_QUERY"
done

# ✴️ Multisite:每個子站有自己的留言資料表,前綴不同,逐站查
WP_PATH="/var/www/network/public_html"

wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
    DB_PREFIX="$(wp --path="$WP_PATH" --url="$SITE_URL" db prefix </dev/null)"
    SITE_QUERY="$(mktemp)"
    sed "s/__PREFIX__/${DB_PREFIX}/g" "$TEMPLATE" > "$SITE_QUERY"

    printf '\n===== %s =====\n' "$SITE_URL"
    wp --path="$WP_PATH" --url="$SITE_URL" db query < "$SITE_QUERY"
    rm -f -- "$SITE_QUERY"
done

rm -f -- "$TEMPLATE"

預期輸出範例:

+------------+-----------------+----------------+---------------------+--------------------------------+
| comment_ID | comment_post_ID | comment_author | comment_date        | preview                        |
+------------+-----------------+----------------+---------------------+--------------------------------+
|        231 |              88 | Amy            | 2026-09-12 09:15:02 | 這道超好吃,家人都大推 [五顆星] |
|        229 |              88 | visitor42      | 2026-09-11 22:40:10 | 很好吃 [some_tag id="12"]      |
+------------+-----------------+----------------+---------------------+--------------------------------+

分支判斷邏輯:

  • 🟢 沒有任何結果 👉 沒有發現已核准又帶括號的留言,這一小步沒有可疑跡象,仍然繼續往下走 ⭕
  • 🟡 括號裡是中文或表情符號(像上面的 [五顆星])👉 多半是一般人的寫法,屬於誤判,不需要處理 🧋
  • 🔴 括號裡是「英文單字加屬性」的形狀(像上面的第二筆),而且出現在食譜評分留言 👉 疑似暗號,先退回待審,不要直接刪:wp –path=”$WP_PATH” comment unapprove “$COMMENT_ID”,確認是惡意後才用 comment delete 處理 🚨

📚 第二小步:盤點這個站註冊了哪些短代碼

這一步很少出現在別人的教學裡,卻最能回答「這個洞在你家能偷到什麼」。清單越長、名字越像「會吐資料」,風險越高;反過來說,只有 WordPress 核心與食譜外掛自己的短代碼,能洩漏的東西就相對有限 🔐

# 💠 單一網站:列出所有已註冊的短代碼名稱
WP_PATH="/var/www/example.com/public_html"
wp --path="$WP_PATH" eval 'global $shortcode_tags; echo implode(PHP_EOL, array_keys($shortcode_tags)), PHP_EOL;' | sort

# 想知道某個短代碼是哪個外掛註冊的
grep -rn --include='*.php' "add_shortcode" "$WP_PATH/wp-content/plugins" | head -n 50

# ❇️ 獨立多站:逐站列出
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"

    printf '\n===== %s =====\n' "$SITE_PATH"
    sudo -u "$OWNER" -- wp --path="$SITE_PATH" eval 'global $shortcode_tags; echo implode(PHP_EOL, array_keys($shortcode_tags)), PHP_EOL;' </dev/null 2>/dev/null | sort
done

# ✴️ Multisite:各子站啟用的外掛可能不同,逐站列出
WP_PATH="/var/www/network/public_html"

wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
    printf '\n===== %s =====\n' "$SITE_URL"
    wp --path="$WP_PATH" --url="$SITE_URL" eval 'global $shortcode_tags; echo implode(PHP_EOL, array_keys($shortcode_tags)), PHP_EOL;' </dev/null 2>/dev/null | sort
done

預期輸出範例:

audio
caption
embed
gallery
playlist
video
wprm-recipe
wprm-recipe-name
...(其餘為你的外掛註冊的短代碼)

分支判斷邏輯:

  • 🟢 清單幾乎都是 WordPress 核心與 wprm- 開頭的短代碼 👉 能洩漏的內容相對有限,優先把更新做完 ✅
  • 🟡 出現名字暗示「讀取使用者資料、私人欄位、檔案、執行查詢」的短代碼 👉 這些就是這個漏洞的「資料出口」,先確認是哪個外掛註冊的、有沒有更新,不需要的直接停用或移除 🔍
  • 🔴 出現你完全不認得的短代碼名稱 👉 用上面的 grep 找出註冊它的檔案,來源不明的外掛要當成可疑對象處理 🚨

🕵️ 第三小步:後門與異常帳號

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
MU_PLUGINS="$WP_PATH/wp-content/mu-plugins"

# ❶ mu-plugins:這個目錄的檔案不用啟用就會自動載入,是常見的藏匿處
if [ -d "$MU_PLUGINS" ]; then
    find "$MU_PLUGINS" -maxdepth 2 -type f -iname "*.php" -ls
else
    echo "沒有 mu-plugins 目錄:$MU_PLUGINS"
fi

# ❷ uploads 底下不該出現 PHP 檔
find "$WP_PATH/wp-content/uploads" -type f -iname "*.php" -ls 2>/dev/null

# ❸ 近 14 天被改過的 PHP 檔(排除快取目錄降低雜訊)
find "$WP_PATH/wp-content" -type f -name "*.php" -mtime -14 -not -path "*/cache/*" -ls | head -n 100

# ❹ 管理員帳號與建立時間
wp --path="$WP_PATH" user list --role=administrator --fields=ID,user_login,user_email,user_registered --format=table

# ❇️ 獨立多站:每站各自檢查,有命中才印出
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"
    MU_PLUGINS="$SITE_PATH/wp-content/mu-plugins"
    HITS=""

    if [ -d "$MU_PLUGINS" ]; then
        HITS="$(find "$MU_PLUGINS" -maxdepth 2 -type f -iname "*.php" -print)"
    fi
    UPLOAD_HITS="$(find "$SITE_PATH/wp-content/uploads" -type f -iname "*.php" -print 2>/dev/null)"

    if [ -n "$HITS" ] || [ -n "$UPLOAD_HITS" ]; then
        printf '\n===== 需要人工確認:%s =====\n' "$SITE_PATH"
        [ -n "$HITS" ] && printf 'mu-plugins:\n%s\n' "$HITS"
        [ -n "$UPLOAD_HITS" ] && printf 'uploads 內的 PHP:\n%s\n' "$UPLOAD_HITS"
    fi

    sudo -u "$OWNER" -- wp --path="$SITE_PATH" user list --role=administrator --fields=ID,user_login,user_email,user_registered --format=table </dev/null 2>/dev/null
done

# ✴️ Multisite:mu-plugins 屬於整個網路,只需要檢查一次;帳號要分網路管理員與各站管理員兩層看
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" -ls
fi

wp --path="$WP_PATH" super-admin list
wp --path="$WP_PATH" user list --role=administrator --fields=ID,user_login,user_email,user_registered --format=table

預期輸出範例:

# ❶ 正常情況
沒有 mu-plugins 目錄:/var/www/example.com/public_html/wp-content/mu-plugins

# ❹ 管理員帳號
+----+------------+-------------------+---------------------+
| ID | user_login | user_email        | user_registered     |
+----+------------+-------------------+---------------------+
|  1 | admin      | admin@example.com | 2024-01-15 10:00:00 |
+----+------------+-------------------+---------------------+

分支判斷邏輯:

  • 🔴 mu-plugins 出現你沒部署過的 PHP 檔,尤其檔名像亂數字串 👉 高度可疑,先保留檔案時間、權限與副本,不要直接刪,進入第六章「A」🚨
  • 🔴 uploads 目錄裡出現 PHP 檔 👉 這個目錄本來只該放圖片與文件,出現 PHP 幾乎都不正常,同樣進入「A」流程 🛑
  • 🟡 近 14 天被改過的 PHP 檔,修改時間剛好對得上可疑留言或異常請求的時間 👉 需要看檔案內容,光看時間不能定罪 🔎
  • 🟡 出現不認識的管理員帳號 👉 「陌生」不等於「惡意」,也可能是維運商的既有帳號,先問過再處理,不要看到就刪 🧾
  • 🟢 以上都沒有異常 👉 這一輪沒有找到跡象;別忘了這個漏洞的主要影響是資訊洩漏,本來就不一定會留下後門,所以「沒找到」是正常的好結果 ✅

🔍 🔍 🔍

🔧 ㊄ 加固檢查:把「換鎖」與「縮小爆炸半徑」一起做

決策原理:這一關分成兩個層次。第一個層次是「換鎖」:wp config shuffle-salts 會重新產生 wp-config.php 裡那八組金鑰與鹽值,所有已登入的人(包括可能已經溜進來的人)都會被強制登出。它的代價是全站使用者都得重新登入,所以要看嚴重度決定:排查發現可疑跡象就一定要做,沒有跡象則屬於選做。第二個層次是「縮小爆炸半徑」:停用高風險 PHP 函式、收緊 wp-config.php 權限,萬一日後有人把這種漏洞串成更嚴重的攻擊,能造成的破壞會小很多。這一層跟本漏洞的直接關係有限,屬於🟡深度防禦,但成本很低 🔒

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
PHP_VER="8.4"
PHP_INI="/etc/php/${PHP_VER}/fpm/php.ini"
ROTATE_SALTS="no"     # 前面的排查發現可疑跡象時,改成 yes

# ❶ 檢查 wp-config.php 的權限與擁有者(建議 640 或更嚴格)
stat -c '%a %U:%G %n' -- "$WP_PATH/wp-config.php"
# 權限過鬆時再調整(請先確認網頁伺服器使用者屬於該群組,否則網站會讀不到設定檔):
# sudo chmod 640 -- "$WP_PATH/wp-config.php"

# ❷ 先看目前的 disable_functions(主機商若已有預設清單,需要合併,別直接覆蓋)
grep -nE '^[;#]*\s*disable_functions' "$PHP_INI"

# ❸ 備份 php.ini,再用冪等替換停用高風險函式,最後重啟 PHP-FPM
sudo cp -- "$PHP_INI" "${PHP_INI}.BAK.$(date +%F_%H%M%S)"
sudo sed -i -E 's/^[;#]*\s*disable_functions\s*=.*/disable_functions = exec,passthru,shell_exec,system,proc_open,popen/' "$PHP_INI"
sudo systemctl restart "php${PHP_VER}-fpm"

# ❹ 用 FPM 自己的設定驗證(php -i 讀的是 CLI 設定,會看錯)
sudo "php-fpm${PHP_VER}" -i | grep -E '^disable_functions'

# ❺ 視情況換鎖:所有人會被強制登出
if [ "$ROTATE_SALTS" = "yes" ]; then
    wp --path="$WP_PATH" config shuffle-salts
fi

# ❇️ 獨立多站:逐站檢查權限;若要換鎖,只對「有疑慮的那一站」執行,不要整批一起
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    printf '%s\n' "$(stat -c '%a %U:%G %n' -- "$CONFIG")"
done

TARGET_PATH="/var/www/site-b/public_html"
TARGET_OWNER="$(stat -c '%U' -- "$TARGET_PATH/wp-config.php")"

if [ "$ROTATE_SALTS" = "yes" ]; then
    sudo -u "$TARGET_OWNER" -- wp --path="$TARGET_PATH" config shuffle-salts
fi

# ✴️ Multisite:金鑰與鹽值屬於同一份 wp-config.php,對整個網路只需要執行一次
WP_PATH="/var/www/network/public_html"

stat -c '%a %U:%G %n' -- "$WP_PATH/wp-config.php"

if [ "$ROTATE_SALTS" = "yes" ]; then
    wp --path="$WP_PATH" config shuffle-salts
fi

預期輸出範例:

640 www-data:www-data /var/www/example.com/public_html/wp-config.php
disable_functions => exec,passthru,shell_exec,system,proc_open,popen => exec,passthru,shell_exec,system,proc_open,popen
Success: Shuffled the salt keys.

說明:⚠️ 為了安全性,建議停用 exec,passthru,shell_exec,system,proc_open,popen 這些高風險函式。若因業務需求必須保留,請再三確認必要性,並搭配額外的隔離措施 🛡️

分支判斷邏輯:

  • 🟢 disable_functions 已包含上述函式 👉 不需要重複設定,繼續下一步 ✅
  • 🟠 wp-config.php 權限大於 640(例如 644 或 666)👉 收緊到 640 或 600,並確認擁有者是正確的網頁伺服器使用者 🔧
  • 🟡 停用函式之後網站出現異常 👉 可能有外掛正好用到被停用的函式,先看錯誤日誌找出是哪一個,再決定是調整清單還是換掉那個外掛 🧾
  • 🔴 前面任何一關出現可疑跡象 👉 把 ROTATE_SALTS 改成 yes,並進入第六章「A」🚨

🧑‍🔧 DevOps 小知識:shuffle-salts 為什麼適合在「有疑慮」的時候用?
官方文件寫明,這個指令是在 WordPress 開始載入之前的階段(before_wp_load)就執行的,它直接改寫設定檔,不會先去載入外掛與佈景主題。也就是說,就算網站因為某個外掛出問題而狀況不太對勁,這條指令依然能把鎖換掉。換完之後,舊的登入 Cookie 全部失效,攻擊者手上的舊憑證也跟著報廢 🔑

[45]


🕵️ 🕵️ 🕵️

📋 ㊅ 三種環境速查表:五個關卡濃縮成一張表

關卡 💠 單一網站 ❇️ 獨立多站 ✴️ Multisite
① 環境判斷 core is-installed–network find 列出所有 wp-config.php –network 結束碼為 0
② 盤點版本 plugin get 逐站取擁有者,自動判定受影響與否 查一次,再逐站看啟用狀態
③ Log 排查 單一存取日誌,統計來源 IP 逐檔統計命中筆數 共用日誌,再由網址回推子站
④ 留言、短代碼、後門 撈括號留言、列短代碼、查 mu-plugins 逐站取前綴、逐站檢查 逐子站查留言;mu-plugins 查一次
⑤ 加固 權限、disable_functions、視情況換鎖 逐站檢查,只對有疑慮的站換鎖 換鎖對整個網路只做一次

🟢 🟢 🟢

🤔 讀到這裡有點喘?幾個常見的擔心先幫你解掉

  • Q:更新之後,我的食譜卡片與星星評分會不會壞掉?
    A:這次修的是「不要執行留言文字裡的短代碼」,不是改食譜卡片的外觀或評分計算,所以功能出問題的機率不高。更新完成後,隨手打開一兩篇食譜,看看卡片與星星是否正常,再對照網頁原始碼裡的 reviewBody,就可以放心了 🍰
  • Q:我的留言區都是人工核准,是不是就沒事?
    A:人工核准確實多了一道關卡,但前提是審核的人要知道「括號暗號」長什麼樣子。多數站長審核時看的是有沒有廣告、有沒有髒話,不太會去看括號,所以人工核准降低的是機率,不是把風險歸零,更新才是根本解法 🔍
  • Q:我完全看不懂上面那些指令,是不是就沒救了?
    A:完全不會。第三章從頭到尾不需要碰任何指令,只要在後台點幾下滑鼠就能完成最基本的防護,第四到第六章是給有 SSH 權限、想深入排查的人看的補充內容 🤗

🪛 🪛 🪛

🛠️ 第六章:來不及更新就先擋住,短期應急與正式修補三部曲

前一章解決的是「怎麼判斷」,這一章才是真正的維運 Runbook。不管你屬於哪一種情境,順序都是同一套:環境判斷 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證;如果前一章已經找到可疑跡象,就要在最前面多加一段「先取證、再止血」。有一件事想先跟你說:這一章所有會「改動東西」的指令,都預設先問一句「這一步做了,之後要怎麼還原」,所以你會看到很多備份與還原的寫法,這是刻意的 🧹

[46]


🔴 🔴 🔴

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

決策原理:第五章只要出現明確跡象(可疑留言、mu-plugins 裡的陌生檔案、uploads 裡的 PHP),優先目標是「保留證據、切斷入口、換掉憑證」,而不是急著更新。最大的敵人是在沒有留下證據的情況下直接清理,把最有價值的線索一起刪掉。這裡的順序是:取證 👉 停用外掛 👉 把可疑留言退回待審 👉 換鎖 👉 隔離後門檔案 👉 確認乾淨後才更新。取證資料裡有資料庫內容與 wp-config.php 副本,所以存放的資料夾一律收到 700 權限,而且放在家目錄,絕對不要放在網站根目錄 🛟

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
STAMP="$(date +%Y%m%d-%H%M%S)"
FORENSIC_DIR="$HOME/wprm-incident-${STAMP}"
PAGE_URL="https://example.com/your-recipe-page/"

mkdir -p -- "$FORENSIC_DIR"
chmod 700 -- "$FORENSIC_DIR"

# ❶ 取證:資料庫快照、日誌、mu-plugins、設定檔副本,以及疑似洩漏頁面的原始 HTML
wp --path="$WP_PATH" db export "$FORENSIC_DIR/db_snapshot_${STAMP}.sql"
sudo tar -czf "$FORENSIC_DIR/access_logs_${STAMP}.tar.gz" /var/log/nginx/ 2>/dev/null
if [ -d "$WP_PATH/wp-content/mu-plugins" ]; then
    cp -a -- "$WP_PATH/wp-content/mu-plugins" "$FORENSIC_DIR/"
fi
cp -a -- "$WP_PATH/wp-config.php" "$FORENSIC_DIR/wp-config.php.snapshot"
curl -sS -- "$PAGE_URL" > "$FORENSIC_DIR/page_${STAMP}.html"
printf '取證資料已存放於:%s\n' "$FORENSIC_DIR"

# ❷ 止血:停用外掛,切斷觸發點(停用不會刪除任何資料)
wp --path="$WP_PATH" plugin deactivate wp-recipe-maker

# ❸ 把確認可疑的留言退回待審(可逆,也保留證據;COMMENT_ID 換成第五章查到的編號)
COMMENT_ID="229"
wp --path="$WP_PATH" comment unapprove "$COMMENT_ID"

# ❹ 換鎖:刷新金鑰與鹽值,所有人都會被強制登出
wp --path="$WP_PATH" config shuffle-salts

# 逐一重設管理員密碼並銷毀 Session(雙重保險)
wp --path="$WP_PATH" user list --role=administrator --field=ID |
while IFS= read -r ADMIN_ID; do
    wp --path="$WP_PATH" user update "$ADMIN_ID" --user_pass="$(openssl rand -base64 24)" --skip-email </dev/null
    wp --path="$WP_PATH" user session destroy "$ADMIN_ID" --all </dev/null
    printf '已重設管理員 ID %s 的密碼並銷毀其 Session\n' "$ADMIN_ID"
done

# ❺ 隔離後門檔案:一次只處理你「親自確認過」的那一個,不要用萬用字元整批搬
SUSPECT_FILE="/var/www/example.com/public_html/wp-content/mu-plugins/suspicious.php"
QUARANTINE_DIR="$HOME/wprm-quarantine-${STAMP}"

if [ -f "$SUSPECT_FILE" ]; then
    mkdir -p -- "$QUARANTINE_DIR"
    chmod 700 -- "$QUARANTINE_DIR"
    mv -- "$SUSPECT_FILE" "$QUARANTINE_DIR/"
    chmod 000 -- "$QUARANTINE_DIR/$(basename -- "$SUSPECT_FILE")"
fi

# ❇️ 獨立多站:只處理有證據的那一站,別因為緊張就動到整台伺服器
INCIDENT_PATH="/var/www/site-b/public_html"
INCIDENT_OWNER="$(stat -c '%U' -- "$INCIDENT_PATH/wp-config.php")"

if sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" core is-installed; then
    echo "確認目標:$INCIDENT_PATH(擁有者:$INCIDENT_OWNER)"
    sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" plugin deactivate wp-recipe-maker
    sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" db export - > "$FORENSIC_DIR/site-b_snapshot_${STAMP}.sql"
    sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" config shuffle-salts
else
    echo "ERROR: 目標路徑不是 WordPress,已停止"
fi
# 之後的「取證細節、留言退回待審、隔離後門」,比照上面單一網站的 ❶ ❸ ❺ 對這一站執行

# ✴️ Multisite:外掛與 mu-plugins 屬於整個網路,事件要當成網路級問題處理
WP_PATH="/var/www/network/public_html"

if wp --path="$WP_PATH" plugin is-active wp-recipe-maker --network; then
    wp --path="$WP_PATH" plugin deactivate wp-recipe-maker --network
else
    wp --path="$WP_PATH" site list --field=url |
    while IFS= read -r SITE_URL; do
        wp --path="$WP_PATH" --url="$SITE_URL" plugin deactivate wp-recipe-maker </dev/null 2>/dev/null || true
    done
fi

wp --path="$WP_PATH" db export "$FORENSIC_DIR/network_snapshot_${STAMP}.sql"
wp --path="$WP_PATH" config shuffle-salts

wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
    printf '\n===== %s =====\n' "$SITE_URL"
    wp --path="$WP_PATH" --url="$SITE_URL" user list --role=administrator --fields=ID,user_login,user_email,user_registered --format=table </dev/null
done

預期輸出範例:

Success: Exported to '/home/admin/wprm-incident-20260919-143000/db_snapshot_20260919-143000.sql'.
取證資料已存放於:/home/admin/wprm-incident-20260919-143000
Plugin 'wp-recipe-maker' deactivated.
Success: Unapproved comment 229.
Success: Shuffled the salt keys.
已重設管理員 ID 1 的密碼並銷毀其 Session

分支判斷邏輯:

  • 🟢 取證、停用、換鎖都成功 👉 進入下方「C. 正式修補」;更新完成、驗證乾淨之後,再決定要不要把外掛重新啟用 ✅
  • 🟠 密碼是隨機產生、沒有顯示出來的 👉 這是刻意的,避免密碼被留在終端機捲動紀錄裡;請各位管理員用「忘記密碼」流程重設,或由你用 wp user update 逐一指定新密碼 🔑
  • 🔴 在隔離的檔案裡看見可疑程式碼(例如把內容編碼後再執行的寫法)👉 把 $FORENSIC_DIR 交給資安人員做深入分析,並持續觀察一週的流量與帳號活動 🚨
  • 🔴 網站牽涉會員資料或線上收款,且確認有內容外洩 👉 這已經超出「更新外掛」的層級,同步啟動內部事件應變流程,並找法務確認是否有通報責任 🛑

如果經過查證,確定某幾則留言是惡意的,而且證據已經保存好,才輪到刪除。這是破壞性操作,執行前務必再三確認留言編號正確,刪除後無法復原 🙅‍♂️

WP_PATH="/var/www/example.com/public_html"
COMMENT_ID="229"

# ⚠️ 破壞性操作:--force 會跳過垃圾桶直接刪除,無法復原
wp --path="$WP_PATH" comment delete "$COMMENT_ID" --force

🧑‍🔧 DevOps 小知識:為什麼「停用」不夠,還要換鎖?
這個漏洞的主要效果是資訊洩漏,如果洩漏出去的內容碰巧包含了登入憑證或金鑰,光是停用外掛,對方手上已經拿到的東西仍然有效。所以在「已經確認有東西被讀走」的情況下,換掉金鑰與鹽值、重設密碼,才能讓那些憑證一起報廢。反過來,如果只是懷疑、沒有任何具體跡象,這一組動作的代價(全站重新登入)就未必划算,這就是第五章 ㊄ 用 ROTATE_SALTS 開關控制的原因 🎛️


🟡 🟡 🟡

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

🚨 短期應急不等於正式修補:停用外掛、改成人工核准、擋掉留言入口,都是「縮短暴露時間」的緩衝手段,真正的解法仍然是升級到修補版本。這一節每一個做法都附了還原方式,等更新完成,記得把它們一一復原,不然網站會一直停在「留言被擋」的狀態 🧱

決策原理:把可以選擇的做法按代價由低到高排:改成人工核准,幾乎沒有代價,但只能降低機率;停用外掛,最乾淨,代價是食譜卡片與評分暫時消失;在伺服器層擋掉留言入口,最有效,代價是整個網站都不能留言。挑哪一個,取決於你的留言區對業務有多重要 🎯

🪶 做法一:把新留言全部改成人工核准(代價最低)

# 💠 單一網站:先記下原值,再修改
WP_PATH="/var/www/example.com/public_html"

ORIG_MODERATION="$(wp --path="$WP_PATH" option get comment_moderation)"
ORIG_PREVIOUS="$(wp --path="$WP_PATH" option get comment_previously_approved)"
printf '原值:comment_moderation=%s|comment_previously_approved=%s(請記下來,更新後要還原)\n' "$ORIG_MODERATION" "$ORIG_PREVIOUS"

wp --path="$WP_PATH" option update comment_moderation 1
wp --path="$WP_PATH" option update comment_previously_approved 0

# 還原(更新完成後執行,把 0 或 1 換成上面記下的原值)
# wp --path="$WP_PATH" option update comment_moderation 0
# wp --path="$WP_PATH" option update comment_previously_approved 1

# ❇️ 獨立多站:只對有疑慮的那一站套用,逐站驗證
TARGET_PATH="/var/www/site-b/public_html"
TARGET_OWNER="$(stat -c '%U' -- "$TARGET_PATH/wp-config.php")"

sudo -u "$TARGET_OWNER" -- wp --path="$TARGET_PATH" option update comment_moderation 1
sudo -u "$TARGET_OWNER" -- wp --path="$TARGET_PATH" option update comment_previously_approved 0

# ✴️ Multisite:留言設定是「每個子站各自一份」,需要逐站套用
WP_PATH="/var/www/network/public_html"

wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
    wp --path="$WP_PATH" --url="$SITE_URL" option update comment_moderation 1 </dev/null
    wp --path="$WP_PATH" --url="$SITE_URL" option update comment_previously_approved 0 </dev/null
    printf '已套用:%s\n' "$SITE_URL"
done

預期輸出範例:

原值:comment_moderation=0|comment_previously_approved=1(請記下來,更新後要還原)
Success: Updated 'comment_moderation' option.
Success: Updated 'comment_previously_approved' option.

分支判斷邏輯:這個做法只影響「之後的新留言」,已經核准的留言不會被追溯處理,所以做完之後,仍然要回頭做第五章的留言查詢;審核時遇到括號長得像暗號的,別放行 🔍

🔌 做法二:停用外掛(最乾淨,食譜卡片會暫時消失)

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
wp --path="$WP_PATH" plugin deactivate wp-recipe-maker

# ❇️ 獨立多站:逐站處理,只對盤點出「受影響」的站執行
TARGET_PATH="/var/www/site-b/public_html"
TARGET_OWNER="$(stat -c '%U' -- "$TARGET_PATH/wp-config.php")"
sudo -u "$TARGET_OWNER" -- wp --path="$TARGET_PATH" plugin deactivate wp-recipe-maker

# ✴️ Multisite:先確認是不是網路啟用,再決定停用範圍
WP_PATH="/var/www/network/public_html"

if wp --path="$WP_PATH" plugin is-active wp-recipe-maker --network; then
    wp --path="$WP_PATH" plugin deactivate wp-recipe-maker --network
else
    echo "不是網路啟用,請用 --url 針對個別子站停用"
fi

分支判斷邏輯:網站的核心流程不依賴食譜卡片與評分(例如你只是暫時放著)👉 直接停用最安全;停用會讓 Google 星等暫時消失,這屬於🟡合理推論的 SEO 影響,更新完成、重新啟用之後才會慢慢恢復 🧋

🚪 做法三:在伺服器層擋掉留言入口(最有效,代價是全站暫時不能留言)

這個做法會連同一般讀者的留言一起擋掉,所以只適合「無法立刻更新、又不想停用外掛」的短暫空窗期。Nginx 的規則要放在該網站的 server 區塊裡,不能直接丟進 conf.d,否則會因為層級錯誤而讓設定檢查失敗;使用面板的環境(例如 HestiaCP、cPanel),include 該放在哪一個位置,請以面板文件為準 🛟

# === Nginx ===
VHOST_CONF="/etc/nginx/sites-enabled/example.com.conf"   # 換成你網站實際的設定檔路徑

sudo tee /etc/nginx/snippets/wprm-temp-block.conf > /dev/null <<'EOF'
# CVE-2026-89274 暫時性緩解:先關掉留言入口,正式更新後請移除這個檔案與 include
location = /wp-comments-post.php {
    return 403;
}

if ($request_method$request_uri ~* "^POST/.*(wp-json/wp/v2/comments|rest_route=/wp/v2/comments)") {
    return 403;
}
EOF

# 手動把下面這一行加進該網站的 server { } 區塊之中:
#     include /etc/nginx/snippets/wprm-temp-block.conf;

sudo nginx -t && sudo systemctl reload nginx

# 還原:先移除 include 那一行,再刪除規則檔,最後重載
sudo sed -i '\|snippets/wprm-temp-block.conf|d' "$VHOST_CONF"
sudo rm -f -- /etc/nginx/snippets/wprm-temp-block.conf
sudo nginx -t && sudo systemctl reload nginx

# === Apache(.htaccess)===
WP_PATH="/var/www/example.com/public_html"
HTACCESS="$WP_PATH/.htaccess"

# 備份放在家目錄,不放網站根目錄
if [ -f "$HTACCESS" ]; then
    cp -- "$HTACCESS" "$HOME/htaccess.BAK.$(date +%F_%H%M%S)"
fi

# 冪等寫法:已經加過就不會重複加
if ! grep -q "BEGIN CVE-2026-89274" "$HTACCESS" 2>/dev/null; then
    cat >> "$HTACCESS" <<'EOF'

# BEGIN CVE-2026-89274 temporary block
<Files "wp-comments-post.php">
    Require all denied
</Files>
<IfModule mod_rewrite.c>
    RewriteEngine On
    RewriteCond %{REQUEST_URI} wp-json/wp/v2/comments [NC,OR]
    RewriteCond %{QUERY_STRING} rest_route=/wp/v2/comments [NC]
    RewriteCond %{REQUEST_METHOD} POST
    RewriteRule ^ - [F,L]
</IfModule>
# END CVE-2026-89274 temporary block
EOF
fi

# 還原:把 BEGIN 到 END 之間的整段刪掉
sed -i '/# BEGIN CVE-2026-89274/,/# END CVE-2026-89274/d' "$HTACCESS"

預期輸出範例:

nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

分支判斷邏輯:

  • 🟢 nginx -t 通過、留言表單送出後看到 403 👉 規則生效,留言入口已暫時關閉 ✅
  • 🟠 nginx -t 失敗 👉 多半是 include 放錯位置,先把那一行移除、確認設定恢復正常,再重新檢查放置的層級,不要在失敗的狀態下重載 🔧
  • 🟡 這組規則只擋「傳統留言入口」與「REST 留言端點」的常見寫法,網址被編碼、換路徑的變化型不一定擋得到,它是緩衝,不是完整的 WAF 🛡️
  • 🟢 如果網站有用 Wordfence 或 ModSecurity 之類的防火牆,也可以改用它們的自訂規則,做法依產品而異,這裡不展開 🧾

[47]


🚑 🚑 🚑

🚑 ㊂ C. 正式修補:備份 👉 Dry-run 👉 更新 👉 驗證

🩵 荷包試算:這整套流程要花多少錢?
只要有 SSH 權限,從備份到驗證都是自己動手就能完成的免費工序,唯一的成本是你的時間。真正貴的是「沒做」:事後請人做鑑識、清後門,或是頁面內容被搜尋引擎收錄之後的補救,都比現在花半小時走完這套流程貴得多 💸

環境判斷與盤點,請回到第五章 ㊀、㊁ 完成,確認自己屬於哪一種架構、哪些站受影響之後,再往下走;不要在還沒確認架構之前,就執行下面任何批次指令。另外有一個設計上的取捨想跟你說:更新這一步,我們沒有沿用常見的「先停用、更新、再啟用」三連發,因為 wp plugin update 本身就會保留外掛原本的啟用狀態,三連發反而會把「原本就停用的外掛」也意外重新啟用。只有在第六章 A 已經先停用外掛的情況下,才需要在確認乾淨之後,手動把它啟用回來 🧭

💾 ❶ 備份:至少要有資料庫與 wp-content

決策原理:外掛更新改的是外掛檔案,但網站內容與設定在資料庫裡,所以最少要有資料庫備份與 wp-content 備份。原則是「3-2-1」:至少 3 份副本、2 種不同儲存媒介、1 份放異地。備份一律放在家目錄、權限收到 700,不放網站根目錄。獨立多站時,網站的檔案擁有者往往不是你目前登入的帳號,所以資料庫改成匯出到標準輸出(db export –),由你自己的 Shell 負責寫檔,就不會遇到「擁有者沒有權限寫進你的備份資料夾」的問題 🦺

# 💠 單一網站
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"
chmod 700 -- "$BACKUP_DIR"

wp --path="$WP_PATH" db export "$BACKUP_DIR/site_pre_update_${STAMP}.sql"
tar -czf "$BACKUP_DIR/site_wp-content_pre_update_${STAMP}.tar.gz" -C "$WP_PATH" "wp-content"

ls -lh -- "$BACKUP_DIR/site_pre_update_${STAMP}.sql" "$BACKUP_DIR/site_wp-content_pre_update_${STAMP}.tar.gz"

# ❇️ 獨立多站:逐站備份;檔名用完整路徑轉換,避免不同站都叫 public_html 而互相覆蓋
SEARCH_ROOT="/var/www"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d-%H%M%S)"

mkdir -p -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"
    SITE_NAME="$(printf '%s' "$SITE_PATH" | tr '/' '_' | sed 's/^_//')"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin is-installed wp-recipe-maker </dev/null 2>/dev/null; then
        continue
    fi

    printf '=== 備份:%s ===\n' "$SITE_PATH"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" db export - </dev/null > "$BACKUP_DIR/${SITE_NAME}_pre_update_${STAMP}.sql"; then
        printf '❌ 資料庫備份失敗,略過這一站:%s\n' "$SITE_PATH"
        continue
    fi

    sudo -u "$OWNER" -- tar -czf - -C "$SITE_PATH" "wp-content" </dev/null > "$BACKUP_DIR/${SITE_NAME}_wp-content_pre_update_${STAMP}.tar.gz"
    printf '完成:%s\n' "$SITE_NAME"
    sleep 3
done

# ✴️ Multisite:對 Network 執行 db export 匯出的是「整個資料庫」,不是單一子站,只需要做一次
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"
chmod 700 -- "$BACKUP_DIR"

wp --path="$WP_PATH" db export "$BACKUP_DIR/multisite_pre_update_${STAMP}.sql"
tar -czf "$BACKUP_DIR/multisite_wp-content_pre_update_${STAMP}.tar.gz" -C "$WP_PATH" "wp-content"

預期輸出範例:

Success: Exported to '/home/admin/wp-security-backup/site_pre_update_20260919-150000.sql'.
-rw-r--r-- 1 admin admin  42M Sep 19 15:00 /home/admin/wp-security-backup/site_pre_update_20260919-150000.sql
-rw-r--r-- 1 admin admin 318M Sep 19 15:01 /home/admin/wp-security-backup/site_wp-content_pre_update_20260919-150000.tar.gz

分支判斷邏輯:兩份備份都存在、檔案大小不是 0 👉 才進入 Dry-run;任何一份失敗 👉 停止,先排除磁碟空間或權限問題,不要在沒有備份的狀態下更新;獨立多站中某一站備份失敗,就把那一站標記為「未完成」,不要因為其他站都成功,就把它一起放進更新名單 🛑

🧪 ❷ Dry-run:先看更新會做什麼,不真的動手

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
wp --path="$WP_PATH" plugin update wp-recipe-maker --dry-run

# ❇️ 獨立多站:只對有安裝的站逐一預覽
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin is-installed wp-recipe-maker </dev/null 2>/dev/null; then
        continue
    fi

    printf '\n===== %s =====\n' "$SITE_PATH"
    sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin update wp-recipe-maker --dry-run </dev/null
done

# ✴️ Multisite:外掛檔案全網共用,預覽一次即可
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" plugin update wp-recipe-maker --dry-run

預期輸出範例:實際欄位會因 WP-CLI 版本略有差異,大致長這樣 📔

Available plugin updates:
+-----------------+----------+---------+----------------+
| name            | status   | version | update_version |
+-----------------+----------+---------+----------------+
| wp-recipe-maker | active   | 10.8.1  | 10.8.4         |
+-----------------+----------+---------+----------------+

分支判斷邏輯:看到目標版本(目前應該是 10.8.4,或更新的版本)👉 進入正式更新;顯示沒有可用更新 👉 先用第五章 ㊁ 的指令確認目前版本,如果已經是 10.8.2 以上就不需要再更新,只要繼續驗證;如果版本仍是 10.8.1 以下卻顯示沒有更新,多半是更新來源連線異常或快取,不要就此認定網站安全 🔮

🚀 ❸ 更新:確認備份與 Dry-run 都沒問題,才真正動手

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"

wp --path="$WP_PATH" plugin update wp-recipe-maker
wp --path="$WP_PATH" plugin get wp-recipe-maker --fields=name,status,version,update,update_version

# 如果外掛是在第六章 A 被你停用的,確認整站乾淨、驗證通過之後,才把它啟用回來:
# wp --path="$WP_PATH" plugin activate wp-recipe-maker

# ❇️ 獨立多站:一次只處理一站,任何一站失敗就立刻停下,不要繼續往下衝
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin is-installed wp-recipe-maker </dev/null 2>/dev/null; then
        continue
    fi

    printf '=== 更新:%s ===\n' "$SITE_PATH"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin update wp-recipe-maker </dev/null; then
        printf '❌ 更新失敗,停止批次流程,請先處理這一站:%s\n' "$SITE_PATH"
        break
    fi

    NEW_VERSION="$(sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin get wp-recipe-maker --field=version </dev/null)"
    printf '完成:%s → %s\n' "$SITE_PATH" "$NEW_VERSION"
    sleep 3
done

# ✴️ Multisite:外掛檔案只需要更新一次,會影響全網
WP_PATH="/var/www/network/public_html"

wp --path="$WP_PATH" plugin update wp-recipe-maker
wp --path="$WP_PATH" plugin get wp-recipe-maker --fields=name,status,version,update,update_version

# 如果外掛曾被網路停用,確認乾淨之後才重新啟用:
# wp --path="$WP_PATH" plugin activate wp-recipe-maker --network

預期輸出範例:

Updating plugin: WP Recipe Maker (10.8.1)...
Success: Updated 1 of 1 plugins.
name: wp-recipe-maker
status: active
version: 10.8.4
update: none
update_version:

分支判斷邏輯:更新成功 👉 立刻進入驗證;更新失敗 👉 不要反覆重跑,先看錯誤訊息,確認磁碟空間、檔案權限與外掛目錄的擁有者;更新後畫面壞掉 👉 先停用外掛,去 Staging 找出是佈景主題、快取還是其他外掛的衝突,不要把版本降回 10.8.1 以下,因為那等於把漏洞重新放回網站 🙅‍♂️

🧑‍🔧 多站環境的實務原則:一次只處理一個網站,至少第一批部署時是如此。先完成一站的「備份 👉 更新 👉 驗證」,確認沒有 PHP Fatal Error、白畫面、後台登入異常,也沒有食譜卡片與星星評分的顯示問題,再處理下一站。這比一個巨大迴圈從頭跑到尾更容易控制風險,而迴圈裡的 break 就是為了「失敗就止步」而放的煞車 🎛️

🔬 ❹ 驗證:更新成功不等於工作完成,至少確認四層

# 💠 單一網站
WP_PATH="/var/www/example.com/public_html"
MIN_SAFE="10.8.2"
PAGE_URL="https://example.com/your-recipe-page/"

# ❶ 版本層:自動比較,不靠肉眼
VERSION="$(wp --path="$WP_PATH" plugin get wp-recipe-maker --field=version)"
if [ "$(printf '%s\n' "$MIN_SAFE" "$VERSION" | sort -V | head -n 1)" = "$MIN_SAFE" ]; then
    printf '🟢 版本 %s 已達安全門檻\n' "$VERSION"
else
    printf '🔴 版本 %s 仍然受影響\n' "$VERSION"
fi

# ❷ 檔案完整性層:跟官方發布版比對 Checksum
wp --path="$WP_PATH" plugin verify-checksums wp-recipe-maker
wp --path="$WP_PATH" core verify-checksums

# ❸ 前台功能層:首頁與食譜頁是否正常,JSON-LD 的 reviewBody 是否只剩留言原文
curl -sS -o /dev/null -w "首頁 HTTP 狀態碼:%{http_code}\n" -- "https://example.com/"
curl -sS -o /dev/null -w "食譜頁 HTTP 狀態碼:%{http_code}\n" -- "$PAGE_URL"
curl -sS -- "${PAGE_URL}?nocache=$(date +%s)" | grep -o '"reviewBody":"[^"]*"' | head -n 5

# ❹ 更新後複查:留言查詢與 mu-plugins(沿用第五章 ㊃ 的指令)

# ❇️ 獨立多站:逐站確認版本與 Checksum
SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
    SITE_PATH="$(dirname -- "$CONFIG")"
    OWNER="$(stat -c '%U' -- "$CONFIG")"

    if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin is-installed wp-recipe-maker </dev/null 2>/dev/null; then
        continue
    fi

    printf '\n===== %s =====\n' "$SITE_PATH"
    sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin get wp-recipe-maker --field=version </dev/null
    sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin verify-checksums wp-recipe-maker </dev/null 2>&1 | tail -n 3
done

# ✴️ Multisite:版本與 Checksum 各查一次,再逐站確認首頁回應
WP_PATH="/var/www/network/public_html"

wp --path="$WP_PATH" plugin get wp-recipe-maker --field=version
wp --path="$WP_PATH" plugin verify-checksums wp-recipe-maker
wp --path="$WP_PATH" core verify-checksums

wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
    curl -sS -o /dev/null -w "%{http_code} ${SITE_URL}\n" -- "$SITE_URL"
done

預期輸出範例:

🟢 版本 10.8.4 已達安全門檻
Success: Verified 1 of 1 plugins.
Success: WordPress installation verifies against checksums.
首頁 HTTP 狀態碼:200
食譜頁 HTTP 狀態碼:200
"reviewBody":"這道超好吃,家人都大推"

🚨 畫重點!Checksum 驗證的侷限:它只能證明「外掛與核心目錄裡的檔案跟官方發布版一致」,證明不了整個網站沒有後門。它看不到 mu-plugins、uploads 目錄,也看不到資料庫裡留下的異常內容,更看不到其他外掛或佈景主題有沒有被動過手腳。所以請把它當成完整性檢查的其中一環,而不是唯一的安全保證,並搭配第五章 ㊃ 的後門檢查一起看 🔐

分支判斷邏輯:

  • 🟢 版本達標、Checksum 通過、前台正常,而且 reviewBody 只剩留言原文 👉 更新成功,這個洞已經補上 ✅
  • 🟡 reviewBody 裡出現你不認得、留言者也沒寫過的內容 👉 可能是快取還留著舊資料,先清掉頁面快取與物件快取,再重新檢查;仍然異常就回到第六章 A 🧹
  • 🟠 Checksum 失敗 👉 外掛檔案被改過或更新不完整,重新安裝官方版本後再驗證一次,並且擴大檢查範圍 🔧
  • 🔴 更新後仍持續出現可疑請求,或 mu-plugins 出現新的陌生檔案 👉 不要當作「已經修好了」,回到第六章 A 重新處理 🚨

更新完成之後,還有幾件收尾的事:把第六章 B 暫時改動的設定(人工核准、伺服器規則)還原;到 Google Search Console 對曾經洩漏內容的頁面要求重新索引;把更新的版本號與時間寫進維運紀錄;訂閱外掛的安全通知,下一次不用等別人告訴你 📔

🔥 一句話總結:真正安全的批次維運,是先盤點、再備份、再 Dry-run,確認無誤才逐站更新,最後驗證。環境判斷錯誤,比指令寫錯更致命;這套 判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證 的閉環,換成下一次別的外掛、別的 CVE,依然可以直接套用 🦸


🧠 🧠 🧠

🤯 舉一反三:遇到這一類漏洞,你可以隨身帶著的三個防禦心法

這次的問題屬於「不受信任的輸入,被丟進會執行程式的函式」這一大類(CWE-94)。下面三個心法,是這一類漏洞的通用防禦知識,不是這個 CVE 的官方要求,但值得記在腦子裡,下次看到類似的公告,就能很快判斷它在講什麼 📜

  • 不受信任的文字,別餵給會「解析並執行」的函式:留言、表單、投稿這類使用者可以自由輸入的欄位,在交給 do_shortcode() 之前,應該先確認它的來源;如果是要產生 JSON-LD、REST API 這種「不用給人看」的輸出,更應該乾脆不解析短代碼 🔐
  • 順序比函式本身更重要:比較安全的處理順序是「輸入清理 👉 短代碼解析 👉 輸出轉義與標籤剝離」。這次的問題,就是先解析、後剝離,剝離做得再嚴謹也追不上已經發生的執行 🚪
  • 縮小「資料出口」,也縮小「入口」:定期盤點站上註冊了哪些短代碼,把不需要的外掛移除;高風險的站,把留言維持人工核准。攻擊者要偷資料,需要一個入口跟一個出口,你能關掉任何一個,這條路就走不通 🔒

🧊 冷知識:「最小驚訝原則」是資安工程師的護身符
軟體設計裡有一條老原則叫「最小驚訝原則」:一個東西的行為,應該符合使用者從它的名字與外觀所做出的預期。函式叫 sanitize,卻在裡面執行程式,就是對這條原則最直接的違背。日後看到函式名稱、按鈕文字與實際行為有落差的程式碼,都可以先在心裡加一個問號 🥟


🏁 🏁 🏁

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

評估維度 分數 一句話評語
漏洞嚴重程度(越高代表風險越大) 9.1 官方評分是嚴重等級,但實戰門檻是「留言要過審核」與「站上要有會吐資料的短代碼」,這兩件事你都握有主導權
官方應變速度 8 更新日誌明列修補並致謝通報者,還在幾天內追加 10.8.4;從通報到修補的實際天數只有單一來源,給分保守【❓待確認】
一般站長自救可行性 9 後台點「立即更新」就能解決,翻留言也不需要任何指令,門檻低、急迫度卻很高
DevOps 技術文件完整度 7 成因、CVSS 向量與修補版本都很清楚,但官方更新日誌只有一行,攻擊細節與受影響的起始版本多半要從第三方公告拼湊【❓待確認】
🏆 加權綜合建議 本週內完成更新 最終建議:【本週內完成升級;留言設成自動核准的站,今天就處理】

🔥 這起事件給了每個站長一個提醒:漏洞不見得躲在複雜的核心系統裡,有時候反而藏在一個「幫你把食譜整理得漂漂亮亮」的日常小工具背後。WP Recipe Maker 本身是一個評價很高的外掛,真正的問題是,只要它還在 10.8.1 以下、留言又是自動核准,隱形條碼上就可能多出你不知道的東西。與其等哪天在 Google 搜尋結果裡看到奇怪的內容,不如現在花五分鐘,打開後台看一眼版本號碼 🔢

[48]


📎 📎 📎

📚 參考資料與延伸資源

列印本文 [62] 👨‍👩‍👧‍👦 59 次瀏覽