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

🚨💳 會員站的收銀台被開了後門?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)才兩天,而且有第三方情報平台標示「已有利用跡象」,所以這顆不要排「下週再說」。這篇帶你看版本、備份、更新,再查有沒有被摸過,最後附上一套由淺入深、每一步都能驗證的救援腳本 🔒

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

[2]


🟦 🟦 🟦

內容目錄

🪧 第一章:案發現場還原!一張「付款通知」,怎麼變成後門的入口?

想像你開了一間會員制俱樂部,門口有個收發室。每次有人在 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。這是我用來說明的例子,不是任何真實站台的紀錄。

[41]


🟪 🟪 🟪

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

🩵 小資族荷包試算:修這個洞要花多少錢?
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 當成字串亂比,也能處理未來可能出現的新格式 🧰

[42]


🟠 🟠 🟠

😬 更新前先看這個:停用與更新各有一個副作用

更新是對的,但不要當成「按下去就一定安然無事」。我沒有找到 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 編號就知道該做什麼。沒有備份之前,千萬不要自己亂刪東西 🤗

🧊 冷知識:「停用」跟「刪除」差在哪?
想像大樓的一扇側門。「停用」是把門鎖起來、鑰匙收進抽屜,門還在原位,哪天有人不小心開了,問題就回來了。「刪除」是把整扇門拆掉、牆補起來。很久不用的外掛,拆門比鎖門安全;還在用的,就老實更新 🧰

[43]


🔴 🔴 🔴

🤿 第四章:技術深潛,一個「商品名稱」怎麼一路走到伺服器執行程式碼?

想知道為什麼的人往下看,只想補洞的前三章已經夠用。先講資料狀況:官方沒有公開程式差異,攻擊字串也沒公開,下面的機制整理自 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,代價是有雜訊,好處是不會因為猜錯字串而漏看 🧭

[44]


🔷 🔷 🔷

🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 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 之類的備份副本,請人工確認哪一個才是正式站。

[45]


📊 📊 📊

📊 ㊂ 日誌排查:看誰一直在敲「付款通知」這扇門

為什麼這樣做:前一章說過,標準存取日誌看不到攻擊內容,只記請求行。所以這一步只抓三個有價值的結果特徵:哪些 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

[46]


🪛 🪛 🪛

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

前一章解決「怎麼判斷」,這一章才動手。主線固定是 備份 👉 更新 👉 驗證;第五章若出現跡象,要先走 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 還原後,狀態要回到你停用前的樣子;原本是停用的外掛不會被開起來,這是刻意的設計。

[47]


🔴 🔴 🔴

🚨 ㊁ 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 照紀錄把外掛開回去。

[48]


🚀 🚀 🚀

🚀 ㊃ 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(任何一站失敗就停) 外掛檔案只有一份,更新一次即可,再逐子站實測

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

[49]


🔒 🔒 🔒

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

更新是根本,加固是「萬一下一顆漏洞又來,讓損害小一點」。先講清楚:這一章的招式擋不住這顆 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 當功能用(極少見) 👉 規則會讓它壞掉,這是它該被擋的原因,請維運人員確認用途。

[50]

🤖 加固三:自動更新,加上每天一次的版本巡檢

為什麼這樣做:這顆漏洞的修補版兩個星期前就有了,大部分人卻是公開後才知道。比較省力的做法是兩層:第一層開啟 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 通知欄位裡」的請求,我沒有資料可以驗證 💰

[51]


🟢 🟢 🟢

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

  • 🌠 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。
  • 🔹 白名單/黑名單:白名單是「只放行名單上的」,黑名單是「只擋名單上的」。

[52]


📎 📎 📎

📚 參考資料與延伸資源

列印本文 [68] 👨‍👩‍👧‍👦 97 次瀏覽