🚨💳 會員站的收銀台被開了後門?s2Member 付款通知 RCE 漏洞全解析(CVE-2026-86596/CVSS 9.8)
🔓 付款通知上的「商品名稱」,竟然能變成伺服器後門?s2Member 未登入 RCE 漏洞自救全攻略(CVE-2026-86596/CVSS 9.8)
🔥 懶人包:
會員制外掛 s2Member(WordPress.org 上有 8,000+ 活躍安裝)被登記了 CVE-2026-86596,CVSS 3.1 評分 9.8,弱點類型是程式碼注入(CWE-94)。問題出在 PayPal 付款通知(IPN)的處理流程:通知裡的 item_name(商品名稱)欄位過濾不完整,之後又被塞進郵件範本,依公開描述,惡意內容最後有機會在伺服器上被當成 PHP 執行 💥
260927 以下確定受影響。官方最新版是 261017,也是這篇建議的目標版本。CVSS 向量寫的是不需要登入、不需要使用者點擊,漏洞公開日是 2026-11-02,距離今天(2026-11-04)才兩天,而且有第三方情報平台標示「已有利用跡象」,所以這顆不要排「下週再說」。這篇帶你看版本、備份、更新,再查有沒有被摸過,最後附上一套由淺入深、每一步都能驗證的救援腳本 🔒
內容目錄
🪧 第一章:案發現場還原!一張「付款通知」,怎麼變成後門的入口?
想像你開了一間會員制俱樂部,門口有個收發室。每次有人在 PayPal 付款,PayPal 就會寄一張「付款通知單」到收發室,單子上有金額、買家、還有一欄「商品名稱」。收發室的工讀生拿到單子,先用修正液塗掉看起來可疑的記號,再把商品名稱抄進一封歡迎信的範本裡寄出去,最後幫買家開通會員資格。平常這套流程很穩定 😌
依公開描述,s2Member 內建的 strip_php_tags_deep() 只會移除字面上的 <?。工讀生塗掉了整齊的那種,卻沒料到有人把記號拆成幾段寫進商品名稱,後面「把商品名稱抄進信件範本、再清掉沒用到的占位符」的步驟,有機會把碎片重新拼成完整的 PHP 開頭標記。拼好之後,信件範本會被當成程式來執行,這就是 CWE-94(程式碼注入)想說的事 🧩
先把前因後果交代清楚。PayPal 的 IPN 端點必須讓 PayPal 的伺服器從外面連進來,所以它天生就是「公開」的,不能要求登入。這也是這顆漏洞危險的原因:公開入口,加上一個過濾不完整的欄位。官方沒有公開程式差異,也沒有公開攻擊字串,下面這條鏈是我依 Wordfence、Patchstack 的公開描述與 CVE 紀錄拼出來的輪廓,每一步都標了信心程度 🔍
- ㊀ 入口是公開的 IPN 端點:✅ 多份公開資料指向網站首頁加上 ?s2member_paypal_notify=1 這個參數的請求。它要接 PayPal 的通知,所以沒有登入門檻(這一句的「所以」是我的推論 🟡) 🚪
- ㊁ 惡意內容藏在 item_name 欄位:✅ Wordfence 的漏洞標題直接寫明 item_name 這個 IPN 參數 📝
- ㊂ 過濾只擋住字面上的 <?:✅ 公開技術描述指出 strip_php_tags_deep() 的處理範圍有限 🧽
- ㊃ 範本插值與占位符清理,可能把碎片重新組合:🟡 描述用的是「可能」二字,我無法在原始碼層級核對 🧱
- ㊄ 重組後的內容被當成 PHP 執行:✅ 弱點分類為 CWE-94,CVSS 的機密性、完整性、可用性三項全部是 High ⚙️
🚨 畫重點:我不會給你攻擊字串
有些資料把具體的拆字寫法寫得很清楚,這篇一律不照搬。你只要記住入口是「付款通知端點」,攻擊者不需要你的帳號,也不需要誘騙任何人點連結。想知道自己中不中標,看第五章的結果導向檢查就夠了,不必靠誰公開的字串 🙅
🧊 冷知識:s2Member 的版本號,其實是「發布日期」
260927 是 2026 年 9 月 27 日、261017 是 10 月 17 日,對照 s2member.com 發布紀錄頁列出的日期完全吻合(版本號長得像日期,這是我的觀察 🟡)。好處是你一眼就能判斷這版「多舊」,也代表這次的修補版(261017)比漏洞公開日(11 月 2 日)早了大約兩個星期 📅
⏱️ 事件時間軸一次看懂
| 時間點 | 發生了什麼大事 | 站長可信度判定 |
|---|---|---|
| 2026-09-08 | CVE 編號被保留 | 🟡 只有單一來源(CIRCL 漏洞資料庫的轉述) |
| 2026-09-27 | 260927 發布,這是「確定受影響」的最後一版 | ✅ s2member.com 發布紀錄頁與 Wordfence 條目 |
| 2026-10-01 | 261001 發布;Wordfence 與 Patchstack 把它列為最低修補版 | 🟡 我沒能直接開頁核對這兩個條目,採用的是第三方彙整的說法 |
| 2026-10-14 | 廠商被通知 | 🟡 只有單一來源(CIRCL 的轉述) |
| 2026-10-17 | 261017 發布,WordPress.org 列為目前最新版,更新日誌寫明修補多項安全問題 | ✅ 我直接檢查過 WordPress.org 外掛頁 |
| 2026-11-02 | 研究者 khanhnv 透過 Wordfence 公開,CVE-2026-86596 正式發布 | ✅ Wordfence、NVD、CVE 紀錄 |
| 到底有沒有人受害? | 有第三方情報平台標示已有利用跡象,但也有資料說尚未確認;CISA KEV 清單截至查詢時未收錄 | 請當成「可能已經有人在掃」處理 |
「公開兩天」加上「疑似已有掃描」,意思是自動化工具大概已經在路上了。這種漏洞最麻煩的是:修補版其實兩個星期前就有了,只是大部分站長沒收到通知。現在補,剛好趕在大規模掃描之前 ⏳
🚨 畫重點:「已被利用」的說法為什麼有出入❓
Patchstack 標示 Known to be exploited、VulnCheck 的情資清單有收錄、另有監測平台記下 11 月 2 日與 3 日的觀測紀錄,但這幾筆我都沒能直接開頁核對,只看到第三方彙整的轉述;另一邊,部分資料明確說「沒有確認的利用」。這些平台彼此會互相引用,不算獨立證據。我的做法是兩邊都不全信,但風險判斷取嚴格的那一邊:當作已經有人在試 🎯
🚨 畫重點:到底要不要登入才能打❓
Wordfence 的條目標題寫 Unauthenticated(免登入),CVSS 向量的 PR:N 也代表不需要權限,Patchstack 的欄位同樣是免登入;但 Wordfence 的描述正文卻有一句講到擁有 custom-level access and above 的攻擊者。三個欄位裡兩個以上指向免登入,而且 IPN 端點本來就公開,所以這篇一律用「免登入」的最壞情況來防禦。這個矛盾我沒辦法替官方解釋 🧭
- ✅ 已確認事實:CVSS 3.1 向量為 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H,翻成白話:只要連上網路就能打、攻擊複雜度低、不需要帳號、不需要受害者點擊、影響範圍不跨出外掛本身,但機密性、完整性、可用性全是最高等級。這是標準的 9.8。
- 🟡 合理推論:因為 IPN 端點必須公開,加上漏洞入口就是這個端點,所以「站上有裝、而且啟用中」就是主要的暴露條件,不需要站長特別設定什麼,跟需要特定表單設定才能觸發的漏洞不一樣。
- 🔴 假設情境:假設有個線上課程網站用 s2Member 搭配 PayPal 收費,付款通知頁面平常一天只有幾筆請求。某天日誌裡同一個 IP 在半小時內打了上百次通知端點,來源也不是 PayPal。這是我用來說明的例子,不是任何真實站台的紀錄。
🎭 第二章:先別自己嚇自己,對號入座看看你是哪一種網站主?
🩵 小資族荷包試算:修這個洞要花多少錢?
WordPress.org 上的 s2Member 框架更新是 NT$ 0 元。s2Member Pro 是從 s2member.com 取得,授權與價格以官方為準,我沒有核對,這裡不報數字。對照事後成本:請人清後門、重設會員資料、處理付款糾紛與信用卡盜刷風險,隨便一項都比更新貴得多 💸
同一封資安警告,小編、自己租主機的玩家、企業 IT 要做的事差很多。先找到自己,再決定往下讀到哪一章 🧭
🙋 我只是負責發文、顧後台的小編或老闆
你不需要懂程式。第三章全程只需要動動滑鼠:看版本、備份、按更新、再檢查會員頁面有沒有被動過。唯一要特別留意的是 Pro 版,它不在 WordPress.org 上,更新要回 s2member.com 的帳號區下載,而且框架與 Pro 版本要一起升 🧑💼
👨💻 我自己租主機架站,手上有好幾個客戶網站
直接跳第五章拿工具箱,裡面有整台主機一次盤點的函式,會用各網站擁有者的身分跑 WP-CLI,不會在 root 底下留下一堆權限錯亂的檔案。這顆漏洞的版本判斷分三段(受影響、建議再升、已達目標),腳本會替你分類,免費框架與 Pro 版也分開查 🔧
🧑🔧 DevOps 小知識:為什麼 Pro 版要單獨處理?
框架掛在 WordPress.org,wp plugin update 能直接更新,wp plugin verify-checksums 也能拿官方檢查碼比對檔案。Pro 版從 s2member.com 取得,更新要手動換 zip,檢查碼比對也做不到(WP-CLI 對非 WordPress.org 外掛的行為沒有明確文件,我標【❓待確認】),所以輸出裡看到 Pro 無法驗證是正常現象,不要誤會成檔案被竄改。另外,Pro 的實際 slug 我沒能核對,下面第五章附了一行指令讓你自己確認 🧱
🏢 我們是企業網站,存了會員與付款資料
s2Member 的工作就是管會員與收款,所以這顆漏洞一旦被利用,伺服器上的會員名單、訂單紀錄、郵件設定都在射程內。官方在公開揭露前兩個星期就釋出了修補版,這點是加分;但如果網站在這段期間一直是舊版,就要把它當成「可能被進入過」的等級來處理。個資是否外洩、要不要依《個人資料保護法》通知當事人,請交給公司法務判斷。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺
🚨 冤枉啊!我早就不靠 s2Member 收費了,只是懶得刪
某個線上社團兩年前用 s2Member 搭 PayPal 收年費,後來改用別的平台,PayPal 的 IPN 網址也沒再設定,外掛卻一直開著。管理員心想:「都沒人付款了,總不會有人打這個端點吧?」
🙅 問題是端點是否存在,跟有沒有人付款無關。只要外掛是啟用狀態,這個公開入口就在那裡。我無法驗證「沒設定 IPN 就一定打不到」,所以與其賭這個,不如直接更新或刪除外掛 🆘
🚥 螢光筆時間:更新、停用、刪除,今天至少選一個
「我沒在用」不算處理。更新到 261017,或停用,或徹底刪除,這三個才算真的把門關上。但請先看下一節:停用有它自己的副作用 🙅♂️
🧮 四種情境對照:你今天該做到哪一步?
| 你的情境 | 風險判斷 | 今天要做的事 | 看哪一章 |
|---|---|---|---|
| 外掛啟用中,版本 260927 以下 | 最高 | 備份 → 更新 → 檢查帳號、檔案、日誌有沒有異常 | 第三、五、六章 |
| 外掛啟用中,版本在 261001 到 261016 之間 | 中(第三方說這區間已修補,但官方日誌沒逐版說明) | 照樣更新到最新版 | 第三、六章 |
| 外掛已停用但沒刪除 | 低 | 直接刪除,或更新後再決定要不要啟用 | 第三章 |
| 使用 s2Member Pro | 同上,依版本而定 | 框架與 Pro 必須同時更新,Pro 要自己換 zip | 第三、六章 |
🧮 版本對照:你的版本安不安全?
| 你的版本 | 判定 | 依據 | 該怎麼辦 |
|---|---|---|---|
| 260927 以下 | 受影響 | ✅ Wordfence 條目標示 260927 以下 | 立刻更新 |
| 261001 到 261016 | 建議再升 | 🟡 Wordfence、Patchstack 列 261001 為最低修補版,我沒能直接核對;官方日誌沒逐版說明 | 一律升到最新版 |
| 261017 以上 | 已達目標 | ✅ WordPress.org 列為最新版,日誌寫明修補多項安全問題 | 版本沒問題,但舊版若曾暴露,仍要做第五章稽核 |
順帶一提,版本號是六位數字,不是小數點分隔的版本號。後面的腳本用 sort -V 逐段比大小,不會把 261017 與 260927 當成字串亂比,也能處理未來可能出現的新格式 🧰
😬 更新前先看這個:停用與更新各有一個副作用
更新是對的,但不要當成「按下去就一定安然無事」。我沒有找到 s2Member 這次更新後的公開相容性案例,所以下面列的是依這類外掛的運作邏輯推出來的風險,並非別人踩過的案例,先知道,才不會事到臨頭手忙腳亂 🧪
| 你想做的事 | 可能的副作用 | 信心程度 | 怎麼降低風險 |
|---|---|---|---|
| 先停用 s2Member 再處理 | 外掛一停,付款通知(IPN)就沒人接了;剛付款的人可能收不到會員資格 | 🟡 依外掛功能推論 | 停用期間盯著 PayPal 的付款紀錄,事後補開通漏掉的人。PayPal 會不會重送通知、重送多久,我沒有核對 |
| 先停用 s2Member 再處理 | 外掛負責的「會員專屬內容保護」會跟著失效,原本鎖住的頁面可能變成任何人都能看 | 🟡 依外掛功能推論 | 停用前先把重要的付費頁面改成「私密」或「草稿」,等外掛回來再改回;或乾脆縮短停用時間,直接更新 |
| 只更新框架、沒更新 Pro | 框架與 Pro 版本不一致,功能可能異常 | 🟡 官方要求兩邊版本一致,細節我沒逐項核對 | 兩個一起更新;腳本在沒有 Pro 安裝包時會整站不動 |
| 更新後馬上開放付款 | 沒實測過就上線,有人付了款卻沒開通 | 🟡 通用風險 | 更新後用自己的測試帳號走一次流程(見第三章 ㊄) |
🚨 出問題的時候,不要降版
把外掛降回 260927 以下,等於重新打開漏洞。更新後哪裡怪怪的,優先請維護人員查原因;正式站來不及處理,就先停用外掛(第六章 B),拿備份到測試站慢慢查 🧯
🛟 第三章:電腦小白看過來!十分鐘無痛自救指南
💬 溫馨小叮嚀:更新外掛像是把壞掉的鎖換新,它保證從現在開始新的小偷進不來,但沒辦法倒帶,檢查昨天有沒有人已經進來逛過。所以最後兩步(實測、查名單)一定要做。網站如果存有會員或付款資料,請把這篇轉給主管或維護人員,由他們判斷要不要做更深的健檢 🩺
每一步我都寫了「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只需要滑鼠 🤏
- ㊀ 先查水表,看看有沒有裝:
登入後台,左邊選單點「外掛」→「已安裝的外掛」,在右上角搜尋框輸入 s2Member。找到「s2Member」就是免費框架,名字多了 Pro 的是付費版,兩個都要看。找不到就是沒裝,這篇可以當作別人家的新聞 🔎 - ㊁ 核對版本號:
版本號寫在外掛名稱下方。看到 261017 或更高就是目標版本;比它低就繼續往下做。免費框架和 Pro 要各自看各自的 🕵️♀️ - ㊂ 動手前先留後路(備份):
到主機商後台點「一鍵備份」,或用你平常的備份外掛先存一份。不知道什麼是備份的話,把這篇網址傳給幫你維護網站的人,請他代勞,這本來就是他的工作。備份要包含資料庫,因為會員資料都在裡面 💾 - ㊃ 按下更新:
免費框架在外掛清單點「立即更新」就好。Pro 版不在 WordPress.org,要到 s2member.com 的帳號區下載最新版 zip,回後台「外掛 → 安裝外掛 → 上傳外掛」,遇到已安裝的提示時選擇用上傳的版本取代目前版本。框架與 Pro 要一起更新,更新完重新整理,確認兩個版本號都到 261017 以上。Pro 的下載與授權細節以官方帳號區為準 🔄 - ㊄ 更新完,實測會員流程:
用無痕視窗(沒登入的狀態)打開一個會員專屬頁面,應該會被擋下或導向登入頁,如果直接看得到內容,就是保護失效。再用一個已付費的測試帳號登入,確認能正常看到內容。你如果有小額測試商品或 PayPal 沙盒設定,就走一次付款流程,看會員資格有沒有自動開通。沒有測試環境的話,至少做前兩項 🧪 - ㊅ 今天真的更新不了,怎麼辦:
最乾淨的做法是在外掛清單把它「停用」。注意兩個副作用(第二章表格):付款通知會沒人接,會員專屬內容的保護也會跟著失效。所以停用之前,請先把重要的付費頁面改成「私密」,停用後馬上打開首頁看一眼;掛了就重新啟用,並在今天內排好更新時間。這只是縮短風險時間,不是修補 🧯 - ㊆ 化身名偵探,查查後台有沒有陌生人:
點「使用者」→「管理員」分頁,裡面應該都是你認識的人、用你們公司的信箱。再點「設定」→「一般」,看「任何人都可以註冊」有沒有被打勾、「新使用者的預設角色」是不是變成了管理員,這兩項被改過很少是正常維運造成的。最後回到「已安裝的外掛」,看有沒有你從沒裝過的外掛名稱 🕵️♀️ 見鬼的情況:多出沒印象的管理員、陌生外掛,或網站首頁出現奇怪的廣告與導向。看到這類狀況請不要自己刪,先截圖,立刻找主機商或維護人員,並告訴他們依本文第六章 A 處理 🔀 - ㊇ 有收款的站,多看一眼 PayPal:
登入你的 PayPal 帳號,看看近期的活動紀錄有沒有不認得的交易,以及通知設定(IPN)的網址有沒有被改成陌生網址。如果確定網站已經被入侵,s2Member 設定裡填過的 PayPal 憑證也建議換掉(這一句是我的推論 🟡)。請注意,金鑰與密碼只在你自己的環境操作,不要貼到聊天視窗、客服工單或任何對話裡 🔐
🫂 新手求助專區:看到專有名詞就頭暈怎麼辦?
直接把下面這段話傳給你的主機客服或維護人員:「你好,我的網站可能有安裝 s2Member 外掛(免費版或 Pro),請幫我確認版本是否已更新到 261017 以上,漏洞編號是 CVE-2026-86596。也請協助檢查 PayPal 付款通知端點的日誌有沒有異常、後台管理員名單有沒有陌生帳號。」對方看到這串 CVE 編號就知道該做什麼。沒有備份之前,千萬不要自己亂刪東西 🤗
🧊 冷知識:「停用」跟「刪除」差在哪?
想像大樓的一扇側門。「停用」是把門鎖起來、鑰匙收進抽屜,門還在原位,哪天有人不小心開了,問題就回來了。「刪除」是把整扇門拆掉、牆補起來。很久不用的外掛,拆門比鎖門安全;還在用的,就老實更新 🧰
🤿 第四章:技術深潛,一個「商品名稱」怎麼一路走到伺服器執行程式碼?
想知道為什麼的人往下看,只想補洞的前三章已經夠用。先講資料狀況:官方沒有公開程式差異,攻擊字串也沒公開,下面的機制整理自 Wordfence 與 Patchstack 的公開描述、CVE 紀錄與 NVD,推論的部分我都標了 🟡,查不到的標【❓待確認】⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | s2Member(會員制、內容保護、付費牆外掛) |
| 外掛代號(slug) | s2member(免費框架)、Pro 版的 slug 我沒能核對,第五章有指令可以查 |
| CVE 編號 | CVE-2026-86596 |
| CVSS 3.1 | 9.8(Critical);Patchstack 頁面的評分有出入(有資料提到 10) |
| CVSS 向量 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 弱點類型 | CWE-94:程式碼生成控制不當(程式碼注入) |
| 受影響與目標版本 | 260927 以下受影響;目標版本 261017(WordPress.org 最新版) |
| 入口 | PayPal IPN 端點,首頁加上 s2member_paypal_notify=1 參數;惡意內容放在 item_name 欄位 |
| 發現者 | khanhnv(透過 Wordfence 公開) |
| 公開時間 | 2026-11-02 |
| 啟用規模 | WordPress.org 上 8,000+ 活躍安裝(Pro 版不在內) |
| EPSS(被利用機率預測) | 部分資料提到約 1.49%,這個數字是機率預測不是事實,而且更新很快,不能拿它當「不急」的理由 |
🧩 三個環節串成一條完整的鏈
- 🏮 環節一:IPN 端點沒有登入門檻,因為它不能有。PayPal 的伺服器不可能先登入你的 WordPress 再送通知,所以外掛靠的是「驗證通知內容」而不是「驗證來訪者」。這是 IPN 整合的常態,不是缺陷。問題在於驗證之前或之後,欄位的內容是否被妥善處理 🚪
- 🏮 環節二:黑名單式的過濾,擋得住整齊的、擋不住拆開的。只移除字面上的 <?,屬於「看到某種特定寫法就拿掉」。這類做法的老毛病是:攻擊者只要換一種寫法、把字拆開,就繞過去了。真正穩的做法是反過來,「只放行確定安全的內容」,也就是白名單 🧽
- 🏮 環節三:過濾完還要經過一道「組裝」。外部資料被放進郵件範本、再清理沒用到的占位符,這個過程如果會把碎片重新接合,就等於過濾白做了。這一環我只能引用公開描述的「可能」,具體程式怎麼走,官方沒公開,我沒能在原始碼層級驗證 🟡。最終結果是 CVSS 裡的 C/I/A 全部拉到 High 🎯
保全手上有一張通緝照,看到長得一樣的就攔下。壞人只要換個造型、戴個帽子就能進門。換成白名單,保全只認「今天預約過的訪客」,沒在名冊上的,長得再像好人也不放行。寫程式時,白名單的做法永遠比黑名單可靠,但需要事先把「什麼是合法」想清楚,所以很多人偷懶選了黑名單 📋
🧑🔧 DevOps 小知識:CVSS 9.8 為什麼是 9.8 而不是 10?
10.0 通常需要「影響範圍會跨出被攻擊的元件」(S:C),這顆的向量是 S:U(範圍不變),所以停在 9.8:網路可達、不用帳號、不用互動、低複雜度、三項衝擊全滿。換句話說,它已經是「不需要任何前提」的等級,再高一點點也只是範圍定義的差別 🛡️
🧊 冷知識:261017 的更新日誌寫「修了多個安全問題」,但沒說哪一個對應這顆 CVE
WordPress.org 的更新日誌提到這個版本修補多項安全問題,沒有逐項對應 CVE 編號,也沒有公開程式差異。所以「升到最新版」是唯一確定覆蓋到的做法,不要試圖自己手動補丁、也不要拿第三方說的「261001 起已修」就停在中間版本 🔒
🚨 畫重點:為什麼不教你「只放行 PayPal 的 IP」❓
你可能會想:既然只有 PayPal 需要打這個端點,那就用防火牆只放行 PayPal 的 IP 位址。理論上正確,但 PayPal 會調整來源位址範圍,我不在這篇寫死任何 IP(寫死會過期,過期的結果是正常付款被擋掉),也沒有把握你的網站前面有沒有 CDN 把來源 IP 換成別人的。所以第六章 B 的備案做法是整個端點先擋掉,代價是連真的付款通知也一起擋,只能當作「還沒修補、又不能停用」時的過渡 🧱
🚨 畫重點:標準日誌看不到「攻擊內容」本身
攻擊者的內容放在 POST 請求的內容區(body),一般 nginx 與 Apache 的存取日誌只記請求行與狀態碼,不記 body。所以就算你在日誌裡 grep 不到 item_name,也不能證明沒被打過。第五章的日誌排查刻意不去找攻擊字串,改看結果特徵:哪些 IP 打了通知端點、頻率多高、User-Agent 像不像 PayPal,代價是有雜訊,好處是不會因為猜錯字串而漏看 🧭
🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 Checklist
🤖 重要提醒:以下指令彙整多份分析報告,並對照 WP-CLI 官方文件與 HestiaCP 文件逐項複查;所有程式碼區塊都做過 bash 語法檢查與 shellcheck,也用模擬的 WP-CLI 環境測過判斷邏輯(包含路徑含空白、Multisite 與重複執行),但沒有在你的真實主機上實跑過 🫡
你的主機環境不一定跟範例一樣。正式環境(尤其是正在營業中的會員網站)的高風險操作,包含更新、停用、刪除檔案、變更權限、重設密碼或執行批次腳本,請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin、bob 都是示範用名稱,請換成你自己的環境 🧙
這一章的流程是:工具箱 👉 盤點版本 👉 日誌排查 👉 帳號與檔案稽核。這一章的指令全部只讀不寫,不會改任何東西。範例環境是 Debian 12/13 搭配 HestiaCP,bash 是預設的 shell。預期輸出只是典型範例,版本不同可能略有差異 📜
🧰 ㊀ 先貼這一段:整章都會用到的小工具
為什麼這樣做:HestiaCP 的每個網站屬於不同的 Linux 使用者。用 root 直接跑 WP-CLI 會留下 root 擁有的檔案,之後網站自己寫入就會失敗。所以先定義幾個小函式:自動找 WP-CLI(有的主機叫 wp,有的叫 wp-cli)、以檔案擁有者身分執行、逐站迴圈,以及專門判斷版本屬於哪一段的 version_state。所有路徑變數都用雙引號包起來,路徑含空白也不會斷 🧱
# ㊀ 工具箱:同一個 SSH 連線貼一次就好,斷線後要重貼
PLUGINS=("s2member" "s2member-pro") # 啟用順序:框架先、Pro 後
PLUGINS_REV=("s2member-pro" "s2member") # 停用順序:Pro 先、框架後
AFFECTED_MAX="260927" # 這一版(含)以下確定受影響
TARGET="261017" # 本文建議的目標版本
SINCE="2026-11-01" # 稽核「這天之後被改過的檔案」,想保守就往前調
HOME_ROOT="/home" # HestiaCP 的使用者家目錄
BACKUP_DIR="${HOME}/wp-security-backup" # 備份放在網站目錄外面,權限 700
STATE_DIR="${BACKUP_DIR}/state" # 停用前的啟用狀態,還原時要用
NGINX_LOG_DIR="/var/log/nginx/domains"
APACHE_LOG_DIR="/var/log/apache2/domains"
# 找出 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_ROOT" -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" ]
}
# 版本判定:affected=確定受影響|unconfirmed=報告說已修補但官方日誌沒明講|fixed=達到目標版本
version_state() {
local v="$1"
if ! [[ "$v" =~ ^[0-9][0-9.]*$ ]]; then
echo "unknown"
elif ! version_lt "$AFFECTED_MAX" "$v"; then
echo "affected"
elif version_lt "$v" "$TARGET"; then
echo "unconfirmed"
else
echo "fixed"
fi
}
預期輸出:貼完沒有任何輸出就是成功。看到 ⚠️ 代表缺 WP-CLI 或 runuser,先補上再往下,不要硬跑。Debian 最小安裝若沒有 runuser,它屬於 util-linux 套件,通常已內建,查不到就請維運人員協助。
驗證(確認版本判斷正確):貼完後跑下面這段,它只做版本判斷,不碰任何網站 🧪
for v in 260312 260927 261001 261016 261017 270101 abc; do printf '%s → %s\n' "$v" "$(version_state "$v")" done
預期輸出(260312 與 260927 要是 affected、261001 與 261016 是 unconfirmed、261017 與 270101 是 fixed、讀不懂的是 unknown):
260312 → affected 260927 → affected 261001 → unconfirmed 261016 → unconfirmed 261017 → fixed 270101 → fixed abc → unknown
- 🟢 結果符合預期 👉 工具箱沒貼錯,往下進行。
- 🟠 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
- 🟡 如果你想把目標版本改成別的(例如官方又出了新版),只要改開頭的 TARGET 一個地方,後面所有函式都會跟著變。
🧑🔧 DevOps 小知識:為什麼迴圈長成 3< <(find …) 這個樣子?
常見寫法 find | while read 有個陷阱:迴圈裡只要有指令會讀標準輸入,就會把清單吃掉一半,結果只處理到第一個網站。改成把 find 的輸出接到檔案描述符 3、用 read … <&3 讀,迴圈內的指令就搶不到輸入;搭配 -print0 與 read -d ”,路徑含空白也安全。wpx 裡的 < /dev/null 是第二道保險。後面的 reactivate_one 用的是同一招 🔧
🔎 ㊁ 盤點版本:哪些站裝了它、現在哪一版?
為什麼這樣做:一台主機上有十個獨立 WordPress,跟一個 Multisite 底下有十個子站,看起來像但處理方式不同:前者要逐一找每個 wp-config.php,後者外掛檔案全網只有一份。這個函式同時判斷安裝類型,並且分別查免費框架與 Pro,輸出一行一個外掛,只讀不寫 🤔
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)"
case "$(version_state "$ver")" in
affected) verdict="🔴 受影響" ;;
unconfirmed) verdict="🟡 建議再升" ;;
fixed) verdict="🟢 已達目標" ;;
*) verdict="❓ 版本讀不懂" ;;
esac
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
預期輸出範例:
🔴 受影響 💠 一般安裝 s2member 版本:260927 狀態:active /home/admin/web/site-a.com/public_html 🔴 受影響 💠 一般安裝 s2member-pro 版本:260927 狀態:active /home/admin/web/site-a.com/public_html 🟡 建議再升 💠 一般安裝 s2member 版本:261001 狀態:inactive /home/bob/web/shop.example/public_html 🟢 已達目標 ✴️ Multisite s2member 版本:261017 狀態:active-network /home/bob/web/network.example/public_html ⚪ 未安裝 💠 一般安裝 - - - /home/bob/web/blog.example/public_html
確認 Pro 版的 slug:Pro 版不在 WordPress.org,我沒能核對它在 WP-CLI 裡的實際代號。工具箱預設填的是 s2member-pro,如果上面的盤點看不到你明明有裝的 Pro,跑下面這行確認,再把工具箱最前面 PLUGINS 與 PLUGINS_REV 兩個陣列裡的名稱改成你看到的 🔍
WP_PATH="/home/admin/web/example.com/public_html" # 列出所有名稱含 s2member 的外掛,確認 Pro 版實際的 slug 與版本 wpx "$WP_PATH" plugin list --fields=name,status,version --format=table | grep -i 's2member'
分支判斷邏輯:
- 🔴 受影響 👉 排入第六章的備份與更新,並接著做 ㊂ 與 ㊃,確認舊版時期有沒有被碰過。
- 🟡 建議再升 👉 第三方說已修補,官方日誌沒逐版說明,一律升到最新版,也建議做 ㊂ 與 ㊃。
- 🟢 已達目標 👉 版本條件已解除,但舊版若曾暴露一段時間,仍建議做 ㊂ 與 ㊃。
- 🟡 狀態是 inactive 👉 外掛已停用,攻擊面暫時不在,仍建議更新或刪除,不要放著。
- ✴️ Multisite 出現 active-network 👉 外掛檔案全網共用,查主站一次就代表整個網路,更新也只做一次。
- 🟠 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。路徑若像 public_html.bak 之類的備份副本,請人工確認哪一個才是正式站。
📊 ㊂ 日誌排查:看誰一直在敲「付款通知」這扇門
為什麼這樣做:前一章說過,標準存取日誌看不到攻擊內容,只記請求行。所以這一步只抓三個有價值的結果特徵:哪些 IP 打了通知端點、哪天暴增、User-Agent 像不像 PayPal。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log,錯誤日誌是 網域.error.log,函式用 zcat -f 一併讀輪替後的 .log.1 與 .log.2.gz。nginx 搭配 Apache 的組合,PHP 錯誤多半記在 Apache 那邊,所以函式兩邊都看。前面如果有 CDN,日誌裡看到的 IP 可能是 CDN 的 🎯
# 取出某網域「目前+輪替後(含 .gz)」的 nginx 存取日誌
scan_access_log() {
local domain="$1"
find "$NGINX_LOG_DIR" -maxdepth 1 -type f -name "${domain}.log*" -print0 \
| xargs -0 -r zcat -f --
}
# 錯誤日誌:nginx 與 Apache 兩邊都看(nginx+Apache 組合的主機,PHP 錯誤多半記在 Apache 那邊)
scan_error_log() {
local domain="$1" dir
for dir in "$NGINX_LOG_DIR" "$APACHE_LOG_DIR"; do
[ -d "$dir" ] || continue
find "$dir" -maxdepth 1 -type f -name "${domain}.error.log*" -print0 \
| xargs -0 -r zcat -f --
done
}
log_scan_one() {
local wp_path="$1" domain
domain="$(site_domain "$wp_path")"
printf '\n===== %s =====\n' "$domain"
echo "--- ❶ 打到 IPN 端點的請求:依來源 IP 統計(前 15 名)---"
scan_access_log "$domain" \
| grep -F 's2member_paypal_notify' \
| awk -F'"' '{ split($1, a, " "); print a[1] }' \
| sort | uniq -c | sort -rn | head -n 15
echo "--- ❷ 同一批請求的 User-Agent 統計(前 10 名)---"
scan_access_log "$domain" \
| grep -F 's2member_paypal_notify' \
| awk -F'"' '{ print $6 }' \
| sort | uniq -c | sort -rn | head -n 10
echo "--- ❸ 每天的請求數(看有沒有突然暴增)---"
scan_access_log "$domain" \
| grep -F 's2member_paypal_notify' \
| awk '{ print substr($4, 2, 11) }' \
| sort | uniq -c
echo "--- ❹ 最近 10 筆原始請求行 ---"
scan_access_log "$domain" \
| grep -F 's2member_paypal_notify' \
| tail -n 10
echo "--- ❺ 錯誤日誌裡與 s2Member 有關的 PHP 錯誤(最近 20 筆)---"
scan_error_log "$domain" \
| grep -Ei 's2member' \
| grep -Ei 'parse error|fatal|eval' \
| tail -n 20
return 0
}
# 💠 單一網站
log_scan_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站/✴️ Multisite:掃主機上每一個網域
each_site log_scan_one
預期輸出範例(有可疑的樣子):
===== example.com =====
--- ❶ 打到 IPN 端點的請求:依來源 IP 統計(前 15 名)---
412 203.0.113.5
3 192.0.2.77
--- ❷ 同一批請求的 User-Agent 統計(前 10 名)---
412 curl/8.5.0
3 PayPal IPN ( https://www.paypal.com/ipn )
--- ❸ 每天的請求數(看有沒有突然暴增)---
2 01/Nov/2026
413 03/Nov/2026
--- ❹ 最近 10 筆原始請求行 ---
203.0.113.5 - - [03/Nov/2026:10:00:00 +0000] "POST /?s2member_paypal_notify=1 HTTP/1.1" 200 5 "-" "curl/8.5.0"
--- ❺ 錯誤日誌裡與 s2Member 有關的 PHP 錯誤(最近 20 筆)---
[error] PHP Parse error: syntax error in ... s2member ... eval()'d code
上面的 IP 與 User-Agent 都是示範,其中 203.0.113.x 與 192.0.2.x 是文件專用的範例位址。PayPal 的正式 User-Agent 與來源 IP 範圍我沒有寫死(會調整,寫死會過期),判讀請用「頻率」與「你到底有沒有用 PayPal 收款」兩個條件。
分支判斷邏輯:
- 🔴 ❶ 單一 IP 打了幾百次、❷ 的 User-Agent 是 curl 或 python 之類的腳本工具,且 ❸ 在同一天暴增 👉 高度可疑,記下 IP 與時間,做完 ㊃;若 ㊃ 也有異常,進第六章 A。
- 🔴 ❺ 出現與 s2Member 相關的 Parse error 或 eval 錯誤 👉 外掛的範本被塞進了不合法的程式碼,這是「有人試過、至少沒完全成功」或「已經成功但寫壞了」的跡象,直接進第六章 A。
- 🟡 你的站根本沒用 PayPal 收款,日誌裡卻有通知端點的請求 👉 沒有正當理由會有人打這個端點,視為可疑。
- 🟡 有正常的 PayPal 通知,筆數跟你的訂單量相近 👉 這是正常現象,不要因為看到端點名稱就緊張。我無法確認這顆漏洞在日誌裡的專屬特徵。
- 🟢 都沒命中 👉 只能說目前沒有直接證據,不能證明沒被打過:日誌可能已輪替、前面有 CDN、攻擊內容在 POST body 裡看不到。繼續做 ㊃。
🕳️ ㊃ 帳號與檔案稽核:有沒有人拿著這把鑰匙進來過
為什麼這樣做:這顆漏洞的終點是伺服器端執行程式碼,所以稽核要同時看資料與檔案兩側。資料側:管理員名單、最近註冊的帳號、排程工作、應用程式密碼。檔案側:uploads 裡的 PHP、會自動載入的 mu-plugins、wp-content 根目錄的 PHP(例如 drop-in 檔案)、揭露日之後被改過的 PHP,以及外掛與核心檔案跟官方版本的比對。全部用 WP-CLI 內建功能完成,不寫 SQL,所以資料表前綴不一樣也不怕。verify-checksums 我刻意不加嚴格模式的旗標,因為嚴格模式會把 readme 的差異也算成錯誤,一堆假警報 🕵️♀️
audit_one() {
local wp_path="$1" opt uid
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 根目錄的檔案(會自動載入,外掛頁看不到)---"
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 "--- ❼ 排程工作(有沒有不認識的 hook)---"
wpx "$wp_path" cron event list --fields=hook,next_run_gmt,recurrence --format=table
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
echo "--- ❿ 每位管理員名下的應用程式密碼(不認識的就是可疑)---"
while IFS= read -r uid <&3; do
printf '管理員 ID %s:\n' "$uid"
wpx "$wp_path" user application-password list "$uid" || true
done 3< <(wpx "$wp_path" user list --role=administrator --field=ID)
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. --- ❾ 幾個常被動手腳的網站設定 --- siteurl = https://example.com home = https://example.com admin_email = admin@example.com users_can_register = 0 default_role = subscriber --- ❿ 每位管理員名下的應用程式密碼(不認識的就是可疑)--- 管理員 ID 1: (沒有任何應用程式密碼)
❹ 的區塊如果沒有任何輸出,就代表 uploads 裡沒有 PHP 類檔案,這是正常的。❽ 若有 Pro 版或其他付費外掛,它們不在 WordPress.org,常會出現無法驗證的提示,這很正常,實際文字依 WP-CLI 版本而異 🧾
分支判斷邏輯:
- 🔴 ❶❷ 出現 2026-11-01 之後註冊、你不認識的帳號 👉 高度可疑。陌生不等於惡意,可能是外包留的,先問清楚再處理。
- 🔴 ❹ 找到 PHP 類檔案,或 ❺ 出現不是你部署的檔案 👉 視為已中招,先別刪,保留證據,進第六章 A。
- 🔴 ❾ 的 users_can_register 變成 1、default_role 變成 administrator,或 siteurl、home 被改成陌生網域 👉 網站設定被動過手腳,這類改動很少是正常維運造成的。
- 🔴 ❿ 出現你不認識的應用程式密碼 👉 應用程式密碼可以繞過登入密碼,視為可疑,進第六章 A。
- 🟡 ❻ 揭露日之後被改過的 PHP 👉 外掛正常更新也會出現,要對照你自己的維運紀錄,別看到就當成入侵。
- 🟡 ❸ 與 ❼ 出現不認識的外掛或排程 hook 👉 先查那是哪個外掛、誰裝的,查不出來就當成可疑。
- 🟡 ❽ 出現 File was added 之類的差異 👉 先確認是不是你自己放的客製檔案;不確定就當成可疑。
- 🟢 全部乾淨 👉 這一輪沒有發現明確跡象,仍建議做第六章的更新與第七章的加固,並多觀察一週。
📋 ㊄ 三環境速查表:把這一章濃縮成一張
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 工具箱 | 貼一次,整個 SSH 連線通用 | ||
| ② 盤點版本 | inventory_one 路徑 | each_site inventory_one | 查主站一次,狀態看 active-network |
| ③ 日誌排查 | 單一網域 | 掃所有網域日誌 | 共用網域日誌,再依 Host 回推子站 |
| ④ 帳號與檔案 | 管理員/檔案/應用程式密碼 | 逐站各自檢查,不能拿 A 站代表 B 站 | 另加 super-admin list |
🛠️ 第六章:疑似中招、還不能更新、準備修補,三條路各走各的
前一章解決「怎麼判斷」,這一章才動手。主線固定是 備份 👉 更新 👉 驗證;第五章若出現跡象,要先走 A 取證與止血。這一章會改動系統狀態,所以每個動作後面都接著「怎麼確認真的生效」,以及失敗時的下一步。以下沿用第五章貼好的工具箱,沒貼的話請先回去貼一次 🧹
🧮 先分流:你該走 A、B 還是 C?
| 第五章的結果 | 你的處境 | 走哪條路 | 要不要刷新鹽值 |
|---|---|---|---|
| 出現任何 🔴 跡象 | 疑似或確定中招 | A 取證、止血,再走 C | 必做(A 的腳本已內含) |
| 沒有 🔴 跡象,但今天不能更新 | 還沒確認中招,只想縮短暴露時間 | B 短期應急,之後務必走 C | 視下方分級 |
| 沒有 🔴 跡象,可以更新 | 多數人在這裡 | C 正式修補 | 視下方分級 |
🔑 鹽值要不要刷?依嚴重程度分三級
鹽值(Salts)是 wp-config.php 裡那八組長字串,WordPress 用它們替登入 Cookie 簽章。wp config shuffle-salts 會把八組全部換掉,所有人立刻被登出,包含可能握有舊 Cookie 的攻擊者。這顆漏洞的最壞結果是攻擊者在伺服器上執行程式,所以要不要刷新,看你有多大的疑慮:
| 等級 | 情況 | 建議 | 代價 |
|---|---|---|---|
| 🔴 必做 | 第五章出現任何 🔴 跡象 | 走 A(腳本內含刷新,並搭配密碼重設、登出所有階段、換資料庫密碼) | 所有人(含會員)要重新登入 |
| 🟡 建議 | 舊版曾暴露在網路上(260927 以下運作過),沒有證據但無法排除 | 更新完成、驗證通過之後,執行一次 rotate_salts_one | 所有人要重新登入,會員網站要預期客服詢問會增加 |
| 🟢 可選 | 早就是 261017 以上、所有稽核乾淨 | 不必刷,想求心安也可以做 | 同上 |
你只改了某位員工的密碼,攻擊者手上可能還有一張沒過期的通行證,照樣進得來。刷新鹽值等於宣布「舊版通行證今天起全部作廢」,不管誰拿著哪一張,都得重新辦。比單純改密碼徹底得多,但連你的合法會員也要重新登入,所以挑離峰時間做 🪪
💾 ㊀ 四個共用零件:備份、刷新鹽值、停用、還原啟用
為什麼這樣做:A、B、C 都會用到其中一兩個,所以先定義成函式,這一步只有定義,不會執行任何動作。備份檔放在 root 家目錄底下的資料夾、權限 700,不放在網站目錄,避免被網址直接下載;wp-config.php 也一起備份,因為鹽值是寫在裡面,刷壞了要靠它還原。最關鍵的是停用與還原要前後呼應:deactivate_one 會先記下「原本哪個外掛是啟用的、啟用在哪裡」,更新完之後 reactivate_one 照紀錄開回去,不會把本來就停用的外掛誤開,也不會漏開 Multisite 某個子站 🔁
# ㊀ 零件一:備份資料庫、wp-content、wp-config.php(任何一步失敗就回傳失敗)
backup_one() {
local wp_path="$1" domain stamp db_file tar_file cfg_file
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}_wp-content_${stamp}.tar.gz"
cfg_file="${BACKUP_DIR}/${domain}_wp-config.php_${stamp}"
install -d -m 700 -- "$BACKUP_DIR" || return 1
df -h -- "$BACKUP_DIR" | tail -n 1
( umask 077; wpx "$wp_path" db export - --single-transaction > "$db_file" )
if [ ! -s "$db_file" ]; then
echo "❌ 資料庫備份失敗或是空檔,停止,不進行任何變更"
return 1
fi
if ! tail -n 1 -- "$db_file" | grep -q 'Dump completed'; then
echo "⚠️ 備份檔結尾沒有 Dump completed 字樣,請人工確認檔案是否完整"
fi
tar -czf "$tar_file" -C "$wp_path" wp-content || { echo "❌ 檔案備份失敗,停止"; return 1; }
chmod 600 -- "$tar_file"
install -m 600 -- "${wp_path}/wp-config.php" "$cfg_file" || return 1
ls -lh -- "$db_file" "$tar_file" "$cfg_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 是否可寫、主機連不連得到 WordPress.org\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
}
# ㊂ 零件三:停用 s2Member,並把「原本哪裡是啟用的」記下來(還原時才知道要開哪裡)
deactivate_one() {
local wp_path="$1" domain state_file slug status url home_url code left=0
domain="$(site_domain "$wp_path")"
install -d -m 700 -- "$STATE_DIR" || return 1
state_file="${STATE_DIR}/${domain}.state"
# 用 touch 而不是清空:同一站重跑時,先前的紀錄不會被洗掉
touch -- "$state_file" && chmod 600 -- "$state_file" || return 1
for slug in "${PLUGINS_REV[@]}"; do
if wpx "$wp_path" core is-installed --network 2> /dev/null; then
# ✴️ Multisite:先處理「全網啟用」,再逐一處理「只在個別子站啟用」
status="$(wpx "$wp_path" plugin get "$slug" --field=status 2> /dev/null || true)"
[ -n "$status" ] || continue
if [ "$status" = "active-network" ]; then
printf 'network|%s\n' "$slug" >> "$state_file"
wpx "$wp_path" plugin deactivate "$slug" --network || true
fi
while IFS= read -r url <&3; do
if [ "$(wpx "$wp_path" --url="$url" plugin get "$slug" --field=status 2> /dev/null || true)" = "active" ]; then
printf 'site|%s|%s\n' "$slug" "$url" >> "$state_file"
wpx "$wp_path" --url="$url" plugin deactivate "$slug" || true
fi
done 3< <(wpx "$wp_path" site list --field=url)
else
# 💠 單一網站、❇️ 獨立多站的每一站
status="$(wpx "$wp_path" plugin get "$slug" --field=status 2> /dev/null || true)"
if [ "$status" = "active" ]; then
printf 'single|%s\n' "$slug" >> "$state_file"
wpx "$wp_path" plugin deactivate "$slug" || true
fi
fi
done
# 驗證一:已安裝的版本,狀態都不能是 active 或 active-network
for slug in "${PLUGINS_REV[@]}"; 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
# 驗證二:首頁還打得開(佈景主題若呼叫了 s2Member 的函式,停用後可能整站掛掉)
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
printf '📝 啟用紀錄已存到 %s(更新完要用 reactivate_one 還原)\n' "$state_file"
return 0
}
# ㊃ 零件四:更新完成後,照 deactivate_one 的紀錄把外掛開回去(框架先、Pro 後)
reactivate_one() {
local wp_path="$1" domain state_file kind slug url status
domain="$(site_domain "$wp_path")"
state_file="${STATE_DIR}/${domain}.state"
if [ ! -s "$state_file" ]; then
printf '沒有 %s 的停用紀錄,不需要還原\n' "$domain"
return 0
fi
while IFS='|' read -r kind slug url <&3; do
case "$kind" in
network) wpx "$wp_path" plugin activate "$slug" --network || return 1 ;;
site) wpx "$wp_path" --url="$url" plugin activate "$slug" || return 1 ;;
single) wpx "$wp_path" plugin activate "$slug" || return 1 ;;
esac
done 3< <(tac -- "$state_file")
for slug in "${PLUGINS[@]}"; do
status="$(wpx "$wp_path" plugin get "$slug" --field=status 2> /dev/null || true)"
[ -n "$status" ] && printf '%s:%s\n' "$slug" "$status"
done
mv -- "$state_file" "${state_file}.done-$(date +%Y%m%d-%H%M%S)"
return 0
}
預期輸出:貼完沒有任何輸出就是成功。四個函式要等到 A、B 或 C 才會被叫用,這時候的輸出範例如下:
/dev/vda1 80G 22G 54G 29% / -rw------- 1 root root 485M Nov 4 10:30 /root/wp-security-backup/example.com_db_20261104-103022.sql -rw------- 1 root root 2.4G Nov 4 10:31 /root/wp-security-backup/example.com_wp-content_20261104-103022.tar.gz -rw------- 1 root root 3.1K Nov 4 10:31 /root/wp-security-backup/example.com_wp-config.php_20261104-103022 Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin Plugin 's2member-pro' deactivated. Plugin 's2member' deactivated. s2member-pro:inactive s2member:inactive 首頁 HTTP 200,正常 📝 啟用紀錄已存到 /root/wp-security-backup/state/example.com.state(更新完要用 reactivate_one 還原)
驗證與失敗分支:
- 🟢 備份:三個檔案都在、大小合理、資料庫檔結尾有 Dump completed 👉 可以進下一步。這個結尾字樣是 mysqldump 與 MariaDB 的慣例輸出,若你的環境用別的工具匯出,字樣可能不同,腳本只會警告,不會擋你。
- 🟠 備份失敗或是空檔 👉 函式立刻停下,先查磁碟空間(已印出 df 結果)與資料庫權限,沒有備份不要往下。
- 🟢 鹽值:前後兩段雜湊不同 👉 已生效,你會被登出,重新登入即可。
- 🟠 鹽值出現 ❌「沒有改變」或指令本身失敗 👉 先確認 wp-config.php 的擁有者可寫;這個指令要連到 WordPress.org 的 API 取得新鹽值,主機連不出去也會失敗,先查網路,不要急著換別的來源。
- 🔴 刷完網站出現「建立資料庫連線時發生錯誤」 👉 用備份還原:cp -p “$BACKUP_DIR/網域_wp-config.php.時間” “網站路徑/wp-config.php”,備份檔名在上面的輸出裡。這份備份含資料庫密碼,確認一切正常後再決定要不要保留。
- 🟢 停用:已安裝的外掛狀態都是 inactive,首頁 HTTP 為 2xx 或 3xx 👉 成功,啟用紀錄已存。
- 🟠 停用後首頁不是 2xx 或 3xx 👉 佈景主題可能呼叫了 s2Member 的函式。先別慌,執行 reactivate_one 網站路徑 把外掛開回去、網站就會恢復,再改走 B 的備案,並把這件事交給維護人員處理。重新啟用代表門又開了,所以要盡快排更新。
- 🟡 reactivate_one 還原後,狀態要回到你停用前的樣子;原本是停用的外掛不會被開起來,這是刻意的設計。
🚨 ㊁ A. 疑似中招:先留證據,再止血,最後才修補
為什麼這樣做:第五章出現明確跡象時,第一優先不是急著更新,而是保留現場。順序是:資料庫與檔案備份、日誌與帳號快照 👉 停用外掛 👉 刷新鹽值 👉 管理員密碼重設、登出所有階段、清除應用程式密碼。這個順序有道理:先拍照存證,再關門,再讓所有舊憑證作廢,最後處理帳號 📸
這支函式會動到外掛、鹽值與帳號,請先確認路徑,而且只對有疑慮的那一站執行,不要整台主機無差別跑。新密碼會直接印在終端機上,也會留在捲動紀錄裡,請在私密環境操作,用安全管道交給管理員本人,並請他登入後立刻自行更換,不要貼進聊天視窗、工單或腳本。user reset-password 預設會寄信,腳本加了「不寄信」的旗標,避免新密碼被寄到攻擊者可能已控制的信箱 🔐
# ㊄ 零件五:確定或高度懷疑被入侵時的收尾(登入憑證全部作廢重發)
# ⚠️ 會讓所有人被登出、管理員密碼被重設。請在「自己的私人終端機」執行,新密碼會直接印在螢幕上
incident_one() {
local wp_path="$1" domain stamp evid uid login pw uuid
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
evid="${BACKUP_DIR}/incident-${domain}-${stamp}"
printf '\n===== 🚨 %s =====\n' "$wp_path"
# ❶ 先留現場:資料庫與檔案備份,加上日誌、帳號、外掛清單、可疑檔案的雜湊值
backup_one "$wp_path" || return 1
install -d -m 700 -- "$evid" || return 1
find "$NGINX_LOG_DIR" -maxdepth 1 -type f -name "${domain}.*log*" -exec cp -p -- {} "$evid/" \;
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table > "${evid}/admins.txt"
wpx "$wp_path" plugin list --fields=name,status,version --format=table > "${evid}/plugins.txt"
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 > "${evid}/suspect-sha256.txt"
printf '📁 證據放在 %s\n' "$evid"
# ❷ 隔離:停用 s2Member(會記下原本的啟用狀態)
deactivate_one "$wp_path" || return 1
# ❸ 全站登入狀態立刻作廢
rotate_salts_one "$wp_path" || return 1
# ❹ 管理員密碼、登入階段、應用程式密碼
while IFS= read -r uid <&3; do
login="$(wpx "$wp_path" user get "$uid" --field=user_login)"
# --skip-email:不寄信;--porcelain:只輸出新密碼,方便你直接複製
pw="$(wpx "$wp_path" user reset-password "$uid" --skip-email --porcelain)" || return 1
wpx "$wp_path" user session destroy "$uid" --all || true
printf '管理員 %s(ID %s)新密碼:%s\n' "$login" "$uid" "$pw"
# 應用程式密碼可以繞過登入密碼,一併清掉(逐筆刪除,避開 --all 旗標的不確定性)
while IFS= read -r uuid <&4; do
[ -n "$uuid" ] || continue
wpx "$wp_path" user application-password delete "$uid" "$uuid" || true
done 4< <(wpx "$wp_path" user application-password list "$uid" --field=uuid 2> /dev/null || true)
done 3< <(wpx "$wp_path" user list --role=administrator --field=ID)
echo "👉 請把上面的新密碼存進密碼管理員,然後清除終端機捲動紀錄(clear && history -c)"
return 0
}
# ㊅ 零件六:換資料庫密碼(HestiaCP)。只在「確定被入侵」時才需要
# 💡 wp-config.php 裡的資料庫密碼若已被讀走,光換鹽值擋不住攻擊者直接連資料庫
rotate_db_password_one() {
local wp_path="$1" owner db old new pwfile oldfile
owner="$(site_owner "$wp_path")"
db="$(wpx "$wp_path" config get DB_NAME)" || return 1
command -v v-change-database-password > /dev/null \
|| { echo "找不到 v-change-database-password,這不是 HestiaCP?請改用你的面板換密碼"; return 1; }
command -v openssl > /dev/null || { echo "找不到 openssl"; return 1; }
old="$(wpx "$wp_path" config get DB_PASSWORD)" || return 1
new="$(openssl rand -hex 20)" # 只含 0-9a-f,不會有引號或特殊字元的麻煩
pwfile="$(mktemp)"; oldfile="$(mktemp)"
chmod 600 -- "$pwfile" "$oldfile"
printf '%s' "$new" > "$pwfile"
printf '%s' "$old" > "$oldfile"
if ! v-change-database-password "$owner" "$db" "$pwfile"; then
rm -f -- "$pwfile" "$oldfile"
echo "❌ 資料庫密碼沒有改成功,wp-config.php 沒動,網站不受影響"
return 1
fi
if wpx "$wp_path" config set DB_PASSWORD "$new" \
&& wpx "$wp_path" db query 'SELECT 1' --skip-column-names > /dev/null; then
rm -f -- "$pwfile" "$oldfile"
echo "✅ 資料庫密碼已更換,網站可連線"
return 0
fi
echo "❌ 新密碼寫入或連線失敗,正在改回舊密碼"
v-change-database-password "$owner" "$db" "$oldfile" || echo "⛔ 改回失敗!舊密碼在備份的 wp-config.php 裡"
wpx "$wp_path" config set DB_PASSWORD "$old" || true
rm -f -- "$pwfile" "$oldfile"
return 1
}
使用方式:貼完上面的函式後,只對有疑慮的那一站執行下面這一行:
incident_one "/home/admin/web/example.com/public_html"
預期輸出範例(節錄):
===== 🚨 /home/admin/web/example.com/public_html ===== (備份輸出,同上一節) 📁 證據放在 /root/wp-security-backup/incident-example.com-20261104-110500 s2member:inactive 首頁 HTTP 200,正常 ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin 管理員 admin(ID 1)新密碼:(一串隨機密碼) 👉 請把上面的新密碼存進密碼管理員,然後清除終端機捲動紀錄(clear && history -c)
驗證(確認真的止血了):跑完後執行下面這段。預期結果是兩個外掛都顯示 inactive(沒安裝的會顯示「未安裝」)、管理員名單只剩你認識的人、最後一段顯示 db_ok 與 1。任何一項不符,就代表那一步沒成功,回頭看對應輸出。這段只讀不寫 🧪
WP_PATH="/home/admin/web/example.com/public_html"
for slug in "${PLUGINS[@]}"; do
printf '%s:' "$slug"
wpx "$WP_PATH" plugin get "$slug" --field=status 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;'
- ⭕ 全部成功 👉 保留證據資料夾,接著換資料庫密碼(見下方),再走 C 正式修補。公司或有會員資料的網站,同步通知內部資安或主管。
- 🟠 備份或證據收集失敗 👉 函式立刻中斷,先處理磁碟空間或資料庫權限,沒有現場快照不要往下。
- 🟠 停用外掛時出現 PHP Fatal Error 👉 外掛檔案可能被改過,請交給資安專業人員做完整清理,不要自己硬刪。
- 🔴 發現後門檔案、不認識的管理員,或網站有個資與線上收款且確認曾遭觸發 👉 這已超出更新外掛的層級,請啟動內部資安事件流程。並把 s2Member 設定裡的 PayPal 憑證、SMTP、API 金鑰等放在資料庫或設定檔的憑證一併換掉(只在自己的環境操作,不要貼給任何人)。
- 🔴 更新前的備份可能已經含有後門,不能當成「乾淨的還原來源」;要還原請用事發前、已知乾淨的備份。
🔑 補一刀:換資料庫密碼(HestiaCP)
為什麼這樣做:wp-config.php 裡有資料庫帳密,伺服器端能執行程式的攻擊者,理論上讀得到。光換鹽值擋不住攻擊者直接連資料庫。rotate_db_password_one 就在上面那一大段函式裡,它的順序是:先請 HestiaCP 改資料庫使用者的密碼,成功之後才改 wp-config.php,最後實際連一次資料庫確認;任何一步失敗都會把密碼改回舊的。中間網站會短暫連不上資料庫,所以挑離峰時間做。密碼用隨機十六進位字元產生,不含引號與特殊符號,不會被 shell 吃掉 🛡️
兩個老實話:第一,HestiaCP 的 v-change-database-password 能不能直接吃「檔案路徑」當密碼參數,我沒能核對到明確文件,函式是照 HestiaCP 的慣例寫的,執行失敗就改用面板的「資料庫」頁面手動改密碼,再用 wp config set DB_PASSWORD 讓設定檔跟上。第二,WP-CLI 寫入密碼時,密碼會短暫出現在處理程序清單,所以請在沒有其他人登入的時段操作 🙈
rotate_db_password_one "/home/admin/web/example.com/public_html"
- 🟢 印出「✅ 資料庫密碼已更換,網站可連線」 👉 新密碼已生效。
- 🟠 印出「❌ 資料庫密碼沒有改成功」 👉 wp-config.php 沒動,網站不受影響;改用面板手動處理。
- 🔴 印出「❌ 新密碼寫入或連線失敗,正在改回舊密碼」 👉 函式會自動改回,改完用 ㊀ 備份的 wp-config.php 對照;若連「改回失敗」也出現,舊密碼在備份的 wp-config.php 裡,請維運人員立刻接手。
🧯 ㊂ B. 短期應急:還沒確認中招,但現在還不能更新
🚨 短期應急 ≠ 正式修補。
停用外掛與擋端點,都只是縮短暴露時間。真正的修補仍然是更新到 261017 以上。而且這一步有成本:停用期間付款通知沒人接、會員專屬內容的保護失效(第二章表格)。能馬上更新,就直接走 C,不要走 B 🧱
選項一(最乾淨):停用外掛。叫用 ㊀ 貼好的 deactivate_one。它會同時處理免費框架與 Pro,Multisite 會連全網和各子站一起處理,並記下啟用狀態,最後驗證狀態與首頁。停用前請先把重要的付費頁面改成「私密」 🧯
# 💠 單一網站(路徑換成你的;Multisite 請對網路根目錄跑一次) deactivate_one "/home/admin/web/example.com/public_html"
預期輸出範例:
Plugin 's2member-pro' deactivated. Plugin 's2member' deactivated. s2member-pro:inactive s2member:inactive 首頁 HTTP 200,正常 📝 啟用紀錄已存到 /root/wp-security-backup/state/example.com.state(更新完要用 reactivate_one 還原)
選項二(較弱):用 nginx 把通知端點先擋掉。適合「選項一會讓佈景主題報錯」或「不想讓會員專屬內容失去保護」的網站。這個做法有兩個大前提要先講清楚:第一,它會把 PayPal 真正的付款通知一起擋掉,等於付款通知進不來,所以只能是備案,擋多久就要補多久的人工開通;第二,HestiaCP 會重寫它自己產生的網域設定,所以不能直接改,只能放進它認得的自訂檔(檔名開頭是 nginx.conf_ 與 nginx.ssl.conf_,放在 /home/使用者/conf/web/網域/,這個路徑來自 HestiaCP 官方論壇的說法,我沒有在文件確認)。下面這段先定義三個小工具(寫入自訂檔、移除自訂檔,以及後面第七章也會用到的 block_uploads_php),nginx -t 沒過會自動撤回,不會把 nginx 弄壞 🧱
# ㊉ 加固二:nginx 擋掉 uploads 裡的 PHP(HestiaCP 的自訂 include)
# 💡 Hestia 會重寫它自己產生的網域設定,所以只放「nginx.conf_ 開頭」的自訂檔,絕不直接改產生的設定
nginx_include_dir() {
local wp_path="$1" owner domain dir
owner="$(site_owner "$wp_path")"
domain="$(site_domain "$wp_path")"
dir="${HOME_ROOT}/${owner}/conf/web/${domain}"
[ -d "$dir" ] || return 1
printf '%s\n' "$dir"
}
# 把一段 nginx 設定寫成自訂 include:nginx_add_include 網站路徑 檔名後綴(設定內容從 stdin 讀)
nginx_add_include() {
local wp_path="$1" suffix="$2" dir file ssl_file
dir="$(nginx_include_dir "$wp_path")" || { echo "找不到 Hestia 的網域設定目錄(❓待確認路徑)"; return 1; }
file="${dir}/nginx.conf_${suffix}"
ssl_file="${dir}/nginx.ssl.conf_${suffix}"
cat > "$file"
ln -sf -- "$file" "$ssl_file"
if nginx -t 2> /dev/null; then
systemctl reload nginx && return 0
fi
echo "❌ nginx -t 沒過,已移除剛寫的設定"
rm -f -- "$file" "$ssl_file"
return 1
}
nginx_remove_include() {
local wp_path="$1" suffix="$2" dir
dir="$(nginx_include_dir "$wp_path")" || return 1
rm -f -- "${dir}/nginx.conf_${suffix}" "${dir}/nginx.ssl.conf_${suffix}"
nginx -t 2> /dev/null && systemctl reload nginx
}
block_uploads_php() {
local wp_path="$1" domain owner name probe code
nginx_add_include "$wp_path" no_uploads_php << 'EOF' || return 1
location ^~ /wp-content/uploads/ {
location ~* \.(php|phtml|phar|pht)(/|$) {
return 403;
}
}
EOF
# 驗證:放一支無害的 .php 與一支 .txt,前者要 403、後者要 200,然後都刪掉
domain="$(site_domain "$wp_path")"
owner="$(site_owner "$wp_path")"
name="zz-probe-$(openssl rand -hex 8)"
probe="${wp_path}/wp-content/uploads/${name}"
printf 'x\n' > "${probe}.php"
printf 'x\n' > "${probe}.txt"
chown "$owner": "${probe}.php" "${probe}.txt"
chmod 644 -- "${probe}.php" "${probe}.txt"
code="$(curl -sSk -o /dev/null -w '%{http_code}' --max-time 20 "https://${domain}/wp-content/uploads/${name}.php" || true)"
printf 'uploads 內的 .php → HTTP %s(預期 403)\n' "$code"
code="$(curl -sSk -o /dev/null -w '%{http_code}' --max-time 20 "https://${domain}/wp-content/uploads/${name}.txt" || true)"
printf 'uploads 內的 .txt → HTTP %s(預期 200,圖片與文件才不會壞)\n' "$code"
rm -f -- "${probe}.php" "${probe}.txt"
echo "🧹 探針已移除"
}
# ⑪ 備案:IPN 端點直接擋(⚠️ 連 PayPal 真正的付款通知也一起擋,只在「還沒修補又不能停用」時用)
ipn_block_on() {
nginx_add_include "$1" s2m_ipn_block << 'EOF'
if ($arg_s2member_paypal_notify) {
return 403;
}
EOF
}
ipn_block_off() { nginx_remove_include "$1" s2m_ipn_block; }
# ⑫ 讓 WordPress.org 版的 s2Member 之後自動更新(Pro 版不在 WP.org,自動更新管不到)
enable_autoupdate_one() {
local wp_path="$1"
wpx "$wp_path" plugin auto-updates enable s2member || return 1
wpx "$wp_path" plugin list --name=s2member --fields=name,version,auto_update --format=table
}
預期輸出:貼完沒有任何輸出就是成功。接著把端點擋起來並驗證,更新完成後務必解除。注意驗證會對你自己的網站端點送一次空請求,不帶任何內容,不會觸發付款流程 🧪
WP_PATH="/home/admin/web/example.com/public_html"
DOMAIN="example.com"
# 備案:暫時擋住 IPN 端點(⚠️ 連 PayPal 真正的付款通知也會一起擋)
ipn_block_on "$WP_PATH" \
&& curl -sSk -o /dev/null -w 'IPN 端點:HTTP %{http_code}(預期 403)\n' --max-time 20 \
"https://${DOMAIN}/?s2member_paypal_notify=1"
# 更新完成後務必解除,並確認不再是 403
ipn_block_off "$WP_PATH" \
&& curl -sSk -o /dev/null -w 'IPN 端點:HTTP %{http_code}(預期不是 403)\n' --max-time 20 \
"https://${DOMAIN}/?s2member_paypal_notify=1"
預期輸出範例:
IPN 端點:HTTP 403(預期 403) IPN 端點:HTTP 200(預期不是 403)
- 🟢 選項一:狀態 inactive、首頁 2xx 或 3xx 👉 暫時安全,立刻排更新。
- 🟢 選項二:開啟後回 403、解除後不再是 403 👉 規則運作正常。解除後的回應碼取決於外掛如何處理空請求,只要不是 403 就算解除成功。
- 🟠 選項一:首頁不是 2xx 或 3xx 👉 見 ㊀ 的失敗分支,改走選項二,並交給維護人員。
- 🟠 選項二:開啟後仍然不是 403 👉 規則沒被載入。先看 nginx_include_dir 有沒有找到目錄(找不到會直接印出提示),再請維運人員確認 HestiaCP 樣板有沒有引入自訂檔。
- 🟡 選項二擋住的期間 👉 請到 PayPal 的 IPN 歷史紀錄補查漏掉的通知,並手動開通已付款的會員。PayPal 會不會自動重送被擋掉的通知、能重送多久,我沒有核對,不要假設它會自己補回來。
- 🟡 之後要回到原狀 👉 更新完成後執行 ipn_block_off 解除擋規則,並用 reactivate_one 照紀錄把外掛開回去。
🚀 ㊃ C. 正式修補:備份 👉 更新 👉 驗證
為什麼這樣做:框架掛在 WordPress.org,WP-CLI 的 plugin update 可以直接更新;Pro 版要靠你自己從 s2member.com 下載 zip,再用 plugin install –force 覆蓋安裝。patch_one 的設計重點有四個:①先檢查有沒有需要更新,已達目標的站直接跳過;②偵測到 Pro 卻沒給 zip 時,整站完全不動,避免框架升了、Pro 沒升的版本錯位;③更新前一定先備份,備份失敗就中止;④更新後逐一驗證版本,任何一個沒到目標就停在這站、不繼續下一站。情境 B、C 要加上 DEACTIVATE_FIRST=1,它會在更新前先停用,更新後自動照紀錄還原。千萬不要為了「保險」而降版,降回舊版等於把洞重新打開 📦
Pro 的 zip 要放在網站使用者讀得到的地方(例如 /tmp),因為 WP-CLI 是用網站使用者的身分在跑。plugin install –force 覆蓋安裝 Pro 版的實際行為,以及 Pro 的安裝包結構是否能直接覆蓋,我沒有逐項核對,所以腳本的驗證會檢查版本號,沒到目標就會停下 🔧
# ㊆ 零件七:更新到目標版本
# PRO_ZIP:Pro 版從 s2member.com 下載的 zip 絕對路徑(要放在網站擁有者讀得到的地方,例如 /tmp)
# DEACTIVATE_FIRST=1:更新前先停用(情境 B、C 用;情境 A 預設不停用)
PRO_ZIP=""
DEACTIVATE_FIRST="${DEACTIVATE_FIRST:-0}"
patch_one() {
local wp_path="$1" domain slug ver need=0 has_pro=0
domain="$(site_domain "$wp_path")"
printf '\n===== %s =====\n' "$domain"
for slug in "${PLUGINS[@]}"; do
ver="$(wpx "$wp_path" plugin get "$slug" --field=version 2> /dev/null || true)"
[ -n "$ver" ] || continue
[ "$slug" = "s2member-pro" ] && has_pro=1
[ "$(version_state "$ver")" = "fixed" ] || need=1
done
if [ "$need" -eq 0 ]; then
echo "🟢 沒有需要更新的 s2Member(未安裝或已達 ${TARGET})"
return 0
fi
# 框架與 Pro 版本必須一致:缺 Pro 的 zip 就整站先不動,避免只升一半
if [ "$has_pro" -eq 1 ] && [ ! -f "$PRO_ZIP" ]; then
echo "⛔ 這站有 s2Member Pro,但 PRO_ZIP 沒設定或檔案不存在。為了不讓框架與 Pro 版本不一致,這站完全沒動"
return 1
fi
backup_one "$wp_path" || return 1
if [ "$DEACTIVATE_FIRST" = "1" ]; then
deactivate_one "$wp_path" || return 1
fi
if wpx "$wp_path" plugin is-installed s2member 2> /dev/null; then
wpx "$wp_path" plugin update s2member || { echo "❌ 框架更新失敗,停在這站"; return 1; }
fi
if [ "$has_pro" -eq 1 ]; then
wpx "$wp_path" plugin install "$PRO_ZIP" --force || { echo "❌ Pro 安裝失敗,停在這站"; return 1; }
fi
# 驗證:每個已安裝的版本都必須達到目標
for slug in "${PLUGINS[@]}"; do
ver="$(wpx "$wp_path" plugin get "$slug" --field=version 2> /dev/null || true)"
[ -n "$ver" ] || continue
printf '%s → %s(%s)\n' "$slug" "$ver" "$(version_state "$ver")"
if [ "$(version_state "$ver")" != "fixed" ]; then
echo "❌ ${slug} 沒有達到 ${TARGET},這站保持現狀,請看上方輸出"
return 1
fi
done
if [ "$DEACTIVATE_FIRST" = "1" ]; then
reactivate_one "$wp_path" || return 1
fi
echo "✅ ${domain} 更新完成"
return 0
}
# 💠 單一網站(情境 A)
patch_one "/home/admin/web/example.com/public_html"
# 💠 單一網站(情境 B/C:先停用再更新,更新完自動還原啟用狀態)
DEACTIVATE_FIRST=1 patch_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:整台主機逐站更新(任何一站失敗就停)
each_site patch_one
使用方式:先處理一站、確認前後台正常,再處理下一站。有 Pro 的話,先設定 PRO_ZIP:
# 有 Pro 版才需要:官方 zip 的完整路徑(先放到 /tmp,讓網站使用者讀得到) PRO_ZIP="/tmp/s2member-pro.zip" # 情境 A:沒有疑似入侵跡象,直接更新 patch_one "/home/admin/web/example.com/public_html" # 情境 B/C:之前已經停用過,或要先停用再更新(更新後會照紀錄自動還原啟用狀態) DEACTIVATE_FIRST=1 patch_one "/home/admin/web/example.com/public_html"
預期輸出範例:
===== example.com ===== /dev/vda1 80G 22G 54G 29% / (備份輸出,同上) Success: Updated 1 of 1 plugins. s2member → 261017(fixed) ✅ example.com 更新完成
如果走的是 B 流程(先前已用 deactivate_one 停用),更新完成後要自己叫用 reactivate_one 把外掛開回去:
reactivate_one "/home/admin/web/example.com/public_html"
- 🟢 看到最後的 ✅ 👉 這一站修補完成。接著手動清網站快取與 CDN 快取,並照第三章 ㊄ 實測會員流程;有刷鹽值需求的(第六章分級的 🟡),在這時候執行 rotate_salts_one。
- 🟢 看到「🟢 沒有需要更新的 s2Member」 👉 這站未安裝或已達目標,不用處理。
- 🟠 看到「⛔ 這站有 s2Member Pro,但 PRO_ZIP 沒設定」 👉 這站完全沒動,是刻意的保護。先下載 Pro 的 zip、設定 PRO_ZIP 再重跑。
- 🟠 備份失敗 👉 腳本直接停下,沒有任何更新,先查磁碟空間與資料庫權限。
- 🟠 出現 ❌「沒有達到 261017」 👉 更新來源沒生效(可能主機連不到 WordPress.org,或 Pro 的 zip 版本太舊)。這站保持現狀,不要反覆硬跑,把輸出交給維運人員。
- 🟠 Permission denied 或寫入失敗 👉 通常是網站檔案擁有者不對,或磁碟空間不足;若是 AlmaLinux 之類啟用 SELinux 的系統,權限看起來正常卻被擋,可能是 SELinux,用 getenforce 查看。HestiaCP 官方支援的是 Debian 與 Ubuntu,這篇範例以 Debian 為準。
- 🔴 更新後網站白畫面 👉 立刻停用外掛(B 選項一),再用備份還原;之後把備份拿到測試站慢慢查,不要在正式站試錯。
🚨 驗證的侷限:
版本號到 261017,只證明外掛檔案已換新,不代表你家沒被進入過;它也驗證不到 Pro、mu-plugins、uploads、資料庫與其他外掛。完整性檢查必須搭配第五章 ㊃ 的稽核。另外,如果這一站已經中招,更新前的備份本身就含有後門,不能當成乾淨的還原來源 🔐
| 情境 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| 疑似中招(A) | incident_one 路徑 | 只對有疑慮的站逐一執行 | 對網路根目錄跑一次,腳本會連子站一起停用 |
| 短期應急(B) | deactivate_one 路徑 | 逐站套用 | 對網路根目錄跑一次 |
| 正式修補(C) | patch_one 路徑 | each_site patch_one(任何一站失敗就停) | 外掛檔案只有一份,更新一次即可,再逐子站實測 |
🔥 一句話總結:「批次」不等於把所有東西塞進一條迴圈。真正安全的做法是先盤點、再備份,更新後逐站驗證,而且每一步失敗就停下 🎯
🔒 第七章:補完洞之後,把第二道門也鎖上
更新是根本,加固是「萬一下一顆漏洞又來,讓損害小一點」。先講清楚:這一章的招式擋不住這顆 CVE 本身,它們擋的是漏洞被打中之後的下一步,屬於通用加固,不是官方要求 🛡️
🧱 加固一:把 PHP 的系統指令函式關掉
為什麼這樣做:這顆漏洞的終點是伺服器端執行程式碼。disable_functions 可以讓 PHP 無法呼叫 exec、system 這類直接跟作業系統溝通的函式,攻擊者拿到執行程式的機會後,能做的事會少一截。清單刻意不含 curl_exec,因為 WordPress 對外連線要靠它,PayPal 的通知驗證也要對外連線,停掉會讓更新、排程與付款流程出問題;也不要寫 eval,它是 PHP 的語言結構,不是函式,寫進去不會有效果 ⚠️
這段會影響這個 PHP 版本底下所有網站。備份、圖片處理類外掛偶爾會用到 exec,改完請實際逛一下前後台。腳本的做法是:先備份每個版本的 php.ini;已經有 disable_functions 設定的話,保留原本停用的函式、合併我們的清單並去重,再用 sed -i -E 只替換那一行(沒有被註解的那一行,不會動到說明文字);找不到才用 tee -a 補在最後,所以跑幾次都不會疊加。設定檢查通過才重啟,沒過就自動還原,同一個版本出現多行設定時則略過、交給人工處理 🔁
# ㊇ 加固一:在 PHP-FPM 的 php.ini 停用「能直接執行系統指令」的函式
# 💡 會影響這個 PHP 版本底下「所有網站」。少數外掛(備份、圖片壓縮)可能用到這些函式,改完請逛一下後台
DISABLE_LIST="exec,passthru,shell_exec,system,proc_open,popen,pcntl_exec"
harden_php_ini() {
local ini ver stamp backup current count merged
stamp="$(date +%Y%m%d-%H%M%S)"
for ini in /etc/php/*/fpm/php.ini; do
[ -f "$ini" ] || continue
ver="$(basename -- "$(dirname -- "$(dirname -- "$ini")")")"
backup="${BACKUP_DIR}/php-${ver}-fpm.ini.${stamp}"
install -d -m 700 -- "$BACKUP_DIR" || return 1
cp -p -- "$ini" "$backup" || return 1
count="$(grep -Ec '^[[:space:]]*disable_functions[[:space:]]*=' "$ini" || true)"
if [ "$count" -gt 1 ]; then
printf '⚠️ %s 有 %s 行 disable_functions,請人工處理,這個版本略過\n' "$ini" "$count"
continue
fi
# 保留原本已停用的函式,再合併我們的清單並去重
current="$(grep -E '^[[:space:]]*disable_functions[[:space:]]*=' "$ini" | head -n 1 | sed -E 's/^[^=]*=[[:space:]]*//' || true)"
merged="$(printf '%s,%s' "$current" "$DISABLE_LIST" | tr ',' '\n' | sed -E 's/[[:space:]]//g; /^$/d' | awk '!seen[$0]++' | paste -sd, -)"
if [ "$count" -eq 1 ]; then
sed -i -E "s|^[[:space:]]*disable_functions[[:space:]]*=.*|disable_functions = ${merged}|" "$ini"
else
printf '\ndisable_functions = %s\n' "$merged" | tee -a "$ini" > /dev/null
fi
# 先驗證設定檔語法再重啟,失敗就還原
if "php-fpm${ver}" -t 2> /dev/null && systemctl restart "php${ver}-fpm"; then
printf '✅ PHP %s 已加固,備份在 %s\n' "$ver" "$backup"
else
cp -p -- "$backup" "$ini"
systemctl restart "php${ver}-fpm" || true
printf '❌ PHP %s 驗證失敗,已還原原本的 php.ini\n' "$ver"
return 1
fi
done
return 0
}
# ㊈ 驗證:在網站放一支隨機檔名的探針,問 PHP 「exec 還在不在」,用完立刻刪除
probe_disabled() {
local wp_path="$1" domain owner name probe out
domain="$(site_domain "$wp_path")"
owner="$(site_owner "$wp_path")"
name="zz-probe-$(openssl rand -hex 8).php"
probe="${wp_path}/${name}"
printf '<?php echo function_exists("exec") ? "exec=OPEN" : "exec=BLOCKED";\n' > "$probe"
chown "$owner": "$probe"
chmod 644 -- "$probe"
out="$(curl -sSk --max-time 20 "https://${domain}/${name}" || true)"
rm -f -- "$probe" # 不管成功失敗,探針一定刪掉
case "$out" in
*exec=BLOCKED*) printf '✅ %s:exec 已停用\n' "$domain" ;;
*exec=OPEN*) printf '❌ %s:exec 仍可用(可能被網域專屬的 pool 設定蓋掉)\n' "$domain"; return 1 ;;
*) printf '❓ %s:探針沒有回應預期內容(HTTP 被擋或網域沒有 https?)\n' "$domain"; return 1 ;;
esac
[ ! -e "$probe" ] && echo "🧹 探針已移除"
return 0
}
harden_php_ini
probe_disabled "/home/admin/web/example.com/public_html"
預期輸出範例:
✅ PHP 8.4 已加固,備份在 /root/wp-security-backup/php-8.4-fpm.ini.20261104-120000 ✅ example.com:exec 已停用 🧹 探針已移除
驗證加固真的有效:光看設定檔不夠,要實際從外面打一次。probe_disabled 在網站放一支隨機檔名的探針,內容只回報 exec 能不能用,測完立刻刪除,不管成功失敗都會刪。探針建好之後用網站使用者擁有、權限 644,結束時會確認檔案已不存在 🧪
- 🟢 印出 exec 已停用、探針已移除 👉 加固生效。
- 🟠 印出「exec 仍可用」 👉 這個網站實際用的 PHP-FPM 版本不是你剛改的那一個,或被網域專屬的 pool 設定蓋掉了。用 grep -H disable_functions /etc/php/*/fpm/pool.d/*.conf 查,有輸出就是被蓋掉。
- 🟠 印出「php.ini 驗證失敗,已還原」 👉 設定沒套用、網站不受影響,把輸出貼給維運人員;要手動復原就把備份檔蓋回 php.ini 再重啟 PHP-FPM。
- 🟡 印出「探針沒有回應預期內容」 👉 可能是憑證、防火牆或網域解析問題,先確認網站本身連得上。
🚨 要誠實講的侷限
disable_functions 擋的是「呼叫系統指令」這條路。攻擊者若已經能執行 PHP,他仍然可以用 PHP 本身的檔案函式寫入、讀取檔案,包含你的資料庫帳密。所以它只是把牆加高,不是把牆變成保險箱,不能取代更新與稽核 🧱
🚫 加固二:禁止執行 uploads 資料夾裡的 PHP
為什麼這樣做:上傳資料夾本來只該放圖片與文件。攻擊者想把後門丟進網站,最常見的落腳處就是這裡。用 nginx 規則讓 /wp-content/uploads/ 底下的 PHP 類檔案一律回 403,即使檔案被放進去也跑不起來。這個 block_uploads_php 函式在第六章 B 已經貼過了(用的是同一組 HestiaCP 自訂 include 小工具),nginx -t 沒過會自動撤回。規則用 ^~ 前綴,讓這個資料夾不再被其他規則攔走,裡面再套一層規則專門攔 PHP 類副檔名。不同 HestiaCP 樣板(例如 nginx 搭配 Apache 的組合)對這條規則的反應可能不同,我沒有逐一驗證,所以函式最後一定會做驗證:PHP 類檔案要 403、一般文字檔要 200,確保圖片與文件不會壞 🧱
WP_PATH="/home/admin/web/example.com/public_html" # 擋掉 uploads 裡的 PHP(函式內含驗證:.php 要 403、.txt 要 200,探針用完即刪) block_uploads_php "$WP_PATH"
預期輸出範例:
uploads 內的 .php → HTTP 403(預期 403) uploads 內的 .txt → HTTP 200(預期 200,圖片與文件才不會壞) 🧹 探針已移除
- 🟢 .php 回 403、.txt 回 200 👉 規則生效且沒有副作用。
- 🟠 .php 回 200 或 500 👉 規則沒被載入,或被別的設定蓋掉。執行 nginx_remove_include “$WP_PATH” no_uploads_php 把規則拆掉(⚠️ 這個函式會刪檔,但只刪我們自己建的那兩個),再請維運人員看樣板。
- 🟠 .txt 也變成 403 或 404 👉 規則影響到靜態檔,同樣先拆除。
- 🟠 nginx -t 沒過 👉 函式已自動撤回,請保留錯誤訊息給維運人員。
- 🟡 如果你的主題或外掛有人真的把 PHP 放進 uploads 當功能用(極少見) 👉 規則會讓它壞掉,這是它該被擋的原因,請維運人員確認用途。
🤖 加固三:自動更新,加上每天一次的版本巡檢
為什麼這樣做:這顆漏洞的修補版兩個星期前就有了,大部分人卻是公開後才知道。比較省力的做法是兩層:第一層開啟 WordPress.org 版框架的自動更新;第二層放一支每天跑的巡檢腳本,只讀不寫,發現版本低於目標就寫進系統日誌。Pro 版不在 WordPress.org,自動更新管不到它,所以巡檢對 Pro 特別重要。代價是自動更新偶爾會造成相容性問題,會員網站請務必搭配每日備份 🛎️
enable_autoupdate_one "/home/admin/web/example.com/public_html"
預期會印出一張表,最後一欄 auto_update 顯示 on 就是開啟成功。這個欄位依 WP-CLI 版本可能略有不同,沒有這個欄位時,後台「外掛」頁的「啟用自動更新」連結也能確認。
接著用 heredoc 建立巡檢腳本。結束標記 EOF 用單引號包住,裡面的 $ 不會被提前展開;建好後立刻做語法檢查,再寫入每天早上 6 點 17 分的排程。腳本內的目標版本是寫死的常數,想改的話用 sed -i 改一行,改完用 grep 確認 ⚙️
cat > /usr/local/sbin/s2m-check.sh << 'EOF'
#!/usr/bin/env bash
# s2Member 版本巡檢:只讀不寫,發現舊版就寫進系統日誌與 /var/log/s2m-check.log
set -u
AFFECTED_MAX="260927"
TARGET="261017"
LOG="/var/log/s2m-check.log"
WPCLI="$(command -v wp || command -v wp-cli || true)"
[ -n "$WPCLI" ] || { echo "找不到 wp-cli" >&2; exit 2; }
version_lt() { [ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]; }
bad=0
while IFS= read -r -d '' cfg; do
wp_path="$(dirname -- "$cfg")"
owner="$(stat -c '%U' -- "$cfg")"
for slug in s2member s2member-pro; do
ver="$(runuser -u "$owner" -- "$WPCLI" --path="$wp_path" plugin get "$slug" --field=version < /dev/null 2> /dev/null || true)"
[ -n "$ver" ] || continue
if ! version_lt "$AFFECTED_MAX" "$ver"; then
msg="🔴 ${slug} ${ver} 受影響:${wp_path}"; bad=1
elif version_lt "$ver" "$TARGET"; then
msg="🟡 ${slug} ${ver} 低於 ${TARGET}:${wp_path}"; bad=1
else
continue
fi
printf '%s %s\n' "$(date '+%F %T')" "$msg" >> "$LOG"
logger -t s2m-check -- "$msg"
done
done < <(find /home -maxdepth 5 -type f -path '*/web/*/public_html/wp-config.php' -print0 2> /dev/null)
exit "$bad"
EOF
chmod 755 /usr/local/sbin/s2m-check.sh
bash -n /usr/local/sbin/s2m-check.sh && echo "語法檢查通過"
# 每天早上 06:17 跑一次(cron.d 的格式要多一欄使用者)
printf '17 6 * * * root /usr/local/sbin/s2m-check.sh\n' > /etc/cron.d/s2m-check
chmod 644 /etc/cron.d/s2m-check
驗證(確認腳本建好了,而且判斷正確):手動跑一次看結果。全部站都已達標時,腳本不會輸出任何東西,結束碼是 0;有舊版時會寫入日誌,結束碼是 1 🧪
/usr/local/sbin/s2m-check.sh; echo "結束碼:$?" cat /var/log/s2m-check.log 2> /dev/null cat /etc/cron.d/s2m-check
預期輸出範例(有一站還是舊版的情況):
結束碼:1 2026-11-04 12:10:03 🔴 s2member 260927 受影響:/home/admin/web/example.com/public_html 17 6 * * * root /usr/local/sbin/s2m-check.sh
- 🟢 「語法檢查通過」、手動跑結束碼 0 或 1、排程檔內容正確 👉 巡檢上線。
- 🟠 沒看到「語法檢查通過」 👉 heredoc 內容有缺漏,用 ls -l /usr/local/sbin/s2m-check.sh 確認檔案存在,再重新貼一次整段。
- 🟠 出現「找不到 wp-cli」 👉 cron 的 PATH 與你的終端機不同;在腳本裡把 WPCLI 那一行改成 wp 的完整路徑(用 command -v wp 查)。
- 🟡 想改目標版本 👉 sed -i -E ‘s/^TARGET=”[0-9]+”/TARGET=”新版本”/’ /usr/local/sbin/s2m-check.sh,再用 grep ‘^TARGET=’ /usr/local/sbin/s2m-check.sh 確認。
- 🟡 不想要了 👉 ⚠️ 兩個檔案都是我們自己建的,用 rm -f /etc/cron.d/s2m-check /usr/local/sbin/s2m-check.sh 移除。
📝 加固四:管好你自己的付款流程
這顆漏洞的入口是付款通知,就算更新了,下面這幾個習慣也能讓下一次出事時損害更小 🧼
- ⭐ 定期看付款通知的日誌。第五章 ㊂ 的日誌排查可以排程化,看「打通知端點的 IP 與頻率」就好,不用看內容。
- ⭐ 不用的付款方式直接關掉。不再使用的 PayPal 整合、舊的測試設定,連同憑證一起清掉。
- ⭐ 付款憑證只放在該放的地方。PayPal 與 SMTP 憑證只填在外掛設定頁,不要貼在客服工單、聊天或文件裡。
- ⭐ 管理員的變動要有人知道。後台新增管理員、外掛被安裝,最好有通知機制。常見資安外掛有提供,可評估是否啟用【❓待確認:依你使用的外掛而定】。
- ⭐ 每日備份,而且備份要放在網站目錄之外。備份放在網站裡,等於把鑰匙放在門墊底下。
🩵 荷包試算:要不要花錢買 WAF 或虛擬修補?
付費防火牆可以在你更新前先擋一層,但這顆漏洞在後台點一下「更新」就解決,今天能更新,通常比月付方案划算。真的卡在相容性、短期無法更新時,再把付費防護當過渡手段,別反過來拿它當不更新的理由。至於 WAF 能不能擋這種「藏在 PayPal 通知欄位裡」的請求,我沒有資料可以驗證 💰
🤔 第八章:讀到快喘不過氣?常見焦慮與幾個通用心法
- 🌠 Q:我已經是 261017 了,就安全了嗎?
A:就這顆 CVE 來說,是的,那是 WordPress.org 的最新版,更新日誌寫明修補多項安全問題。但如果舊版曾對外暴露,更新只能擋住下一次,擋不了已經發生的事,所以還是建議做完第五章的稽核 📦 - 🌟 Q:我是 261001 到 261016 之間,要不要升?
A:要。Wordfence 與 Patchstack 把 261001 列為最低修補版,但我沒能直接核對,官方更新日誌也沒逐版說明。一路升到最新版,沒有任何壞處 🧭 - ✨ Q:我沒用 PayPal 收款,還需要更新嗎?
A:需要。依公開描述,漏洞入口是 PayPal IPN 端點,沒設定 IPN 不代表端點不存在,而且我無法驗證「沒用 PayPal 就一定打不到」。s2Member 其他付款通道(例如其他金流的通知端點)是否有類似問題,我沒有查到資料 🙅 - 🌠 Q:我把 s2Member 停用就好了,對不對?
A:停用能關掉這個入口,但副作用在第二章講過:付款通知沒人接、會員專屬內容的保護跟著失效。短期應急可以,長期請更新或刪除 🤗 - ✨ Q:Pro 版沒有授權,更新不了怎麼辦?
A:到 s2member.com 的帳號區下載 zip,在後台上傳覆蓋,或用 PRO_ZIP 搭配 patch_one 安裝。授權是否過期、能不能下載最新版,我無法替你確認,請以官方帳號區為準 🔍 - 🌟 Q:刷了鹽值之後,會員會怎樣?
A:所有人(包含會員)都會被登出,需要重新登入。會員網站請挑離峰時間做,並預期客服詢問會增加。這是刻意的,因為舊的登入憑證要一併作廢 🔑 - ✨ Q:看不懂那些指令,是不是沒救了?
A:不會。第三章全程用滑鼠就能完成最基本的處理,第五章以後是給有 SSH 權限的人看的補充 🔍
🧩 舉一反三:「外部送來的資料」這一類漏洞怎麼防
下面這些是通用防禦知識,不是本 CVE 的官方要求,但下次看到類似新聞能對號入座 🫡
- ⭐ 外部送來的欄位,一律當成不可信。付款通知、表單、API 回呼,不論來源看起來多正規,進到程式裡的每個欄位都要當成可能被竄改。
- ⭐ 黑名單不如白名單。「看到某種壞寫法就拿掉」永遠有人能換個寫法繞過;「只放行確定安全的」才站得住。
- ⭐ 過濾要放在「最靠近使用」的地方。資料在流程中會被拼接、替換、組裝,前面過濾完,後面又被重組,等於白過濾。要在資料真正被使用之前,再檢查一次。
- ⭐ 公開的回呼端點(webhook)要驗證來源,不是驗證來訪者。這類端點沒有登入機制,更要靠簽章或回頭向對方確認內容。
- ⭐ 修補與事件應變是兩件事。舊版只要曾暴露在網路上,更新完還要查日誌、查帳號、查檔案,缺一不可。
🏆 第九章:這次到底該多緊張?事件綜合評估
| 評估維度(十分制,數字越高代表風險或應對品質越正向) | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 9.8 | 免登入、免互動、低複雜度,入口是本來就必須公開的付款通知端點,幾乎沒有前提條件 |
| 官方應變速度 | 7 | 修補版比公開揭露早大約兩個星期,是加分項;但更新日誌沒有逐項對應 CVE,最低修補版本也要靠第三方才知道,扣一點 🟡 |
| 一般站長自救可行性 | 7 | 免費框架後台點「更新」就好;Pro 要手動換 zip、框架與 Pro 要一起升,更新後還要實測會員與付款流程,門檻比單點更新高一些 |
| DevOps 技術文件完整度 | 5 | 攻擊字串沒公開、是否需要登入的描述互相矛盾、是否已被利用的說法也不一致,排查只能靠結果特徵 |
| 🏆 加權綜合建議 | 立即更新 | 最終建議:【今天內備份並更新到 261017 以上,框架與 Pro 一起升;第五章稽核有任何 🔴 就走第六章 A;舊版曾暴露的網站,更新後視情況刷新鹽值】 |
🔥 這種漏洞最危險的是「大家還沒更新、自動化掃描已經上線」的那幾天。入口是公開的付款通知,不需要你做錯任何設定,也不需要誰犯錯點了什麼,只要版本舊、外掛開著就有風險。花五分鐘打開外掛頁看一眼版本號,比事後花五天清後門便宜太多 🔢
📎 附錄 A:一頁式決策表(印出來貼在螢幕旁)
| 你看到的狀況 | 先做什麼 | 接著做什麼 |
|---|---|---|
| 版本 260927 以下,沒有其他異常 | 備份 | 更新(C),實測會員與付款流程,再做第五章稽核;舊版曾暴露就考慮刷鹽值 |
| 版本在 261001 到 261016 | 備份 | 更新到最新版(C),照常稽核 |
| 今天無法更新 | 走 B:停用(先把付費頁面改私密),或 nginx 擋端點 | 盡快更新(C),更新後 reactivate_one 或 ipn_block_off 還原 |
| 日誌有大量陌生 IP 打通知端點,或錯誤日誌有 s2Member 的 Parse error | 不要刪任何東西,走 A 取證與止血 | 換資料庫密碼與付款憑證,再走 C;有個資的網站通知內部資安與法務 |
| uploads、mu-plugins 出現異常 PHP,或出現不認識的管理員與應用程式密碼 | 不要刪,走 A | 同上,並交給資安專業人員做完整清理 |
| 更新後網站白畫面 | 停用外掛(B 選項一) | 用備份到測試站查原因,不要降版 |
| Pro 版沒給 zip,腳本整站不動 | 到 s2member.com 帳號區下載最新版 zip | 設定 PRO_ZIP 後重跑 patch_one |
📖 附錄 B:名詞小辭典
- 🔹 CVE:漏洞的國際編號,CVE-2026-86596 就是這次的編號。
- 🔹 CVSS:漏洞嚴重度評分,滿分 10 分,9.0 以上屬於 Critical。
- 🔹 CWE-94:程式碼注入類弱點的分類編號。
- 🔹 EPSS:預測「這個漏洞未來被利用的機率」的評分,是機率不是事實。
- 🔹 IPN:Instant Payment Notification,PayPal 付款後主動通知你網站的機制。
- 🔹 item_name:付款通知裡的「商品名稱」欄位,這次漏洞的入口。
- 🔹 KEV:Known Exploited Vulnerabilities,已知被利用的漏洞清單,不同機構各有各的清單。
- 🔹 RCE:Remote Code Execution,遠端程式碼執行,也就是別人能在你的伺服器上跑他的程式。
- 🔹 WAF:Web Application Firewall,網站應用程式防火牆。
- 🔹 WP-CLI:WordPress 的命令列工具,用來在主機上管理網站。
- 🔹 鹽值(Salts):wp-config.php 裡八組長字串,用來簽章登入 Cookie。
- 🔹 白名單/黑名單:白名單是「只放行名單上的」,黑名單是「只擋名單上的」。
📚 參考資料與延伸資源
- 📌 WordPress.org|s2Member 外掛頁
- 📌 s2Member 官方|發布紀錄(Release Archive)
- 📌 Wordfence|s2Member 260927 以下免登入 RCE(item_name IPN 參數)
- 📌 Patchstack|s2Member 260927 免登入 RCE 條目
- 📌 NVD|CVE-2026-86596
- 📌 CVE.org|CVE-2026-86596
- 📌 CIRCL 漏洞資料庫|CVE-2026-86596
- 📌 Rapid7|CVE-2026-86596
- 📌 WP-CLI 官方文件|wp plugin update
- 📌 WP-CLI 官方文件|wp plugin verify-checksums
- 📌 WP-CLI 官方文件|wp plugin auto-updates enable
- 📌 WP-CLI 官方文件|wp config shuffle-salts
- 📌 WP-CLI 官方文件|wp user reset-password
- 📌 WP-CLI 官方文件|wp user session
- 📌 WP-CLI 官方文件|wp user application-password
- 📌 HestiaCP 論壇|Custom nginx settings per domain













