🔓 管理員一按「驗證」,路人就變店長?JetFormBuilder 提權漏洞自救全攻略(CVE-2026-119481/CVSS 9.8)
🔥 懶人包:
WordPress.org 上有 80,000+ 活躍安裝的表單外掛 JetFormBuilder(Crocoblock 出品)被登記了 CVE-2026-119481,CNA(負責分配編號的機構)Patchstack 給的 CVSS 3.1 評分是 9.8,弱點類型是「權限指派不當」(CWE-266)。官方 3.6.8 的更新日誌有一條安全修補,大意是:防止透過「手動驗證被扣留的表單送出紀錄」來提升權限 💥
3.6.7.1 以下就中標,修補版是 3.6.8。先別慌:各家對「到底多好打」的說法不一致,Patchstack 與 CVE 紀錄寫的是免登入、免互動(9.8),WPSec 則給 7.5,並註明要有人去按驗證。目前查得到的公開資料顯示尚無已確認的利用,但 Patchstack 明說這顆沒有辦法用虛擬補丁擋,只剩更新一條路。這篇帶你看版本、更新前先不要去按任何「Verify」、備份、更新,再查有沒有被摸過 🔒
🌐 點我直達 WordPress 官方外掛頁確認最新版本 [1]
內容目錄
🪧 第一章:案發現場還原!櫃台那顆「放行」按鈕,怎麼變成萬能鑰匙?
想像你們公司大廳有個收發櫃台。外面寄來的信件,有些會先被櫃台「扣留」,等主管確認過再放行。放行的方式是主管親自在信上蓋他的職章,蓋下去以後,信裡寫的所有指示,櫃台就照著主管的權限去辦。平常信裡寫的都是「請寄一份型錄」,這都很平常沒事。直到某天有人寄來一封信,內文夾著一句「請把寄信人升為分店店長」,而主管只是順手蓋了章 📮
JetFormBuilder 的表單送出後,可以先被扣留,等管理員在後台確認才繼續往下走(官方更新日誌稱之為 held submissions)。依官方 3.6.8 的更新日誌,修補前「手動驗證」這個動作有辦法被利用來提升權限。換句話說,可能不是攻擊者自己多厲害,而是管理員那一下善意的點擊,替對方蓋了職章 🙋♀️
先把能確定的與不能確定的分清楚。官方只用一句話交代這次修補,沒有公開程式差異,也沒有公開攻擊內容。下面這條攻擊鏈,是我根據官方更新日誌、Patchstack 與 WPSec 的公開描述拼出來的輪廓,每一步都標了信心程度 🔍
- ㊀ 站上有「先扣留、再手動驗證」的表單流程:✅ 官方更新日誌明寫修補對象是「手動驗證被扣留的送出紀錄」,所以這個流程是前提 📝
- ㊁ 有人送出一份夾帶特製內容的表單:🟡 Patchstack 頁面把所需權限列為「未登入」,所以送件的人不需要帳號;送什麼內容,官方沒公開 📨
- ㊂ 送出紀錄躺在後台,等待驗證:🟡 後台的紀錄頁在 JetFormBuilder 選單下,狀態欄位的實際文字依版本而異 🗂️
- ㊃ 管理員按下手動驗證,原本被扣留的動作開始執行:🟡 WPSec 的描述是這些動作會以「按下驗證的那位管理員」的權限重播,也因此它把「需要有人互動」列入評分 🔑
- ㊄ 結果是帳號被升級:🟡 弱點類型是「權限指派不當」,最直接的想像是某個會指派角色的表單動作被借用了管理員權限;是哪個動作,官方沒說 👑
🧊 冷知識:NVD 顯示「Deferred」,不代表這個 CVE 沒發布
CVE-2026-119481 在 2026-11-04 就已經在 CVE 資料庫正式發布,NVD 頁面上的 Deferred 只是代表 NVD 還沒做完自己的補充分析,跟編號是否有效是兩回事。看到這個字樣就以為是假消息,反而會錯過更新 🧐
🚨 畫重點:我不會給你攻擊內容
官方沒公開,各家報告也沒有可信的原始出處,這篇一律不寫。你只要記住:這顆洞的關鍵動作是「管理員去驗證了不該驗證的東西」。想知道自己中不中標,看第五章的結果導向檢查就夠了,不必靠誰公開的字串 🙅
🧊 冷知識:別把「這一顆」跟「前幾顆」搞混
JetFormBuilder 這幾個月被登記過好幾筆漏洞。例如 CVE-2026-12793(3.6.2 以下,Wordfence 登記為免登入即可建立管理員帳號,9.8 分),以及同樣在 3.6.8 修補的 CVE-2026-119480(路徑穿越類,6.5 分)。它們是不同的洞,編號不同、成因不同。看到外掛頁寫「已修補」之前,先確認你比對的是 CVE-2026-119481,也就是版本要到 3.6.8 🧭
⏱️ 事件時間軸一次看懂
| 時間點 | 發生了什麼大事 | 站長可信度判定 |
|---|---|---|
| 2026-10-24 | 研究員 dzaku 向 Patchstack 回報這個問題 | ✅ Patchstack 頁面標註 |
| 2026-10-30 | 修補版 3.6.8 釋出,CVE 編號在同一天被保留(Reserved) | ✅ 官方外掛頁與 CVE 紀錄;3.6.8 的確切上架時間以我先前核對的官方發布頁為準,這次未重新比對 |
| 2026-11-02 | Patchstack 公開這筆漏洞條目 | ✅ Patchstack 頁面 |
| 2026-11-03 | WPSec 發布公告,評分與 Patchstack 不同 | ✅ 我先前核對過,這次沒有重新取得公告網址,所以參考資料沒放連結 |
| 2026-11-04 | CVE-2026-119481 正式發布 | ✅ CVE 紀錄與多個漏洞資料庫 |
| 到底有沒有人受害? | CISA 的評估(SSVC)是「尚無利用、可自動化、技術衝擊為全面」;VulDB 註記沒有可用的利用程式 | 目前沒有已確認的被利用紀錄,但「可自動化」代表一旦出現工具,擴散會很快 |
今天是 2026-11-06,公開到現在才兩天。現階段查不到公開的攻擊程式,是你最舒服的時間窗口:不用跟任何人賽跑,把備份、更新、檢查按部就班做完就好 ⏳
🚨 畫重點:什麼是 SSVC 裡的「可自動化」❓
你可以把它想成「這道鎖的開法可以寫成流水線」:不需要老師傅一把一把撬,寫好腳本就能對著成千上萬個網站一路試下去。CISA 給的是 yes,所以雖然現在沒有已確認的利用,也不建議拖 🏭
- ✅ 已確認事實:官方 3.6.8 更新日誌列出四項安全修補,其中一項是防止透過手動驗證被扣留的送出紀錄來提升權限;WordPress.org 頁面顯示 80,000+ 活躍安裝;Patchstack 表示這顆沒有虛擬補丁規則可套用。
- 🟡 合理推論:CVE 紀錄的向量 AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H 翻成白話:從網路就能打、難度低、不需要帳號、不需要受害者互動、影響範圍不跨出外掛,但機密性、完整性、可用性全部拉到 High。可是 WPSec 認為難度偏高、需要互動,兩邊的差異見第四章。
- 🔴 假設情境:假設有個補習班網站,報名表單設定成「先扣留,等行政人員確認再建立學員帳號」。行政人員每天按很多次驗證,其中一筆其實是別人夾帶特製內容送來的。這是我用來說明的例子,不是任何真實站台的紀錄。
🎭 第二章:先別自己嚇自己,對號入座看看你是哪一種網站主?
🩵 小資族荷包試算:修這個洞要花多少錢?
JetFormBuilder 核心外掛在 WordPress.org 的免費版,更新是 NT$ 0 元。Crocoblock 的付費訂閱與 PRO 附加元件各自怎麼更新、是否受影響,我沒有核對到官方說法,這裡不報數字也不下結論。對照事後成本:請人清後門、重建被竄改的帳號與內容,隨便一項都比更新貴得多 💸
同一封資安警告,小編、自己租主機的玩家、企業 IT 要做的事差很多。先找到自己,再決定往下讀到哪一章 🧭
🙋 我只是負責發文、顧後台的小編或老闆
你不需要懂程式。第三章全程動動滑鼠:看版本、備份、按更新,再去看後台有沒有不認識的待驗證紀錄。更新完成前,先不要按任何一筆紀錄的 Verify,這是這次最重要的一條「不要做」 🧑💼
👨💻 我自己租主機架站,手上有好幾個客戶網站
直接跳第五章拿工具箱,裡面有整台主機一次盤點的函式,會用各網站擁有者的身分跑 WP-CLI,不會在 root 底下留下一堆權限錯亂的檔案。這顆漏洞只有一道版本門檻(低於 3.6.8),判斷比「每條版本線各有修補版」的漏洞單純 🔧
🧑🔧 DevOps 小知識:為什麼不寫 nginx 擋規則?
Patchstack 明確寫著這顆漏洞沒有虛擬補丁規則可以套用。從攻擊者角度看,它的入口是一般的表單送出,沒有固定的網址特徵,亂擋不是擋不到,就是把正常表單一起擋掉。所以本文的短期應急(第六章 B)走的是「停用外掛」與「暫停手動驗證」,而不是假裝有一條神奇的規則 🧱
🏢 我們是企業網站,存了會員與訂單資料
免費外掛沒有 SLA,出事時誰也不保證多久修好。這次官方先釋出修補版、幾天後才公開細節,這點是加分。但如果網站有「先扣留再驗證」的流程、而且舊版曾暴露過,就要把它當成「可能被進入過」的等級來處理。個資是否外洩、要不要通知當事人,請交給公司法務判斷。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺
🚨 冤枉啊!我只放了一張單純的聯絡表單,沒用什麼驗證流程
某間工作室只用 JetFormBuilder 做了一張「聯絡我們」,表單送出就直接寄信,從沒在後台按過什麼驗證。管理員心想:「沒有待驗證的東西,總不會憑空出事。」
🕵️♀️ 依官方更新日誌,這次修補的是「手動驗證被扣留的送出紀錄」這條路,沒用這個流程確實會讓風險大幅下降。但我無法驗證「沒設定就一定打不到」:Patchstack 把所需權限列為免登入、CVE 向量寫免互動,兩者跟「必須有人去按驗證」並不完全吻合,我也沒看到程式碼差異。與其賭這個,不如直接更新,成本只有一次點擊 🆘
🚥 螢光筆時間:更新、停用、刪除,今天至少選一個
「我沒用到那個功能」不算處理。更新到 3.6.8,或停用,或徹底刪除,這三個才算真的把門關上 🙅♂️
🧮 四種情境對照:你今天該做到哪一步?
| 你的情境 | 風險判斷 | 今天要做的事 | 看哪一章 |
|---|---|---|---|
| 外掛啟用中,而且有「先扣留、再手動驗證」的流程 | 最高 | 先停手不按 Verify → 備份 → 更新 → 檢查帳號與待驗證紀錄 | 第三、五、六章 |
| 外掛啟用中,但表單都是直接送出(不扣留) | 中(依官方描述條件不成立,但無法保證) | 照樣更新,順便把用不到的外掛刪掉 | 第三、六章 |
| 外掛已停用但沒刪除 | 低 | 直接刪除,或更新後再停用 | 第三章 |
| 已經看到不認識的管理員或待驗證紀錄 | 視為已中招 | 不要刪東西,先取證再止血 | 第六章 A |
🧮 版本對照:你的版本安不安全?
| 產品 | 外掛代號(slug) | 受影響範圍 | ✅ 修到這版才安全 |
|---|---|---|---|
| JetFormBuilder(WordPress.org 免費版) | jetformbuilder | 3.6.7.1 以下(CVE/Patchstack 寫法) | 3.6.8 |
最早從哪一版開始受影響,不同來源寫得不一樣:CVE 與 Patchstack 寫「到 3.6.7.1 為止」沒有下限,WPSec 則寫從 3.3.0 起算。所以請當成「低於 3.6.8 一律有風險」處理,不要自己猜哪一版安全。順帶一提,版本號是四段的 3.6.7.1,不是 3.6.71,中間的點不能少。後面的腳本用 sort -V 逐段比大小,不會把 3.6.10 誤判成比 3.6.8 舊 🧰
😬 更新前先看這個:真實踩過的雷,與 3.6.8 會改變的行為
更新是對的,但不要當成「按下去就一定安然無事」。我找到兩個公開案例,都不是這次 CVE 造成的,放在這裡是提醒你更新後要實際測試 🧪
| 案例 | 發生了什麼 | 給你的提醒 |
|---|---|---|
| WordPress.org 論壇主題「Unfunctional after the latest update (1.2.0)」(已關閉;發文時間我沒有核對到,是很早期版本的案例) | 發文者更新外掛後,表單無法顯示,編輯器出現「Error loading block: Invalid block」,另外有一則 PHP 警告指向外掛內的短代碼檔案;改用短代碼嵌入也一樣失敗。外掛貢獻者回覆後,發文者在後續留言確認已經恢復正常。 | 更新可能讓表單區塊或短代碼出問題。先備份、先在測試站試跑,更新後馬上打開有表單的頁面確認。 |
| Crocoblock 官方說明文章「Form Shows Error on the Front End During Submission」(頁面標示 2026-03-24 修改) | 官方把「nonce_failed」列為最常見的送出錯誤之一。這是 WordPress 的一次性安全驗證權杖過期造成的,常見原因是頁面被快取,表單帶著過期的權杖送出。官方建議的處理包含:排除表單頁的快取、清瀏覽器快取、把快取時間縮短到十二小時以內,或在表單設定裡關閉「Enable form safety」。 | 更新後如果表單送出失敗,先查快取,不要第一時間懷疑新版有問題。注意「關閉 form safety」是官方列出的臨時解法之一,但等於放掉一道驗證,能不關就不關。 |
3.6.8 的更新日誌一共 19 項,其中四項是安全修補,我整理成白話,並標出該測什麼:
| 官方更新日誌的安全項目(摘要) | 白話意思 | 更新後請測 |
|---|---|---|
| 防止透過手動驗證被扣留的送出紀錄來提升權限 | 本次 CVE 的主角(🟡 官方沒有逐項對應編號,但描述與標題吻合) | 有「先扣留再驗證」流程的表單,驗證後的結果是否正常 |
| 限制 render states,只能觸發它自己的動作 | 表單在不同狀態下,不能再去觸發別的動作 | 多階段表單、條件式動作是否仍按預期執行 |
| 防止透過送出欄位裡的巨集(macros)執行短代碼 | 訪客填的內容,不能再被當成短代碼展開 | 寄出的通知信、確認訊息有沒有出現原始的巨集文字 |
| 防止透過媒體欄位任意刪除檔案 | 上傳欄位相關的檔案刪除動作被收緊 | 有檔案上傳的表單:上傳、替換、刪除是否正常 |
其他 15 項多半是介面與相容性的修正(例如遮罩輸入、右至左語言、Repeater 資料遺失等),另外有一項「把驗證規則限制在支援的欄位類型」,如果你的表單有設定進階驗證,更新後也請實際送一次看看 🧾
🚨 測出問題的時候,不要降版
把外掛降回 3.6.7.1 以下,等於重新打開漏洞。表單壞掉時,先把那張表單所在的頁面暫時轉成草稿,拿備份到測試站慢慢查。正式站處理不完,就先停用外掛(第六章 B),不要拿舊版當解法 🧯
🛟 第三章:電腦小白看過來!五分鐘無痛自救指南
💬 溫馨小叮嚀:更新外掛像是把壞掉的鎖換新,它保證從現在開始新的小偷進不來,但沒辦法倒帶,檢查昨天有沒有人已經進來逛過。所以最後兩步(看待驗證紀錄、查管理員名單)一定要做。網站如果存有會員或訂單資料,請把這篇轉給主管或維護人員,由他們判斷要不要做更深的健檢 🩺
每一步我都寫了「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只需要動動滑鼠 🤏
- ㊀ 先查水表,看看有沒有裝:
登入後台,左邊選單點「外掛」→「已安裝的外掛」,在右上角搜尋框輸入 JetFormBuilder。找到名稱像「JetFormBuilder — Dynamic Blocks Form Builder」的就是了。找不到就是沒裝,這篇可以當作別人家的新聞 🔎 - ㊁ 核對版本號:
版本號寫在外掛名稱下方。看到 3.6.8 或更高就是已修補;比它低就是受影響,繼續往下做 🕵️♀️ - ㊂ 更新完成前,先不要按任何 Verify:
後台左邊選單有「JetFormBuilder」,裡面有表單送出紀錄的頁面。在更新完成、並且確認過紀錄之前,不要點任何一筆紀錄的「Verify」(手動驗證)。這是這次最容易被利用的那個動作,先凍結它就好,不用刪任何東西。選單名稱依版本略有不同 🧊 - ㊃ 動手前先留後路(備份):
到主機商後台點「一鍵備份」,或用你平常的備份外掛先存一份。不知道什麼是備份的話,把這篇網址傳給幫你維護網站的人,請他代勞,這本來就是他的工作 💾 - ㊄ 按下更新:
在外掛清單點 JetFormBuilder 的「立即更新」。更新完重新整理,確認版本號跳到 3.6.8 以上。Crocoblock 的 PRO 附加元件是各自獨立的外掛,是否也要更新,請以它們各自的後台提示為準【❓待確認:我沒有查到附加元件是否受影響】 🔄 - ㊅ 看一眼待驗證的紀錄:
更新後再回到 JetFormBuilder 的送出紀錄頁,看有沒有狀態是「等待驗證」之類、而且你完全沒印象的紀錄。有的話,不要點驗證、也不要刪,截圖給維護人員。沒有的話,這一步就過關 📋 - ㊆ 更新完,每張表單都送出一次:
拿一個測試用的假資料,把每一張公開表單實際送出一次,看畫面有沒有出現錯誤、通知信有沒有收到。如果出現 nonce_failed,多半是快取問題,照上一章表格的建議處理。有壞掉的,先把那張表單所在頁面轉成草稿,再找維護人員處理 🧪 - ㊇ 今天真的更新不了,怎麼辦:
最乾淨的做法是在外掛清單把它「停用」。注意:如果你的佈景主題有直接呼叫 JetFormBuilder 的功能,停用後網站可能整個報錯。停用完馬上打開首頁看一眼;掛了就重新啟用,並在今天內排好更新時間,期間守住「不按 Verify」這條。這只是縮短風險時間,不是修補 🧯 - ㊈ 化身名偵探,查查後台有沒有陌生人:
點「使用者」→「所有使用者」,用「管理員」篩選,裡面應該都是你認識的人、用你們公司的信箱。再切到全部使用者,看最近註冊的帳號有沒有陌生名字。 見鬼的情況:多出沒印象的管理員,或你完全不認得的新帳號出現在管理員清單。看到這類狀況請不要自己刪,先截圖,立刻找主機商或維護人員,並告訴他們依本文第六章 A 處理 🔀
🫂 新手求助專區:看到專有名詞就頭暈怎麼辦?
直接把下面這段話傳給你的主機客服或維護人員:「你好,我的網站可能有安裝 JetFormBuilder 外掛,請幫我確認版本是否已更新到 3.6.8 以上,漏洞編號是 CVE-2026-119481。也請協助檢查後台管理員名單有沒有異常,以及表單送出紀錄裡有沒有不認識的待驗證項目。」對方看到這串 CVE 編號就知道該做什麼。沒有備份之前,千萬不要自己亂刪東西 🤗
🧊 冷知識:「停用」跟「刪除」差在哪?
想像一間店。「停用」是把鐵門拉下來,店面還在,哪天有人拿鑰匙開門,生意就又開張,也代表風險還在。「刪除」是把店面退租、貨架搬空。很久不用的外掛,退租比拉鐵門安全;還在用的,就老實更新 🏪
🤿 第四章:技術深潛,「放行」之後到底發生了什麼事?
想知道為什麼的人往下看,只想補洞的前三章已經夠用。先講資料狀況:官方沒有公開程式差異,攻擊內容也沒公開,下面的機制整理自官方更新日誌、Patchstack、WPSec 與 CVE 紀錄,推論的部分我都標了 🟡,查不到的標【❓待確認】⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | JetFormBuilder — Dynamic Blocks Form Builder(Crocoblock/Jetmonsters) |
| 外掛代號(slug) | jetformbuilder |
| CVE 編號 | CVE-2026-119481 |
| CVSS 3.1(CNA:Patchstack) | 9.8(Critical) |
| CVSS 向量(CVE 紀錄) | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 另一種說法(WPSec) | 評分 7.5,攻擊複雜度 High、需要使用者互動(UI:R),受影響範圍從 3.3.0 起算 |
| 弱點類型 | CVE 與 Patchstack:CWE-266(權限指派不當);WPSec:CWE-269(權限管理不當) |
| 受影響與修補版本 | 3.6.7.1 以下受影響,3.6.8 修補 |
| 發現者 | dzaku(Patchstack 頁面署名) |
| 公開時間 | Patchstack 2026-11-02;CVE 2026-11-04 |
| 啟用規模 | WordPress.org 80,000+ 活躍安裝 |
| Patchstack 的因應 | 無法指派虛擬補丁規則,更新就是修補 |
| CISA SSVC | 尚無利用、可自動化、技術衝擊為全面 |
🧩 三個環節串成一條完整的鏈
- 🏮 環節一:表單送出的資料會先被「扣留」。這是功能不是缺陷:站長希望某些送出先經過人工確認。扣留期間,這份資料原本該被當成「還沒被信任」 🚪
- 🏮 環節二:管理員按下手動驗證,動作才真正執行。官方更新日誌直接點名修補的就是這個動作。依 WPSec 的描述,被扣留的動作是在按下驗證的當下,以那位管理員的權限去跑 🧾
- 🏮 環節三:被扣留資料裡的內容,影響了動作的行為。具體是哪個欄位、哪個動作,公開資料沒有說;弱點分類是「權限指派不當」,最終的結果是低權限的身分被賦予了不該有的權限 🟡 🎯
舊流程像是主管一蓋章,整封信按主管的權限全部生效。比較安全的做法是:主管蓋章只代表「我確認這個人是真的」,信裡每一個動作,還是要用寄信人原本的身分去核對能不能做。官方沒有公開 3.6.8 實際怎麼改,這是我用來理解的比喻,不是程式碼的描述 📋
🧊 冷知識:Patchstack 的「優先級」跟「分數」不是同一件事
這筆條目在 Patchstack 上,CVSS 分數是 9.8,但該站另外標註的處理優先級是 Medium。分數是漏洞本身的嚴重度;優先級是 Patchstack 自己的處理排序,它怎麼算,頁面上沒有逐項說明,我的理解是偏向「實際被利用的可能性」🟡。兩者可以不一致,也不能拿來互相否定。我的建議是兩個都看,然後照較保守的那個行動 🧭
🧑🔧 DevOps 小知識:同一顆洞,為什麼 9.8 跟 7.5 差這麼多?
兩邊的差別就三個欄位:AC(攻擊複雜度)、UI(是否需要受害者互動)、受影響下限。CVE 紀錄寫低複雜度、免互動,算出 9.8;WPSec 認為得湊齊特定條件、而且要有人去按驗證,算出 7.5。我手上沒有程式差異,無法判定誰對。實務上的判斷是:兩邊都同意的部分就是行動清單,也就是修補版是 3.6.8、問題出在手動驗證,所以「先凍結 Verify,再更新」在兩種說法下都成立 🛡️
🧊 冷知識:同一個版本裡,其實修了不只一個地方
3.6.8 一共 19 項更新,其中四項是安全修補(見第二章表格)。官方沒有說哪一項對應哪個 CVE,所以「更新到 3.6.8」是唯一能確定覆蓋到的做法,不要試圖只手動補其中一項 🔒
🚨 畫重點:「完整觸發條件」目前查不到
哪些表單設定組合一定能觸發、攻擊者到底是不是未登入,官方與各家的說法有出入。所以第五章的排查我刻意不依賴某一條攻擊內容,改看結果特徵:管理員名單有沒有多人、最近有沒有新帳號、uploads 裡有沒有 PHP 檔、檔案有沒有被改。代價是會有一些雜訊,好處是不會因為猜錯入口而漏看真正的異常 🧭
🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 Checklist
🤖 重要提醒:以下指令由五份來源報告(ChatGPT、DeepSeek、Gemini、Grok、Meta AI)彙整,經 Claude 複查修正與整理,所有程式碼區塊都做過 bash 語法檢查,並用模擬環境(假的 WP-CLI、假的日誌與設定目錄)實際跑過主要分支 🫡
但模擬不等於你的真實主機,你的環境不一定跟範例一樣。正式環境(尤其是正在營業中的網站)的高風險操作——包含更新、停用、刪除檔案、變更權限、重設密碼或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin 都是示範用名稱,請換成你自己的環境 🧙
這一章的流程是:工具箱 👉 盤點版本 👉 帳號稽核 👉 日誌排查 👉 檔案稽核。這一章的指令全部只讀不寫,不會改任何東西。預期輸出只是典型範例,版本不同可能略有差異。另外,這顆漏洞的終點是「帳號被提權」,所以我把帳號稽核放在日誌之前 📜
🫂 先講一個指令幫不上忙的地方
「有沒有不認識的待驗證紀錄」要用肉眼看:後台 JetFormBuilder 的送出紀錄頁,找狀態是等待驗證、而你沒印象的項目。這個資料表的名稱與欄位我沒有在官方文件核對到,所以我不寫 SQL 去撈,免得寫出一條看起來合理、其實撈不到東西的指令。看到可疑的紀錄,不要點驗證、不要刪,截圖留存 🧊
🧰 ㊀ 先貼這一段:整章都會用到的小工具
為什麼這樣做:HestiaCP 的每個網站屬於不同的 Linux 使用者。用 root 直接跑 WP-CLI 會留下 root 擁有的檔案,之後網站自己寫入就會失敗。所以先定義幾個小函式:自動找 WP-CLI(有的主機叫 wp,有的叫 wp-cli)、以檔案擁有者身分執行、逐站迴圈,以及專門比版本號的 is_fixed。所有路徑變數都用雙引號包起來,路徑含空白也不會斷 🧱
# ㊀ 工具箱:同一個 SSH 連線貼一次就好,斷線後要重貼
SLUG="jetformbuilder"
FIXED="3.6.8"
# 找出 WP-CLI(有的主機叫 wp,有的叫 wp-cli)
WPCLI="$(command -v wp || command -v wp-cli || true)"
if [ -z "$WPCLI" ]; then
echo "⚠️ 找不到 wp 或 wp-cli,請先安裝 WP-CLI 再往下"
fi
command -v runuser > /dev/null || echo "⚠️ 找不到 runuser,請改請維運人員協助"
# 列出主機上每一個 WordPress(HestiaCP 標準位置:/home/使用者/web/網域/public_html)
list_wp_configs() {
find /home -maxdepth 5 -type f -path '*/web/*/public_html/wp-config.php' -print0 2> /dev/null
}
site_owner() { stat -c '%U' -- "$1/wp-config.php"; }
site_domain() { basename -- "$(dirname -- "$1")"; }
# 以「網站檔案擁有者」的身分跑 WP-CLI:wpx 網站路徑 wp指令...
wpx() {
local wp_path="$1" owner home_dir
shift
owner="$(site_owner "$wp_path")" || return 1
home_dir="$(getent passwd "$owner" | cut -d: -f6)"
runuser -u "$owner" -- env HOME="${home_dir:-/tmp}" "$WPCLI" --path="$wp_path" "$@" < /dev/null
}
# 逐站執行:任何一站失敗就停下,不會連鎖出事
each_site() {
local callback="$1" cfg
while IFS= read -r -d '' cfg <&3; do
"$callback" "$(dirname -- "$cfg")" || {
printf '⛔ 在 %s 停下,請先處理這一站\n' "$(dirname -- "$cfg")"
return 1
}
done 3< <(list_wp_configs)
}
# 版本比較:version_lt A B 成立,代表 A 比 B 舊(用 sort -V 逐段比數字)
version_lt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
# 這顆 CVE 只有一道門檻:低於 3.6.8 就受影響
is_fixed() { ! version_lt "$1" "$FIXED"; }
預期輸出:貼完沒有任何輸出就是成功。看到 ⚠️ 代表缺 WP-CLI 或 runuser,先補上再往下,不要硬跑。
驗證(確認 is_fixed 判斷正確):貼完後跑下面這段,它只做版本判斷,不碰任何網站 🧪
for v in 3.6.7.1 3.6.8 3.6.10 3.7 3.3.0 3.6.8.1; do if is_fixed "$v"; then echo "$v 已修補"; else echo "$v 受影響"; fi done
預期輸出(3.6.7.1 與 3.3.0 要是「受影響」,其餘「已修補」):
3.6.7.1 受影響 3.6.8 已修補 3.6.10 已修補 3.7 已修補 3.3.0 受影響 3.6.8.1 已修補
- 🟢 結果符合預期 👉 工具箱沒貼錯,往下進行。
- 🟠 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
- 🟡 3.6.10 被判成已修補是刻意的:用 sort -V 逐段比數字,不是比字串。
🧑🔧 DevOps 小知識:為什麼迴圈長成 3< <(find …) 這個樣子?
常見寫法 find | while read 有個陷阱:迴圈裡只要有指令會讀標準輸入,就會把清單吃掉一半,結果只處理到第一個網站。改成把 find 的輸出接到檔案描述符 3、用 read … <&3 讀,迴圈內的指令就搶不到輸入;搭配 -print0 與 read -d ”,路徑含空白也安全。wpx 裡的 < /dev/null 是第二道保險 🔧
🔎 ㊁ 盤點版本:哪些站裝了它、現在哪一版?
為什麼這樣做:一台主機上有十個獨立 WordPress,跟一個 Multisite 底下有十個子站,看起來像但處理方式不同:前者要逐一找每個 wp-config.php,後者外掛檔案全網只有一份。這個函式同時判斷安裝類型,輸出一行一個結果,只讀不寫 🤔
inventory_one() {
local wp_path="$1" kind ver status verdict
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-\t%s\n' "$wp_path"
return 0
fi
ver="$(wpx "$wp_path" plugin get "$SLUG" --field=version 2> /dev/null || true)"
if [ -z "$ver" ]; then
printf '⚪ 未安裝\t%s\t-\t-\t%s\n' "$kind" "$wp_path"
return 0
fi
status="$(wpx "$wp_path" plugin get "$SLUG" --field=status 2> /dev/null || true)"
if is_fixed "$ver"; then verdict="🟢 已修補"; else verdict="🔴 受影響"; fi
printf '%s\t%s\t版本:%s\t狀態:%s\t%s\n' "$verdict" "$kind" "$ver" "${status:--}" "$wp_path"
return 0
}
# 💠 單一網站(路徑換成你的)
inventory_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:整台主機一次盤點(只讀不寫)
each_site inventory_one
預期輸出範例:
🔴 受影響 💠 一般安裝 版本:3.6.7.1 狀態:active /home/admin/web/site-a.com/public_html 🟢 已修補 💠 一般安裝 版本:3.6.8 狀態:active /home/bob/web/shop.example/public_html ⚪ 未安裝 💠 一般安裝 - - /home/bob/web/blog.example/public_html 🟢 已修補 ✴️ Multisite 版本:3.6.8 狀態:active-network /home/bob/web/network.example/public_html
分支判斷邏輯:
- 🔴 受影響 👉 排入第六章的備份與更新,並接著做 ㊂ 到 ㊄,確認舊版時期有沒有被碰過。
- 🟢 已修補 👉 版本條件已解除,但舊版若曾暴露一段時間,仍建議做 ㊂ 到 ㊄。
- 🟡 狀態是 inactive 👉 外掛已停用,攻擊面暫時不在,仍建議更新或刪除,不要放著。
- ✴️ Multisite 出現 active-network 👉 外掛檔案全網共用,查主站一次就代表整個網路,更新也只做一次。
- 🟠 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。路徑若像 public_html.bak 之類的備份副本,請人工確認哪一個才是正式站。
👤 ㊂ 帳號稽核:有沒有人被「升官」了
為什麼這樣做:這顆漏洞的終點是帳號被提權,所以最直接的痕跡就在使用者清單。這段做三件事:列出全部管理員、列出最近註冊的 10 個帳號(任何角色,因為被升級前的帳號可能還是一般角色)、看幾個常被動手腳的網站設定。全部用 WP-CLI 內建功能完成,不寫 SQL,所以資料表前綴不一樣也不怕 🕵️♀️
accounts_one() {
local wp_path="$1" opt
printf '\n===== %s =====\n' "$(site_domain "$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 "--- ❸ 幾個常被動手腳的網站設定 ---"
for opt in siteurl home admin_email users_can_register default_role; do
printf '%s = %s\n' "$opt" "$(wpx "$wp_path" option get "$opt")"
done
return 0
}
# 💠 單一網站
accounts_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:逐站各自檢查
each_site accounts_one
# ✴️ Multisite:多看一層「超級管理員」
wpx "/home/admin/web/network.example.com/public_html" super-admin list
預期輸出範例(正常的樣子,節錄):
===== example.com ===== --- ❶ 全部管理員:逐位確認你認不認識 --- +----+------------+-------------------+---------------------+ | ID | user_login | user_email | user_registered | +----+------------+-------------------+---------------------+ | 1 | admin | admin@example.com | 2024-01-15 08:00:00 | +----+------------+-------------------+---------------------+ --- ❸ 幾個常被動手腳的網站設定 --- siteurl = https://example.com home = https://example.com admin_email = admin@example.com users_can_register = 0 default_role = subscriber
❷ 的表格格式與 ❶ 類似,多了一欄 roles。實際文字依 WP-CLI 版本而異 🧾
分支判斷邏輯:
- 🔴 ❶ 出現你不認識的管理員,或註冊時間落在 2026-10-24 前後、而你沒新增過的帳號 👉 高度可疑。陌生不等於惡意,可能是外包或同事留的,先問清楚,問不到就當成可疑,進第六章 A。
- 🟡 ❷ 有不認識的低權限帳號 👉 可能只是一般會員,也可能是被提權前的帳號,對照註冊時間與信箱網域判斷。
- 🔴 ❸ 的 users_can_register 變成 1、default_role 變成 administrator,或 siteurl/home 被改成陌生網域 👉 網站設定被動過手腳,這類改動很少是正常維運造成的。
- 🟡 Multisite 的 super-admin list 出現不認識的名字 👉 比一般管理員更嚴重,直接進第六章 A。
- 🟢 全部乾淨 👉 這一輪沒有發現明確跡象,仍要做 ㊃ ㊄,並繼續看後台待驗證紀錄。
📊 ㊃ 日誌排查:看誰一直在敲「不該被敲」的門
為什麼這樣做:標準存取日誌只記「請求行」:方法、網址、狀態碼,不記 POST 的內容,所以日誌能回答的問題有限。我只抓四個有價值的結果特徵:哪些網址被 POST 最多、有沒有 POST 打到不常見的 PHP 檔、有沒有人碰過 uploads 裡的 PHP 類檔案、哪些 IP 成功登入(wp-login.php 回 302)最多次。HestiaCP 預設的網站日誌在 /var/log/nginx/domains/網域.log,函式用 zcat -f 一併讀輪替後的 .log.1 與 .log.2.gz。前面如果有 CDN,日誌裡看到的 IP 可能是 CDN 的 🎯
# 取出某網域「目前+輪替後(含 .gz)」的 nginx 存取日誌
scan_domain_log() {
local domain="$1"
find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.log*" -print0 \
| xargs -0 -r zcat -f --
}
log_scan_one() {
local wp_path="$1" domain
domain="$(site_domain "$wp_path")"
printf '\n===== %s =====\n' "$domain"
echo "--- ❶ POST 請求最多的網址(前 15 名,已去掉問號後面的參數)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST /{ split($2, a, " "); sub(/\?.*/, "", a[2]); print a[2] }' \
| sort | uniq -c | sort -rn | head -n 15
echo "--- ❷ 打到非常規 PHP 檔的 POST(最近 20 筆)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST [^ ?]*\.php/ && $2 !~ /\/(index|wp-login|wp-cron|xmlrpc|admin-ajax|wp-comments-post)\.php/' \
| tail -n 20
echo "--- ❸ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---"
scan_domain_log "$domain" \
| awk -F'"' 'tolower($2) ~ /\/wp-content\/uploads\/[^ ?]*\.(php|phtml|phar)/' \
| tail -n 20
echo "--- ❹ 登入成功(wp-login.php 回 302)次數最多的來源 IP(前 10 名)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST \/wp-login\.php/ { split($1, a, " "); split($3, b, " "); if (b[1] == 302) c[a[1]]++ } END { for (ip in c) print c[ip], ip }' \
| sort -rn | head -n 10
return 0
}
# 💠 單一網站
log_scan_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站/✴️ Multisite:掃主機上每一個網域
each_site log_scan_one
預期輸出範例(有可疑的樣子):
===== example.com =====
--- ❶ POST 請求最多的網址(前 15 名,已去掉問號後面的參數)---
412 /contact/
37 /wp-login.php
2 /wp-content/plugins/foo/bar.php
--- ❷ 打到非常規 PHP 檔的 POST(最近 20 筆)---
198.51.100.9 - - [05/Nov/2026:10:00:03 +0800] "POST /wp-content/plugins/foo/bar.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
--- ❸ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---
198.51.100.9 - - [05/Nov/2026:10:00:04 +0800] "GET /wp-content/uploads/2026/11/x.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
--- ❹ 登入成功(wp-login.php 回 302)次數最多的來源 IP(前 10 名)---
2 192.0.2.7
分支判斷邏輯:
- 🔴 ❸ 有命中 👉 uploads 裡有人執行過 PHP,視為高度可疑,直接進第六章 A,並同時做 ㊄ 的 ❶。
- 🔴 ❹ 出現你不認識的 IP 成功登入管理介面,時間又在新管理員註冊之後 👉 帳號可能已被拿去用,進第六章 A。
- 🔴 ❷ 有命中,來源是陌生 IP、User-Agent 像腳本工具 👉 記下 IP 與時間,做完 ㊄;若 ㊄ 也有異常,進第六章 A。
- 🟡 ❶ 的表單網址 POST 數量突然暴增 👉 可能是正常流量,也可能是機器人在灌表單,要結合時間與 IP 判讀。我無法確認這顆漏洞在日誌裡的專屬特徵。
- 🟢 都沒命中 👉 只能說目前沒有直接證據,不能證明沒被打過:日誌可能已輪替、前面有 CDN,攻擊也可能沒走你查的這個日誌。繼續做 ㊄。
🕳️ ㊄ 檔案稽核:提權之後,攻擊者通常會做什麼
為什麼這樣做:一旦拿到管理員身分,下一步常見的是從後台裝外掛、改檔案,把後門放進網站。所以稽核要看檔案側:uploads 裡的 PHP、會自動載入的 mu-plugins、wp-content 根目錄的 PHP(例如 drop-in 檔案)、起算日之後被改過的 PHP、外掛清單、排程工作,以及外掛與核心檔案跟官方版本的比對。起算日預設是廠商回報的 2026-10-24;但漏洞存在的時間可能更早,想更保守就往前調 🕵️♀️
# 掃描起點:預設從廠商回報的日子(2026-10-24)算起,想更保守就再往前調
SINCE="2026-10-24"
files_one() {
local wp_path="$1"
printf '\n===== %s(擁有者:%s)=====\n' "$wp_path" "$(site_owner "$wp_path")"
echo "--- ❶ uploads 底下的 PHP 類檔案(正常網站應該是空的)---"
find "${wp_path}/wp-content/uploads" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.pht' -o -iname '*.phar' \) \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
echo "--- ❷ mu-plugins 與 wp-content 根目錄的 PHP(會自動載入,後台外掛頁看不到)---"
find "${wp_path}/wp-content/mu-plugins" -maxdepth 2 -type f \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
find "${wp_path}/wp-content" -maxdepth 1 -type f -name '*.php' \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
echo "--- ❸ ${SINCE} 之後被改過的 PHP(外掛正常更新也會出現,需人工比對)---"
find "${wp_path}/wp-content" -type f -name '*.php' -newermt "$SINCE" \
-not -path '*/cache/*' \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort | tail -n 100
echo "--- ❹ 外掛清單(有沒有你不認識的外掛)---"
wpx "$wp_path" plugin list --fields=name,status,version --format=table
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
return 0
}
# 💠 單一網站
files_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:逐站各自檢查,不能拿 A 站代表 B 站
each_site files_one
預期輸出範例(正常的樣子,節錄):
--- ❶ uploads 底下的 PHP 類檔案(正常網站應該是空的)--- --- ❻ 外掛與核心檔案跟官方版本比對 --- Success: Verified 12 of 13 plugins. Success: WordPress installation verifies against checksums.
❶ 沒有任何輸出,代表 uploads 裡沒有 PHP 類檔案,這是你要的結果。❻ 若有付費外掛或不在 WordPress.org 的外掛,常會出現無法驗證的提示,這很正常,實際文字依 WP-CLI 版本而異 🧾
分支判斷邏輯:
- 🔴 ❶ 找到 PHP 類檔案,或 ❷ 出現不是你部署的檔案 👉 視為已中招,先別刪,保留證據,進第六章 A。
- 🟡 ❸ 起算日之後被改過的 PHP 👉 外掛正常更新也會出現,要對照你自己的維運紀錄,別看到就當成入侵。
- 🟡 ❹ 與 ❺ 出現不認識的外掛或排程 hook 👉 先查那是哪個外掛、誰裝的,查不出來就當成可疑。
- 🟡 ❻ 出現 File was added 之類的差異 👉 先確認是不是你自己放的客製檔案;不確定就當成可疑。
- 🟢 全部乾淨 👉 這一輪沒有發現明確跡象,仍建議做第六章的更新與第七章的加固,並多觀察一週。
📋 ㊅ 三環境速查表:把這一章濃縮成一張
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 工具箱 | 貼一次,整個 SSH 連線通用 | ||
| ② 盤點版本 | inventory_one 路徑 | each_site inventory_one | 查主站一次,狀態看 active-network |
| ③ 帳號稽核 | accounts_one 路徑 | each_site accounts_one | 另加 super-admin list |
| ④ 日誌排查 | 單一網域 | 掃所有網域日誌 | 共用網域日誌,再依 Host 回推子站 |
| ⑤ 檔案稽核 | files_one 路徑 | 逐站各自檢查,不能拿 A 站代表 B 站 | 外掛與 uploads 檔案結構與單站類似,子站的上傳資料夾要另外看 |
🛠️ 第六章:疑似中招、還不能更新、準備修補,三條路各走各的
前一章解決「怎麼判斷」,這一章才動手。主線固定是 備份 👉 預覽 👉 更新 👉 驗證;第五章若出現跡象,要先走 A 取證與止血。這一章會改動系統狀態,所以每個動作後面都接著「怎麼確認真的生效」,以及失敗時的下一步。以下沿用第五章貼好的工具箱,沒貼的話請先回去貼一次 🧹
💾 ㊀ 三個共用零件:備份、刷新鹽值、停用外掛
為什麼這樣做:A、B、C 都會用到其中一兩個,所以先定義成函式,這一步只有定義,不會執行任何動作。備份檔放在 root 家目錄、權限 700,不放在網站目錄,避免被網址直接下載。資料庫匯出若結尾沒有 Dump completed 字樣,函式會直接視為失敗並停下,不會硬著頭皮往下。鹽值(Salts)是 wp-config.php 裡那八組長字串,WordPress 用它們替登入 Cookie 簽章。這顆漏洞的最壞結果是攻擊者已經取得管理員身分,他手上的登入狀態可能還活著,所以我的判斷是:舊版曾暴露、又出現任何可疑跡象,就用 wp config shuffle-salts 強制刷新,所有人,包含可能握有 Cookie 的攻擊者,立刻被登出。代價是正常使用者包含你自己都要重新登入 🔑
你只改了某位員工的密碼,攻擊者手上可能還有一張沒過期的通行證,照樣進得來。刷新鹽值等於宣布「舊版通行證今天起全部作廢」,不管誰拿著哪一張,都得重新辦。比單純改密碼徹底得多 🪪
# ㊀ 共用零件一:備份(資料庫+wp-content),任何一步失敗就回傳失敗
BACKUP_DIR="${HOME}/wp-security-backup"
backup_one() {
local wp_path="$1" domain stamp db_file tar_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"
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 3 -- "$db_file" | grep -q 'Dump completed'; then
echo "❌ 備份檔結尾沒有 Dump completed 字樣,視為不完整,停止"
echo " 可用 tail -n 3 \"$db_file\" 自己看一眼,確認完整再手動往下"
return 1
fi
tar -czf "$tar_file" -C "$wp_path" wp-content || { echo "❌ 檔案備份失敗,停止"; return 1; }
chmod 600 -- "$tar_file"
ls -lh -- "$db_file" "$tar_file"
return 0
}
# ㊁ 共用零件二:刷新 wp-config.php 裡八組金鑰與鹽值(所有人的登入狀態立刻作廢)
rotate_salts_one() {
local wp_path="$1" domain stamp before after
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
install -d -m 700 -- "$BACKUP_DIR" || return 1
# 備份放在網站目錄「外面」,因為檔案裡有資料庫密碼
install -m 600 -- "${wp_path}/wp-config.php" \
"${BACKUP_DIR}/${domain}_wp-config.php.${stamp}" || return 1
# 只留短雜湊做前後比對,不把真正的金鑰印在螢幕上
before="$(wpx "$wp_path" config get AUTH_KEY | sha256sum | cut -c1-12)"
wpx "$wp_path" config shuffle-salts || return 1
after="$(wpx "$wp_path" config get AUTH_KEY | sha256sum | cut -c1-12)"
if [ "$before" = "$after" ]; then
printf '❌ %s 的鹽值沒有改變,請檢查 wp-config.php 是否可寫\n' "$domain"
return 1
fi
printf '✅ %s 鹽值已更新(%s → %s),wp-config.php 權限:' "$domain" "$before" "$after"
stat -c '%a %U:%G' -- "${wp_path}/wp-config.php"
return 0
}
# ㊂ 共用零件三:停用 JetFormBuilder(Multisite 會連全網與各子站一起處理)
deactivate_one() {
local wp_path="$1" url status home_url code
if wpx "$wp_path" core is-installed --network 2> /dev/null; then
wpx "$wp_path" plugin deactivate "$SLUG" --network 2> /dev/null || true
while IFS= read -r url <&3; do
wpx "$wp_path" --url="$url" plugin deactivate "$SLUG" 2> /dev/null || true
done 3< <(wpx "$wp_path" site list --field=url)
else
wpx "$wp_path" plugin deactivate "$SLUG" 2> /dev/null || true
fi
# 驗證一:狀態不能是 active 或 active-network
status="$(wpx "$wp_path" plugin get "$SLUG" --field=status 2> /dev/null || true)"
if [ -z "$status" ]; then
echo "$SLUG:未安裝,不需處理"
else
printf '%s:%s\n' "$SLUG" "$status"
case "$status" in
active|active-network)
echo "❌ 仍是啟用狀態,請看上方輸出"
return 1
;;
esac
fi
# 驗證二:首頁還打得開(佈景主題若硬性呼叫了外掛的函式,停用後可能整站掛掉)
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
return 0
}
預期輸出:貼完沒有任何輸出就是成功。三個函式要等到 A、B 或 C 才會被叫用,這時候的輸出範例如下:
/dev/vda1 80G 22G 54G 29% / -rw------- 1 root root 485M Nov 6 10:30 /root/wp-security-backup/example.com_db_20261106-103022.sql -rw------- 1 root root 2.4G Nov 6 10:31 /root/wp-security-backup/example.com_wp-content_20261106-103022.tar.gz Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin Plugin 'jetformbuilder' deactivated. jetformbuilder:inactive 首頁 HTTP 200,正常
驗證與失敗分支:
- 🟢 備份:兩個檔案都在、大小合理 👉 可以進下一步。
- 🟠 備份失敗、是空檔,或印出「沒有 Dump completed 字樣」 👉 函式立刻停下,先查磁碟空間(已印出 df 結果)與資料庫權限,沒有完整備份不要往下。
- 🟢 鹽值:前後兩段雜湊不同 👉 已生效,你會被登出,重新登入即可。
- 🟠 鹽值出現 ❌「沒有改變」或指令本身失敗 👉 先確認 wp-config.php 的擁有者可寫;這個指令要連到 WordPress.org 的 API 取得新鹽值,主機連不出去也會失敗,先查網路,不要急著換別的來源。
- 🔴 刷完網站出現「建立資料庫連線時發生錯誤」 👉 用備份還原:cp -p “$BACKUP_DIR/網域_wp-config.php.時間” “網站路徑/wp-config.php”,備份檔名在 ls “$BACKUP_DIR” 的輸出裡。這份備份含資料庫密碼,確認一切正常後再決定要不要保留。
- 🟢 停用:狀態是 inactive,首頁 HTTP 為 2xx 或 3xx 👉 成功。
- 🟠 停用後首頁不是 2xx/3xx 👉 佈景主題可能呼叫了 JetFormBuilder 的函式。先別慌,執行 wpx “網站路徑” plugin activate jetformbuilder 恢復網站,再把這件事交給維護人員處理。重新啟用代表門又開了,所以要盡快排更新,並守住「不按 Verify」。
這三個函式在我的模擬環境裡實際測過:備份遇到不完整的匯出會停下、鹽值沒有改變會回報失敗。鹽值「真的改變」的路徑要在真機驗證,因為模擬環境沒有真的去連 WordPress.org 🧪
🚨 ㊁ A. 疑似中招:先留證據,再止血,最後才修補
為什麼這樣做:第五章出現明確跡象時,第一優先是保留現場,更新往後排。順序是:資料庫快照與日誌 👉 停用外掛 👉 刷新鹽值 👉 管理員密碼重設與登出所有階段。這個順序有道理:先拍照存證,再關門,再讓所有舊憑證作廢,最後處理帳號。如果攻擊者已經是管理員,他可能裝過外掛、讀過 wp-config.php,所以腳本結束時會提醒你接著換資料庫密碼 🧯
這支腳本會動到外掛、鹽值與帳號,請先確認路徑,而且只對有疑慮的那一站執行,不要整台主機無差別跑。新密碼會留在終端機的捲動紀錄裡,請在私密環境操作,用安全管道交給管理員本人,並請他登入後立刻自行更換,不要貼進聊天視窗或腳本 🔐
# 前提:已貼過「工具箱」與「共用零件」。取證完成前,什麼都不要刪
incident_one() {
local wp_path="$1" domain stamp evid out uid ans
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
evid="${HOME}/jfb-incident-${stamp}"
out="${evid}/${domain}"
install -d -m 700 -- "$evid" "$out" || return 1
echo "=== ❶ 取證:資料庫、日誌、帳號、外掛、排程、可疑檔案雜湊 ==="
( umask 077; wpx "$wp_path" db export - --single-transaction > "${out}/db_${stamp}.sql" )
if [ ! -s "${out}/db_${stamp}.sql" ]; then
echo "資料庫快照失敗或是空檔,停止"
return 1
fi
find /var/log/nginx/domains -maxdepth 1 -type f -name "${domain}.log*" \
-exec cp -p -- {} "$out/" \;
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table > "${out}/admins.txt"
wpx "$wp_path" plugin list --fields=name,status,version --format=table > "${out}/plugins.txt"
wpx "$wp_path" cron event list --fields=hook,next_run_gmt,recurrence \
--format=table > "${out}/cron.txt" 2> /dev/null || true
find "${wp_path}/wp-content/uploads" "${wp_path}/wp-content/mu-plugins" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.phar' \) -print0 2> /dev/null \
| xargs -0 -r sha256sum > "${out}/suspect-sha256.txt"
echo "=== ❷ 隔離:停用 JetFormBuilder ==="
deactivate_one "$wp_path" || return 1
echo "=== ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)==="
rotate_salts_one "$wp_path" || return 1
echo "=== ❹ 管理員名單與密碼重設 ==="
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email --format=table
read -r -p "要替上面每一位管理員重設密碼並登出所有階段嗎?(y/N):" ans
if [ "${ans:-}" = "y" ]; then
while IFS= read -r uid <&3; do
wpx "$wp_path" user reset-password "$uid" --skip-email --show-password || return 1
wpx "$wp_path" user session destroy "$uid" --all || true
done 3< <(wpx "$wp_path" user list --role=administrator --field=ID)
else
echo "已略過密碼重設"
fi
wpx "$wp_path" cache flush || true
printf '\n✅ %s 止血完成,證據放在 %s\n' "$domain" "$out"
ls -lh -- "$out"
echo "⚠️ 還沒做完:資料庫密碼與 wp-config.php 裡的其他金鑰,攻擊者若已能執行程式就可能讀過,請接著換掉"
return 0
}
# 只對「有疑慮的那一站」執行,不要整台主機一起跑
incident_one "/home/admin/web/example.com/public_html"
預期輸出範例:
=== ❷ 隔離:停用 JetFormBuilder === Plugin 'jetformbuilder' deactivated. jetformbuilder:inactive 首頁 HTTP 200,正常 === ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)=== Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin === ❹ 管理員名單與密碼重設 === (列出管理員後詢問是否重設,選 y 會顯示每位管理員的新隨機密碼) ✅ example.com 止血完成,證據放在 /root/jfb-incident-20261106-103500/example.com ⚠️ 還沒做完:資料庫密碼與 wp-config.php 裡的其他金鑰,攻擊者若已能執行程式就可能讀過,請接著換掉
驗證(確認真的止血了):跑完後執行下面這段。預期結果是外掛顯示 inactive(沒安裝會顯示「未安裝」)、管理員名單只剩你認識的人、最後一行顯示 db_ok 與 1。任何一項不符,就代表那一步沒成功,回頭看對應輸出。這段只讀不寫 🧪
WP_PATH="/home/admin/web/example.com/public_html" printf '%s:' "$SLUG" wpx "$WP_PATH" plugin get "$SLUG" --field=status 2> /dev/null || echo "未安裝" 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 👉 外掛檔案可能被改過,請交給資安專業人員做完整清理,不要自己硬刪。
- 🔴 發現後門檔案、不認識的管理員,或網站有個資與線上收款且確認曾遭觸發 👉 這已超出更新外掛的層級,請啟動內部資安事件流程,並把 SMTP、API 金鑰等放在資料庫或設定檔的憑證一併換掉。
- 🔴 更新前的備份可能已經含有後門,不能當成「乾淨的還原來源」;要還原請用事發前、已知乾淨的備份。
👤 補一刀:處理「不認識的管理員」,先降權、不要直接刪
為什麼這樣做:第五章或上面驗證裡找到的陌生管理員,直接刪除是不可逆的,萬一其實是外包留的,或你之後需要追查他做過什麼,就沒得查了。比較穩的做法是先把角色降成最低權限的訂閱者,帳號與紀錄都還在,隨時可以改回來。取證資料夾裡的 admins.txt 已經存了降權前的名單。下面把使用者編號寫成變數,請換成你確認過的那一個 ⚠️
WP_PATH="/home/admin/web/example.com/public_html" SUSPECT_ID="7" # 換成你確認過的陌生管理員編號 # 降成訂閱者(可逆) wpx "$WP_PATH" user set-role "$SUSPECT_ID" subscriber # 驗證:角色要變成 subscriber,並登出他的所有登入階段 wpx "$WP_PATH" user get "$SUSPECT_ID" --field=roles wpx "$WP_PATH" user session destroy "$SUSPECT_ID" --all
預期輸出:第一行會出現以 Success 開頭的訊息,第二行顯示 subscriber,第三行也是 Success 開頭的訊息。實際文字依 WP-CLI 版本而異。要還原就把 subscriber 換回 administrator 重跑第一行。若角色沒變,多半是編號打錯,用 wpx “$WP_PATH” user list –field=ID 重新確認 🧪
🔑 再補一刀:換掉資料庫密碼,並讓 wp-config.php 對得起來
為什麼這樣做:wp-config.php 裡有資料庫帳密,攻擊者若能在後台裝外掛或改檔案,理論上讀得到。順序很重要:先在 HestiaCP 面板把這個資料庫使用者的密碼改掉,再讓 wp-config.php 跟上,中間網站會短暫連不上資料庫,所以挑離峰時間做。面板操作我不寫成指令,因為各版本的指令參數我沒有逐一核對;面板上的「資料庫」頁面改密碼最不容易出錯。下面的函式只負責第二步:讓你輸入新密碼(輸入時不顯示),寫進 wp-config.php,再實際連一次資料庫確認 🛡️
密碼會出現在這個指令的參數裡,同一台主機上的其他使用者在極短時間內有機會從程序清單看到。這是 WP-CLI 這個指令的限制,所以請在沒有其他人登入的時段操作,並使用只含英數字元的隨機密碼,避免特殊符號被 Shell 吃掉 🙈
# 前提:已貼過「工具箱」。資料庫密碼請先在 HestiaCP 面板改好(建議用英數字元的隨機密碼)
rotate_db_password_one() {
local wp_path="$1" db_user new_pw
db_user="$(wpx "$wp_path" config get DB_USER)" || return 1
printf '資料庫使用者:%s\n' "$db_user"
read -r -s -p "請輸入剛在面板設定的新密碼(輸入時不會顯示):" new_pw
echo
if [ -z "$new_pw" ]; then
echo "沒有輸入,停止,wp-config.php 沒有變動"
return 1
fi
wpx "$wp_path" config set DB_PASSWORD "$new_pw" || return 1
wpx "$wp_path" db query 'SELECT 1 AS db_ok;'
}
rotate_db_password_one "/home/admin/web/example.com/public_html"
- 🟢 最後印出 db_ok 與 1 👉 新密碼已生效,網站恢復連線。
- 🔴 出現 Access denied 👉 面板上設定的密碼與你剛輸入的不一致。用面板重設一次、再重新輸入,或用 ㊀ 備份的 wp-config.php 還原。
- 🟡 網站正在對外服務卻還沒完成這一步 👉 會有短暫的資料庫連線錯誤,屬預期,完成後即恢復。
🧯 ㊂ B. 短期應急:還沒確認中招,但現在還不能更新
🚨 短期應急 ≠ 正式修補。
停用外掛與凍結手動驗證,只是縮短暴露時間。真正的修補仍然是更新到 3.6.8 以上。我刻意沒有提供 nginx 擋規則,原因在第二章:Patchstack 明說這顆沒有虛擬補丁可套用,亂擋不是擋不到,就是把正常表單一起擋掉 🧱
選項一(最乾淨):停用外掛。直接叫用 ㊀ 貼好的 deactivate_one。Multisite 會連全網和各子站一起處理,最後驗證狀態與首頁。停用的代價是前台的表單會消失,訪客暫時送不出資料,請事先公告或在頁面放替代聯絡方式 🧯
# 💠 單一網站(路徑換成你的;Multisite 請對網路根目錄跑一次) deactivate_one "/home/admin/web/example.com/public_html"
預期輸出範例:
Plugin 'jetformbuilder' deactivated. jetformbuilder:inactive 首頁 HTTP 200,正常
選項二(較弱):不停用,但全站凍結「手動驗證」。適合選項一會讓佈景主題報錯、或表單是營運命脈的網站。做法不靠指令,靠管理規矩,請明確通知所有有後台權限的人:
- 🧊 更新完成前,沒有任何人可以點送出紀錄裡的 Verify。不認識的紀錄一律不碰,截圖通報。
- 🧊 先把管理員的人數降到最少。暫時用不到的管理員帳號降成編輯,能減少「剛好有人去按驗證」的機會。
- 🧊 設定一個明確的更新期限。寫在行事曆上,不要用「有空再更新」。
- 🟢 選項一:狀態 inactive、首頁 2xx 或 3xx 👉 暫時安全,立刻排更新。
- 🟠 選項一:首頁不是 2xx/3xx 👉 見 ㊀ 的失敗分支,改走選項二,並交給維護人員。
- 🟡 選項二:只代表降低風險,不代表安全。因為我無法確認攻擊是否一定要經過管理員手動驗證(官方與評分來源說法不一),它只能當最後手段。
- 🟡 要還原外掛時,用 wpx “網站路徑” plugin activate jetformbuilder,再用 plugin get jetformbuilder –field=status 確認回到 active。
🚀 ㊃ C. 正式修補:備份 👉 預覽 👉 更新 👉 驗證
為什麼這樣做:JetFormBuilder 核心外掛掛在 WordPress.org,WP-CLI 的 plugin update 可以直接更新,plugin verify-checksums 能拿官方檢查碼比對外掛檔案有沒有被動過。腳本用 heredoc 建立,結束標記用單引號包住,裡面的 $ 不會被提前展開,建好後立刻做語法檢查。腳本裡還加了幾個保險:使用者名稱與網域先用格式檢查擋掉怪字元、備份不完整就整個停下、最後用「版本號」判定成敗,而不是看更新指令有沒有印出 Success。千萬不要為了「保險」而降版,降回舊版等於把洞重新打開 📦
cat > /usr/local/sbin/jfb-patch.sh <<'JFB_PATCH_EOF'
#!/usr/bin/env bash
# jfb-patch.sh:單一網站的 JetFormBuilder 修補流程(備份 → 預覽 → 確認 → 更新 → 驗證)
# 用法(用 root 執行):jfb-patch.sh 網站使用者 網域
set -euo pipefail
SLUG="jetformbuilder"
FIXED="3.6.8"
WPCLI="$(command -v wp || command -v wp-cli || true)"
if [ -z "$WPCLI" ]; then
echo "❌ 找不到 wp 或 wp-cli" >&2
exit 1
fi
if [ "$#" -ne 2 ]; then
echo "用法:$0 網站使用者 網域" >&2
exit 2
fi
SITE_USER="$1"
SITE_DOMAIN="$2"
[[ "$SITE_USER" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo "❌ 使用者名稱格式不對" >&2; exit 2; }
[[ "$SITE_DOMAIN" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "❌ 網域格式不對" >&2; exit 2; }
WP_PATH="/home/${SITE_USER}/web/${SITE_DOMAIN}/public_html"
if [ ! -f "${WP_PATH}/wp-config.php" ]; then
echo "❌ 找不到 ${WP_PATH}/wp-config.php" >&2
exit 1
fi
HOME_DIR="$(getent passwd "$SITE_USER" | cut -d: -f6 || true)"
wpx() {
runuser -u "$SITE_USER" -- env HOME="${HOME_DIR:-/tmp}" "$WPCLI" --path="$WP_PATH" "$@" < /dev/null
}
version_lt() {
[ "$1" != "$2" ] && [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n 1)" = "$1" ]
}
is_fixed() { ! version_lt "$1" "$FIXED"; }
get_ver() { wpx plugin get "$SLUG" --field=version 2> /dev/null || true; }
ver="$(get_ver)"
if [ -z "$ver" ]; then
echo "⚪ ${SITE_DOMAIN} 沒有安裝 JetFormBuilder,不需處理"
exit 0
fi
printf '目前版本:%s\n' "$ver"
if is_fixed "$ver"; then
echo "🟢 版本已達 ${FIXED},略過更新,直接進驗證"
else
echo "🔴 ${SITE_DOMAIN} 的版本低於 ${FIXED},開始處理"
BACKUP_DIR="${HOME}/wp-security-backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
DB_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_db_${STAMP}.sql"
TAR_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_wp-content_${STAMP}.tar.gz"
install -d -m 700 -- "$BACKUP_DIR"
( umask 077; wpx db export - --single-transaction > "$DB_FILE" )
if [ ! -s "$DB_FILE" ]; then
echo "❌ 資料庫備份是空檔,停止,沒有進行任何更新" >&2
exit 1
fi
if ! tail -n 3 -- "$DB_FILE" | grep -q 'Dump completed'; then
echo "❌ 備份檔結尾沒有 Dump completed 字樣,視為不完整,停止" >&2
exit 1
fi
tar -czf "$TAR_FILE" -C "$WP_PATH" wp-content
chmod 600 -- "$TAR_FILE"
ls -lh -- "$DB_FILE" "$TAR_FILE"
echo "--- 預覽(不會真的更新)---"
wpx plugin update "$SLUG" --dry-run
read -r -p "確認要更新嗎?(y/N):" ans
if [ "${ans:-}" = "y" ]; then
wpx plugin update "$SLUG"
else
echo "已略過更新"
fi
fi
echo "--- 驗證 ---"
fail=0
ver="$(get_ver)"
if is_fixed "$ver"; then
echo "✅ ${SLUG}:${ver}"
else
echo "❌ ${SLUG}:${ver:-未知} 仍低於 ${FIXED}" >&2
fail=1
fi
wpx plugin verify-checksums "$SLUG" || fail=1
wpx cache flush || true
home_url="$(wpx option get home)"
code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/" || true)"
case "$code" in
2*|3*) echo "✅ ${SITE_DOMAIN}:首頁 HTTP ${code}" ;;
*) echo "❌ ${SITE_DOMAIN}:首頁 HTTP ${code:-無回應},請立刻檢查" >&2; fail=1 ;;
esac
echo "⚠️ 還沒做完:到後台 JetFormBuilder → Form Records,檢查有沒有你不認識的「Waiting verification」紀錄,更新完成前不要按 Verify"
exit "$fail"
JFB_PATCH_EOF
chown root:root /usr/local/sbin/jfb-patch.sh
chmod 750 /usr/local/sbin/jfb-patch.sh
bash -n /usr/local/sbin/jfb-patch.sh && echo "語法檢查通過"
驗證(確認腳本建好了):最後一行預期印出「語法檢查通過」。沒有這行就代表 heredoc 內容有缺漏,用 ls -l /usr/local/sbin/jfb-patch.sh 看檔案是否存在,再重新貼一次整段 🧪
使用方式:一次一站,先處理一站、確認前後台正常,再處理下一站。把第五章盤點出的 🔴 站,逐一用「使用者、網域」當參數跑:
# 一次一站:先處理一站、確認前後台正常,再處理下一站 /usr/local/sbin/jfb-patch.sh admin example.com
預期輸出範例:
目前版本:3.6.7.1 🔴 example.com 的版本低於 3.6.8,開始處理 -rw------- 1 root root 485M Nov 6 11:15 /root/wp-security-backup/example.com_db_20261106-111500.sql -rw------- 1 root root 2.4G Nov 6 11:16 /root/wp-security-backup/example.com_wp-content_20261106-111500.tar.gz --- 預覽(不會真的更新)--- (WP-CLI 列出 jetformbuilder 將從 3.6.7.1 更新到 3.6.8 的表格) 確認要更新嗎?(y/N):y Success: Updated 1 of 1 plugins. --- 驗證 --- ✅ jetformbuilder:3.6.8 Success: Verified 1 of 1 plugins. ✅ example.com:首頁 HTTP 200 ⚠️ 還沒做完:到後台 JetFormBuilder → Form Records,檢查有沒有你不認識的「Waiting verification」紀錄,更新完成前不要按 Verify
- 🟢 看到最後的 ✅ 👉 這一站修補完成。接著手動清網站快取與 CDN 快取,並照第三章 ㊅ ㊆ 看待驗證紀錄、把每張公開表單送出一次。
- 🟠 備份失敗或不完整 👉 腳本直接停下,沒有任何更新,先查磁碟空間與資料庫權限。
- 🟠 出現 ❌「仍低於 3.6.8」 👉 更新來源沒生效,或你在確認時回答了 N;把輸出交給維運人員,不要反覆硬跑。
- 🟠 verify-checksums 出現 File was added 👉 外掛資料夾裡多了官方沒有的檔案,逐一確認,不確定就視為可疑,回到 A。
- 🟠 腳本離開時回傳 1 且印出 ❌ 首頁錯誤 👉 更新後網站異常,立刻停用外掛(B 選項一),再用備份還原;之後把備份拿到測試站慢慢查,不要在正式站試錯。
- 🟡 預覽時沒有列出可用更新 👉 不代表已是最新,以最後的版本判定為準;也要確認主機連得到 WordPress.org。
🔁 最後一步:打開自動更新,把「有空再更新」的空窗壓到最小
為什麼這樣做:這次從修補版釋出到公開細節只隔幾天,下一次也會是這個節奏。WP-CLI 的 plugin auto-updates 有 enable 與 status 兩個子指令可用。代價是偶爾要處理更新後的相容性問題,所以重要網站請務必搭配每日備份。Multisite 的自動更新是在網路管理後台設定的,這段指令我沒有針對它驗證 🔄
WP_PATH="/home/admin/web/example.com/public_html" wpx "$WP_PATH" plugin auto-updates enable "$SLUG" wpx "$WP_PATH" plugin auto-updates status "$SLUG"
- 🟢 status 顯示啟用(文字依 WP-CLI 版本而異,大意是 Enabled) 👉 設定生效。
- 🟠 顯示未啟用 👉 回頭看 enable 那一行有沒有錯誤訊息,通常是檔案權限或網站載入失敗,請交給維運人員。
🚨 驗證的侷限:
verify-checksums 只能證明免費版目錄裡的檔案和官方發布版一致,檢查不到 mu-plugins、uploads、資料庫裡的帳號與設定,也看不出別的外掛有沒有被動過。它只是完整性檢查的其中一環,必須搭配第五章的稽核。另外,如果這一站已經中招,更新前的備份本身就可能含有問題,不能當成乾淨的還原來源 🔐
| 情境 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| 疑似中招(A) | incident_one 路徑 | 只對有疑慮的站逐一執行 | 對網路根目錄跑一次,腳本會連子站一起停用 |
| 短期應急(B) | deactivate_one 路徑 | 逐站套用 | 對網路根目錄跑一次 |
| 正式修補(C) | jfb-patch.sh 使用者 網域 | 一次一站,確認後再下一站 | 外掛檔案只有一份,更新一次即可,再逐子站驗證表單 |
🔥 一句話總結:「批次」不等於把所有東西塞進一條迴圈。真正安全的做法是先盤點、再備份、預覽,確認後逐站更新,最後驗證,而且每一步失敗就停下 🎯
🔒 第七章:補完洞之後,把第二道門也鎖上
更新是根本,加固是「萬一下一顆漏洞又來,讓損害小一點」。先講清楚:這一章的招式擋不住這顆 CVE 本身,它們擋的是帳號被提權之後的下一步(例如在伺服器上執行指令、把後門丟進上傳資料夾),屬於通用加固,不是官方要求。因為 Patchstack 已說明這顆沒有虛擬補丁,加固更不能取代更新 🛡️
🧱 加固一:把 PHP 的系統指令函式關掉
為什麼這樣做:disable_functions 可以讓 PHP 無法呼叫 exec、system 這類直接跟作業系統溝通的函式,攻擊者拿到執行程式的機會後,能做的事會少一截。清單刻意不含 curl_exec,因為 WordPress 對外連線要靠它,停掉會讓外掛更新與排程出問題;也不要寫 eval,它是 PHP 的語言結構,不是函式,寫進去不會有效果。備份、圖片處理類外掛偶爾會用到 exec,改完請實際測前後台 ⚠️
這個函式有幾個設計重點:它會先讀出你 php.ini 裡現有的 disable_functions,跟上面的清單合併、去掉重複,不會把你原本已經停用的函式洗掉(直接覆蓋的話,反而可能讓防護變弱);沒有那一行才補在最後,所以跑幾次結果都一樣;設定檢查通過才用 reload 重新載入(比 restart 溫和,不會硬斷線),沒過就自動還原備份 🔁
# 對主機上每一個 PHP-FPM 版本做「可重複執行」的合併:保留原本已停用的函式,只補上缺的
harden_php_ini() {
local ini ver bak cur merged
local want="exec,passthru,shell_exec,system,proc_open,popen"
while IFS= read -r -d '' ini <&3; do
ver="$(basename -- "$(dirname -- "$(dirname -- "$ini")")")"
bak="${ini}.BAK.$(date +%F_%H%M%S)"
cp -p -- "$ini" "$bak" || return 1
# 讀出目前生效的那一行(沒有就是空字串),跟我們的清單合併、去重
cur="$(sed -n -E 's/^disable_functions[[:space:]]*=[[:space:]]*(.*)$/\1/p' "$ini" | head -n 1)"
merged="$(printf '%s,%s\n' "$cur" "$want" | tr ',' '\n' \
| sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//' \
| awk 'NF && !seen[$0]++' | paste -sd, -)"
if grep -q -E '^disable_functions[[:space:]]*=' "$ini"; then
sed -i -E "s|^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 > /dev/null 2>&1; then
systemctl reload "php${ver}-fpm"
printf 'PHP %s:已套用並重新載入\n' "$ver"
else
cp -p -- "$bak" "$ini"
printf 'PHP %s:設定檢查沒過,已還原備份:%s\n' "$ver" "$bak"
fi
grep -H '^disable_functions' "$ini"
done 3< <(find /etc/php -maxdepth 3 -type f -path '*/fpm/php.ini' -print0)
}
harden_php_ini
# 檢查個別網域的 pool 設定有沒有另外覆蓋(沒輸出代表沒覆蓋)
grep -H 'disable_functions' /etc/php/*/fpm/pool.d/*.conf
預期輸出範例:
PHP 8.4:已套用並重新載入 /etc/php/8.4/fpm/php.ini:disable_functions = exec,passthru,shell_exec,system,proc_open,popen
如果你原本就有別的函式在清單裡,輸出會是「你原本的清單+上面缺的那些」。最後那行 grep … pool.d/*.conf 沒有輸出,代表沒有個別網域的 pool 設定在覆蓋它,這是好事。出現「設定檢查沒過,已還原備份」代表設定沒套用、網站不受影響,把輸出貼給維運人員。每次執行都會留下一份 .BAK.時間 備份檔,確認沒問題後可以自己整理,我沒有寫自動刪除 🧹
驗證加固真的有效:光看設定檔不夠,要實際從外面打一次。下面的探針用隨機檔名,內容只回報 exec 能不能用,測完立刻刪除。預期回應是 EXEC-DISABLED;若是 EXEC-AVAILABLE,代表這個網站實際用的 PHP-FPM 版本不是你剛改的那一個,或被別處覆蓋。請確認最後印出「探針已移除」🧪
# 驗證 disable_functions 真的生效:放一個「隨機檔名」的無害探針,從外面打一次,馬上刪掉
probe_disabled() {
local wp_path="$1" domain name probe out
domain="$(site_domain "$wp_path")"
name="__probe_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
probe="${wp_path}/${name}"
cat > "$probe" <<'PROBE_EOF'
<?php
echo function_exists('exec') ? 'EXEC-AVAILABLE' : 'EXEC-DISABLED';
PROBE_EOF
out="$(curl -sS --max-time 20 "https://${domain}/${name}" || true)"
rm -f -- "$probe"
printf '探針回應:%s\n' "${out:-(無回應)}"
if [ -e "$probe" ]; then
printf '⚠️ 探針檔案還在,請手動刪除:%s\n' "$probe"
else
echo "探針已移除"
fi
}
probe_disabled "/home/admin/web/example.com/public_html"
- 🟢 回應 EXEC-DISABLED、探針已移除 👉 加固生效。
- 🟠 回應 EXEC-AVAILABLE 👉 查這個網域實際用哪個 PHP 版本,並檢查 pool 設定有沒有覆蓋。
- 🟠 印出「探針檔案還在」 👉 手動刪掉那個檔案,別留在網站根目錄。
- 🟡 沒有回應 👉 可能是憑證、防火牆或網域解析問題,先確認網站本身連得上。
🚨 要誠實講的侷限
disable_functions 擋的是「呼叫系統指令」這條路。攻擊者若已經能執行 PHP,他仍然可以用 PHP 本身的檔案函式寫入、讀取檔案。所以它只是把牆加高,不是把牆變成保險箱,不能取代更新與稽核 🧱
🚫 加固二:禁止執行 uploads 資料夾裡的 PHP
為什麼這樣做:上傳資料夾本來只該放圖片與文件。帳號被提權後,想把後門丟進網站,最常見的落腳處就是這裡。用 nginx 規則讓 /wp-content/uploads/ 底下的 PHP 類檔案一律回 403,即使檔案被放進去也跑不起來。HestiaCP 的網站設定檔會在重建網域或續約 SSL 時被樣板覆蓋,所以千萬不要直接改 nginx.conf;官方論壇的做法是在 /home/使用者/conf/web/網域/ 放 nginx.conf_任意名稱(HTTP)與 nginx.ssl.conf_任意名稱(HTTPS),樣板會自動載入 🧱
函式裡有三道保險:先確認設定目錄存在,再確認該網域的 nginx.conf 真的有 include 這類自訂檔(沒有的話寫了也不會生效,函式會直接略過並告訴你);規則的比對結尾包含 (/|$),連 x.php/foo 這種把路徑接在檔名後面的繞法也一併擋住;nginx -t 沒過時,會自動撤回剛建的兩個檔案。不同 HestiaCP 樣板(例如 nginx 搭配 Apache 的組合)對這條規則的反應可能不同,我沒有逐一驗證,所以最後一定要跑驗證:一般圖片還能正常顯示,才算沒副作用 🔍
# 用 HestiaCP 認得的自訂檔,禁止執行 uploads 底下的 PHP(面板重建設定時不會被蓋掉)
block_uploads_php() {
local owner="$1" domain="$2"
local conf_dir="/home/${owner}/conf/web/${domain}"
local http_inc="${conf_dir}/nginx.conf_no_uploads_php"
local ssl_inc="${conf_dir}/nginx.ssl.conf_no_uploads_php"
if [ ! -d "$conf_dir" ]; then
printf '找不到 %s,略過\n' "$conf_dir"
return 1
fi
# 確認主設定檔真的有 include 自訂檔,否則寫了也不會生效
if ! grep -q 'conf_\*' "${conf_dir}/nginx.conf" 2> /dev/null; then
printf '%s 裡找不到 conf_* 的 include,略過,請維運人員確認樣板\n' "${conf_dir}/nginx.conf"
return 1
fi
tee "$http_inc" "$ssl_inc" > /dev/null <<'NGINX_EOF'
# 上傳資料夾只該放圖片與文件,PHP 類檔案一律回 403
location ^~ /wp-content/uploads/ {
location ~* \.(php|phtml|phar|pht)(/|$) {
return 403;
}
}
NGINX_EOF
# 語法檢查通過才重新載入;沒過就把剛建的兩個檔案撤掉
if nginx -t; then
systemctl reload nginx
printf '已套用:%s\n' "$domain"
else
rm -f -- "$http_inc" "$ssl_inc"
printf 'nginx 設定檢查沒過,已撤回規則:%s\n' "$domain"
return 1
fi
}
# ⚠️ 拆除規則(會刪檔,只刪我們自己建的兩個檔案)
unblock_uploads_php() {
local owner="$1" domain="$2"
local conf_dir="/home/${owner}/conf/web/${domain}"
rm -f -- "${conf_dir}/nginx.conf_no_uploads_php" "${conf_dir}/nginx.ssl.conf_no_uploads_php"
if nginx -t; then
systemctl reload nginx
printf '已解除:%s\n' "$domain"
else
echo "nginx 設定檢查沒過,請先查原因,不要 reload"
return 1
fi
}
# 驗證:PHP 探針要 403,uploads 裡的一般圖片仍然是 200(探針用完立刻刪除)
check_uploads_block() {
local wp_path="$1" image_url="$2" domain name probe
domain="$(site_domain "$wp_path")"
name="__probe_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
probe="${wp_path}/wp-content/uploads/${name}"
printf '<?php echo "PROBE-RAN";\n' > "$probe"
curl -sS -o /dev/null -w "uploads 內的 PHP 探針:HTTP %{http_code}\n" --max-time 20 \
"https://${domain}/wp-content/uploads/${name}" || true
rm -f -- "$probe"
if [ -e "$probe" ]; then
printf '⚠️ 探針檔案還在,請手動刪除:%s\n' "$probe"
fi
curl -sS -o /dev/null -w "一般圖片:HTTP %{http_code}\n" --max-time 20 "$image_url" || true
}
# 套用(把使用者與網域換成你的)
block_uploads_php "admin" "example.com"
check_uploads_block "/home/admin/web/example.com/public_html" \
"https://example.com/wp-content/uploads/你網站上任一張圖片.jpg"
預期輸出範例:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok nginx: configuration file /etc/nginx/nginx.conf test is successful 已套用:example.com uploads 內的 PHP 探針:HTTP 403 一般圖片:HTTP 200
- 🟢 探針 403、圖片 200 👉 規則生效且沒有副作用。
- 🟠 探針回 200 或 500 👉 規則沒被載入,或被別的設定蓋掉。立刻執行 unblock_uploads_php “使用者” “網域” 把規則拆掉,再請維運人員看樣板。⚠️ 這個函式會刪檔,但只刪我們自己建的那兩個。
- 🟠 圖片也變成 403 或 404 👉 規則影響到靜態檔,同樣先拆除。
- 🟠 印出「找不到 conf_* 的 include,略過」 👉 這個網域的樣板沒有載入自訂檔,函式什麼都沒寫,請維運人員確認樣板。
- 🟠 nginx -t 報錯 👉 函式已自動撤回,請保留錯誤訊息給維運人員。
- 🟡 探針檔案還在 👉 手動刪掉,別留著。
🧑🔧 DevOps 小知識:這一章的重載為什麼都用 reload?
restart 會把服務整個停掉再起,進行中的請求會被硬切斷;reload 則是讓新的設定生效、舊的連線慢慢收尾。營業中的網站盡量用 reload,而且一律先做設定檢查(php-fpm 版本 -t、nginx -t),沒過就不重載 🔧
📝 加固三:管好帳號、驗證習慣與更新節奏
這顆漏洞的核心是「管理員那一下點擊」。就算更新了,下面這幾個習慣也能讓下一次出事時損害更小。以下是通用建議,不是本 CVE 的官方要求 🧼
- ⭐ 驗證前先看內容。把「手動驗證」當成簽名蓋章:沒看過內容、不認識來源,就不蓋。
- ⭐ 管理員越少越好。只留真的需要的人,其他降成編輯。這次的風險之所以需要「有人按驗證」,管理員越多,被誘導的機會越大。
- ⭐ 不要開放公開註冊,預設角色維持最低。第五章帳號稽核的 ❸ 就是檢查這兩項:users_can_register 要是 0,default_role 要是 subscriber。
- ⭐ 為後台登入加一道額外驗證。常見的資安外掛有提供雙因素驗證,可評估是否啟用【❓待確認:依你使用的外掛而定】。
- ⭐ 把後台內建的外掛與佈景主題編輯器關掉。WordPress 有一個設定常數(DISALLOW_FILE_EDIT)可以做到。這是 WordPress 通用做法,我沒有替它寫成指令,也沒有針對你的環境驗證,請交給維運人員評估。
- ⭐ 開自動更新,並搭配每日備份。見第六章 C 的最後一步。
- ⭐ 管理員的變動要有人知道。後台新增管理員、外掛被安裝,最好有通知機制。常見資安外掛有提供,可評估是否啟用【❓待確認:依你使用的外掛而定】。
🩵 荷包試算:要不要花錢買 WAF 或虛擬修補?
付費的虛擬修補服務是在更新前先擋一層,但這顆漏洞 Patchstack 明說無法指派虛擬補丁規則,所以拿它當「不更新的理由」站不住腳。今天能在後台點一下「更新」,通常比月付方案划算;真的卡在相容性、短期無法更新時,守住「凍結 Verify」並排定更新期限,才是更實在的過渡手段。至於一般 WAF 能不能擋這種「看起來像正常表單送出」的請求,我沒有資料可以驗證 💰
🤔 第八章:讀到快喘不過氣?常見焦慮與幾個通用心法
- 🌠 Q:我已經是 3.6.8 了,就安全了嗎?
A:就這顆 CVE 來說,是的,那是官方的修補版。但如果舊版曾對外暴露,更新只能擋住下一次,擋不了已經發生的事,所以還是建議做完第五章的稽核,尤其是帳號那一步 📦 - 🌟 Q:我沒有用任何扣留或手動驗證的功能,還需要更新嗎?
A:需要。官方的修補描述指向手動驗證,沒有這個流程風險會小很多,但各家對觸發條件的說法不完全一致,而且 3.6.8 還一併修了另外三項安全問題,我無法驗證「沒用就一定打不到」 🧭 - ✨ Q:更新後我的表單壞了,可以降版嗎?
A:不建議。降版等於重新打開漏洞。先照第二章表格找出是哪個行為變更造成,並檢查是不是快取(nonce_failed);問題解決不了,先把那張表單下架 🙅 - 🌠 Q:我看到一筆待驗證的紀錄,內容看起來很正常,可以按嗎?
A:更新到 3.6.8 之後,官方已經修補了這條路,風險大幅下降。但在更新前,一律不要按;更新後如果紀錄來源你不認得,也建議先問清楚再處理 🤗 - ✨ Q:看不懂那些指令,是不是沒救了?
A:不會。第三章全程用滑鼠就能完成最基本的處理,第五章以後是給有 SSH 權限的人看的補充 🔍
🧩 舉一反三:「人工放行」這一類流程,為什麼特別容易出事
下面這些是通用防禦知識,不是本 CVE 的官方要求,但下次看到類似新聞能對號入座 🫡
- ⭐ 放行的人,不該替內容背書。管理員按下「通過」,代表的是「這份資料可以進入下一關」,不該等於「這份資料裡的每個動作都用我的權限執行」。
- ⭐ 權限要在動作執行當下,用原本的身分重新檢查。只靠前面過濾,等到人工放行才執行時,條件已經變了。
- ⭐ 資料也可能是指令的一部分。被扣留的送出資料,看起來只是表單內容,若會被程式解讀成要做什麼,就要限制它能影響的範圍。
- ⭐ 修補與事件應變是兩件事。舊版只要曾暴露在網路上,更新完還要查日誌、查帳號、查檔案,缺一不可。
🏆 第九章:這次到底該多緊張?事件綜合評估
| 評估維度(十分制,數字越高代表風險或應對品質越正向) | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 9.8 | 這是 CNA 與 CVE 紀錄的分數;WPSec 另給 7.5。取最保守的 9.8 當作行動依據,因為我沒有程式差異可以判定誰對 🟡 |
| 官方應變速度 | 8 | 2026-10-24 回報、10-30 就有修補版,約六天,加分;官方更新日誌只用一句話交代,沒有逐項對應 CVE,扣一點 |
| 一般站長自救可行性 | 8 | 核心外掛在 WordPress.org,後台點「更新」就好;需要多做的是更新前凍結 Verify、更新後測表單與看帳號 |
| DevOps 技術文件完整度 | 5 | 沒有程式差異、沒有公開的觸發條件、評分與前提互相矛盾,排查只能靠結果特徵 |
| 🏆 加權綜合建議 | 立即更新 | 最終建議:【今天內更新到 3.6.8 以上;更新完成前不按任何 Verify;帳號或檔案稽核出現可疑跡象,走第六章 A】 |
🔥 這種漏洞最危險的時刻,是工具出現、你卻還沒更新的那幾天;現在還沒有公開的攻擊程式,反而是最從容的時間。花五分鐘打開外掛頁看一眼版本號,比事後花五天清後門便宜太多。CISA 把它標成「可自動化」,意思就是別賭它不會被拿去量產 🔢
📎 附錄 A:一頁式決策表(印出來貼在螢幕旁)
| 你看到的狀況 | 先做什麼 | 接著做什麼 |
|---|---|---|
| 版本低於 3.6.8,沒有其他異常 | 先停手不按 Verify,備份 | 更新(C),逐張測表單,再做第五章稽核 |
| 版本低於 3.6.8,今天無法更新 | 走 B:停用外掛,或全站凍結 Verify | 排定更新日期,更新後做第五章 ㊂ ㊄ |
| 出現不認識的管理員或待驗證紀錄 | 不要刪,走 A 取證與止血,陌生管理員先降權 | 換資料庫密碼,再走 C;有個資的網站通知內部資安與法務 |
| uploads 或 mu-plugins 出現 PHP 檔 | 不要刪,走 A | 交給資安專業人員做完整清理 |
| 更新後表單壞掉 | 先查快取(nonce_failed),對照第二章表格 | 不要降版,必要時把該表單下架 |
| 更新後網站白畫面 | 停用外掛(B 選項一) | 用備份到測試站查原因 |
📖 附錄 B:名詞小辭典
- 🔹 被扣留的送出紀錄(held submission):表單送出後先不處理,等管理員確認才繼續往下走的資料。
- 🔹 手動驗證(Verify manually):管理員在後台確認某筆被扣留的紀錄,並讓它繼續執行後續動作的操作。
- 🔹 CVE:漏洞的國際編號,CVE-2026-119481 就是這次的編號。
- 🔹 CVSS:漏洞嚴重度評分,滿分 10 分,9.0 以上屬於 Critical。
- 🔹 CNA:有資格分配 CVE 編號的機構,這次是 Patchstack。
- 🔹 CWE-266/CWE-269:兩種都和「權限給錯」有關的弱點分類,前者是權限指派不當,後者是權限管理不當。
- 🔹 SSVC:CISA 用來判斷漏洞處理優先順序的評估方式,這次的結論是尚無利用、可自動化、技術衝擊為全面。
- 🔹 虛擬補丁:在不改外掛程式的前提下,由防火牆規則擋掉特定攻擊;這次 Patchstack 表示無法提供。
- 🔹 nonce:WordPress 的一次性安全驗證權杖,過期或被快取時會出現 nonce_failed 錯誤。
- 🔹 WP-CLI:WordPress 的命令列工具,用來在主機上管理網站。
- 🔹 鹽值(Salts):wp-config.php 裡八組長字串,用來簽章登入 Cookie。
📚 參考資料與延伸資源
- 📌 WordPress.org|JetFormBuilder 外掛頁(含 3.6.8 更新日誌) [1]
- 📌 Patchstack|JetFormBuilder 3.6.7.1 以下提權漏洞條目 [53]
- 📌 CVE.org|CVE-2026-119481 [54]
- 📌 NVD|CVE-2026-119481 [55]
- 📌 VulDB|CVE-2026-119481 [56]
- 📌 OpenCVE|CVE-2026-12793(3.6.2 以下,另一顆免登入提權漏洞) [57]
- 📌 VulDB|CVE-2026-119480(同在 3.6.8 修補的路徑穿越類漏洞) [58]
- 📌 Crocoblock 說明中心|Form Shows Error on the Front End During Submission [59]
- 📌 WordPress.org 論壇|Unfunctional after the latest update (1.2.0) [60]
- 📌 WP-CLI 官方文件|wp plugin update [61]
- 📌 WP-CLI 官方文件|wp plugin verify-checksums [62]
- 📌 WP-CLI 官方文件|wp plugin auto-updates [63]
- 📌 WP-CLI 官方文件|wp config shuffle-salts [64]
- 📌 WP-CLI 官方文件|wp user reset-password [65]
- 📌 WP-CLI 官方文件|wp user session [66]
- 📌 HestiaCP 官方文件|CLI 參考 [67]
- 📌 HestiaCP 論壇|Custom nginx settings per domain [68]













