🔑 一句「隨便」就能冒充客戶本人?LatePoint 預約外掛帳號接管漏洞全解析(CVE-2026-118066/CVSS 9.1)
🔥 懶人包:
WordPress 預約外掛 Appointment Booking Plugin – LatePoint(外掛代號 latepoint)被登記了 CVE-2026-118066,CVSS 3.1 評分 9.1,弱點類型是驗證機制不當(CWE-287)。依 NVD 與 Wordfence 的公開描述,沒登入的人有機會繞過「重設密碼」的驗證,接管預約客戶的帳號,客戶專區裡的預約紀錄、訂單與個資都在射程內 💥
5.7.6 以下受影響,官方 2026-11-02 釋出 5.7.7 修補,隔天的 5.7.8 又補了另一項沒公開細節的安全修補,所以我的建議是直接升到 5.7.8 或更新的版本。目前沒有證據顯示這個洞能直接拿下網站管理員,但如果你開了「WordPress 使用者當客戶」的連動功能,連動的帳號要多看一眼。CISA 的評估是尚無已知利用、可自動化、技術衝擊為全面,距離揭露才三天,這篇帶你看版本、備份、更新,再查有沒有客戶被動過 🔒
🌐 點我直達 WordPress 官方外掛頁確認最新版本 [1]
內容目錄
🪧 第一章:案發現場還原!住戶忘了密碼,管理室怎麼把門禁碼交給「不是住戶的人」?
想像一棟社區大樓,住戶忘記門禁卡時,可以到管理室說「我要重設」,警衛會給一組十五分鐘內有效的開門碼。警衛的規矩是拿著那組碼去對住戶名冊,對得上的人,就能設定新的門禁密碼。這套流程平常沒問題,直到某天有人走到窗口,開門碼那一欄寫的不是號碼,而是「隨便」。警衛的名冊比對卻把它當成「名冊裡隨便哪一位都行」,於是翻開名冊,把第一位住戶的門禁密碼換成了陌生人指定的新密碼 🏢
外掛用客戶送來的重設碼去資料庫找人。依 NVD 與 Wordfence 的描述,修補前的程式碼在比對時,遇到特定的字串會把它當成 SQL 的條件語法,而不是「要比對的內容」。就像點餐單上備註欄寫了「隨便」,店員沒有把它當成客人的指定,而是當成「任何一份都可以拿」。這個備註不該擁有的權力,就是漏洞所在 🍱
先把背景交代清楚。LatePoint 是一套 WordPress 預約系統,美容、瑜伽、顧問、維修這類要排時段的網站很常用。它有一個「客戶專區」,客戶登入後能看預約、看訂單、改個人資料。客戶要重設密碼時,外掛會產生一組寄給客戶的重設碼,有效時間是 900 秒,也就是十五分鐘。這次出問題的環節,就是拿重設碼去找人的那一步 📅
官方只在更新日誌裡寫「強化外掛安全性」,沒有 CVE 編號,也沒有說明改了什麼。下面這條攻擊鏈,是我根據 NVD、Wordfence 的描述,加上官方原始碼的差異,拼出來的輪廓,每一步都標了信心程度 🔍
- ㊀ 攻擊者先設法知道「要冒充誰」:🟡 公開的預約流程在某些條件下會回傳客戶的姓名與信箱。依 5.7.7 修補的位置推測,這一步和「從訂單意圖開始」的預約流程有關,但官方沒有說明細節 📝
- ㊁ 對那位客戶觸發一次「忘記密碼」:✅ 重設碼與它的建立時間會被記下來,只在十五分鐘內有效,攻擊者因此得到一個時間窗口(官方原始碼可查)⏱️
- ㊂ 用特殊字串冒充重設碼:✅ 依 NVD 與 Wordfence,這個字串被拿去當查詢語法,結果命中的是客戶名單裡編號最小的那一位,不是最初鎖定的那一位。我沒有把字串、參數名稱與請求格式寫出來,下方會說明 🚪
- ㊃ 設定新密碼、登入客戶專區:🟡 密碼換成攻擊者指定的,原本的客戶就被擠出自己的帳號。能看到的是這位客戶的預約、訂單與個資,目前沒有資料顯示能走到網站後台 🧾
🧊 冷知識:官方更新日誌寫得很含蓄,這很常見
5.7.6(2026-10-26)與 5.7.7(2026-11-02)的更新日誌,安全項目都只寫「強化外掛安全性」,5.7.7 那條另外感謝 Wordfence 回報。你不能靠更新日誌判斷這次到底修了哪個洞,唯一可靠的判斷依據是版本號。NVD 還提到,5.7.5 加的輸入防護與字串過濾,擋不住這一種攻擊,因為問題出在「字串被當成語法」,不是「字串裡有危險字元」 🧐
🚨 畫重點:我不會給你攻擊字串,也不會寫請求細節
有些來源把送出的欄位名稱與請求路徑寫得很具體,這篇一律不照搬。你只需要知道入口是「公開的預約與重設密碼流程」,攻擊者不需要你的帳號。想知道自己有沒有被動過,看第五章的結果導向檢查就夠了 🙅
⏱️ 事件時間軸一次看懂
| 時間點 | 發生了什麼大事 | 站長可信度判定 |
|---|---|---|
| 2026-10-26 | LatePoint 5.7.6 發布,更新日誌寫「強化外掛安全性」 | ✅ 官方更新日誌 |
| 2026-11-02 | 5.7.7 發布,是 Wordfence 列出的最低修補版;更新日誌同樣只寫強化安全,並感謝 Wordfence 回報 | ✅ 官方更新日誌與 Wordfence 頁面 |
| 2026-11-03 | 5.7.8 發布,內含另一項沒公開細節的安全修補 | ✅ 官方更新日誌;細節我查不到,所以只能寫到這裡 |
| 2026-11-06 | Wordfence 公開這筆漏洞,頁面署名發現者 Jakub Herman | ✅ Wordfence 頁面 |
| 2026-11-07 | NVD 收錄 CVE-2026-118066 | ✅ NVD 紀錄 |
| 2026-11-08 | CISA-ADP 的 SSVC 評估:尚無利用、可自動化、技術衝擊為全面 | ✅ NVD 頁面 |
| 到底有沒有人受害? | 目前查得到的官方資料都沒有說「已被利用」,但「可自動化」代表攻擊程式寫起來很容易 | 請當成「可能已經有人在掃」處理 |
看到「尚無利用」不要放心。可自動化的意思是,一旦有人把流程寫成腳本,就能對著大量網站一個一個試。更新前的這幾天是最容易被錯過的空窗期,修補版其實早在公開揭露前四天就上架了,沒更新的站,是自己把這四天留給了攻擊者 ⏳
- ✅ 已確認事實:官方原始碼顯示,修補前的比對函式把字串 IS NULL、IS NOT NULL 當成查詢語法;重設碼有效時間 900 秒;5.7.7 的修補把兩個入口的輸入限制為只含英文字母與數字。
- 🟡 合理推論:CVSS 向量 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:N 翻成白話:連上網路就能打、不用帳號、不用受害者點任何東西、難度低,會洩漏與竄改資料,但不會讓網站掛掉,所以分數是 9.1 而不是 9.8。
- 🔴 假設情境:假設有間瑜伽教室用 LatePoint 收預約,客戶專區存著學員姓名、電話與課程紀錄。這個情境是我用來說明的例子,不是任何真實網站的紀錄。
🎭 第二章:先別自己嚇自己,對號入座看看你是哪一種網站主?
🩵 小資族荷包試算:修這個洞要花多少錢?
LatePoint 核心外掛在 WordPress.org 上,更新是 NT$ 0 元。付費附加元件與授權方案的價格我沒有核對,這裡不報數字。對照事後成本:客戶帳號被接管,要逐一聯絡客戶、處理客訴、評估要不要通知,每一項都比花五分鐘更新貴得多 💸
同一封資安警告,小編、自己租主機的玩家、企業 IT 要做的事差很多。先找到自己,再決定往下讀到哪一章 🧭
🙋 我只是負責發文、顧後台的小編或老闆
你不需要懂程式。第三章全程動動滑鼠:看版本、備份、按更新,最後用自己的測試信箱走一次忘記密碼流程。唯一要留意的是,LatePoint 另外有付費附加元件,核心外掛更新之後,附加元件要自己確認有沒有跳出相容提示,這部分我查不到官方說明,所以標【❓待確認】🧑💼
👨💻 我自己租主機架站,手上有好幾個客戶網站
直接跳第五章拿工具箱,裡面有整台主機一次盤點的函式,會用各網站擁有者的身分跑 WP-CLI,不會在 root 底下留下一堆權限錯亂的檔案。這顆漏洞只有一條版本門檻(低於 5.7.7),但我建議的線是 5.7.8,所以盤點結果會分三種顏色 🔧
🧑🔧 DevOps 小知識:客戶帳號與 WordPress 帳號是兩套
LatePoint 的客戶有自己的資料表與登入機制,預設不等於 WordPress 使用者。只有開啟「讓 WordPress 使用者當客戶」之類的連動功能,客戶資料才會帶一個 wordpress_user_id,對應到一個真正的 WordPress 帳號。5.7.7 的程式碼裡有一道檢查,連動只允許訂閱者、客戶這類低權限角色,我沒有查到 5.7.6 有沒有同樣的檢查,所以【❓待確認】。這也是我說「管理員被接管目前沒有證據」,但仍然要看連動帳號的原因 🧱
🏢 我們是企業網站,存了客戶與訂單資料
免費外掛沒有 SLA,出事時誰也不保證多久修好。這次官方在公開揭露前四天就釋出修補版,是加分。但如果你的客戶資料裡有個資,而且舊版曾暴露過,就要把它當成「客戶帳號可能被進入過」的等級來處理。個資是否外洩、要不要依《個人資料保護法》通知當事人,請交給公司法務判斷。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺
🚨 冤枉啊!預約功能早就沒在用了,外掛只是懶得刪
某間工作室兩年前改用別家預約系統,舊的預約頁面刪了,LatePoint 卻一直開著。管理員心想:「頁面都沒了,總不能憑空打進來吧?」
🕵️♀️ 依公開描述,入口是公開的預約與重設密碼流程,而這類流程通常不靠頁面存在與否來啟動。我無法驗證「頁面刪了就一定打不到」,客戶資料表裡的舊客戶也還在。與其賭這個,不如直接更新或刪除外掛 🆘
🚥 螢光筆時間:更新、停用、刪除,今天至少選一個
「我沒在用」不算處理。更新到 5.7.8 以上,或停用,或徹底刪除,這三個才算真的把門關上 🙅♂️
🧮 四種情境對照:你今天該做到哪一步?
| 你的情境 | 風險判斷 | 今天要做的事 | 看哪一章 |
|---|---|---|---|
| 外掛啟用中,版本低於 5.7.7,客戶專區有開放 | 最高 | 備份 → 更新 → 檢查客戶資料有沒有被動過 | 第三、五、六章 |
| 外掛啟用中,版本剛好是 5.7.7 | 中 | 已過最低修補版,仍建議升到 5.7.8 以上 | 第三、六章 |
| 外掛已停用但沒刪除 | 低 | 直接刪除,或更新後再決定要不要啟用 | 第三章 |
| 有開 WordPress 使用者連動客戶 | 同上,另加看連動帳號 | 更新之外,要看第五章 ㊂ 的連動帳號與角色 | 第三、五、六章 |
🧮 版本對照:你的版本安不安全?
| 產品 | 外掛代號(slug) | 受影響範圍 | ✅ 修到這版才安全 |
|---|---|---|---|
| Appointment Booking Plugin – LatePoint | latepoint | 5.7.6 以下 | 最低 5.7.7,建議 5.7.8 以上 |
最早從哪一版開始受影響,我取得的資料沒有寫明,所以請當成「低於 5.7.7 一律有風險」處理。後面的腳本用 sort -V 逐段比大小,不會把 5.7.10 誤判成比 5.7.8 舊 🧰
😬 更新前先看這個:5.7.7 一併改變的行為
更新是對的,但不要當成「按下去就一定安然無事」。我拿到的官方差異,涵蓋 5.7.7 發布時的那一筆提交,裡面除了修補本身,還有幾項會影響日常操作的調整。5.7.6 到 5.7.7 之間若還有更早的提交,這份資料看不到,所以下表只代表「這一筆」。這幾項我都是從程式碼讀出來的,沒有找到使用者實際踩雷的公開案例,所以不寫成案例,只列「更新後請測什麼」 🧪
| 程式碼裡看到的變更 | 白話意思 | 更新後請測 |
|---|---|---|
| 重設密碼與「從訂單意圖開始」兩個入口,只接受英文字母與數字 | 官方產生的憑證都是十六進位字串,不受影響;自己客製串接、帶符號的自訂碼會被拒絕 | 忘記密碼流程、預約連結的續填流程 |
| 登入失敗次數限制,現在把大小寫、空白不同的同一個信箱或電話算成同一個人 | 同一個信箱換寫法再試,不會再重新計次 | 客戶連續輸入錯誤後,鎖定與解鎖是否符合預期 |
| 信箱格式檢查多加一道 WordPress 的 is_email | 帶引號、很罕見的信箱寫法會被擋下 | 用你網站上實際在用的客戶信箱格式試一次預約 |
| Google、Facebook 社群登入,一律要檢查 nonce;只有 LatePoint Pro 低於 1.2.5 的舊版才放行沒帶 nonce 的請求 | 搭配較舊 Pro 附加元件的站,社群登入的行為可能和以往不同 | 用社群帳號登入客戶專區一次 |
| 預約組合(bundle)下單時,會檢查該組合存在、啟用、沒有被隱藏 | 已下架的組合不能再被下單,會顯示「Selected bundle is not available.」 | 有賣組合方案的站,買一次看看 |
| 後台的部分客戶清單查詢,多加一層依權限過濾 | 依函式名稱,我推測權限受限的後台帳號看到的客戶範圍可能縮小🟡 | 用非管理員的後台帳號開客戶清單 |
🚨 測出問題的時候,不要降版
把外掛降回 5.7.6 以下,等於重新打開漏洞。表單或流程壞掉時,先確認是不是上表那幾項造成,找不出原因就把外掛停用(第六章 B),拿備份到測試站慢慢查 🧯
🛟 第三章:電腦小白看過來!五分鐘無痛自救指南
💬 溫馨小叮嚀:更新外掛像是把壞掉的鎖換新,它保證新的小偷進不來,但沒辦法倒帶,檢查昨天有沒有人已經進來逛過。所以最後兩步(走一次忘記密碼、看客戶有沒有異常)一定要做。網站如果存有客戶個資,請把這篇轉給主管或維護人員,由他們判斷要不要做更深的健檢 🩺
每一步我都寫了「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只需要動動滑鼠 🤏
- ㊀ 先查水表,看看有沒有裝:
登入後台,左邊選單點「外掛」→「已安裝的外掛」,在右上角搜尋框輸入 LatePoint。找到「Appointment Booking Plugin – LatePoint」就是核心外掛;名字帶 LatePoint 的其他項目多半是附加元件,這次的主角是核心外掛。找不到就是沒裝,這篇可以當作別人家的新聞 🔎 - ㊁ 核對版本號:
版本號寫在外掛名稱下方。看到 5.7.8 或更高就是建議版;剛好 5.7.7 已過最低修補版,但建議再升一次;比 5.7.7 低就是受影響,繼續往下做 🕵️♀️ - ㊂ 動手前先留後路(備份):
到主機商後台點「一鍵備份」,或用你平常的備份外掛先存一份。不知道什麼是備份的話,把這篇網址傳給幫你維護網站的人,請他代勞,這本來就是他的工作 💾 - ㊃ 按下更新:
在外掛清單點 LatePoint 的「立即更新」。更新完重新整理頁面,確認版本號跳到 5.7.8 以上。如果旁邊的附加元件跳出相容性或更新提示,也一併處理,但授權與相容的細節我查不到,請以廠商說明為準 🔄 - ㊄ 更新完,實際走一次客戶流程:
用你自己收得到信的測試信箱,在前台預約一次,再到客戶專區按「忘記密碼」,收信、設定新密碼、登入,確認能看到剛才的預約。這個測試客戶只是臨時用品,驗證完要回後台把它刪掉,別留在客戶名單裡。有任何一步出錯,先別動,截圖交給維護人員 🧪 - ㊅ 今天真的更新不了,怎麼辦:
最乾淨的做法是在外掛清單把它「停用」。停用之後預約表單與客戶專區都會消失,請先在首頁掛一則「暫時改用電話或訊息預約」的公告。停用完馬上打開首頁看一眼;網站掛了就重新啟用,並在今天內排好更新時間。停用只是縮短暴露時間,不是修補 🧯 - ㊆ 化身名偵探,查查客戶與後台有沒有異常:
點「使用者」→「管理員」分頁,裡面應該都是你認識的人。再進 LatePoint 的客戶清單,看有沒有客戶資料近期被改過(電話或信箱突然變了),也可以問問最近有沒有客戶反映「沒申請卻收到重設密碼的信」或「突然登不進去」。見鬼的情況:多出沒印象的管理員、客戶回報帳號被改、預約紀錄憑空出現。看到這類狀況請不要自己改或刪,先截圖,立刻找主機商或維護人員,並告訴他們依本文第六章 A 處理 🔀
🫂 新手求助專區:看到專有名詞就頭暈怎麼辦?
直接把下面這段話傳給你的主機客服或維護人員:「你好,我的網站可能有安裝 LatePoint 預約外掛,請幫我確認版本是否已更新到 5.7.8 以上,漏洞編號是 CVE-2026-118066。也請協助檢查客戶資料有沒有近期被異常修改,以及後台管理員名單有沒有異常。」對方看到這串 CVE 編號就知道該做什麼。沒有備份之前,千萬不要自己亂刪東西 🤗
🧊 冷知識:「停用」跟「刪除」差在哪?
停用像是把店門拉下鐵捲門,店還在,東西也還在,鑰匙還在老闆手上,哪天有人拉開鐵捲門,問題就回來了。刪除則是把整間店搬走,客戶資料表還留在資料庫,但沒有人能再去呼叫它。很久不用的外掛,搬走比拉門安全;還在用的,就老實更新 🏪
🤿 第四章:技術深潛,一句「隨便」怎麼一路走到「換掉客戶密碼」?
想知道為什麼的人往下看,只想補洞的前三章已經夠用。先講資料狀況:我手上有官方 Trac 的程式碼差異(5.7.7 發布提交),也有 NVD 與 Wordfence 的描述,沒有公開的攻擊字串。下面的機制整理自這三種來源,能從程式碼確認的我標 ✅,推論標 🟡,查不到的標【❓待確認】⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | Appointment Booking Plugin – LatePoint |
| 外掛代號(slug) | latepoint |
| CVE 編號 | CVE-2026-118066 |
| 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-287:驗證機制不當 |
| 受影響與修補版本 | 5.7.6 以下受影響,5.7.7 為最低修補版,建議 5.7.8 以上 |
| 發現者 | Jakub Herman(Wordfence 頁面署名) |
| 公開時間 | Wordfence 2026-11-06,NVD 2026-11-07,CISA-ADP 評估 2026-11-08 |
| 利用前提 | 不需登入;網站上要有至少一位客戶(比對命中的是客戶編號最小的那一位,依 NVD 與 Wordfence 描述) |
🧩 三個環節串成一條完整的鏈
- 🏮 環節一:比對函式把字串當成語法。✅ 官方 lib/models/model.php 裡負責組 WHERE 條件的函式,會檢查值是不是剛好等於 IS NULL 或 IS NOT NULL,是的話就直接拼成 SQL 的條件,而不是當成要比對的內容。對內部程式來說這是方便的寫法,對外部傳進來的字串就變成漏洞 🚪
- 🏮 環節二:輸入只被「清洗」,沒有被「限制格式」。🟡 WordPress 的字串清洗函式處理的是標籤與多餘空白,不會動這種純文字,所以前面加的防護擋不住。重設碼明明是一串固定格式的十六進位字串,卻沒有人檢查「看起來像不像」 🧾
- 🏮 環節三:重設流程只靠「憑證比對」加「十五分鐘時窗」。✅ 比對被騙過之後,剩下的只有時窗,而時窗只要攻擊者先替對方觸發一次忘記密碼就能開啟。結果是 CVSS 裡的機密性與完整性拉到 High,可用性維持 None 🎯
圖書館櫃台以前只問「借書證號碼是多少」,客人回答什麼都收,連「隨便一張」也當成條件去查。修補後的做法是在查名冊之前先看一眼:號碼只能是數字和字母,夾了空格或符號的,不用查,直接說無效。0.5 秒的檢查,把整條路堵住 📚
🧑🔧 DevOps 小知識:CVSS 9.1 為什麼不是 9.8?
常見的 9.8 是機密性、完整性、可用性三項都是 High。這顆的向量尾端是 A:N,代表它不會讓網站停擺,只會讓資料被看與被改,所以是 9.1。這不代表比較不危險:被看的是客戶個資,被改的是客戶密碼 🛡️
🔧 5.7.7 到底修了什麼?
以下三項是我從官方 Trac 差異逐行讀出來的,分三級標記。這份差異是 5.7.7 發布提交(r3779410)的內容 🧾
- ✅ 在兩個入口加上格式檢查。重設密碼流程(用憑證找客戶)與「從訂單意圖開始」的預約流程,現在在拿字串去查資料庫之前,先確認它只含英文字母與數字,通不過的直接當作沒有這個人。官方的程式註解寫得很直白:這種憑證是十六進位字串,帶空格或運算子的值永遠無效。
- ✅ 新增安全的「是 NULL」寫法。model.php 新增了兩個固定的物件和 where_null、where_not_null 兩個方法,程式註解說明:請求傳進來的值一定是字串或陣列,不可能和這種物件完全相同。
- ✅ 舊寫法沒有被拿掉。判斷式從「等於 IS NULL」改成「等於 IS NULL 或等於標記物件」,也就是說,字串 IS NULL 現在仍然會被當成語法。修補的做法是在入口處把關,沒有從根上移除這個行為。
- 🟡 推論:既然根上的行為還在,其他地方若還有把外部輸入傳進去的路徑,理論上仍有相同風險。官方隔天又釋出 5.7.8,並說又有一項安全修補,與這個推論吻合,但我沒有 5.7.8 的差異,無法確認。
- 【❓待確認】5.7.8 到底改了什麼、是否動到同一個檔案,我查不到。所以「更新到 5.7.8 以上」是唯一能確定覆蓋到的做法,不要試圖只手動補其中一項。
🧊 冷知識:同一次提交裡,還順手改了別的事
同一筆提交也調整了登入失敗次數的計算方式、信箱格式驗證、社群登入的 nonce 檢查與預約組合的可用性檢查(第二章表格已列)。這些看起來和這顆 CVE 無關,但它們也在同一個版本,所以更新後要一起測 🔒
🚨 畫重點:「完整觸發條件」目前查不到
攻擊者具體怎麼知道要冒充誰、請求格式長怎樣,公開資料沒有寫完整。所以第五章的排查我刻意不依賴某一條攻擊字串,改看結果特徵:客戶資料近期有沒有被改、連動帳號角色有沒有異常、預約請求有沒有異常暴衝、後台有沒有多出東西。代價是會有一些雜訊,好處是不會因為猜錯入口而漏看真正的異常 🧭
🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 Checklist
🤖 重要提醒:以下指令由多個 AI(ChatGPT、DeepSeek、Gemini、Grok、Meta AI)產生的分析彙整,經 Claude 複查修正與整理,所有程式碼區塊都做過 bash 語法檢查,並在模擬的 WP-CLI 環境裡跑過流程邏輯 🫡
你的主機環境不一定跟範例一樣。正式環境(尤其是正在營業中的網站)的高風險操作,包含更新、停用、刪除檔案、變更權限、重設密碼或執行批次腳本,請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin 都是示範用名稱,請換成你自己的環境。客戶資料表裡有姓名、信箱、電話,檢查的輸出會直接顯示在終端機,請在私密環境操作,不要把輸出貼進公開的聊天室或論壇 🧙
這一章的流程是:工具箱 👉 盤點版本 👉 客戶資料表 👉 日誌排查 👉 帳號與檔案稽核。這一章的指令全部只讀不寫,不會改任何東西。預期輸出只是典型範例,版本不同可能略有差異 📜
🧰 ㊀ 先貼這一段:整章都會用到的小工具
為什麼這樣做:HestiaCP 的每個網站屬於不同的 Linux 使用者。用 root 直接跑 WP-CLI 會留下 root 擁有的檔案,之後網站自己寫入就會失敗。所以先定義幾個小函式:自動找 WP-CLI(有的主機叫 wp,有的叫 wp-cli)、以檔案擁有者身分執行、逐站迴圈,以及專門比版本號的 is_fixed 與 is_best。所有路徑變數都用雙引號包起來,路徑含空白也不會斷 🧱
# ㊀ 工具箱:同一個 SSH 連線貼一次就好,斷線後要重貼
PLUGINS=("latepoint")
FIXED="5.7.7" # 官方資料列為最低已修補版
BEST="5.7.8" # 建議版:5.7.8 另含一項未公開細節的安全修補
SINCE="$(date -d '30 days ago' +%F)" # 稽核起點,想看更久就手動改,例如 "2026-10-01"
# 起點日期格式不對就直接擋下,因為它會被放進 SQL 條件
[[ "$SINCE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || echo "⚠️ SINCE 格式要是 YYYY-MM-DD"
# 找出 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 runuser > /dev/null || echo "⚠️ 找不到 runuser,請改請維運人員協助"
# 列出主機上每一個 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 home_dir
shift
owner="$(site_owner "$wp_path")" || return 1
home_dir="$(getent passwd "$owner" | cut -d: -f6)"
runuser -u "$owner" -- env HOME="${home_dir:-/tmp}" "$WPCLI" --path="$wp_path" "$@" < /dev/null
}
# 逐站執行:任何一站失敗就停下,不會連鎖出事
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 舊(用 sort -V,不用字串比大小)
version_lt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
# 兩道門檻:低於 5.7.7 受影響;5.7.7 已修補但建議升到 5.7.8 以上
is_fixed() { ! version_lt "$1" "$FIXED"; }
is_best() { ! version_lt "$1" "$BEST"; }
預期輸出:貼完沒有任何輸出就是成功。看到 ⚠️ 代表缺 WP-CLI 或 runuser,或日期格式不對,先補上再往下,不要硬跑。
驗證(確認版本判斷正確):貼完後跑下面這段,它只做版本判斷,不碰任何網站 🧪
for v in 5.7.6 5.7.7 5.7.8 5.7.10 5.8.0 5.6.9; do if is_best "$v"; then echo "$v 已是建議版以上" elif is_fixed "$v"; then echo "$v 已修補,建議再升級" else echo "$v 受影響"; fi done
預期輸出(5.7.6 與 5.6.9 要是「受影響」,5.7.7 要是「已修補,建議再升級」,其餘「已是建議版以上」):
5.7.6 受影響 5.7.7 已修補,建議再升級 5.7.8 已是建議版以上 5.7.10 已是建議版以上 5.8.0 已是建議版以上 5.6.9 受影響
- 🟩 結果符合預期 👉 工具箱沒貼錯,往下進行。
- 🟧 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
- 🟨 5.7.10 被判成建議版以上是刻意的:用 sort -V 逐段比數字,不是比字串。
🧑🔧 DevOps 小知識:為什麼迴圈長成 3< <(find …) 這個樣子?
常見寫法 find | while read 有個陷阱:迴圈裡只要有指令會讀標準輸入,就會把清單吃掉一半,結果只處理到第一個網站。改成把 find 的輸出接到檔案描述符 3、用 read … <&3 讀,迴圈內的指令就搶不到輸入;搭配 -print0 與 read -d ”,路徑含空白也安全。wpx 裡的 < /dev/null 是第二道保險 🔧
🔎 ㊁ 盤點版本:哪些站裝了它、現在哪一版?
為什麼這樣做:一台主機上有十個獨立 WordPress,跟一個 Multisite 底下有十個子站,看起來像但處理方式不同:前者要逐一找每個 wp-config.php,後者外掛檔案全網只有一份。這個函式同時判斷安裝類型,輸出一行一個結果,只讀不寫 🤔
inventory_one() {
local wp_path="$1" kind slug ver status verdict found=0
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
for slug in "${PLUGINS[@]}"; do
ver="$(wpx "$wp_path" plugin get "$slug" --field=version 2> /dev/null || true)"
[ -n "$ver" ] || continue
found=1
status="$(wpx "$wp_path" plugin get "$slug" --field=status 2> /dev/null || true)"
if is_best "$ver"; then verdict="🟢 建議版以上"
elif is_fixed "$ver"; then verdict="🟡 已修補,建議再升級"
else verdict="🔴 受影響"; fi
printf '%s\t%s\t%s\t版本:%s\t狀態:%s\t%s\n' \
"$verdict" "$kind" "$slug" "$ver" "${status:--}" "$wp_path"
done
if [ "$found" -eq 0 ]; then
printf '⚪ 未安裝\t%s\t-\t-\t-\t%s\n' "$kind" "$wp_path"
fi
return 0
}
# 💠 單一網站(路徑換成你的)
inventory_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:整台主機一次盤點(只讀不寫)
each_site inventory_one
預期輸出範例:
🔴 受影響 💠 一般安裝 latepoint 版本:5.7.6 狀態:active /home/bob/web/site-a.com/public_html 🟡 已修補,建議再升級 💠 一般安裝 latepoint 版本:5.7.7 狀態:active /home/bob/web/shop.example/public_html 🟢 建議版以上 💠 一般安裝 latepoint 版本:5.7.8 狀態:active /home/bob/web/book.example/public_html ⚪ 未安裝 💠 一般安裝 - - - /home/bob/web/blog.example/public_html
分支判斷邏輯:
- 🟥 受影響 👉 排入第六章的備份與更新,並接著做 ㊂ 到 ㊄,確認舊版時期有沒有被碰過。
- 🟨 已修補,建議再升級 👉 5.7.7 已解除這顆 CVE 的條件,但建議升到 5.7.8 以上;舊版若曾暴露一段時間,仍建議做 ㊂ 到 ㊄。
- 🟩 建議版以上 👉 版本條件已解除,但舊版若曾暴露,仍建議做 ㊂ 到 ㊄。
- 🟨 狀態是 inactive 👉 外掛已停用,攻擊面暫時不在,仍建議更新或刪除,不要放著。
- ✴️ Multisite 出現 active-network 👉 外掛檔案全網共用,查主站一次就代表整個網路,更新也只做一次。
- 🟧 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。路徑若像 public_html.bak 之類的備份副本,請人工確認哪一個才是正式站。
🧑💼 ㊂ 客戶資料表檢查:有沒有客戶被動過?
為什麼這樣做:這顆漏洞的終點是「客戶帳號被接管」,所以最直接的痕跡在客戶資料表。這段做三件事:數客戶總數與連動 WordPress 帳號的數量、列出最近有更新紀錄的客戶、列出連動的 WordPress 帳號與它們的角色。表名用 wp db tables 找,不寫死 wp_ 前綴;欄位名稱先用 SHOW COLUMNS 查過才用,不存在就略過,不硬猜;全程不查密碼與重設憑證的欄位。已知限制:客戶自己改資料、下單也會更新時間,所以單看時間沒有結論;另外我沒有確認更新時間是以 UTC 還是站台時區儲存,建議拿一個你剛改過資料的客戶對一下 🧐
# 客戶資料表檢查:只讀不寫,而且不碰任何密碼或權杖欄位
customers_one() {
local wp_path="$1" tbl cols ids
printf '\n===== %s =====\n' "$(site_domain "$wp_path")"
while IFS= read -r tbl <&3; do
[[ "$tbl" =~ ^[A-Za-z0-9_]+$ ]] || { printf '略過不認得的表名:%s\n' "$tbl"; continue; }
printf -- '--- 資料表:%s ---\n' "$tbl"
# 欄位名稱先查再用,欄位不存在就略過對應的檢查,不硬猜
cols="$(wpx "$wp_path" db query "SHOW COLUMNS FROM \`${tbl}\`" --skip-column-names | cut -f1)"
has_col() { printf '%s\n' "$cols" | grep -qx -- "$1"; }
echo "--- ❶ 客戶總數,以及連動到 WordPress 帳號的數量 ---"
if has_col wordpress_user_id; then
wpx "$wp_path" db query \
"SELECT COUNT(*) AS total_customers, SUM(wordpress_user_id > 0) AS linked_to_wp_users FROM \`${tbl}\`;"
else
wpx "$wp_path" db query "SELECT COUNT(*) AS total_customers FROM \`${tbl}\`;"
fi
echo "--- ❷ ${SINCE} 之後有更新紀錄的客戶(最近 50 筆)---"
if has_col id && has_col email && has_col created_at && has_col updated_at; then
wpx "$wp_path" db query \
"SELECT id, email, created_at, updated_at FROM \`${tbl}\` WHERE updated_at >= '${SINCE}' ORDER BY updated_at DESC LIMIT 50;"
else
echo "【❓待確認】欄位名稱和預期不同,略過。請先看 SHOW COLUMNS 的結果"
fi
echo "--- ❸ 連動到的 WordPress 帳號與角色(最多 200 個)---"
if has_col wordpress_user_id; then
ids="$(wpx "$wp_path" db query \
"SELECT wordpress_user_id FROM \`${tbl}\` WHERE wordpress_user_id > 0 ORDER BY wordpress_user_id LIMIT 200;" \
--skip-column-names | paste -sd, -)"
if [ -n "$ids" ]; then
wpx "$wp_path" user list --include="$ids" --fields=ID,user_login,roles,user_registered --format=table
else
echo "沒有連動的 WordPress 帳號"
fi
fi
done 3< <(wpx "$wp_path" db tables '*latepoint_customers' --all-tables)
return 0
}
# 💠 單一網站
customers_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站
each_site customers_one
預期輸出範例(節錄):
===== example.com ===== --- 資料表:wp_latepoint_customers --- --- ❶ 客戶總數,以及連動到 WordPress 帳號的數量 --- +-----------------+--------------------+ | total_customers | linked_to_wp_users | +-----------------+--------------------+ | 128 | 0 | +-----------------+--------------------+ --- ❷ 2026-10-10 之後有更新紀錄的客戶(最近 50 筆)--- +----+-------------------+---------------------+---------------------+ | id | email | created_at | updated_at | +----+-------------------+---------------------+---------------------+ | 17 | amy@example.org | 2026-09-02 10:11:03 | 2026-11-05 22:41:09 | +----+-------------------+---------------------+---------------------+ --- ❸ 連動到的 WordPress 帳號與角色(最多 200 個)--- 沒有連動的 WordPress 帳號
分支判斷邏輯:
- 🟥 ❷ 出現更新時間集中在揭露前後幾天、而且你問過客戶並不是他們自己改的 👉 疑似被接管,直接進第六章 A,並同時做 ㊃。
- 🟥 ❸ 列出的連動帳號,角色出現 administrator、editor 這類不是一般客戶的角色,或有你不認得的帳號 👉 連動帳號有異常,進第六章 A 並走最嚴格的一級。
- 🟨 ❶ 的連動數大於 0,但 ❸ 的角色都是 subscriber 或 customer 👉 屬於預期範圍,仍建議更新,並對連動帳號保持觀察。
- 🟨 ❷ 有一些零星更新 👉 客戶自己改資料、下單都會更新時間,請對照客戶實際行為,不要直接當成入侵。
- 🟧 出現「欄位名稱和預期不同」 👉 先把 SHOW COLUMNS 的結果交給維運人員核對,不要自己猜欄位名稱。
- 🟩 都沒有異常 👉 只能說這一輪沒有發現明確跡象,不能證明沒被動過:我無法確認「改密碼」一定會留下更新時間。
如果同一個資料庫裡放了不只一套 WordPress,–all-tables 會把別套的客戶表也列出來。這時請對照輸出裡的資料表前綴,確認哪一個是目前這站的 🗂️
📊 ㊃ 日誌排查:看誰一直在敲「不該被敲」的門
為什麼這樣做:公開資料沒有寫完整的請求格式,標準存取日誌也不記錄 POST 的內容,只記「請求行」:方法、網址、狀態碼。所以日誌能回答的問題很有限,我只抓四個結果特徵:哪個來源 IP 在短時間內對 admin-ajax.php 送了異常多的 POST、哪幾天最忙(看有沒有突然暴衝)、有沒有腳本類 User-Agent 在送 POST、有沒有人碰過 uploads 裡的 PHP 類檔案。LatePoint 的前台請求是否都經過 admin-ajax.php,我沒有逐一核對 🟡。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log,函式用 zcat -f 一併讀輪替後的 .log.1 與 .log.2.gz。前面如果有 CDN,日誌裡看到的 IP 可能是 CDN 的 🎯
# 取出某網域「目前+輪替後(含 .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
domain="$(site_domain "$wp_path")"
printf '\n===== %s =====\n' "$domain"
echo "--- ❶ admin-ajax.php 的 POST:每小時、每個來源 IP 的次數(前 20 名)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST \/wp-admin\/admin-ajax\.php/ {
split($1, a, " "); ip = a[1]
t = $1; sub(/^[^[]*\[/, "", t)
print substr(t, 1, 14), ip
}' \
| sort | uniq -c | sort -rn | head -n 20
echo "--- ❷ admin-ajax.php 的 POST:最忙的 10 天(對照平常,看有沒有突然暴衝)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST \/wp-admin\/admin-ajax\.php/ {
t = $1; sub(/^[^[]*\[/, "", t)
print substr(t, 1, 11)
}' \
| sort | uniq -c | sort -rn | head -n 10
echo "--- ❸ admin-ajax.php 的 POST 來自腳本類 User-Agent(最近 20 筆)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST \/wp-admin\/admin-ajax\.php/ && tolower($6) ~ /(curl|python|go-http|wget|libwww|okhttp|scrapy|axios|node-fetch)/' \
| tail -n 20
echo "--- ❹ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---"
scan_domain_log "$domain" \
| awk -F'"' 'tolower($2) ~ /\/wp-content\/uploads\/[^ ?]*\.(php|phtml|phar)/' \
| tail -n 20
return 0
}
# 💠 單一網站
log_scan_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站/✴️ Multisite:掃主機上每一個網域
each_site log_scan_one
預期輸出範例(有可疑的樣子,節錄):
===== example.com =====
--- ❶ admin-ajax.php 的 POST:每小時、每個來源 IP 的次數(前 20 名)---
412 08/Nov/2026:11 203.0.113.5
37 08/Nov/2026:12 198.51.100.9
--- ❷ admin-ajax.php 的 POST:最忙的 10 天(對照平常,看有沒有突然暴衝)---
2210 08/Nov/2026
340 07/Nov/2026
--- ❸ admin-ajax.php 的 POST 來自腳本類 User-Agent(最近 20 筆)---
203.0.113.5 - - [08/Nov/2026:11:00:01 +0800] "POST /wp-admin/admin-ajax.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
--- ❹ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---
分支判斷邏輯:
- 🟥 ❹ 有命中 👉 uploads 裡有人執行過 PHP,視為高度可疑,直接進第六章 A 最嚴格的一級,並同時做 ㊄。
- 🟥 ❶ 某個陌生 IP 在短時間內送出大量 POST,❸ 又出現腳本工具 👉 記下 IP 與時間,對照 ㊂ 的客戶更新時間;兩邊吻合就進第六章 A。
- 🟨 ❷ 某天明顯暴衝 👉 可能是促銷或機器人在灌表單,要結合時間與 IP 判讀。我無法確認這顆漏洞在日誌裡的專屬特徵,攻擊者也可以偽造 User-Agent。
- 🟩 都沒命中 👉 只能說目前沒有直接證據,不能證明沒被打過:日誌可能已輪替、前面有 CDN,攻擊也可能沒走你查的這個日誌。繼續做 ㊄。
🕳️ ㊄ 帳號與檔案稽核:有沒有人順手留下別的東西
為什麼這樣做:目前公開資料指向的是客戶帳號被接管,沒有說能執行程式。這一步屬於「保守起見多看一眼」:管理員名單、最近註冊的帳號、外掛清單、uploads 裡的 PHP、會自動載入的 mu-plugins、揭露日之後被改過的 PHP,以及外掛與核心檔案跟官方版本的比對。全部用 WP-CLI 內建功能完成,不寫 SQL,所以資料表前綴不一樣也不怕。plugin verify-checksums 只能比對 WordPress.org 上的外掛,付費附加元件常會顯示無法驗證,這是正常現象 🕵️♀️
audit_one() {
local wp_path="$1" opt
printf '\n===== %s(擁有者:%s)=====\n' "$wp_path" "$(site_owner "$wp_path")"
echo "--- ❶ 全部管理員 ---"
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table
echo "--- ❷ 最近註冊的 10 個帳號(任何角色)---"
wpx "$wp_path" user list --orderby=registered --order=DESC --number=10 \
--fields=ID,user_login,user_email,user_registered,roles --format=table
echo "--- ❸ 外掛清單(有沒有你不認識的外掛)---"
wpx "$wp_path" plugin list --fields=name,status,version --format=table
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 與 wp-content 根目錄的 PHP(會自動載入,後台外掛頁看不到)---"
find "${wp_path}/wp-content/mu-plugins" -maxdepth 2 -type f \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
find "${wp_path}/wp-content" -maxdepth 1 -type f -name '*.php' \
-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 | user_login | user_email | user_registered | +----+------------+-------------------+---------------------+ | 1 | admin | admin@example.com | 2024-01-15 08:00:00 | +----+------------+-------------------+---------------------+ --- ❹ uploads 底下的 PHP 類檔案(正常網站應該是空的)--- --- ❼ 外掛與核心檔案跟官方版本比對 --- Success: Verified 12 of 13 plugins. Success: WordPress installation verifies against checksums.
分支判斷邏輯:
- 🟥 ❶❷ 出現揭露日之後註冊、你不認識的帳號 👉 高度可疑。陌生不等於惡意,可能是外包留的,先問清楚再處理。
- 🟥 ❹ 找到 PHP 類檔案,或 ❺ 出現不是你部署的檔案 👉 視為已中招,先別刪,保留證據,進第六章 A 最嚴格的一級。
- 🟨 ❻ 揭露日之後被改過的 PHP 👉 外掛正常更新也會出現,要對照你自己的維運紀錄,別看到就當成入侵。
- 🟨 ❸ 出現不認識的外掛 👉 先查那是哪個外掛、誰裝的,查不出來就當成可疑。
- 🟨 ❼ 出現 File was added 之類的差異 👉 先確認是不是你自己放的客製檔案;不確定就當成可疑。
- 🟥 ❽ 的 users_can_register 變成 1、default_role 變成 administrator,或 siteurl/home 被改成陌生網域 👉 網站設定被動過手腳,這類改動很少是正常維運造成的。
- 🟩 全部乾淨 👉 這一輪沒有發現明確跡象,仍建議做第六章的更新,並多觀察一週。
📋 ㊅ 三環境速查表:把這一章濃縮成一張
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 工具箱 | 貼一次,整個 SSH 連線通用 | ||
| ② 盤點版本 | inventory_one 路徑 | each_site inventory_one | 查主站一次,狀態看 active-network |
| ③ 客戶資料表 | customers_one 路徑 | each_site customers_one | 每個子站各有一張客戶表,函式會逐表列出,連動帳號則共用同一組使用者 |
| ④ 日誌排查 | 單一網域 | 掃所有網域日誌 | 共用網域日誌,再依 Host 回推子站 |
| ⑤ 帳號與檔案 | 管理員/檔案/設定 | 逐站各自檢查,不能拿 A 站代表 B 站 | 另加 super-admin list |
🛠️ 第六章:發現不對勁怎麼辦?從「更新」到「全面止血」的修復流程
先講順序:先備份,再更新,最後才決定要不要動鹽值與帳號。更新是這顆漏洞的正解,其他動作都是「萬一已經被碰過」的加碼。這一章的指令會改動系統狀態,每個動作後面都附驗證,驗不過去的地方也寫了下一步 🧯
🌡️ 鹽值要不要刷新?先看你在哪一級
WordPress 的鹽值(wp-config.php 裡那八組金鑰)會影響登入 cookie 的簽章。LatePoint 的客戶登入狀態也是用 WordPress 的雜湊函式簽的,所以刷新鹽值會把所有人登出,包含攻擊者手上的客戶連線。但要注意:它不會改掉攻擊者已經設好的新密碼,被改密碼的客戶之後得走正常的「忘記密碼」流程(更新後這條路是安全的)。所以不是越多越好,要看狀況 🎚️
| 等級 | 你看到的狀況 | 要做的事 |
|---|---|---|
| 🟩 一級 | 只是版本偏舊,第五章沒有任何異常 | 備份、更新、驗證。不用刷新鹽值,刷了只會平白把所有客戶登出 |
| 🟨 二級 | 確認有客戶資料被改,但連動帳號與檔案都乾淨 | 走 A 流程更新並刷新鹽值,再請受影響的客戶自行重設密碼 |
| 🟥 三級 | 連動的 WordPress 帳號角色異常、多出管理員、uploads 或 mu-plugins 有 PHP 類檔案 | 走 A 流程,鎖定可疑帳號、刷新鹽值、更換資料庫密碼,並當成完整資安事件處理(見「補一刀」與通報提醒) |
🧱 ㊀ 兩個共用零件:備份與刷新鹽值
後面的 A、C 流程都會用到這兩個函式。為什麼這樣做:備份要確定「真的備到了」,所以資料庫匯出完要檢查檔案不是空的、結尾有 Dump completed;外掛目錄用 plugin path latepoint –dir 取資料夾(不是單一的 latepoint.php),打包完還會翻開看裡面有沒有主檔,沒有就判定失敗。備份檔放在你的家目錄底下、權限 700,不放在網站目錄裡,避免被別人從網址抓走 🗄️
# ㊀ 共用零件一:備份(資料庫+LatePoint 外掛目錄),任何一步失敗就回傳失敗
BACKUP_DIR="${HOME}/wp-security-backup"
backup_one() {
local wp_path="$1" domain stamp db_file tar_file plugin_dir
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
db_file="${BACKUP_DIR}/${domain}_db_${stamp}.sql"
tar_file="${BACKUP_DIR}/${domain}_latepoint_${stamp}.tar.gz"
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
# --dir 取得的是外掛「資料夾」,不是單一個 latepoint.php
plugin_dir="$(wpx "$wp_path" plugin path latepoint --dir 2> /dev/null || true)"
if [ -z "$plugin_dir" ] || [ ! -d "$plugin_dir" ]; then
echo "❌ 找不到 LatePoint 外掛資料夾,停止"
return 1
fi
( umask 077; tar -czf "$tar_file" -C "$(dirname -- "$plugin_dir")" "$(basename -- "$plugin_dir")" ) \
|| { echo "❌ 外掛目錄備份失敗,停止"; return 1; }
if ! tar -tzf "$tar_file" | grep -q '/latepoint\.php$'; then
echo "❌ 備份檔裡找不到 latepoint.php,備份不完整,停止"
return 1
fi
ls -lh -- "$db_file" "$tar_file"
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
}
預期輸出:貼上函式本身沒有輸出。實際呼叫時,備份成功會列出兩個檔案與大小;刷新鹽值成功會看到類似:
✅ example.com 鹽值已更新(3fa9c01b22d4 → 8e71b5aa09c3),wp-config.php 權限:640 admin:admin
- 🟩 看到兩個檔案、大小不是 0 👉 備份完成,可以進到更新。
- 🟥 出現「資料庫備份失敗或是空檔」👉 停止,不要更新。常見原因是磁碟空間不足(看函式開頭印出的 df 結果)或資料庫連線設定有問題。
- 🟥 出現「鹽值沒有改變」👉 通常是 wp-config.php 沒有寫入權限,或金鑰是寫在別的檔案。先確認擁有者與權限,再重跑,不要手動亂改。
- 🟨 刷新後的 wp-config.php 權限若不是 640 或 600,請與你原本的設定比對,別順手改成 777。
- ✅ 驗證方式已內建:函式用「刷新前後的短雜湊」比對,不把真正的金鑰印在螢幕上;備份的 wp-config.php 放在網站目錄外,用完請自行決定保留多久,裡面有資料庫密碼。
🚑 ㊁ 流程 A:懷疑被碰過,先取證再止血
適用:第五章有任何一項亮紅燈。為什麼這樣做:一旦開始清理,證據就沒了。所以順序是「取證 👉 更新 👉 (選配)鎖帳號 👉 (選配)刷新鹽值」。這個函式把四件事串在一起,需要判斷的地方(更新、鎖帳號、刷新鹽值)都會停下來問你,預設答案是「不做」。注意它不會先停用外掛再啟用:停用再啟用會改變你原本的啟用狀態(Multisite 更是如此),直接更新最單純 🔁
⚠️ 高風險提醒:這段會匯出完整資料庫(含客戶個資與密碼雜湊)、複製日誌、並可能登出所有人。取證資料夾權限是 700,請不要上傳到公開空間,也不要寄到一般信箱 🔐
# 前提:已貼過「工具箱」「共用零件」。取證完成前,什麼都不要刪
incident_one() {
local wp_path="$1" domain stamp evid out ver ans plugin_dir
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
evid="${HOME}/latepoint-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" plugin list --fields=name,status,version --format=table > "${out}/plugins.txt"
plugin_dir="$(wpx "$wp_path" plugin path latepoint --dir 2> /dev/null || true)"
if [ -d "$plugin_dir" ]; then
( umask 077; tar -czf "${out}/latepoint-plugin_${stamp}.tar.gz" \
-C "$(dirname -- "$plugin_dir")" "$(basename -- "$plugin_dir")" )
fi
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 "=== ❷ 止血:更新 LatePoint(不先停用,直接更新到官方最新版)==="
wpx "$wp_path" plugin update latepoint --dry-run
read -r -p "確認要更新 LatePoint 嗎?(y/N):" ans
if [ "${ans:-}" != "y" ]; then
echo "已略過更新,這一站的洞還開著,請改走 B 停用外掛"
return 1
fi
wpx "$wp_path" plugin update latepoint || return 1
ver="$(wpx "$wp_path" plugin get latepoint --field=version)"
if ! is_fixed "$ver"; then
printf '❌ 更新後版本仍是 %s,低於 %s,請改走 B 停用外掛\n' "$ver" "$FIXED"
return 1
fi
printf '✅ 目前版本:%s\n' "$ver"
is_best "$ver" || printf '🟡 低於建議版 %s,請確認官方更新通道,不要放著不管\n' "$BEST"
echo "=== ❸ 鎖住連動的 WordPress 帳號(可略過)==="
echo "請先看第五章 ㊂ 的 ❸,挑出角色不是一般客戶、或你不認得的帳號 ID"
local -a ids=()
local uid
read -r -p "要鎖定的 WordPress 使用者 ID(空白分隔,直接 Enter 略過):" -a ids
for uid in "${ids[@]}"; do
[[ "$uid" =~ ^[0-9]+$ ]] || { printf '略過不合法的 ID:%s\n' "$uid"; continue; }
wpx "$wp_path" user session destroy "$uid" --all || true
wpx "$wp_path" user reset-password "$uid" --skip-email || true
done
echo "=== ❹ 刷新鹽值(所有人都會被登出,包含全部 LatePoint 客戶)==="
read -r -p "符合第六章的刷新條件嗎?要現在刷新嗎?(y/N):" ans
if [ "${ans:-}" = "y" ]; then
rotate_salts_one "$wp_path" || return 1
else
echo "已略過鹽值刷新"
fi
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"
預期輸出(節錄):
=== ❶ 取證:資料庫、日誌、帳號、外掛、可疑檔案 === === ❷ 止血:更新 LatePoint(不先停用,直接更新到官方最新版)=== 確認要更新 LatePoint 嗎?(y/N):y ✅ 目前版本:5.7.8 === ❸ 鎖住連動的 WordPress 帳號(可略過)=== === ❹ 刷新鹽值(所有人都會被登出,包含全部 LatePoint 客戶)=== ✅ example.com 止血完成,證據放在 /home/admin/latepoint-incident-20261109-101500
驗證(確認更新與帳號狀態,只讀不寫):
WP_PATH="/home/admin/web/example.com/public_html"
for slug in "${PLUGINS[@]}"; do
printf '%s:' "$slug"
wpx "$WP_PATH" plugin get "$slug" --field=version 2> /dev/null || echo "未安裝"
done
wpx "$WP_PATH" user list --role=administrator --fields=ID,user_login,user_email --format=table
wpx "$WP_PATH" db query 'SELECT 1 AS db_ok;'
預期結果:LatePoint 版本是 5.7.8 以上、管理員名單只有你認得的人、最後一行顯示 db_ok 1 🧪
- 🟩 版本達標、管理員名單乾淨、資料庫可連線 👉 止血完成,接著做「受影響客戶的處理」。
- 🟥 更新後版本仍低於 5.7.7 👉 改走流程 B(停用),並檢查主機能不能連到 WordPress.org 的更新通道。
- 🟧 db_ok 出不來或報連線錯誤 👉 資料庫密碼或連線設定有問題,先查 wp-config.php,不要繼續往下動作。
- 🟨 更新後前台預約頁出錯 👉 先看 PHP 錯誤日誌,確認是不是 Pro 附加元件版本太舊;不要退回舊版,舊版就是有洞的版本。
🧩 補一刀(只有三級才做):更換資料庫密碼
三級代表攻擊者可能已經拿到執行能力,wp-config.php 裡的資料庫密碼就該視為外洩。先到 HestiaCP 面板把資料庫使用者的密碼改掉,再跑下面這個函式,它會提示你輸入新密碼(輸入時不會顯示在畫面上),並同步寫進 wp-config.php 🔑
# 前提:已貼過「工具箱」。資料庫密碼請先在 HestiaCP 面板改好(建議用英數字元的隨機密碼)
rotate_db_password_one() {
local wp_path="$1" db_user new_pw
db_user="$(wpx "$wp_path" config get DB_USER)" || return 1
printf '資料庫使用者:%s\n' "$db_user"
read -r -s -p "請輸入剛在面板設定的新密碼(輸入時不會顯示):" new_pw
echo
if [ -z "$new_pw" ]; then
echo "沒有輸入,停止,wp-config.php 沒有變動"
return 1
fi
wpx "$wp_path" config set DB_PASSWORD "$new_pw" || return 1
wpx "$wp_path" db query 'SELECT 1 AS db_ok;'
}
rotate_db_password_one "/home/admin/web/example.com/public_html"
預期輸出與驗證:函式最後會用 WP-CLI 實際連一次資料庫。看到 db_ok 1 代表新密碼已生效;如果網站出現「建立資料庫連線時發生錯誤」,代表面板改的密碼和你輸入的不一致,用備份的 wp-config.php 還原,再核對一次密碼。沒有輸入就按 Enter,函式會停止,檔案不會動 🧾
📬 受影響客戶怎麼處理?
- ✅ 更新到 5.7.8 以上,讓客戶走正常的「忘記密碼」流程重設,這條路在修補後是安全的。
- 【❓待確認】我查不到官方有提供「用 WP-CLI 批次重設 LatePoint 客戶密碼」的指令,所以不提供這類指令,免得誤導你。
- 🟡 若確認客戶個資可能外洩,各國通報義務不同。此為資訊統整,非專業建議,重大決定前請諮詢真人專家(例如個資法務或你的資安顧問)。
⏸️ ㊂ 流程 B:暫時更新不了,先把門關起來
適用:更新失敗、客製化程式還沒測完、或要先等維運人員。為什麼這樣做:這顆漏洞的入口是一般的網站請求,不是某個固定網址,所以不要指望用 nginx 規則擋網址(攔不到),也把預約頁改成草稿沒有用(🟡 重設流程不一定要靠那些頁面)。最有把握的做法就是停用外掛。停用後預約功能會暫停,客戶端的忘記密碼也會暫停 🚪
WAF 若能檢查 POST 內容、並針對這顆漏洞出了規則,可以當作額外一層,但我查不到各家 WAF 規則的實際覆蓋範圍,【❓待確認】,不建議拿來取代更新。
# 停用 LatePoint(Multisite 連全網與各子站一起處理)
deactivate_one() {
local wp_path="$1" slug url status home_url code left=0
for slug in "${PLUGINS[@]}"; do
if wpx "$wp_path" core is-installed --network 2> /dev/null; then
wpx "$wp_path" plugin deactivate "$slug" --network 2> /dev/null || true
while IFS= read -r url <&3; do
wpx "$wp_path" --url="$url" plugin deactivate "$slug" 2> /dev/null || true
done 3< <(wpx "$wp_path" site list --field=url)
else
wpx "$wp_path" plugin deactivate "$slug" 2> /dev/null || true
fi
done
# 驗證一:已安裝的版本,狀態都不能是 active 或 active-network
for slug in "${PLUGINS[@]}"; do
status="$(wpx "$wp_path" plugin get "$slug" --field=status 2> /dev/null || true)"
[ -n "$status" ] || continue
printf '%s:%s\n' "$slug" "$status"
case "$status" in
active|active-network) left=1 ;;
esac
done
# 驗證二:首頁還打得開(佈景主題或附加元件若依賴 LatePoint,停用後可能出錯)
home_url="$(wpx "$wp_path" option get home)"
code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/" || true)"
case "$code" in
2*|3*) printf '首頁 HTTP %s,正常\n' "$code" ;;
*) printf '⚠️ 首頁 HTTP %s:停用後網站有問題,補救見下方分支判斷\n' "${code:-無回應}" ;;
esac
if [ "$left" -eq 1 ]; then
echo "❌ 仍有版本是啟用狀態,請看上方輸出"
return 1
fi
return 0
}
使用方式(網站路徑換成你的;Multisite 對網路根目錄跑一次就夠):
# 💠 單一網站(路徑換成你的;Multisite 請對網路根目錄跑一次) deactivate_one "/home/admin/web/example.com/public_html"
預期輸出:
latepoint:inactive 首頁 HTTP 200,正常
- 🟩 狀態是 inactive、首頁 200 👉 門已關上,記得排定更新時間,不要把「暫時」變成「永久」。
- 🟥 出現「仍有版本是啟用狀態」👉 停用沒成功,可能是權限或 Multisite 的子站設定,請維運人員處理,不要放著。
- 🟧 首頁不是 2xx 或 3xx 👉 佈景主題或其他外掛依賴 LatePoint,臨時方案是把問題頁面先拿掉依賴;不要為了網站好看就重新啟用有洞的版本。
🧰 ㊃ 流程 C:沒有可疑跡象,標準更新
適用:一級狀況,大多數人走這條。為什麼這樣做:把「備份 👉 預覽 👉 確認 👉 更新 👉 驗證」包成一支可以重複執行的腳本,跑第二次也不會壞事(版本已經夠新就直接跳過)。腳本放在 /usr/local/sbin,權限 750,只有 root 能跑。下面是「建立檔案」這一步,用 heredoc 寫入,結尾會做語法檢查 🔧
⚠️ 高風險提醒:這會在系統目錄寫入檔案;更新外掛會改動網站程式。請先確認你有備份能力與回復計畫。
cat > /usr/local/sbin/latepoint-patch.sh <<'LP_PATCH_EOF'
#!/usr/bin/env bash
# latepoint-patch.sh:單一網站的 LatePoint 修補流程(備份 → 預覽 → 確認 → 更新 → 驗證)
# 用法:latepoint-patch.sh 網站使用者 網域
set -euo pipefail
SLUG="latepoint"
FIXED="5.7.7"
BEST="5.7.8"
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
HOME_DIR="$(getent passwd "$SITE_USER" | cut -d: -f6 || true)"
wpx() {
runuser -u "$SITE_USER" -- env HOME="${HOME_DIR:-/tmp}" "$WPCLI" --path="$WP_PATH" "$@" < /dev/null
}
version_lt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
is_fixed() { ! version_lt "$1" "$FIXED"; }
is_best() { ! version_lt "$1" "$BEST"; }
ver="$(wpx plugin get "$SLUG" --field=version 2> /dev/null || true)"
if [ -z "$ver" ]; then
echo "⚪ ${SITE_DOMAIN} 沒有安裝 LatePoint,不需處理"
exit 0
fi
printf '目前版本:%s\n' "$ver"
if ! is_best "$ver"; then
echo "🔴 ${SITE_DOMAIN} 的版本低於建議版 ${BEST},開始處理"
BACKUP_DIR="${HOME}/wp-security-backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
DB_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_db_${STAMP}.sql"
TAR_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_latepoint_${STAMP}.tar.gz"
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
PLUGIN_DIR="$(wpx plugin path "$SLUG" --dir 2> /dev/null || true)"
if [ -z "$PLUGIN_DIR" ] || [ ! -d "$PLUGIN_DIR" ]; then
echo "❌ 找不到 LatePoint 外掛資料夾,停止,沒有進行任何更新" >&2
exit 1
fi
( umask 077; tar -czf "$TAR_FILE" -C "$(dirname -- "$PLUGIN_DIR")" "$(basename -- "$PLUGIN_DIR")" )
if ! tar -tzf "$TAR_FILE" | grep -q '/latepoint\.php$'; then
echo "❌ 外掛備份裡找不到 latepoint.php,停止,沒有進行任何更新" >&2
exit 1
fi
echo "✅ 已備份:${DB_FILE}"
echo "✅ 已備份:${TAR_FILE}"
echo "--- 預覽(不會真的更新)---"
wpx plugin update "$SLUG" --dry-run
read -r -p "確認要更新 LatePoint 嗎?(y/N):" ans
if [ "${ans:-}" = "y" ]; then
wpx plugin update "$SLUG"
else
echo "已略過更新"
fi
fi
echo "--- 驗證 ---"
fail=0
ver="$(wpx plugin get "$SLUG" --field=version 2> /dev/null || true)"
if is_best "$ver"; then
echo "✅ ${SLUG}:${ver}"
elif is_fixed "$ver"; then
echo "🟡 ${SLUG}:${ver} 已過最低修補版,但低於建議版 ${BEST},請確認更新通道"
else
echo "❌ ${SLUG}:${ver} 仍低於 ${FIXED}" >&2
fail=1
fi
wpx plugin verify-checksums "$SLUG" || fail=1
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}:首頁 HTTP ${code}" ;;
*) echo "❌ ${SITE_DOMAIN}:首頁 HTTP ${code:-無回應},請立刻檢查" >&2; fail=1 ;;
esac
echo "👉 還沒結束:請照第三章 ㊅ 實際走一次預約與忘記密碼流程"
exit "$fail"
LP_PATCH_EOF
chown root:root /usr/local/sbin/latepoint-patch.sh
chmod 750 /usr/local/sbin/latepoint-patch.sh
bash -n /usr/local/sbin/latepoint-patch.sh && echo "語法檢查通過"
預期輸出:貼完最後一行顯示 語法檢查通過 就是成功,沒有訊息的步驟(chown、chmod)沒訊息就是正常。若看到 syntax error,多半是貼的時候被剪貼簿切掉一段,重貼一次 ✂️
實際執行(一次一站,兩個參數:網站使用者、網域):
# 一次一站:兩個參數就夠了(LatePoint 在 WordPress.org 上,更新與檢查碼都走官方通道) /usr/local/sbin/latepoint-patch.sh admin example.com
預期輸出(節錄):
目前版本:5.7.6 🔴 example.com 的版本低於建議版 5.7.8,開始處理 ✅ 已備份:/root/wp-security-backup/example.com_db_20261109-101500.sql ✅ 已備份:/root/wp-security-backup/example.com_latepoint_20261109-101500.tar.gz --- 預覽(不會真的更新)--- 確認要更新 LatePoint 嗎?(y/N):y --- 驗證 --- ✅ latepoint:5.7.8 ✅ example.com:首頁 HTTP 200 👉 還沒結束:請照第三章 ㊅ 實際走一次預約與忘記密碼流程
實際的備份路徑依你執行時的使用者而不同。預期輸出只是典型範例,版本不同可能略有差異 📜
- 🟩 兩個 ✅ 加上首頁 200 👉 版本與首頁都正常,還差最後一步:人工功能測試。
- 🟨 顯示「已過最低修補版,但低於建議版」👉 更新通道可能卡在舊版,檢查主機能不能連到官方,晚點再跑一次。
- 🟥 顯示「仍低於 5.7.7」👉 更新沒成功,改走流程 B 先停用。
- 🟧 已略過更新(你按了 N)👉 沒有任何變更,備份檔還在,可以日後再跑。
已知限制:verify-checksums 只能比對 WordPress.org 上的官方外掛,LatePoint 的付費附加元件(Pro 功能)不在比對範圍,檢查碼通過不等於附加元件也沒問題 🧐
🧪 最後一關:手動功能測試
更新完成不代表預約流程沒壞。建議用測試客戶完整走一次:到 LatePoint 後台新增一位測試客戶(信箱用你自己收得到信的),再到前台客戶專區走一次「忘記密碼」、收信、設定新密碼、登入,確認看得到預約清單。測完回後台刪掉測試客戶,再用下面這段確認客戶表裡已經沒有它 🧼
WP_PATH="/home/admin/web/example.com/public_html"
# ❶ 建一個測試客戶:請在 LatePoint 後台手動新增,信箱用你自己收得到信的,例如 test+lp@你的網域
# ❷ 到前台客戶專區走一次「忘記密碼」,收信、設定新密碼、登入,確認能看到預約清單
# ❸ 驗證完成後,回後台把這個測試客戶刪掉,再用下面這行確認客戶表裡已沒有它
while IFS= read -r tbl <&3; do
[[ "$tbl" =~ ^[A-Za-z0-9_]+$ ]] || continue
wpx "$WP_PATH" db query "SELECT id, email, created_at FROM \`${tbl}\` WHERE email LIKE 'test+lp@%';"
done 3< <(wpx "$WP_PATH" db tables '*latepoint_customers' --all-tables)
預期結果:查詢沒有任何資料列(空白)就是已經清乾淨;若還看得到那一筆,回後台再刪一次。
📋 三環境怎麼套用?
| 動作 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| 備份 | backup_one 路徑 | 逐站各備一份 | 備一份即可(資料庫與外掛全網共用) |
| 更新 | 跑一次腳本 | 每個網域各跑一次腳本 | 對網路根目錄跑一次 |
| 停用 | 跑 deactivate_one | 逐站跑 | 函式會連網路與各子站一起處理 |
| 功能測試 | 測一次 | 每站都要測 | 至少挑兩個子站測 |
🧱 第七章:順手加固,但請先知道這些「擋不住本 CVE」
🚨 先講清楚:這一章的措施是一般性的網站加固,擋不住這顆漏洞本身。這顆漏洞是客戶帳號被接管,不需要執行 PHP。要擋它只有更新或停用。加固的價值是,萬一同時有別的問題,損害會被限縮 🛡️
🔒 ㊀ 關掉危險的 PHP 函式
為什麼這樣做:就算有人上傳了惡意檔案,沒辦法呼叫系統指令,傷害也會小很多。這段會對主機上每一個 PHP-FPM 版本做「可重複執行」的替換,改之前先備份、改完先做語法檢查,檢查沒過就自動還原。有些外掛(備份、圖片處理、部分金流)會用到這些函式,停用後可能出錯,所以改完要實際測網站 ⚙️
# 對主機上每一個 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 版本會印出已套用,沒輸出的 grep 代表個別網域沒有另外覆蓋。看到「語法檢查沒過,已還原」代表設定檔有問題,原檔沒被動到。
驗證(放一個無害的探針,用完立刻刪):
# 驗證 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" <<'PHP'
<?php
echo function_exists('exec') ? 'EXEC-AVAILABLE' : 'EXEC-DISABLED';
PHP
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-FPM 有沒有 reload,以及該網域的 pool 設定有沒有覆蓋。
- 🟧 出現「探針檔案還在」👉 照訊息指的路徑手動刪除,這個檔案本身是無害的,但不該留著。
📁 ㊁ 禁止執行 uploads 裡的 PHP
為什麼這樣做:上傳資料夾只該放圖片與文件。HestiaCP 認得網域目錄下的 nginx.conf_* 自訂檔,寫在這裡面的規則在面板重建設定時不會被蓋掉。函式套用後會先檢查 nginx 設定,沒過就撤回,不會把網站弄掛 🧱
⚠️ 高風險提醒:這會寫入並重新載入 nginx 設定。少數外掛會把需要執行的 PHP 放在 uploads 裡(不建議,但存在),套用前請先確認。
# 用 HestiaCP 認得的自訂檔,禁止執行 uploads 底下的 PHP(面板重建設定時不會被蓋掉)
block_uploads_php() {
local owner="$1" domain="$2"
local conf_dir="/home/${owner}/conf/web/${domain}"
local http_inc="${conf_dir}/nginx.conf_no_uploads_php"
local ssl_inc="${conf_dir}/nginx.ssl.conf_no_uploads_php"
if [ ! -d "$conf_dir" ]; then
printf '找不到 %s,略過\n' "$conf_dir"
return 1
fi
tee "$http_inc" > /dev/null <<'NGX'
# 上傳資料夾只該放圖片與文件,PHP 類檔案一律回 403
location ^~ /wp-content/uploads/ {
location ~* \.(php|phtml|phar)$ {
return 403;
}
}
NGX
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_uploads_php() {
local owner="$1" domain="$2"
local conf_dir="/home/${owner}/conf/web/${domain}"
rm -f -- "${conf_dir}/nginx.conf_no_uploads_php" "${conf_dir}/nginx.ssl.conf_no_uploads_php"
if nginx -t; then
systemctl reload nginx
printf '已解除:%s\n' "$domain"
else
echo "nginx 設定檢查沒過,請先查原因,不要 reload"
return 1
fi
}
# 驗證:PHP 探針要 403,uploads 裡的一般圖片仍然是 200(探針用完立刻刪除)
check_uploads_block() {
local wp_path="$1" image_url="$2" domain name probe
domain="$(site_domain "$wp_path")"
name="__probe_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
probe="${wp_path}/wp-content/uploads/${name}"
printf '<?php echo "PROBE-RAN";\n' > "$probe"
curl -sS -o /dev/null -w "uploads 內的 PHP 探針:HTTP %{http_code}\n" --max-time 20 \
"https://${domain}/wp-content/uploads/${name}" || true
rm -f -- "$probe"
[ -e "$probe" ] && printf '⚠️ 探針檔案還在,請手動刪除:%s\n' "$probe"
curl -sS -o /dev/null -w "一般圖片:HTTP %{http_code}\n" --max-time 20 "$image_url" || true
}
# 套用(把使用者與網域換成你的)
block_uploads_php "admin" "example.com"
check_uploads_block "/home/admin/web/example.com/public_html" \
"https://example.com/wp-content/uploads/你網站上任一張圖片.jpg"
預期輸出與驗證:套用成功會顯示「已套用:網域」;驗證函式會在 uploads 放一個 PHP 探針,預期回應 403,一般圖片仍然是 200,探針用完立刻刪除。若看到「探針檔案還在」,手動刪掉。若網站圖片突然全部壞掉,用 unblock_uploads_php 拆除(它只會刪我們自己建的兩個檔案)。
📝 ㊂ 表單與登入頁的一般性建議(不需要改系統設定)
- ✅ 客戶專區和登入頁前面加驗證碼或人機驗證,可以降低機器人批次觸發「忘記密碼」的機會,但這不是針對本 CVE 的修補。
- ✅ 後台管理員帳號一律開啟雙重驗證,讓「客戶帳號被接管」不會連帶變成「管理員帳號被接管」。
- ✅ 管理員帳號與客戶帳號分開,不要用同一個信箱註冊兩邊。
- 🟡 把重設密碼信件的寄送記錄留下來,萬一要查,能知道誰在什麼時候被觸發過。
預算有限的小站,把力氣花在「準時更新」比花在 WAF 更划算:這顆漏洞靠更新就能解。WAF 的價值在於你來不及更新的空窗期,而且前提是它能看懂 POST 內容、廠商也出了規則,這兩點我都查不到公開證據,【❓待確認】。要買,請先問清楚這兩件事 🏷️
💬 第八章:大家最常問的五個問題
Q1:我只是用 LatePoint 的免費版,也要處理嗎?
要。漏洞在核心外掛,沒有說只影響某個授權版本。版本在 5.7.6 以下就在受影響範圍 ✅
Q2:我的站沒有任何客戶資料,還會中嗎?
🟡 依公開描述,比對命中的是客戶編號最小的客戶,所以「至少有一位客戶」是前提。沒有任何客戶時風險很低,但你的站未來會有客戶,更新才是長久之計。
Q3:我更新到 5.7.7 就夠了嗎?
最低標準是夠了,但官方隔天又釋出 5.7.8,並說明有另一項安全修補。我沒辦法確認 5.7.8 的差異,所以建議更新到 5.7.8 以上,不要停在 5.7.7 ✅
Q4:日誌裡沒找到任何可疑請求,代表沒被打過嗎?
不代表。標準日誌不記錄 POST 的內容,日誌可能輪替,前面有 CDN 時 IP 也會失真。「沒找到」只能說明這一輪沒發現直接證據,不能當成安全證明 🔍
Q5:要不要通知客戶?
🟡 若第五章發現客戶資料有被改的跡象,通知客戶改密碼是合理做法。是否構成法律上的通報義務,要看你所在地的法規與資料量。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 📣
這顆漏洞和很多經典漏洞同一個脈絡:程式把外部傳進來的字串,直接當成自己內部的指令或符號來解讀。解法也相似:在資料進入核心邏輯之前,先限制它的格式,而不是事後再清洗。看到外掛的更新說明寫「加強輸入驗證」,大多是在補這類洞 🧠
⚖️ 第九章:這次事件的整體評估
以下分數是我根據公開資料做的主觀判斷,不是官方評級,也不代表廠商好壞,只用來幫你決定行動優先順序 🧮
| 評估面向 | 分數(滿分 10) | 判斷依據 |
|---|---|---|
| 漏洞嚴重度 | 9.1 | 官方 CVSS,無需登入、可接管客戶帳號,機密性與完整性皆為 High |
| 官方回應 | 約 7 | 揭露前四天就釋出修補版,值得肯定;但更新日誌只寫「強化安全性」,沒有說明風險,使用者容易忽略 🟡 |
| 自救可行性 | 約 8 | 免費核心外掛可直接更新;難點在已受影響客戶的後續處理,沒有已驗證的批次重設工具 |
| 公開文件完整度 | 約 6 | 完整觸發條件未公開、5.7.8 差異查不到 |
🎯 結論:立刻把 LatePoint 更新到 5.7.8 以上;只有第五章亮紅燈時,才升級成取證與止血流程。這不是等下週的事 🏃
📎 附錄 A:我該走哪條路?一張決策表
| 你的狀況 | 建議做法 | 對應章節 |
|---|---|---|
| 版本 5.7.6 以下,第五章沒異常 | 流程 C,標準更新,不刷鹽值 | 第六章 ㊃ |
| 客戶資料疑似被改 | 流程 A,更新+刷新鹽值+請客戶重設密碼 | 第六章 ㊁ |
| 連動帳號角色異常或 PHP 類檔案 | 流程 A 最嚴格一級,加換資料庫密碼,視為資安事件 | 第六章 ㊁ 與補一刀 |
| 暫時無法更新 | 流程 B,先停用 | 第六章 ㊂ |
| 已是 5.7.8 以上 | 不需修補,補做排查看看舊版時期有沒有被碰過 | 第五章 |
📎 附錄 B:名詞小辭典
- 📘 CVE:全球通用的漏洞編號,方便各家查同一個問題。
- 📘 CVSS:漏洞嚴重度的評分系統,滿分 10。
- 📘 CWE-287:驗證機制不當,白話就是「該查證身分的地方沒查清楚」。
- 📘 鹽值(Salt):WordPress 用來簽登入 cookie 的一組隨機字串,換掉之後所有人都得重新登入。
- 📘 WP-CLI:用指令列管理 WordPress 的官方工具。
- 📘 白名單:只允許符合規定格式的內容通過,其他一律拒絕。
- 📘 標記物件(sentinel):程式內部用來代表特殊意思的固定值,本文指程式內部的專用物件,與使用者輸入的字串不同。
🔗 參考來源
- Wordfence Threat Intelligence:LatePoint 5.7.6 以下 未驗證驗證繞過 [60]
- NVD:CVE-2026-118066 [61]
- CVE.org:CVE-2026-118066 [62]
- WordPress.org:LatePoint 外掛頁 [1]
- LatePoint 官方更新日誌 [63]
- WordPress Plugins Trac:5.7.7 發布提交(r3779410) [64]
- WordPress Plugins Trac:5.7.6 與 5.7.7 差異比較 [65]
- WP-CLI:plugin 指令文件 [66]
- WP-CLI:config shuffle-salts [67]













