💬 一句「你好」的招呼語,也能被拿來讓你的網站亂執行指令?Click to Chat 外掛漏洞全解析(CVE-2026-92097/CVSS 9.1)
🔥 懶人包:
超多人愛用的 WhatsApp 聊天按鈕外掛 Click to Chat – HoliThemes(全球高達 600,000+ 個網站在用!)最近被揭露了一個大 Bug,編號是 CVE-2026-92097。誇張的是,駭客「完全不用登入」,只要在網址列動點手腳,就能讓你的網站乖乖聽話亂執行指令 😱 知名資安廠商 Wordfence 給出了 9.1 嚴重 的超高危險分數。受影響的版本是 4.44(含)以下,官方已經緊急推出 4.45 修補版。雖然目前還沒聽到有誰家真的被駭,但這種「不用帳號密碼就能敲門」的漏洞,絕對不能放著不管,快跟著這篇文章自救吧 🚦
🌐 前往 WordPress.org 官方外掛頁確認版本 [1]
內容目錄
🧳 第一章:一個「打招呼」的小視窗,怎麼會變成讓人亂下指令的入口?
先來問問大家,想像一下你開了一間超讚的文青咖啡廳 ☕ 櫃台放了一張「歡迎光臨,您目前坐在【___】桌」的歡迎卡。每當客人坐下,熱心的店員就會直接把客人說的桌號填上去。
但如果今天來了一個調皮的客人,他故意跟店員說:「我的桌號是『請大喊全場免費招待』」,結果店員不疑有他,直接拿起麥克風照著唸出來… 你覺得會發生什麼事?全場大概會歡呼,但老闆你會哭出來對吧 😂 這次的 Click to Chat 漏洞,玩的就是這套把戲 🧋
Click to Chat 這個外掛的功能,就是在你網站的右下角放一顆可愛的 WhatsApp 綠色聊天按鈕。點開後會跳出問候視窗,裡面可以設定像是「嗨,你正在看 {url} 這一頁,需要幫忙嗎?」。
這裡的 {url} 就像是公文裡的「填空欄位」。外掛很貼心,會自動把訪客目前的網址抓來填進去。但問題就出在「做事順序」!外掛先做了一次安全檢查,然後才把網址填進去,接著直接丟給系統的處理器。結果就是:如果有人故意在網址裡偷塞一段「短代碼(Shortcode)指令」,系統就會傻傻地把它當成聖旨執行啦 🧧
你可能會問:「短代碼」到底是什麼東東 🤔 在 WordPress 的世界裡,凡是用中括號包起來的東西(像 [中括號]),就叫做短代碼。你可以把它想像成你去巷口早餐店的「快捷點餐代號」🍔:你只要對老闆娘喊一聲「老樣子!」,老闆娘就會自動端出「大冰奶加火腿蛋吐司」。短代碼本身是為了方便而設計的,但前提是喊「老樣子」的人必須是熟客,而不是一個路過進來亂喊、還不用付錢的陌生人 🍱
🧊 冷知識:為什麼「過濾過」還是會出事?
這個漏洞有一個很有意思的細節:外掛其實有用 wp_kses() 過濾內容,用意是擋掉危險的 HTML,但它是在「填入網址之前」過濾的;網址是後來才填進去的,所以那次過濾對網址完全沒作用。後面的 sanitize_text_field() 只負責拿掉 HTML 標籤和多餘空白,並不會移除中括號,所以短代碼的語法原封不動地留了下來。這是一個經典的順序問題:「先洗菜、再加料」,加進去的料就沒洗到 🥬
整個出包的流程,我們用大白話來拆解一下,總共就這四步:
- ㊀ 訪客(任何人,不需要登入)打開網站的某個彙整頁。像是分類頁、標籤頁,網址列最後面可以被人隨意加上奇怪的符號 ✅
- ㊁ 外掛準備顯示問候視窗,把當下的網址填進 {url}。它抓了網址,卻沒有把危險的中括號洗掉 ✅
- ㊂ 填好的問候語被送進系統處理。系統開始讀取這些文字 ✅
- ㊃ 網址裡的中括號被當成「短代碼指令」執行了!至於駭客能做到什麼壞事?那就要看你的網站上「剛好有哪些快捷指令」可以被他濫用了 🟡
⏱️ 這件事跑得多快?時間軸一次看
我幫大家把各大資安網站的資料比對了一下,整理成下面這個簡單的時間軸。好消息是,這個漏洞被公開的時候,官方早就已經準備好修補版了!這點值得給個讚 👍
| 時間 | 發生了什麼事 | 可信度 |
|---|---|---|
| 2026-09-15 | 廠商收到通知、CVE 編號被保留 | ✅ 已確認 |
| 2026-10-13 | Wordfence 揭露、Patchstack 同日發布漏洞條目;此時修補版 4.45 已經火速上架啦! | ✅ 已確認 |
| 2026-10-14 | CVE 紀錄正式發布(UTC 凌晨 01:27) | ✅ 已確認 |
| 發現者 | CVE 紀錄署名 Kuba | ✅ 已確認 |
| EPSS(被利用機率預測) | 0.00525,百分位約 0.43(2026-10-14 資料) | ✅ 已確認 |
| 是否已被實際利用 | 我查到的公開資料中沒有任何實際攻擊或公開攻擊程式的紀錄;CISA KEV 清單我這邊無法直接驗證,OpenCVE 等來源顯示未列入 | 【❓待確認】 |
😦 碎碎念:CVSS 為什麼有人寫 9.1、有人寫 5.3?
CVE 紀錄裡 Wordfence 給的是 CVSS 3.1 的 9.1(危險程度超高:網路可達、免登入、機密與完整性影響都是「高」)。Patchstack 頁面則標 5.3 中等優先度。兩邊差這麼多,到底誰的算法比較貼近現實?這部分【❓待確認】。實務上你只要記得:兩個數字都沒有說「可以放著不管」,而且影響程度的關鍵,是你網站上到底裝了什麼危險指令 🚦
那它被成功利用之後,最壞會走到哪一步?一樣照「確定程度」一層一層說給你聽:
- ✅ 已確認事實:不用登入、不用誰點任何東西,彙整頁網址就能讓網站執行任意短代碼(CWE-94)。受影響版本 ≤ 4.44,4.45 已修補。
- ✅ 已確認事實:WordPress.org 的 4.45 更新日誌寫的是「彙整頁標題與網址在問候視窗及預填訊息中,不再帶有 HTML 標記」。這句話本身沒提資安,我拿它來交叉對照修補版號,不能把它解讀成「官方已完整說明修補細節」。
- 🟡 合理推論:「任意短代碼執行」不等於「任意 PHP 執行」。能造成多大破壞,取決於你的佈景主題、頁面編輯器與其他外掛註冊了哪些短代碼;如果其中有會讀取資料、顯示內容、甚至接受參數去做事的短代碼,風險就會放大。
- 🔴 假設情境:如果某個外掛註冊了「包含檔案」或「顯示任意資料庫內容」之類的危險短代碼,攻擊者就可能借道讀到不該讀的東西。這只是為了說明風險鏈而做的假設,不是這次事件裡已被證實的事。
🎭 第二章:先別慌!來看看你屬於哪一種苦主情境
🩵 荷包試算:更新要花錢嗎?
別擔心!Click to Chat 基本版是 WordPress.org 的免費外掛,更新完全不用錢,只要在後台點「立即更新」就搞定了!相較之下,如果真的不幸被駭客入侵,事後找工程師來「清後門」、「修復商譽」的費用,那可是會讓你荷包大失血的喔!💸
漏洞這種事,同一份新聞稿,小編、自架玩家、公司 IT 該做的事完全不一樣。把四種常見的人拆開來聊,你直接對號入座就好 👇
🙋 一般輕量使用者:我只是個快樂的網站小編
如果你平常只負責發發文章、回回留言,那你要做的事情超簡單:確認版本、按更新、打完收工! 後面太硬核的技術章節可以直接跳過,跟著第三章的步驟點點滑鼠就能自救囉 🧋
👨💻 自架玩家:我有一台自己的主機,掌管好幾個網站
你的重點在於「快狠準」以及「絕對不能漏掉任何一個網站」!如果你的主機剛好是 HestiaCP 這類面板,記得要地毯式盤點每一站。千萬別以為「A網站更新了,B網站就沒事」📜
🏢 企業情境:我的網站有會員資料或線上購物車
如果你的 Click to Chat 還停在 4.44 或更舊的版本,拜託現在、立刻、馬上更新!這類網站通常裝了一大堆外掛,被「短代碼」互相影響的機率超高。更新完後,最好還是請工程師稍微檢查一下有沒有異常,畢竟牽涉到客戶個資,小心駛得萬年船 🩺
🚨 高風險誤用:外掛早就沒在用了,但一直懶得刪
「啊!那個聊天按鈕我早就不放了,只是外掛一直沒關掉啦~🤡 」
🙅♂️ 這種心態最危險!只要外掛還處於「啟用」狀態,漏洞的程式碼就會一直載入。不用就果斷刪除,這才是保護網站的最好習慣 🪤
🚨 畫重點!千萬別踩雷:
有人以為「我的網站是 Elementor 等頁面編輯器做的,聊天按鈕只在首頁,應該不會中」。這個想法站不住腳:官方說明提到的觸發位置是分類、標籤、作者這類彙整頁,這些頁面幾乎每個網站都有。至於預設設定是否就含有 {url}?這點【❓待確認】,所以請一律當作「版本舊了就算暴露在危險中」處理 🙅♂️
🧑🔧 DevOps 小知識:更新之後還要多看兩件事
第一,4.45 是 CVE 紀錄與 Patchstack 共同指向的修補版,更新到 4.45 以上即可。第二,攻擊的實際 payload 與觸發端點,官方都沒有公開細節,我也不打算猜;所以日誌排查只能用「請求網址裡出現中括號」這種通用指標,且 access log 不記錄 POST 內容,找不到不代表沒被打到。這部分第五章會一步步帶你做 🛡️
🛟 第三章:五分鐘無痛自救指南(電腦小白也能跟著做)
🫂 新手求助:官方的求助管道在哪?
外掛作者 HoliThemes 在 WordPress.org 有自己的支援討論區(外掛頁面的「支援」分頁),可以發問,也能看到其他人遇到的狀況。🆘
不管你是哪一種情境,這段「自救指南」都請跟著走一遍。全程不需要打任何一行可怕的程式碼,只要動動滑鼠就能完成 🤏
- ㊀ 先去哪裡看版本?
就像去大賣場買鮮奶要看保存期限一樣!登入 WordPress 後台,點擊左邊選單的「外掛」→「已安裝的外掛」,找找看有沒有 Click to Chat – HoliThemes。名稱下方就會顯示版本號囉!沒裝?那恭喜你,安全下莊 🔢 - ㊁ 看到版本號,怎麼判斷安不安全?
超級簡單的數學題:小於 4.45 就是危險(例如 4.44、4.40),4.45 或以上的數字就是安全的修補版 🚥 - ㊂ 怎麼更新?
如果版本太舊,外掛名稱下方通常會出現一個閃亮的「立即更新」按鈕,給它大力點下去就對了!大約去起身去泡杯咖啡的時間就會更新完畢 ☕ - ㊃ 更新前,要備份嗎?
一定要!這就像出國旅行出發前要買旅平險一樣。最簡單的方法就是請主機商幫忙做個快照備份 🛟 - ㊄ 更新完,怎麼確認有沒有「已經被偷闖空門」?
去後台的「使用者」選單,看看有沒有突然多出你不認識的「管理員」帳號。如果有奇怪的英文名字或亂碼信箱,那就要提高警覺了 🔍 - ㊅ 判斷不出來,或發現可疑東西,怎麼辦?
先深呼吸,千萬不要自己亂刪東西!把畫面截圖下來,連同這篇文章的網址傳給幫你架站的工程師或主機客服,說:「我的外掛有 CVE-2026-92097 漏洞,可以幫我檢查嗎?」專業的事交給專業的來就對了 🙇
✅ 小提醒: 如果你現在人不方便,真的沒辦法馬上更新,最快的一招就是先到後台把 Click to Chat 按「停用」。這樣雖然聊天按鈕會暫時消失,但可以立刻阻斷駭客的入侵管道,等你回家再好好處理 🧯
🤿 第四章:技術深潛篇——這條「網址變指令」的路徑,在伺服器上到底長什麼樣?
接下來是給有 SSH 權限、想知道「為什麼會這樣、怎麼查」的人看的內容。只想補洞的話,前三章已經夠用了;想知道細節的,我們繼續往下拆 ⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | Click to Chat – HoliThemes(WordPress.org 外掛庫,另有 PRO 版) |
| 外掛代號(slug) | click-to-chat-for-whatsapp |
| CVE 編號 | CVE-2026-92097 |
| CVSS | 9.1(Wordfence/CNA);Patchstack 頁面標 5.3 |
| CVSS 向量 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N |
| 弱點類型 | CWE-94:程式碼生成控制不當(程式碼注入) |
| 受影響版本 | ≤ 4.44 |
| 官方修補版 | 4.45 |
| 漏洞位置 | greetings_dialog() 函式、greetings-1/greetings-2 樣板 |
| 觸發條件 | 彙整頁(分類、標籤、作者)+問候語含 {url} 佔位符;預設設定是否含 {url}【❓待確認】 |
| 是否已遭利用 | 未查到實際攻擊與公開 PoC;KEV 我無法直接驗證【❓待確認】 |
先把「為什麼過濾沒擋住」講清楚,因為這是看懂整件事的關鍵。簡化後的程式流程大概是這樣(以下是概念示意,不是原始碼,也不是攻擊程式):
- 第一關:外掛把你在後台填的問候語(含 {url})先過 wp_kses(),確保後台設定裡沒有惡意 HTML。這一步是對的。
- 第二關:顯示彙整頁時,把 {url} 換成 REQUEST_URI 的內容,中間只過了 sanitize_text_field()。它會拿掉標籤、多餘空白與換行,不會處理中括號。
- 第三關:替換完成的字串交給 do_shortcode()。而 do_shortcode() 的設計本來就是「看到 [名稱] 就去執行同名的短代碼」。
- 漏洞形成:第一關的過濾發生在第二關「加料」之前。網址是在加料時才進來的,所以那道過濾對它沒有任何作用。
🧑🔧 DevOps 小知識:為什麼不建議你在日誌裡硬找「某個固定字串」?
Wordfence 與 Patchstack 都沒有公開具體的 payload 範例或觸發端點,網路上若有人給你一串「保證能抓到」的特徵字,多半是臆測。可以確定的只有一件事:攻擊必須把中括號放進彙整頁的請求網址裡(字面的 [中括號],或是被編碼過的 %5B %5D)。所以日誌排查的邏輯是「找請求行裡出現中括號的彙整頁請求」,而不是找某個神祕關鍵字。另外,Nginx 與 Apache 預設的 combined 日誌格式不記錄 POST 內容,如果攻擊是走 POST,你在日誌裡看不到內文;找不到線索只能說「目前沒有直接證據」,不能說「一定沒事」🧠
🧊 冷知識:同一類漏洞,每隔幾年就換個外掛重演一次
「把使用者輸入的東西,在過濾之後又重新拼進一個會被當程式解讀的字串」,是程式碼注入類漏洞最經典的成因。這次的特別之處是「使用者輸入」不是表單欄位,而是網址列本身。很多開發者習慣只防表單,卻忘了 URL、Header、Cookie 同樣是外面來的資料,只是比較不顯眼 🎭
🕵️ 第五章:資安排查 Checklist——怎麼確認自己「有沒有已經被進來過」?
🤖 重要提醒:以下指令由五份來源報告(DeepSeek、Gemini、Meta AI、Grok、ChatGPT)彙整,經 Claude 複查修正與整理,並依 HestiaCP 官方文件與 WP-CLI 官方文件逐項複查,所有程式碼區塊都做過 bash 語法檢查
但你的主機環境不一定跟範例一樣。正式環境(尤其是正在營業中的網站)的高風險操作——包含更新、停用、刪除檔案、變更權限、重設密碼或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin、bob 都是示範用名稱,請換成你自己的環境 🧙
先說這一章的地圖:環境判斷 👉 盤點(版本比對)👉 Log 日誌排查 👉 後門檢查 👉 加固檢查 五關,每一關都分成「為什麼這樣做、指令、預期輸出、看到什麼該怎麼辦」,而且單一網站、獨立多站、Multisite 三種架構都有對應。本文預設環境是 Debian 12 + HestiaCP,網站放在 HestiaCP 的標準位置 /home/使用者/web/網域/public_html,並假設跟 HestiaCP 預設一樣是 Apache + Nginx 反向代理的組合。預期輸出只是典型範例,版本不同可能略有差異 🧭
🧰 ㊀ 先貼這一段:整章都會用到的小工具
這段只是定義幾個小函式,不會動到任何網站。請在同一個 SSH 連線裡先貼一次,連線中斷後要重貼。wpu 的作用,是讓 WP-CLI 一律用「網站擁有者」的身分執行,而不是 root,避免把檔案權限搞亂;version_lt 則是用「版本排序」來比大小,不會把 4.9 和 4.45 搞反。沒訊息就是成功;如果最上面出現「找不到 wp 或 wp-cli」,代表你的 WP-CLI 不在這個帳號的 PATH 裡,先處理這件事再往下 🪛
# ㊀ 工具箱:同一個 SSH 連線貼一次就好,連線中斷後要重貼
PLUGIN="click-to-chat-for-whatsapp"
FIXED="4.45"
# 找出 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
# 以「網站檔案擁有者」的身分執行 WP-CLI:wpu 擁有者 網站路徑 wp指令...
wpu() {
local owner="$1" wp_path="$2"
shift 2
sudo -u "$owner" -H -- "$WPCLI" --path="$wp_path" "$@"
}
# 由網站路徑反推擁有者與網域(HestiaCP 標準路徑:/home/使用者/web/網域/public_html)
site_owner() { stat -c '%U' -- "$1/wp-config.php"; }
site_domain() {
local p="$1"
p="${p#/home/*/web/}"
printf '%s\n' "${p%%/*}"
}
# 列出主機上每一個 WordPress(以 NUL 分隔,路徑含空白也不會斷)
list_wp_configs() {
sudo find /home -maxdepth 7 -type f -name 'wp-config.php' \
-path '*/web/*/public_html/*' -not -path '*/wp-content/*' -print0 2>/dev/null
}
# 逐站執行:each_site 函式名稱(任何一站失敗就停下,不會連鎖出事)
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 舊(不用字串比大小,2.9 與 2.45 才不會搞反)
version_lt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
classify_version() {
local v="$1"
if [ -z "$v" ]; then
echo "⚪ 未安裝"
elif version_lt "$v" "$FIXED"; then
echo "🔴 受影響(低於 $FIXED)"
else
echo "🟢 已達修補版本($FIXED 以上)"
fi
}
🧑🔧 DevOps 小知識:為什麼迴圈都長成 3< <(find …) 這個樣子?
常見寫法是 find | while read,但迴圈裡只要有任何指令會讀標準輸入(sudo、mysql 都有可能),就會把清單吃掉一半,結果只處理到第一個網站。把 find 的輸出接到檔案描述符 3,再用 read … <&3 讀,迴圈內就不會互相搶輸入;加上 -print0 與 read -d ”,路徑含空格也不會斷掉 🔧
🧭 ㊁ 環境判斷:你在管一個 WordPress,還是一整台 WordPress 主機?
為什麼這樣做:「一台主機上有 10 個 WordPress」跟「一個 Multisite 裡有 10 個子站」長得很像,其實完全不同。前者要逐一找每個 wp-config.php,後者外掛檔案全網只有一份。判斷錯了,後面的備份、更新、驗證範圍都會跟著跑偏。這一步只讀不寫 🤔
detect_one() {
local wp_path="$1" owner kind
owner="$(site_owner "$wp_path")"
if wpu "$owner" "$wp_path" core is-installed --network 2>/dev/null; then
kind="✴️ Multisite"
elif wpu "$owner" "$wp_path" core is-installed 2>/dev/null; then
kind="💠 一般安裝"
else
kind="❓ 載入失敗"
fi
printf '%s\t擁有者:%s\t%s\n' "$kind" "$owner" "$wp_path"
return 0
}
# 💠 單一網站:只看自己這一站
detect_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站/✴️ Multisite:把整台主機掃一遍
each_site detect_one
預期輸出範例:
💠 一般安裝 擁有者:admin /home/admin/web/example.com/public_html 💠 一般安裝 擁有者:admin /home/admin/web/site-a.com/public_html 💠 一般安裝 擁有者:bob /home/bob/web/shop.example/public_html ✴️ Multisite 擁有者:bob /home/bob/web/network.example/public_html
看到什麼,該怎麼辦:
- 全部都是「一般安裝」,而且只有一行 → 你是 💠 單一網站。
- 多行、每行都是「一般安裝」→ ❇️ 獨立多站,後面每一站都要各自處理,不能拿 A 站結果代表 B 站。
- 出現「Multisite」→ ✴️ Multisite,外掛檔案屬於整個 Network,更新一次即可,但要逐 Site 驗證。
- 出現「❓ 載入失敗」→ 先查 PHP、檔案權限或路徑,不要直接對它下更新指令。同一站如果被找到兩個 wp-config.php,多半是備份或測試副本,請人工確認哪個才是正式站。
- 出現 Permission denied 或 sudo: command not found → Debian 最小安裝可能沒裝 sudo,用 root 登入或先安裝;AlmaLinux 看起來權限正常卻被擋,可能是 SELinux,用 getenforce 檢查。
🔎 ㊂ 盤點(版本比對):誰裝了它?現在是哪一版?
為什麼這樣做:版本號不寫死在判斷邏輯裡,改用上面的 classify_version 即時判斷。這一步只讀不寫,放心跑 🔍
inventory_one() {
local wp_path="$1" owner ver status
owner="$(site_owner "$wp_path")"
if ! wpu "$owner" "$wp_path" core is-installed 2>/dev/null; then
printf '❓ 載入失敗\t-\t-\t%s\n' "$wp_path"
return 0
fi
ver="$(wpu "$owner" "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null || true)"
status="$(wpu "$owner" "$wp_path" plugin get "$PLUGIN" --field=status 2>/dev/null || true)"
printf '%s\t版本:%s\t狀態:%s\t%s\n' \
"$(classify_version "$ver")" "${ver:--}" "${status:--}" "$wp_path"
return 0
}
# 💠 單一網站
inventory_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:整台主機一次盤點(只讀不寫)
each_site inventory_one
# ✴️ Multisite:外掛檔案全網只有一份,查主站一次就代表整個網路
# status 欄顯示 active-network 代表全網啟用
inventory_one "/home/bob/web/network.example/public_html"
預期輸出範例:
🔴 受影響(低於 4.45) 版本:4.44 狀態:active /home/admin/web/site-a.com/public_html 🟢 已達修補版本(4.45 以上) 版本:4.45 狀態:inactive /home/bob/web/shop.example/public_html ⚪ 未安裝 版本:- 狀態:- /home/bob/web/blog.example/public_html
看到什麼,該怎麼辦:🔴 的站直接排進今天的修補清單;🟢 的站版本沒問題,但如果它舊版時期曾經對外開放,後面三關仍要做;⚪ 代表沒裝,不用管。狀態是 inactive 也不能完全放心:外掛檔案還在磁碟上,只是沒載入,是否安全要靠更新或刪除來確定 🟡
📊 ㊃ Log 日誌排查:找「請求網址裡的中括號」,不要找 body
為什麼這樣做:前面說過,官方沒公開 payload,標準日誌也不記 POST 內容。所以我們只能找一個必然會留下的痕跡:彙整頁的請求網址裡出現了中括號。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log,輪替後的舊檔是 .log.1、.log.2.gz,下面的函式會用 zcat -f 一併讀取純文字與壓縮檔。反向代理模式下,Nginx 的日誌才有真實訪客 IP;如果前面還擋了一層 CDN,看到的可能是 CDN 的 IP。腳本只切出「請求行」那一欄來比對,時間戳記裡的方括號不會誤報 🔎
# 取出某網域「目前+輪替後(含 .gz)」的所有 nginx 存取日誌
scan_domain_log() {
local domain="$1"
sudo find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.log*" -print0 \
| sudo xargs -0 -r zcat -f --
}
log_scan_one() {
local wp_path="$1" domain literal encoded
domain="$(site_domain "$wp_path")"
printf '\n===== %s =====\n' "$domain"
# 只看「請求行」那一欄(用雙引號切欄),時間戳記裡的方括號不會誤報
literal="$(scan_domain_log "$domain" | awk -F'"' '$2 ~ /\[.*\]/' | tail -n 20)"
encoded="$(scan_domain_log "$domain" | awk -F'"' 'tolower($2) ~ /%5b|%5d|%255b|%255d/' | tail -n 20)"
echo "--- ❶ 請求行有「字面方括號」(最值得看)---"
if [ -n "$literal" ]; then
printf '%s\n' "$literal"
echo "--- ❷ 上面這些請求的來源 IP 排行 ---"
printf '%s\n' "$literal" | awk '{print $1}' | sort | uniq -c | sort -rn | head -n 10
else
echo "(無命中)"
fi
echo "--- ❸ 請求行有「編碼過的方括號」(掃描器試探也會留下,單獨出現不代表中招)---"
if [ -n "$encoded" ]; then
printf '%s\n' "$encoded"
else
echo "(無命中)"
fi
return 0
}
# 💠 單一網站
log_scan_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站/✴️ Multisite:掃主機上每一個網域
each_site log_scan_one
預期輸出範例(可疑的樣子):
===== example.com =====
--- ❶ 請求行有「字面方括號」(最值得看)---
203.0.113.42 - - [15/Oct/2026:10:00:00 +0800] "GET /category/news/?x=[foo] HTTP/1.1" 200 5 "-" "python-requests/2.31.0"
--- ❷ 上面這些請求的來源 IP 排行 ---
1 203.0.113.42
--- ❸ 請求行有「編碼過的方括號」(掃描器試探也會留下,單獨出現不代表中招)---
(無命中)
看到什麼,該怎麼辦:
- ❶ 有命中,而且請求的是分類、標籤或作者頁 → 視為有人在試探或已利用。記下 IP 與時間,再把後門檢查做完整;若同時在後門檢查發現異常,直接跳到第六章 A。
- ❶ 有命中,但路徑是購物車篩選、搜尋功能這類本來就會用 [] 當參數的網址(例如 WooCommerce 篩選)→ 多半是正常使用,對照 User-Agent 與頻率再判斷。
- ❸ 只有編碼過的方括號 → 掃描器的試探也會留下,單獨出現不代表中招。
- 完全沒有命中 → 只能說「目前沒有直接證據」,不能證明沒被打過,因為日誌可能已經輪替、前面有 CDN,或是攻擊根本不是走 GET。繼續往下做檔案檢查。
🕳️ ㊄ 後門檢查:光是版本正確,不代表整站乾淨
為什麼這樣做:這次漏洞的直接後果是「執行短代碼」,而不是「寫入檔案」,所以不能期待一定會在檔案系統留下痕跡。但如果有人借道串到可寫檔的短代碼,常見的持久化位置還是那幾個:uploads 裡的 PHP 檔、mu-plugins(不用在後台啟用就會自動載入)、新增的管理員帳號、被改過的網站設定。以下每一項都只讀不寫,也不會刪任何東西。掃描起點設在官方揭露日 2026-10-13,想更保守可以改成 2026-09-15 🕵️♀️
# 掃描起點:官方揭露日(2026-10-13)。想更保守,可以改成 2026-09-15(廠商收到通知那天)
SINCE="2026-10-13"
audit_one() {
local wp_path="$1" owner prefix qf opt
owner="$(site_owner "$wp_path")"
printf '\n===== %s(擁有者:%s)=====\n' "$wp_path" "$owner"
echo "--- ❶ uploads 底下的 PHP 類檔案(正常網站應該是空的)---"
sudo 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(不用啟用就會自動載入,後台外掛頁看不到)---"
sudo 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(外掛正常更新也會出現,需人工比對)---"
sudo 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 "--- ❹ 管理員帳號 ---"
wpu "$owner" "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table
echo "--- ❺ 最近註冊的 10 個帳號(任何角色)---"
prefix="$(wpu "$owner" "$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"
wpu "$owner" "$wp_path" db query < "$qf"
rm -f -- "$qf"
echo "--- ❻ 幾個常被動手腳的網站設定 ---"
for opt in siteurl home admin_email users_can_register default_role; do
printf '%s = %s\n' "$opt" "$(wpu "$owner" "$wp_path" option get "$opt")"
done
return 0
}
# 💠 單一網站
audit_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:逐站各自檢查,不能拿 A 站的結果代表 B 站
each_site audit_one
# ✴️ Multisite:檔案與設定檢查做一次;帳號要多看一層「超級管理員」
audit_one "/home/bob/web/network.example/public_html"
wpu "bob" "/home/bob/web/network.example/public_html" super-admin list
預期輸出範例:
===== /home/admin/web/example.com/public_html(擁有者:admin)===== --- ❶ uploads 底下的 PHP 類檔案(正常網站應該是空的)--- --- ❹ 管理員帳號 --- +----+------------+-------------------+---------------------+ | ID | user_login | user_email | user_registered | +----+------------+-------------------+---------------------+ | 1 | admin | admin@example.com | 2024-01-15 10:00:00 | +----+------------+-------------------+---------------------+ --- ❻ 幾個常被動手腳的網站設定 --- siteurl = https://example.com home = https://example.com admin_email = admin@example.com users_can_register = 0 default_role = subscriber
看到什麼,該怎麼辦:
- ❶ 找到任何 PHP 類檔案 → 視為已中招。先別刪,也別急著更新,直接進第六章 A,那裡有取證與隔離流程。
- ❷ mu-plugins 出現不是你們部署的 PHP 檔,檔名像亂碼 → 高度可疑。
- ❸ 揭露日之後被改過的 PHP 清單 → 外掛正常更新也會出現,需要和你自己的維運紀錄比對,不要看到就當成入侵。
- ❹❺ 出現不認識的管理員,或是註冊時間落在 2026-10-13 之後的帳號 → 高度可疑。但「陌生」不等於「惡意」,可能是外包或維運商留下的,先問清楚再處理。
- ❻ users_can_register 變成 1、default_role 變成 administrator,或是 siteurl/home 被改成陌生網域 → 網站設定被動過手腳,這類改動很少是正常維運造成的。
- 全部乾淨 → 這一輪沒有發現明確跡象,仍建議做下一關的加固,並持續觀察一週的日誌。
🫂 新手求助:我只有 HestiaCP 控制台,不會 SSH 怎麼辦?
HestiaCP 網頁控制台在「網站」→ 你的網域 →「檔案管理員」(若有啟用)可以用滑鼠看 wp-content/uploads 裡有沒有 .php。看不懂或不確定的話,把整章指令連同這篇文章丟給你的主機商或維運廠商,請他們幫你跑一次,這不是你的責任,找人協助是正常做法 🤗
🔧 ㊅ 加固檢查:就算修好了,也把第二道門鎖起來
為什麼這樣做:更新外掛才是根本,但這起漏洞的教訓是「外面來的字串,被當成程式去執行」。以下三件事,每一件都是「通用加固」,不是 CVE 官方要求,目的是即使下次又出現類似漏洞,也能縮小殺傷力。每一個會改動系統狀態的動作,後面都附有驗證 🛡️
❶ PHP:停用高風險函式(縮小「萬一被執行了」的後果)
說明:這份清單刻意不含 curl_exec,因為 WordPress 的對外連線(外掛更新、排程)要靠 curl,停用了網站會出問題;也不要加 eval,它是 PHP 的語言結構,不是函式,寫進去沒有效果。部分備份或圖片處理外掛會用到 exec 或 proc_open,改完請實際測一次前台與後台。腳本會先備份 php.ini、設定檢查通過才重啟,沒過就自動還原,所以重複執行也不會疊加 ⚠️
# 對主機上每一個 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)"
sudo cp -- "$ini" "$bak"
sudo sed -i -E "s/^[;#]*[[:space:]]*disable_functions[[:space:]]*=.*/${line}/" "$ini"
# 檔案裡本來就沒有這一行的話,補在最後
if ! sudo grep -q '^disable_functions' "$ini"; then
printf '\n%s\n' "$line" | sudo tee -a "$ini" > /dev/null
fi
# 設定檢查通過才重啟;沒過就還原備份
if sudo "php-fpm${ver}" -t > /dev/null 2>&1; then
sudo systemctl restart "php${ver}-fpm"
printf 'PHP %s:已套用並重新啟動\n' "$ver"
else
sudo cp -- "$bak" "$ini"
printf 'PHP %s:設定檢查沒過,已還原備份:%s\n' "$ver" "$bak"
fi
sudo grep -H '^disable_functions' "$ini"
done 3< <(sudo find /etc/php -maxdepth 3 -type f -path '*/fpm/php.ini' -print0)
}
harden_php_ini
# 驗證一:服務有活著;驗證二:個別網域的 pool 設定沒有另外覆蓋(沒輸出代表沒覆蓋)
systemctl is-active php8.4-fpm
sudo 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 active
看到什麼,該怎麼辦:最後一行 active 代表 FPM 活著;grep … pool.d/*.conf 沒有輸出代表沒有個別網域的設定在覆蓋它(這是好事);出現「設定檢查沒過,已還原備份」→ 設定沒套用,網站不受影響,把輸出貼給維運人員。要復原就把 .BAK.時間 備份檔蓋回 php.ini 再重啟 PHP-FPM 🛡️
🧑🔧 DevOps 小知識:open_basedir 不用你再全域設定
有些教學建議在 php.ini 全域寫死 open_basedir,但你一寫死某個網站的路徑,其他網站全部壞掉。HestiaCP 的 PHP-FPM 樣板本來就會為每個網域設好 open_basedir,你可以用 sudo grep -H open_basedir /etc/php/*/fpm/pool.d/*.conf 確認自家主機有沒有(看到每個網域一行就對了)🔒
❷ 檔案權限與鹽值(Salts):依嚴重程度決定要不要刷
鹽值(Salts)就是 wp-config.php 裡那八組長字串,WordPress 靠它們幫登入 Cookie 簽章。把它們換掉,就像把整棟樓的鎖芯換掉:所有人,包括可能已經偷到 Cookie 的攻擊者,都會被強制登出。因為這個漏洞是「免登入的遠端執行」類型,我的判斷是:舊版(≤ 4.44)曾經對外暴露過,就刷;只是剛好裝了新版、從沒暴露過,可以不用。刷完所有使用者(包含你自己)都要重新登入。下面的函式會先把 wp-config.php 備份到網站目錄外面(因為裡面有資料庫密碼),權限收緊到 640,再執行 wp config shuffle-salts,最後拿 AUTH_KEY 的短雜湊做前後比對,確認真的換掉,而且不會把金鑰印在螢幕上 🔑
# 刷新鹽值:wp-config.php 裡那八組長字串一次全換掉,所有人的登入狀態立刻作廢
rotate_salts_one() {
local wp_path="$1" owner domain stamp bdir before after
owner="$(site_owner "$wp_path")"
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
bdir="$HOME/wp-security-backup"
mkdir -p -- "$bdir" && chmod 700 -- "$bdir" || return 1
# 備份放在網站目錄「外面」,因為裡面有資料庫密碼
sudo install -m 600 -o "$(id -u)" -g "$(id -g)" -- \
"$wp_path/wp-config.php" "$bdir/${domain}_wp-config.php.${stamp}" || return 1
# 只留短雜湊做前後比對,不把真正的金鑰印在螢幕上
before="$(wpu "$owner" "$wp_path" config get AUTH_KEY | sha256sum | cut -c1-12)"
sudo chmod 640 -- "$wp_path/wp-config.php"
wpu "$owner" "$wp_path" config shuffle-salts || return 1
after="$(wpu "$owner" "$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),權限:' "$domain" "$before" "$after"
stat -c '%a %U:%G' -- "$wp_path/wp-config.php"
return 0
}
# 💠 單一網站
rotate_salts_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:只列「確定舊版暴露過、或有疑慮」的網站,不要整台主機無差別全刷
EXPOSED_SITES=(
"/home/admin/web/site-a.com/public_html"
"/home/bob/web/shop.example/public_html"
)
for WP_PATH in "${EXPOSED_SITES[@]}"; do
rotate_salts_one "$WP_PATH" || break
sleep 2
done
# ✴️ Multisite:鹽值屬於同一份 wp-config.php,對網路根目錄刷一次就好,不要每個子站各刷一次
rotate_salts_one "/home/bob/web/network.example/public_html"
預期輸出範例:
Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),權限:640 admin:admin
看到什麼,該怎麼辦:看到 ✅ 且兩段雜湊不同 → 已生效,你的後台會被登出,重新登入即可;看到 ❌「鹽值沒有改變」→ 多半是 wp-config.php 不可寫,先查擁有者與權限;網站出現「資料庫連線錯誤」→ 立刻用備份還原:sudo cp -p “$HOME/wp-security-backup/網域_wp-config.php.時間” “網站路徑/wp-config.php”。備份檔放在 $HOME/wp-security-backup,確認一切正常後,請你再決定要不要保留(刪除前請再三確認)🪤
❸ 暫時擋掉「網址含中括號」的請求(只在還不能更新時用)
如果你因為相容性或營運因素,今天還不能更新,Nginx 層擋掉含中括號的網址是一個權宜辦法。HestiaCP 的網站設定檔會在「重建網域」或續約 SSL 時被樣板覆蓋,所以千萬不要直接改 nginx.conf;官方提供的做法是在 /home/使用者/conf/web/網域/ 放 nginx.conf_任意名稱(HTTP)與 nginx.ssl.conf_任意名稱(HTTPS),樣板會自動載入,且不會被覆蓋。下面的函式在 nginx -t 沒過時,會自動撤回剛建立的檔案,不會讓 Nginx 掛掉;拆除後也會再驗證一次 🧱
# 用 HestiaCP 認得的 nginx.conf_ 自訂檔擋掉含方括號的網址(面板重建設定時不會被蓋掉)
block_brackets() {
local owner="$1" domain="$2"
local conf_dir="/home/$owner/conf/web/$domain"
local http_inc="$conf_dir/nginx.conf_ctc_bracket_block"
local ssl_inc="$conf_dir/nginx.ssl.conf_ctc_bracket_block"
if [ ! -d "$conf_dir" ]; then
printf '找不到 %s,略過\n' "$conf_dir"
return 1
fi
sudo tee "$http_inc" > /dev/null <<'EOF'
# 暫時封鎖 CVE-2026-92097:網址含方括號(含編碼過的寫法)一律回 403(外掛更新完成後請移除)
if ($request_uri ~* "\[|\]|%5b|%5d|%255b|%255d") {
return 403;
}
EOF
sudo ln -sf "$http_inc" "$ssl_inc"
# 語法檢查通過才重新載入;沒過就把剛建的兩個檔案撤掉
if sudo nginx -t; then
sudo systemctl reload nginx
printf '已套用:%s\n' "$domain"
else
sudo rm -f -- "$http_inc" "$ssl_inc"
printf 'nginx 設定檢查沒過,已撤回規則:%s\n' "$domain"
return 1
fi
}
unblock_brackets() {
local owner="$1" domain="$2"
local conf_dir="/home/$owner/conf/web/$domain"
sudo rm -f -- "$conf_dir/nginx.conf_ctc_bracket_block" "$conf_dir/nginx.ssl.conf_ctc_bracket_block"
if sudo nginx -t; then
sudo systemctl reload nginx
printf '已解除:%s\n' "$domain"
else
printf 'nginx 設定檢查沒過,請先查原因,不要 reload\n'
return 1
fi
}
# 驗證:兩個請求都不會碰到 PHP,也不會留下任何暫存檔
check_block() {
local domain="$1"
curl -sS -o /dev/null -w '含方括號的網址:HTTP %{http_code}\n' --max-time 20 -g "https://${domain}/?q=[zzz]"
curl -sS -o /dev/null -w '一般網址:HTTP %{http_code}\n' --max-time 20 "https://${domain}/"
}
# 套用(把使用者與網域換成你的)
block_brackets "admin" "example.com"
check_block "example.com"
# 外掛更新完成後,把規則拆掉,並再驗證一次
unblock_brackets "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 含方括號的網址:HTTP 403 一般網址:HTTP 200 (更新完成、解除封鎖後) 已解除:example.com 含方括號的網址:HTTP 200 一般網址:HTTP 200
看到什麼,該怎麼辦:套用後「含方括號」回 403、「一般網址」回 200 → 規則生效;兩個都是 403 → 規則寫太寬或其他設定衝突,立刻執行 unblock_brackets 解除;解除後含方括號的網址又回到 200(或 404)→ 確認已拆乾淨。副作用要先知道:購物網站的商品篩選(例如網址裡有 filter[] 的參數)也會被擋,所以只能當暫時措施,更新完請馬上拆掉。這個驗證用的 curl 請求不會留下任何暫存檔 ♻️
🧑🔧 DevOps 小知識:這條規則擋不到什麼?
它只看網址(REQUEST_URI),攻擊若是把短代碼塞在別的位置(例如 Header 或 POST 內容),這條規則看不到。不過依 Wordfence 的描述,這次問題的來源是 REQUEST_URI,所以這條規則的方向是對的;只是「完整擋住」我沒有實際打過、沒有驗證過,不能把它當成修補的替代品【❓待確認】。純 Nginx(沒有 Apache 在後面)的架構,樣板載入順序可能不同,同樣沒有實機驗證 🧩
📋 ㊆ 三環境速查表:把五關濃縮成一張
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 環境判斷 | detect_one | each_site detect_one | –network 成功即是 |
| ② 盤點版本 | inventory_one | 逐站迴圈,動態取擁有者 | 查一次代表全網,看是否 active-network |
| ③ Log 排查 | 單一網域日誌 | 掃所有網域日誌 | 共用網域日誌 |
| ④ 後門檢查 | 檔案+帳號+設定 | 逐站各自檢查 | 檔案一次;帳號加看超級管理員 |
| ⑤ 加固 | php.ini+權限+Salt | Salt 只刷暴露過的站 | Salt 對網路根目錄刷一次 |
🤔 讀到有點喘?幾個常見焦慮先幫你解掉
- 🌠 Q:我的 Click to Chat 已經是 4.45,是不是就安全了?
A:就這個 CVE 來說,是的,4.45 就是官方修補版。但如果你的舊版曾經對外暴露,更新只能擋住「下一次」,擋不了「已經發生的事」,所以還是建議做完第五章的檢查 📦 - 🌟 Q:我根本沒用聊天按鈕,還需要處理嗎?
A:需要。只要外掛啟用著,有漏洞的程式碼就在載入。不用的外掛最乾淨的做法是直接刪除,而不是留著 🙅 - ✨ Q:更新之後,聊天按鈕外觀會不會跑掉?
A:這種安全修補通常不會改你的版面,但問候視窗的內容顯示(彙整頁標題與網址)有調整過。更新前先備份,更新後實際到幾個分類頁看一眼,是最保險的做法 💾 - 🌠 Q:我看不懂那些指令,是不是沒救了?
A:不會。第三章全程用滑鼠就能完成最基本的處理,第四章到第六章是給有 SSH 權限、想深入排查的人看的補充 🤗 - ✨ Q:Patchstack 說 5.3 中等,我是不是可以慢慢更新?
A:不建議。兩家分數落差的原因我查不到官方說明【❓待確認】,而且更新本身只要幾分鐘。風險不確定的時候,選成本最低的那條路:今天就更新 🚦
🛠️ 第六章:已經中招、還沒中招、準備修補——三種狀況各走各的路
前面解決的是「怎麼判斷」,這一章才是真正的維運 Runbook。主線流程固定是 判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證,只是如果你在第五章發現了跡象,要先走 A(止血取證)再回到主線 🧹
🚨 ㊀ A. 已中招/疑似中招:先止血、留證據,再修補
為什麼這樣做:一旦第五章出現明確跡象(uploads 裡有 PHP、日誌有可疑請求且伴隨異常、冒出陌生管理員),第一目標是「切斷入口、保留現場、隔離後門」,而不是急著更新。在沒留證據的情況下直接清理,會把最有價值的線索一起刪掉。還有一個現實要先講:如果確認攻擊者真的執行過程式,單純「刪一個可疑檔、更新外掛」常常不夠,因為他可能已經留下別的持久化管道,這時用已知乾淨的備份還原,再更新、再驗證通常比逐個清理可靠。下面的函式會依序做:停用外掛、打包取證、把可疑檔搬到隔離區(不刪除)、重設所有管理員密碼並登出、刷新鹽值(呼叫上一章的函式)。這支腳本會動到帳號與檔案,請先確認路徑再貼,且不要對整台主機無差別執行 🛟
# 前提:已貼過「工具箱」(01)與「刷新鹽值」(07)兩段
# 已中招/疑似中招:先隔離 → 取證 → 隔離可疑檔 → 重設憑證。取證完成前,什麼都不要刪
incident_one() {
local wp_path="$1" owner domain stamp evid uploads quar file rel admin_id
owner="$(site_owner "$wp_path")"
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
evid="$HOME/ctc-incident-${stamp}"
uploads="$wp_path/wp-content/uploads"
quar="$evid/quarantine"
mkdir -p -- "$quar" || return 1
chmod 700 -- "$evid" "$quar" || return 1
echo "=== ❶ 隔離:先停用外掛(只改設定,不動檔案)==="
if ! wpu "$owner" "$wp_path" plugin deactivate "$PLUGIN"; then
echo "停用失敗,改用搬移外掛目錄的方式阻斷載入"
sudo mv -- "$wp_path/wp-content/plugins/$PLUGIN" "$evid/plugin-dir" || return 1
fi
echo "=== ❷ 取證:日誌、uploads、mu-plugins、外掛目錄、資料庫 ==="
sudo find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.*" -printf '%f\0' \
| sudo tar -czf "$evid/logs-${domain}.tar.gz" -C /var/log/nginx/domains --null -T - \
|| echo "日誌打包失敗或找不到,請確認路徑"
sudo tar -czf "$evid/uploads-snapshot.tar.gz" -C "$wp_path/wp-content" uploads \
|| echo "uploads 打包失敗"
sudo tar -czf "$evid/mu-plugins.tar.gz" -C "$wp_path/wp-content" mu-plugins 2>/dev/null \
|| echo "沒有 mu-plugins 目錄(這是好事)"
sudo tar -czf "$evid/plugin-dir.tar.gz" -C "$wp_path/wp-content/plugins" "$PLUGIN" 2>/dev/null \
|| echo "外掛目錄已被搬走或不存在"
( umask 077; wpu "$owner" "$wp_path" db export - > "$evid/db-snapshot.sql" )
if [ ! -s "$evid/db-snapshot.sql" ]; then
echo "資料庫快照失敗或是空檔,停止"
return 1
fi
sudo find "$uploads" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.pht' -o -iname '*.phar' \) -print0 \
| sudo xargs -0 -r sha256sum > "$evid/suspect-sha256.txt"
echo "=== ❸ 把 uploads 裡的可疑檔搬進隔離區(保留相對路徑,不刪除)==="
while IFS= read -r -d '' file <&3; do
rel="${file#"$wp_path"/}"
sudo mkdir -p -- "$quar/$(dirname -- "$rel")"
sudo mv -- "$file" "$quar/$rel"
sudo chmod 000 -- "$quar/$rel"
printf '已隔離:%s\n' "$rel"
done 3< <(sudo find "$uploads" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.pht' -o -iname '*.phar' \) -print0)
echo "=== ❹ 重設所有管理員密碼並登出所有 Session ==="
while IFS= read -r admin_id <&3; do
wpu "$owner" "$wp_path" user reset-password "$admin_id" --skip-email
wpu "$owner" "$wp_path" user session destroy "$admin_id" --all
printf '已重設並登出管理員 ID:%s\n' "$admin_id"
done 3< <(wpu "$owner" "$wp_path" user list --role=administrator --field=ID)
echo "=== ❺ 刷新鹽值(函式在上一節,會先備份 wp-config.php)==="
rotate_salts_one "$wp_path" || return 1
wpu "$owner" "$wp_path" cache flush
printf '\n取證資料放在:%s\n' "$evid"
ls -lh -- "$evid"
return 0
}
# 💠 單一網站(確認過路徑再貼,這支會動到檔案與帳號)
incident_one "/home/admin/web/example.com/public_html"
# 已確認是惡意的陌生帳號:⚠️ 無法復原,先確認快照存在,再把確認過的 ID 填進去
remove_admin() {
local wp_path="$1" evid="$2" bad_id="$3" owner
owner="$(site_owner "$wp_path")"
if [[ "$bad_id" =~ ^[0-9]+$ ]] && [ -s "$evid/db-snapshot.sql" ]; then
wpu "$owner" "$wp_path" user delete "$bad_id" --reassign=1 --yes
else
echo "ID 格式不對,或資料庫快照不存在,已略過刪除"
return 1
fi
}
# remove_admin "/home/admin/web/example.com/public_html" "$HOME/ctc-incident-20261015-103000" "87"
預期輸出範例:
=== ❶ 隔離:先停用外掛(只改設定,不動檔案)=== Plugin 'click-to-chat-for-whatsapp' deactivated. Success: Deactivated 1 of 1 plugins. === ❸ 把 uploads 裡的可疑檔搬進隔離區(保留相對路徑,不刪除)=== 已隔離:wp-content/uploads/2026/10/xxxx.php === ❹ 重設所有管理員密碼並登出所有 Session === 已重設並登出管理員 ID:1 === ❺ 刷新鹽值(函式在上一節,會先備份 wp-config.php)=== ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),權限:640 admin:admin 取證資料放在:/root/ctc-incident-20261015-103000
看到什麼,該怎麼辦:
- 取證與隔離都成功 → 繼續下面的 C「正式修補」;如果是公司網站或有會員資料,同步通知內部資安或主管,並把證據目錄放在受保護的位置(腳本已把權限設為只有擁有者可讀)。
- 停用外掛時出現 PHP Fatal Error → 代表外掛檔案可能已被改過,腳本會自動改用搬移目錄的方式阻斷載入,後續請交給資安專業人員做完整清理。
- 隔離完成後,uploads 又長出新的 PHP 檔 → 代表還有常駐後門或排程在運作,請檢查 crontab -l -u 擁有者 與 wp cron event list,不要急著重新啟用任何外掛。
- 管理員密碼重設後,沒有人知道新密碼 → 這是刻意的設計。請讓管理員走「忘記密碼」信件流程;若網站寄信功能也不可信,請由主機端人工設定新密碼,而且不要把密碼寫進腳本或貼進聊天視窗。
驗證(確認真的止血了):執行下面三行,預期結果是「status 顯示 inactive」「uploads 裡沒有任何 PHP 檔」「雜湊值和刷新前不同」。三項有任何一項不符,就代表那一步沒成功,請回頭看對應輸出 🧪
WP_PATH="/home/admin/web/example.com/public_html" OWNER="$(site_owner "$WP_PATH")" wpu "$OWNER" "$WP_PATH" plugin get "$PLUGIN" --field=status sudo find "$WP_PATH/wp-content/uploads" -type f \( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.pht' -o -iname '*.phar' \) -print wpu "$OWNER" "$WP_PATH" user list --role=administrator --fields=ID,user_login,user_registered --format=table
🧊 冷知識:為什麼只改密碼不夠?
WordPress 的登入 Cookie 是靠鹽值簽章的。攻擊者若趁機偷到管理員的 Cookie,你只改密碼是不夠的,因為他手上的 Cookie 依然有效;重新產生鹽值,就像把整棟樓的鎖芯換掉,所有人(包括攻擊者)都會被踢下線。代價是正常使用者也要重新登入,這就是為什麼強烈建議在「疑似中招」時一定要刷 🔑
🧯 ㊁ B. 短期應急:還沒確認中招,但現在還不能立刻更新
🚨 短期應急 ≠ 正式修補。
停用外掛、擋網址都只是縮短暴露時間,真正的修補仍然是更新到 4.45 以上。而且擋網址有代價:含中括號的正常請求(像購物網站的商品篩選)也會被擋;停用外掛則是聊天按鈕會消失 🧱
選項一(最乾淨):直接停用外掛。指令如下。成功時會看到 Success 字樣;停用後 plugin get 要顯示 inactive,否則就是沒成功,請回頭看第一行的錯誤訊息 🧯
WP_PATH="/home/admin/web/example.com/public_html" OWNER="$(site_owner "$WP_PATH")" wpu "$OWNER" "$WP_PATH" plugin deactivate "$PLUGIN" wpu "$OWNER" "$WP_PATH" plugin get "$PLUGIN" --field=status
選項二:聊天按鈕不能中斷時,用第五章 ㊅ ❸ 的 block_brackets 擋掉含中括號的網址,並在 24 小時內完成正式修補。套用後 nginx -t 報錯 → 函式會自動撤回剛建的兩個檔案,不用額外處理,但請把錯誤訊息保留下來給維運人員看 ♻️
🚑 ㊂ C. 正式修補:備份 👉 Dry-run 👉 更新 👉 驗證
Click to Chat 是 WordPress.org 的外掛,所以 WP-CLI 的 plugin update 就能直接更新,也能用 plugin verify-checksums 拿官方校驗碼比對外掛檔案有沒有被動過。下面的函式把「備份 → Dry-run → 更新 → 版本確認 → 四層驗證」包在一起:資料庫或檔案備份失敗就停手,不會硬更新;plugin update 後版本仍然偏低(可能是更新快取沒刷新)時,改用 plugin install –version=4.45 –force 覆蓋安裝;更新本身不需要先停用外掛,WordPress 會處理檔案替換。千萬不要為了「保險」而降版,降回舊版等於把洞重新打開 📦
# 前提:已貼過「工具箱」(01)
# 驗證:版本 → 官方校驗碼 → uploads 乾淨度 → 首頁回應。只讀不寫(除了清快取)
verify_one() {
local wp_path="$1" owner domain ver status
owner="$(site_owner "$wp_path")"
domain="$(site_domain "$wp_path")"
ver="$(wpu "$owner" "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null || true)"
status="$(wpu "$owner" "$wp_path" plugin get "$PLUGIN" --field=status 2>/dev/null || true)"
printf '第一層 版本:%s | 狀態:%s | %s\n' "${ver:--}" "${status:--}" "$(classify_version "$ver")"
echo "第二層 外掛檔案與 WordPress.org 官方校驗碼比對(有 File was added 就要人工看)"
wpu "$owner" "$wp_path" plugin verify-checksums "$PLUGIN" || echo "校驗有差異,請逐行確認"
echo "第三層 uploads 底下的 PHP 類檔案(沒列出任何檔案就是乾淨)"
sudo find "$wp_path/wp-content/uploads" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.pht' -o -iname '*.phar' \) -print
echo "第四層 前台回應"
wpu "$owner" "$wp_path" cache flush
curl -sS -o /dev/null -w "首頁:HTTP %{http_code}\n" --max-time 20 "https://${domain}/"
return 0
}
# 正式修補:備份 → Dry-run → 更新 → 版本確認 → 驗證
update_ctc() {
local wp_path="$1" owner name stamp bdir ver
owner="$(site_owner "$wp_path")"
name="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
bdir="$HOME/wp-security-backup"
ver="$(wpu "$owner" "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null || true)"
if [ -z "$ver" ]; then
printf '%s 沒有安裝 %s,略過\n' "$name" "$PLUGIN"
return 0
fi
printf '\n%s 目前版本:%s(%s)\n' "$name" "$ver" "$(classify_version "$ver")"
echo "❶ 備份"
install -d -m 700 -- "$bdir" || return 1
( umask 077; wpu "$owner" "$wp_path" db export - > "$bdir/${name}_db_${stamp}.sql" )
if [ ! -s "$bdir/${name}_db_${stamp}.sql" ]; then
echo "資料庫備份失敗或是空檔,停止,不進行更新"
return 1
fi
sudo tar -czf "$bdir/${name}_wp-content_${stamp}.tar.gz" -C "$wp_path" wp-content \
|| { echo "檔案備份失敗,停止,不進行更新"; return 1; }
sudo chmod 600 -- "$bdir/${name}_wp-content_${stamp}.tar.gz"
ls -lh -- "$bdir/${name}_"*"_${stamp}".*
if version_lt "$ver" "$FIXED"; then
echo "❷ Dry-run(沒列出更新,不代表已是最新,以最後的版本判定為準)"
wpu "$owner" "$wp_path" plugin update "$PLUGIN" --dry-run
echo "❸ 更新"
wpu "$owner" "$wp_path" plugin update "$PLUGIN" || echo "plugin update 沒成功,稍後會改用指定版本安裝"
ver="$(wpu "$owner" "$wp_path" plugin get "$PLUGIN" --field=version 2>/dev/null || true)"
if version_lt "$ver" "$FIXED"; then
echo "版本仍低於 $FIXED(可能是更新快取沒刷新),改用指定版本覆蓋安裝"
wpu "$owner" "$wp_path" plugin install "$PLUGIN" --version="$FIXED" --force \
|| { echo "指定版本安裝也失敗,請查原因,不要重複硬跑"; return 1; }
fi
else
echo "❷❸ 已經是 $FIXED 以上,不需要更新,直接驗證"
fi
echo "❹ 驗證"
verify_one "$wp_path"
}
# 💠 單一網站
update_ctc "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:把「確認有風險」的站手動列進陣列;一站走完才進下一站,中間強制停下來讓你人工確認
TARGET_SITES=(
"/home/admin/web/site-a.com/public_html"
"/home/bob/web/shop.example/public_html"
)
for WP_PATH in "${TARGET_SITES[@]}"; do
printf '\n==================== %s ====================\n' "$WP_PATH"
update_ctc "$WP_PATH" || { echo "這一站失敗,先停在這裡處理"; break; }
read -r -p "請開瀏覽器確認這一站前後台與 WhatsApp 按鈕正常,按 Enter 繼續下一站,Ctrl+C 中斷:"
done
# ✴️ Multisite:外掛檔案全網只有一份,對網路根目錄跑一次就好,子站不用重跑
update_ctc "/home/bob/web/network.example/public_html"
預期輸出範例:
example.com 目前版本:4.44(🔴 受影響(低於 4.45)) ❶ 備份 -rw------- 1 root root 45M Oct 15 10:30 /root/wp-security-backup/example.com_db_20261015-103022.sql -rw------- 1 root root 320M Oct 15 10:30 /root/wp-security-backup/example.com_wp-content_20261015-103022.tar.gz ❸ 更新 Success: Updated 1 of 1 plugins. ❹ 驗證 第一層 版本:4.45 | 狀態:active | 🟢 已達修補版本(4.45 以上) Success: Verified 1 of 1 plugins. 第三層 uploads 底下的 PHP 類檔案(沒列出任何檔案就是乾淨) 首頁:HTTP 200
看到什麼,該怎麼辦:備份失敗 → 函式會直接停下,先排查磁碟空間或權限;更新失敗 → 外掛維持原狀,改用指定版本安裝;更新後版本仍是 🔴 → 更新來源沒生效,請把輸出給維運人員;第二層出現 File was added → 外掛資料夾裡多了官方沒有的檔案,請逐一確認,不確定就視為可疑,回到 A;更新後網站白畫面 → 立刻停用外掛,再用 $HOME/wp-security-backup 裡的資料庫與 wp-content 備份還原 🛑
🚨 備份與驗證的侷限:
備份檔裡有資料庫內容,所以腳本用 umask 077 與 chmod 600 讓檔案只有擁有者能讀,但備份目錄就在家目錄下,請你確認它不是網頁可直接下載的位置。另外,如果這一站已經中招,更新前的備份本身就含有後門,不能當成「乾淨的還原來源」,要還原請用事發前的、已知乾淨的備份。最後,校驗通過只能證明「外掛目錄的檔案跟官方一致」,證明不了整站沒有後門,後門可能在 mu-plugins、uploads、資料庫、排程裡,這些都不在比對範圍內 🪤
🔥 一句話總結:真正安全的批次維運,是先判斷環境、盤點、備份,確認無誤後才逐站更新,最後驗證。環境判斷錯誤,比指令寫錯更致命。這套 判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證 的閉環,換成下一個外掛、下一個 CVE,一樣可以直接套用 🦸
🧩 舉一反三:「程式碼注入」這一整類漏洞,通用的防禦心法
這次的 CVE-2026-92097 屬於 CWE-94(程式碼注入)。以下是這類漏洞通用的防禦招式,不是本 CVE 的官方要求,但值得記在腦子裡 🧠
- 過濾要放在「最後一步」,不是「第一步」。這次就是先過濾、後加料,加進去的網址沒被洗到。原則是:任何會被當成程式解讀的字串,在交給直譯器之前的那一刻,都要重新處理一次。
- 網址、Header、Cookie 都是「外面來的資料」。很多開發者習慣只防表單,卻忘了網址列本身也是使用者可以隨意寫的輸入。
- 「短代碼」本身是便利功能,不要把它交給不受信任的字串。只要資料來源不是你信任的作者,就不該傳給 do_shortcode(),更不該讓中括號原封不動地進去。
- 漏洞修補與事件應變是兩件事。只要舊版曾經暴露在網路上,更新完還要查日誌、查檔案、查帳號,缺一不可。
🏆 第七章:事件綜合評估(10 分制,數字越高代表風險或應對品質越正向)
| 評估維度 | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 8 | 免登入、免互動、網址即可觸發,Wordfence 評 9.1;但實際影響取決於站上有哪些短代碼,Patchstack 評 5.3,我取中間偏高的 8 分(這是我的判斷,不是官方數字)🟡 |
| 官方應變速度 | 8 | 廠商 9/15 收到通知,10/13 揭露時修補版 4.45 已存在,不是「公開當天才修」的類型(確切上架日未查到);扣分在更新日誌的說明對站長不夠白話 |
| 一般站長自救可行性 | 9 | WordPress.org 官方外掛,後台直接「立即更新」,幾分鐘就能完成,對新手門檻很低 |
| DevOps 技術文件完整度 | 5 | 根因與受影響位置寫得清楚,但沒有公開 payload、觸發端點與預設設定是否含 {url},日誌排查只能用通用指標【❓待確認】 |
| 🏆 加權綜合建議 | 今天內更新 | 最終建議:【24 小時內更新到 4.45 以上;若舊版曾暴露,再做第五章檢查,疑似中招就走第六章 A】 |
🔥 這起事件最大的提醒是:漏洞最危險的時候,不是被發現的時候,而是「已經公開、你還沒更新」的那段空窗。這次廠商有先修再公開,而且我沒查到實際攻擊證據,這給了站長一點喘息空間;但這種空間通常只有幾天。與其等哪天網站被掛上奇怪的連結才發現不對勁,不如現在就花五分鐘,打開後台看一眼 Click to Chat 的版本號,然後做完第五章那幾個檢查 🔢
📚 參考資料與延伸資源
- 📌 Wordfence|Click to Chat ≤ 4.44 Unauthenticated Arbitrary Shortcode Execution via REQUEST_URI via {url} Placeholder [40]
- 📌 Patchstack|Click to Chat – HoliThemes ≤ 4.44 Unauthenticated Arbitrary Shortcode Execution [41]
- 📌 NVD|CVE-2026-92097 官方詳情頁 [42]
- 📌 OpenCVE|CVE-2026-92097 [43]
- 📌 FIRST|EPSS 資料(CVE-2026-92097) [44]
- 📌 WordPress.org|Click to Chat – HoliThemes 外掛頁與更新日誌 [1]
- 📌 WP-CLI 官方文件|wp config shuffle-salts [45]
- 📌 WP-CLI 官方文件|wp plugin update [46]
- 📌 WP-CLI 官方文件|wp plugin verify-checksums [47]
- 📌 WP-CLI 官方文件|wp user reset-password [48]










