🚨Gravity Forms 表單漏洞從看懂到救援一次搞定 🚪 大門換了電子鎖,後門卻忘了換!(CVE-2026-103039/CVSS 9.8)
📮 有人塞了一個「會自己組裝的包裹」給你的網站?Gravity Forms 未登入物件注入漏洞自救全攻略(CVE-2026-103039/CVSS 9.8)
🔥 懶人包:
商業表單外掛 Gravity Forms(2023 年的報導提到有近百萬個網站在用)被登記了 CVE-2026-103039,CVSS 3.1 評分 9.8,弱點類型是不受信任資料的反序列化(CWE-502)。3.1.2(含)以前的版本,會把訪客在特定表單欄位送出的資料,直接交給「不限制物件類別」的函式處理,沒登入的人就能把 PHP 物件塞進你的文章中繼資料 📮
官方 3.1.3(2026-10-01)已經修掉,撰文當天(2026-10-31)的最新版是 3.2.1。先別慌:外掛本身沒有現成的利用鏈,要從「塞進物件」走到「接管網站」,得靠站上其他外掛、佈景主題,或特定的主機設定湊齊條件。不過 3.1.3 比公開揭露早了 28 天上架,公開之後任何人都能拿新舊版本對照,所以這篇帶你看版本、找出入口、備份更新,再查有沒有被摸過 🔍
🌐 點我直達 Gravity Forms 官方更新日誌確認最新版本
內容目錄
🪧 第一章:案發現場還原!一份表單資料,怎麼被當成「組裝說明書」照單全收?
想像你開了一間網拍倉庫,櫃台有位很認真的店員,專門處理客人寄回來的訂單回函。回函上有個「備註清單」欄位,客人一列一列填,店員就照抄進商品卡的備註欄。平常抄到的都是「請附發票」「週末不在家」。直到某天,有個路人寄來的回函,備註欄裡寫的根本不是備註,而是一份「請照這份說明書組裝」的零件圖。店員沒多看一眼,直接動手組了 📦
PHP 想把複雜的資料(陣列、物件)存進資料庫,會先把它「打包」成一長串文字,這叫序列化;要用的時候再「拆開還原」,這叫反序列化。拆的時候如果不限制「允許組裝哪些零件」,寄件的人在說明書裡寫什麼,程式就組什麼,連他自己設計的零件也照組不誤 🧩
先講出事的地方。Gravity Forms 的表單可以直接建立 WordPress 文章,其中有一種欄位叫 Post Custom Field(文章自訂欄位),訪客填的內容會變成文章的自訂欄位。當它的輸入類型選了 List(清單),又打開 Content Template(內容範本)時,送出的值會一路走進 GFFormsModel::create_post()。依 Wordfence 公告的描述,這個函式呼叫的是 WordPress 全域的 maybe_unserialize(),沒有改用 Gravity Forms 在 3.1.1 就加進來、已經把 allowed_classes 設成 false 的安全版 GFCommon::maybe_unserialize()。大門換了電子鎖,後門卻忘了換,大概就是這種感覺 🚪
官方更新日誌沒有逐項說明哪一行修補對應哪個 CVE,也沒有公開程式差異,Wordfence 的公告頁面我這邊也讀不到內容。所以下面這條攻擊鏈,是依多份資料對公告的轉述加上 CVE 紀錄的版本範圍拼出來的輪廓,每一步都標了信心程度 🔍
- ㊀ 站上有一張特定設定的表單:✅ 依公告描述,要有 Post Custom Field,輸入類型是 List,並且啟用 Content Template,這樣訪客送出的值才會原封不動走到有問題的那一行 📝
- ㊁ 訪客不需要登入就能送出:✅ CVSS 向量裡的權限需求是 None、使用者互動是 None,也就是不用帳號,也不用誘騙任何人點連結 🚪
- ㊂ 送出的字串被還原成 PHP 物件,寫進文章的中繼資料:✅ 這就是物件注入本身,外掛層級的直接結果到這裡為止 🗃️
- ㊃ 物件能不能被拿去做壞事,要看站上還有什麼:🟡 外掛自己沒有可直接利用的物件鏈,但站上其他外掛或佈景主題若有,風險會升級成執行任意程式碼 ⚙️
- ㊄ 還有一條走 WordPress 核心的路:🔴 若自訂欄位被對應到受保護的 _wp_page_template,核心的 validate_file() 與 WP_Theme::__toString() 可以被串起來,主機若同時讓 PEAR 目錄可讀、register_argc_argv 又是開啟的,公告說有機會走到遠端程式碼執行。條件相當苛刻,別直接當成「裝了就會中」 🧪
🧊 冷知識:官方更新日誌連續五個版本,安全項目都只有同一句話
3.1.1、3.1.2、3.1.3、3.2.0、3.2.1,每一版的安全欄位都只寫「Added security enhancements」,沒有 CVE 編號,也沒有細節。所以「版本號有沒有過門檻」是唯一能拿來判斷的依據,看更新日誌是看不出來這版修了什麼的 📜
🚨 畫重點:我不會給你攻擊字串
有些報告會寫出欄位長什麼樣、字串該怎麼組。這篇一律不照搬。你只需要記得入口是「公開表單裡的 List 類型 Post Custom Field」,想知道自己有沒有這道門,第五章有直接查資料庫的指令,不必靠任何人公開的字串 🙅
🧊 冷知識:這不是 Gravity Forms 第一次在同一個地方跌倒
2023 年,Patchstack 通報過 CVE-2023-28782:2.7.3 以前的版本,同樣是沒登入的人對 List 欄位送資料,同樣卡在 maybe_unserialize(),2.7.4(2023-04-11)修掉。當時的報導提到外掛本身沒有實用的物件鏈,風險取決於站上其他程式,這段話放到今天幾乎可以原封不動再用一次。差別在於這次依公告的描述,外掛自己已經做好安全版函式,卻漏改了一處 🔁
⏱️ 事件時間軸一次看懂
| 時間點 | 發生了什麼大事 | 站長可信度判定 |
|---|---|---|
| 2023-04-11 | 2.7.4 發布,修掉 CVE-2023-28782(2.7.3 以前,List 欄位的未登入物件注入) | ✅ Bitdefender、Heimdal Security、歐盟 CERT-EU 公告皆有記載 |
| 2026-09-03 | 3.1.1 發布,安全欄位只寫 Added security enhancements | ✅ 官方更新日誌 |
| 2026-09-17 | 3.1.2 發布,同上 | ✅ 官方更新日誌 |
| 2026-10-01 | 3.1.3 發布,安全欄位同上;多份資料指它就是本次漏洞的修補版 | ✅ 發布日期來自官方更新日誌;🟡 「它修了這顆洞」是第三方對照 NVD 版本範圍的結論,官方沒明講 |
| 2026-10-20 | 3.2.0 發布,新增 Repeater 欄位等功能 | ✅ 官方更新日誌 |
| 2026-10-28 | 3.2.1 發布,另外修了「表單存取設定與表單管理權限執行不一致」的問題 | ✅ 官方更新日誌,那一項是獨立的修正,不等於本次 CVE |
| 2026-10-29 | Wordfence 公開漏洞資訊,研究者署名 Tin Pham(TF1T)、Hao Ngo、Trong Pham | 🟡 多份資料一致 |
| 2026-10-30 | CVE-2026-103039 正式發布,NVD 收錄 | 🟡 多份資料一致 |
| 到底有沒有人受害? | 我查到的資料沒有看到公開的攻擊程式碼,也沒有看到被實際利用的報告;CVE 紀錄裡 CISA-ADP 的評估(據轉述)是尚無利用、可自動化、技術衝擊為全面 | 【❓待確認】 |
算一下時間:官方修補版 10 月 1 日上架,Wordfence 10 月 29 日才公開,中間隔了 28 天。好消息是已經有解藥可以打;壞消息是這種「更新日誌只寫一句話」的修補,懂行的人可以直接拿 3.1.2 和 3.1.3 的程式碼逐行比對,找出差在哪。所以時間窗口早在 3.1.3 上架那天就開始倒數,不用等到今天 ⏳
🧊 冷知識:POP Chain 就是「把樂高零件接起來」
物件被塞進網站之後,單獨一塊零件通常什麼事也做不了。但 PHP 物件在被銷毀、被當成字串讀取時,會自動執行特定的方法,如果站上不同外掛、佈景主題裡剛好有幾塊零件能一塊接一塊地觸發,最後就能拼出「寫入檔案」「執行指令」這類危險動作。這種接起來的零件組合,資安圈叫它 POP Chain(Property-Oriented Programming Chain)。Gravity Forms 自己沒有完整的那一條,所以風險大小取決於你的站上還裝了什麼 🧱
- ✅ 已確認事實:受影響版本是 3.1.2(含)以前,3.1.3 以上不在 CVE 公告的受影響範圍;CVSS 向量為 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H,翻成白話:連上網路就能打、難度低、不需要帳號、不需要受害者點擊;官方最新版是 3.2.1。
- 🟡 合理推論:CVSS 把攻擊複雜度標成 Low,但現實裡要打中,得先碰到「有 List 類型 Post Custom Field 並啟用內容範本」的表單,真要拿到執行權限還要再湊物件鏈。我的判斷是:有這種表單的站今天就該處理,沒有的站不必半夜爬起來,但仍建議更新,因為連續五個版本都帶著沒說明內容的安全強化。
- 🔴 假設情境:假設有個活動網站,用 Gravity Forms 收「來賓名單」,每位來賓的資料用 List 欄位一列一列填,再自動建立成後台的文章。這個設定剛好符合前面講的入口。這是我用來說明的例子,不是任何真實站台的紀錄。
🎭 第二章:先別自己嚇自己,對號入座看看你是哪一種網站主?
🩵 小資族荷包試算:這顆洞,最大的坎其實是「授權」
Gravity Forms 是商業外掛,沒有在 WordPress.org 上架。依官方的授權到期說明,授權過期之後外掛仍會留在網站上繼續運作,但收不到更新,連安全更新也一起停,也下載不到核心外掛檔案。換句話說,修補版就在那裡,授權過期的人卻拿不到。續約費用以官方頁面為準,我沒有核對,這裡不報數字。對照事後成本:請人清後門、處理被搜尋引擎標記的流量損失,隨便一項都比續約貴 💸
同一封資安警告,小編、自己租主機的玩家、企業 IT 要做的事差很多。先找到自己在哪一格,再決定往下讀到哪一章 🧭
🙋 我只是負責發文、顧後台的小編或老闆
你不需要懂程式。第三章全程用滑鼠:看版本、備份、按更新、再翻一遍有沒有哪張表單踩中條件。唯一可能卡住你的是授權。後台沒有出現「立即更新」,多半是授權過期或沒有填,這時候要先到官方帳號處理續約,才拿得到新版 🧑💼
👨💻 我自己租主機架站,手上有好幾個客戶網站
直接跳第五章拿工具箱,裡面有整台主機一次盤點的函式,會用各網站擁有者的身分跑 WP-CLI,不會在 root 底下留下一堆權限錯亂的檔案。這顆漏洞只有一條版本門檻(3.1.3),判斷比「每條版本線各有修補版」的漏洞單純,麻煩的是商業外掛的更新管道,下面的 DevOps 小知識有講 🔧
🧑🔧 DevOps 小知識:為什麼 wp plugin update 不一定管用?
一般外掛掛在 WordPress.org,WP-CLI 能直接查到新版,wp plugin verify-checksums 也能拿官方檢查碼比對檔案。Gravity Forms 不在那裡,更新資訊要靠授權金鑰向官方查,所以 WP-CLI 可能看不到新版;官方檢查碼也不一定拿得到,比對不到不代表檔案被竄改。官方另外提供 Gravity Forms CLI 附加元件(外掛代號 gravityformscli),有 wp gf update、wp gf version、wp gf tool verify-checksums 這幾個指令,授權金鑰可以放在 wp-config.php 的 GF_LICENSE_KEY 常數裡(3.1.1 的更新日誌寫明授權檢查會優先讀這個常數)。第六章的修補腳本就是照這個順序設計的 🧱
🏢 我們是企業網站,表單裡收了客戶資料
表單外掛最特別的地方是它碰的就是個資:姓名、電話、需求內容。如果網站有這張符合條件的表單,而且舊版曾暴露過一段時間,就要把它當成「可能被進入過」的等級來處理。個資是否外洩、要不要依《個人資料保護法》通知當事人,請交給公司法務判斷。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺
🚨 冤枉啊!我早就沒在用那個外掛了,只是懶得刪
某間工作室兩年前買了授權,做過一張報名表,案子結束後把外掛停用,資料夾卻一直留在主機上,授權也沒續。管理員心想:「都停用了,總不會憑空打進來吧?」
🕵️♀️ 外掛停用之後,WordPress 不會載入它的程式,漏洞的入口就不在了,風險確實大幅下降(🟡 這是 WordPress 載入機制的推論,我沒有逐版驗證)。但這個狀況有兩個後患:授權過期代表它再也收不到更新,哪天誰順手重新啟用,舊版就原封不動回來;而且那份舊程式碼還躺在主機上,也是白白多一個備胎給攻擊者 🆘
🚥 螢光筆時間:更新、停用、刪除,今天至少選一個
更新到 3.2.1 最好;用不到了就直接刪,需要時再從官方帳號下載;停用只能算暫時的保險,不算處理完畢 🙅♂️
🧮 四種情境對照:你今天該做到哪一步?
| 你的情境 | 風險判斷 | 今天要做的事 | 看哪一章 |
|---|---|---|---|
| 外掛啟用中,有 List 類型的 Post Custom Field 表單,且啟用內容範本 | 最高 | 備份 → 更新 → 檢查中繼資料、帳號與檔案有沒有被動過 | 第三、五、六章 |
| 外掛啟用中,但沒有用 Post Custom Field | 中(依公告描述條件不成立,但我無法保證沒有別的路徑) | 照樣更新,連續五個版本都帶著沒說明內容的安全強化 | 第三、六章 |
| 外掛已停用但沒刪除 | 低 | 直接刪除,或授權有效就更新後再決定要不要啟用 | 第三章 |
| 授權已過期,更新按鈕沒出現 | 同第一或第二列,依表單有無而定 | 續約後更新;來不及就走短期應急 | 第三、六章 B |
🧮 版本對照:你的版本安不安全?
| 版本範圍 | 狀態 | 建議 |
|---|---|---|
| 3.1.2(含)以前 | 🔴 落在 CVE-2026-103039 受影響範圍 | 盡快更新到 3.2.1 |
| 3.1.3 到 3.2.0 | 🟡 已過 CVE 門檻 | 建議升到 3.2.1 |
| 3.2.1(2026-10-28) | 🟢 撰文當天的官方最新版 | 維持,並保持授權有效 |
為什麼 3.1.3 已經過了門檻,我還是建議升到 3.2.1?3.2.1 的更新日誌多了一項「表單存取設定與表單管理權限執行不一致」的修正,這項跟本次 CVE 無關,但同樣是權限相關的問題,值得一起補上。順帶一提,如果你還停在 2.x,從 2.x 跳到 3.x 算是跨大版本,先備份、先在測試站跑一遍再上正式站,這是我的建議,不是官方硬性規定 🧰
😬 更新前先看這個:真實踩過的雷,與 3.1.2 到 3.2.1 會改變的行為
更新是對的,但不要當成「按下去就一定安然無事」。我在 Gravity Forms 的舊論壇與官方社群找到三個公開案例,三個都不是這次 CVE 造成的,放在這裡是提醒你維運時哪些地方容易出狀況。舊論壇的帖子頁面沒有顯示年份,我查不到確切日期,屬於存檔資料,參考它的情境就好 🧪
| 案例 | 發生了什麼 | 給你的提醒 |
|---|---|---|
| 舊論壇:Fatal Error after upgrading to latest version of Gravity Forms | 站長更新後網站直接掛掉。回覆指出原因是佈景主題的 functions.php 自己寫了程式,直接呼叫 Gravity Forms 的函式,而外掛當下沒有啟用,函式不存在,整站就噴 Fatal error。 | 停用外掛之前,先用第五章 ㊂ 的指令看佈景主題有沒有直接呼叫它;停用完馬上打開首頁確認。 |
| 舊論壇:Gravity Forms support for WP-CLI | 發文者用 WP-CLI 管理多個 WordPress 的部署與升級,發現 WP-CLI 偵測不到 Gravity Forms 的更新,只好回後台手動檢查,批次維運的好處就沒了。 | 別假設 wp plugin update 一定看得到新版。第六章的腳本先檢查更新欄位,看不到再走官方 CLI 附加元件。 |
| 官方社群:WP-CLI license update not working | 發文者照指南用指令更新授權金鑰,指令回報成功,後台卻還是不斷跳出請輸入授權的提示。 | 更新完授權,別只看指令的 Success,要回到後台確認提示真的消失,也要確認更新通道能查到新版。 |
官方更新日誌裡,從 3.1.2 走到 3.2.1 這三個版本,有幾項行為變更特別容易踩到你的自訂設定。我把相關項目整理成白話,並標出更新後該測什麼 🔍
| 官方更新日誌項目(摘要) | 白話意思 | 更新後請測 |
|---|---|---|
| 3.2.0:修正 Multi-File Upload 無法用於 Post Custom Field | 用 Post Custom Field 收多檔上傳的表單,行為可能跟以前不同 | 實際上傳檔案送出一次,檢查建立出來的文章 |
| 3.2.0:移除 gform_enable_repeater_field_button 過濾器,Repeater 欄位永遠啟用 | 如果你的自訂程式碼用過這個過濾器,它不會再起作用 | 搜尋佈景主題與外掛有沒有用到這個名稱 |
| 3.2.0:修正值含 & 符號時,Radio/Checkbox 搜尋不到 entry、GFAPI::get_entries() 回傳編碼後的值 | 選項文字裡有 & 的表單,資料的讀取與比對結果可能跟以前不一樣 | 有 & 的選項實際送出,再到 Entries 搜尋 |
| 3.2.1:修正表單存取設定與表單管理權限執行不一致 | 權限判斷的規則變得一致,非管理員角色看得到什麼、碰得到什麼可能跟以前不同 | 用編輯、客服這類非管理員角色登入,實際看一遍表單與 Entries |
| 3.2.1:棄用 gform_progress_steps 過濾器與 IS_ADMIN 常數 | 只是棄用,不是刪除,但自訂程式碼可能開始出現棄用警告 | 看 PHP 錯誤日誌有沒有新的 deprecated 訊息 |
| 3.2.1:merge tag 使用 :urlencode 時,逗號也會被編碼 | 通知信或確認訊息裡帶網址參數的設定,產生的連結內容會略有不同 | 收一封通知信,點開裡面的連結 |
🚨 測出問題的時候,不要降版
把外掛降回 3.1.2 以前,等於把洞重新打開。表單壞掉時,優先檢查是不是上表某一項造成的;正式站來不及處理,就先走第六章 B 的短期應急,把備份拿到測試站慢慢查 🧯
🛟 第三章:電腦小白看過來!五分鐘無痛自救指南
💬 溫馨小叮嚀:更新外掛像是幫手機裝上最新的系統修補,它保證接下來的攻擊打不進來,卻不會回頭幫你檢查昨天有沒有人偷翻過相簿。所以最後兩步(找表單、查名單)一定要做。網站如果存有會員或客戶資料,請把這篇轉給主管或維護人員,由他們判斷要不要做更深的健檢 🩺
每一步我都寫了「正常的樣子」跟「覺得怪怪的該怎麼辦」,全程只需要滑鼠。後台選單名稱可能因 Gravity Forms 版本略有不同,找不到的話,用左上角的搜尋或截圖問維護人員,不要硬點 🤏
- ㊀ 先查水表,看看有沒有裝、現在幾版:
登入後台,左邊選單點「外掛」→「已安裝的外掛」,在搜尋框輸入 Gravity。版本號寫在外掛名稱下方。看到 3.2.1 或更高就是目前最新;3.1.3 到 3.2.0 已經過了這顆漏洞的門檻,建議還是升上去;3.1.2(含)以前就是受影響,繼續往下做。找不到這個外掛,就是沒裝,這篇可以當作別人家的新聞 🔎 - ㊁ 確認授權還有效:
後台左邊的「Forms(表單)」→「Settings(設定)」,頁面上方會有 License Key(授權金鑰)欄位,旁邊會顯示授權狀態。正常會看到狀態是有效;如果顯示過期,更新按鈕多半不會出現,要先到官方帳號續約。這把金鑰等同密碼,請不要貼到公開的聊天室或社群貼文,截圖時也要把它遮起來 🔑 - ㊂ 動手前先留後路(備份):
到主機商後台點「一鍵備份」,或用你平常的備份外掛先存一份。不知道什麼是備份的話,把這篇網址傳給幫你維護網站的人,請他代勞,這本來就是他的工作。沒有備份之前,千萬不要自己亂刪東西 💾 - ㊃ 按下更新:
回到「外掛」→「已安裝的外掛」,Gravity Forms 底下如果有黃色的更新提示,點「立即更新」。沒有提示、但授權確定有效的人,到 Gravity Forms 官方網站的帳號區下載最新的 zip,回後台點「外掛」→「安裝外掛」→「上傳外掛」,選剛下載的檔案,遇到「已安裝」的提示時,選擇用上傳的版本取代目前版本。更新完重新整理頁面,確認版本號跳到 3.2.1 或更高。請只用官方帳號下載的檔案,不要用來路不明的下載站 🔄 - ㊄ 找出你有沒有踩中條件的表單:
這是判斷「這顆洞跟你有沒有關係」的關鍵。點「Forms(表單)」→「Forms」,一張一張打開表單編輯器。左側欄位工具箱裡有一區叫「Post Fields(文章欄位)」,如果表單從來沒用過這一區,通常跟這顆漏洞無關。有的話,點選畫面上標著「Custom Field」的那個欄位,看右側的設定面板:「Field Type(欄位類型)」是不是選了 List?再往下找,有沒有勾選「Create content template(建立內容範本)」?兩個都符合,就是前面講的入口條件。選項的名稱與位置依版本可能略有差異,看不懂就截圖傳給懂行的人,不要自己改設定 📝 - ㊅ 更新完,每張表單都送出一次:
拿一組測試用的假資料,把每一張公開表單實際送出一次,看畫面有沒有出現錯誤、通知信有沒有收到、後台 Entries 有沒有多一筆。上一章表格列的幾項行為變更,最容易在這一步冒出來。如果表單會自動建立文章,也要去「文章」列表看一下建立出來的內容對不對。有壞掉的,先把那張表單所在的頁面轉成草稿,再找維護人員處理 🧪 - ㊆ 今天真的更新不了,怎麼辦:
最乾淨的做法是在外掛清單把它「停用」。有個陷阱要先知道:如果你的佈景主題有直接呼叫 Gravity Forms 的功能,停用之後整個網站可能直接顯示錯誤頁,這是上一章案例一發生過的事。停用完馬上打開首頁看一眼;掛了就重新啟用,改成把有表單的頁面全部轉為草稿當過渡,並在今天內排好更新時間。另外也可以到「Forms」列表,把踩中條件的表單切成 Inactive(停用),但停用的表單會不會拒收直接送來的請求,我沒有查到官方說明,只能當輔助,不能當成修補 🧯 - ㊇ 化身名偵探,查查後台有沒有陌生的東西:
點「使用者」→「管理員」分頁,裡面應該都是你認識的人、用你們公司的信箱。再到「文章」→「所有文章」,依日期排序,看最近有沒有你沒印象的草稿或待審文章,尤其是標題像亂碼、或建立時間集中在凌晨的。這顆漏洞的痕跡會寫進這類文章的自訂欄位裡,所以這一步比看管理員名單更直接。回到「已安裝的外掛」,也看一下有沒有你從沒裝過的外掛名稱。🚨 見鬼的情況:多出沒印象的管理員、陌生外掛,或網站首頁出現奇怪的廣告與導向。看到這類狀況請不要自己刪,先截圖,立刻找主機商或維護人員,並告訴他們依本文第六章 A 處理 🔀
🫂 新手求助專區:看到專有名詞就頭暈怎麼辦?
直接把下面這段話傳給你的主機客服或維護人員:「你好,我的網站有安裝 Gravity Forms 外掛,漏洞編號是 CVE-2026-103039。請協助確認版本是否已更新到 3.2.1 以上,並檢查網站有沒有使用『Post Custom Field』且輸入類型為『List』的表單,以及後台管理員名單、最近新增的文章有沒有異常。」對方看到這串編號就知道該做什麼。請不要把授權金鑰、後台帳號密碼貼進聊天視窗,需要時用對方指定的安全管道交給他。找人幫忙本來就是正常的事,不是你的問題 🤗
🧊 冷知識:「停用」跟「刪除」差在哪?
停用像是把配電盤裡的保險絲拔掉,盒子還掛在牆上,只要有人順手把保險絲插回去,電又通了,問題也跟著回來。刪除是整個配電盤拆走。很久不用的外掛,拆走比拔保險絲安全;還在用的,就老實更新。授權有效的人,要用時再從官方帳號下載就好 🧰
🤿 第四章:技術深潛,一段字串怎麼一路走到「伺服器上的程式」?
只想補洞的人,前三章已經夠用,想知道為什麼的再往下看。先交代資料狀況:官方沒有公開程式差異,Wordfence 的公告頁面我這邊讀不到內容,NVD 的查詢結果在我的環境也是空的。下面的機制,是把多份資料對公告的轉述互相對照、再比對 CVE 紀錄的版本範圍與分數整理出來的,推論的部分我都標了 🟡,查不到的標【❓待確認】⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱與代號 | Gravity Forms,外掛代號(slug)gravityforms |
| CVE 編號 | CVE-2026-103039 |
| CVSS 3.1 | 9.8(Critical) |
| CVSS 向量 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 弱點類型 | CWE-502:不受信任資料的反序列化(PHP 物件注入) |
| 受影響與修補版本 | 3.1.2(含)以前受影響,3.1.3 起修補,撰文當天最新版 3.2.1 |
| 問題所在 | GFFormsModel::create_post() 處理 Post Custom Field(輸入類型 List、啟用內容範本)時,呼叫 WordPress 全域 maybe_unserialize() |
| 利用前提 | 站上有符合上述設定、且訪客能送出的表單(依公告描述) |
| 外掛內的利用鏈 | 沒有完整的 POP Chain(依公告描述) |
| 發現者署名 | Tin Pham(TF1T)、Hao Ngo、Trong Pham(🟡 其中一份資料提供全名,其餘只寫 Tin Pham 等人) |
| 公開時間 | Wordfence 2026-10-29;CVE 與 NVD 2026-10-30(🟡 多份資料一致) |
| 使用規模 | 2023 年的報導提到近百萬個網站,非 WordPress.org 官方統計 |
🧩 四個環節串成一條完整的鏈
- 🏮 環節一:入口是正常功能。表單收訪客資料、再建立成文章,本來就是 Gravity Forms 賣的功能,不是缺陷。問題出在表單資料走到 create_post() 之後,怎麼被「還原」 🚪
- 🏮 環節二:該用安全版,卻用了全域版。Gravity Forms 在 3.1.1 加進自己的 GFCommon::maybe_unserialize(),還原時把 allowed_classes 設成 false。WordPress 全域那支沒有這道限制,字串裡寫什麼類別,就還原成什麼物件 🧾
- 🏮 環節三:物件被寫進文章的中繼資料。這就是物件注入本身。外掛自己沒有可直接利用的鏈,所以光有這一步,頂多是你的資料被塞入不該有的東西 🗃️
- 🏮 環節四:物件什麼時候「醒來」?🟡 這是我的推論,公告沒有逐字寫:WordPress 讀取文章中繼資料時,本身就會對看起來像序列化的內容再還原一次,所以被塞進去的物件,可能要等到某段程式讀取那筆資料時,才真正被組出來,PHP 的魔術方法(例如 __wakeup、__destruct、__toString)才有機會被觸發。這也是第五章的檢查指令全部直接用 SQL 讀原始資料、不用 get_post_meta() 的原因:查的時候別幫對方再「還原」一次 🧱
前面的倉庫店員,原本收到說明書就照組。安全版的做法是換一個規矩:不管說明書寫什麼,零件都只收下來放進玻璃櫃當標本,一個也不組。標本再怪也傷不了人。這次的問題,就是店員在其中一個櫃檯,忘了換成新規矩 🗄️
🧑🔧 DevOps 小知識:CVSS 為什麼是 9.8,現實卻沒那麼容易中?
CVSS 向量裡的攻擊複雜度是 AC:L(低),代表評分時沒有把「得先有這種表單設定」算成額外難度,所以才會一路拉到 9.8。現實裡要拿到執行權限,還要再湊齊物件鏈。我的判斷:9.8 該當成「立即處理」的訊號,但它不等於「裝了就會被接管」。另外,CISA-ADP 的 SSVC 評估(據轉述)是尚無利用、可自動化、技術衝擊為全面,也就是說目前沒看到有人用,但要自動化大規模去試並不難 🛡️
🧑🔧 DevOps 小知識:allowed_classes 從哪一版 PHP 才有?
PHP 7.0 起,unserialize() 才多了第二個參數,可以用 allowed_classes 指定允許還原哪些類別,設成 false 就是全部不允許,遇到物件只會產生一個空殼。更徹底的做法,是資料交換改用 JSON,從源頭避開還原 PHP 物件這件事。這是通用的防禦知識,不是本 CVE 的官方要求 🔧
🧑🔧 DevOps 小知識:那條走 WordPress 核心的路,條件有多苛刻?
公告描述的路徑是:自訂欄位被對應到受保護的 _wp_page_template,核心的 validate_file() 接受非純量的值,接著 WP_Theme::__toString() 會去解析攻擊者可控的路徑。要走到執行程式碼,主機還得同時滿足兩件事:PEAR 的安裝目錄可以被讀取,而且 register_argc_argv 是開啟的。這兩項都是主機層級的設定,不同環境差很多,我沒辦法替你的主機判斷。想自己看,可以用第五章 ㊄ 的 ❶ 查一次頁面範本欄位有沒有長得不像檔名的值 🧪
🚨 畫重點:「完整觸發條件」目前查不到
Content Template 在表單設定的資料庫欄位裡到底叫什麼名字,我沒能查證,所以第五章 ㊂ 的指令刻意不去判斷「有沒有啟用內容範本」,改成先把所有用了 Post Custom Field 的表單全抓出來,再請你進編輯器確認。代價是會有一些誤報,好處是不會因為猜錯欄位名稱,把真正有風險的表單漏掉。另外,「表單一定要嵌在頁面上才能被打到」這件事我也沒查到說明,所以沒有嵌入頁面的表單,一樣當成有風險處理 🧭
🕵️ 第五章:怎麼確認你家有沒有被摸過?排查 Checklist
🤖 重要提醒:以下指令由五份來源報告(ChatGPT、DeepSeek、Gemini、Grok、Meta AI)彙整,經 Claude 複查修正與整理,並依 HestiaCP 官方文件與 WP-CLI 官方文件逐項複查,所有程式碼區塊都做過 bash 語法檢查 🫡
但你的主機環境不一定跟範例一樣。正式環境(尤其是正在營業中的網站)的高風險操作——包含更新、停用、刪除檔案、變更權限、重設密碼或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter。範例中的 /home/admin/web/example.com/public_html 與 admin、bob 都是示範用名稱,請換成你自己的環境 🧙
這一章的流程是:工具箱 👉 盤點版本 👉 找出入口 👉 日誌排查 👉 帳號與檔案稽核。本章所有指令都只讀不寫,不會改任何東西。預期輸出只是典型範例,不同版本的 WP-CLI 與 MySQL 輸出格式可能略有差異,查詢結果的欄位預設以 Tab 分隔 📜
🧰 ㊀ 先貼這一段:整章都會用到的小工具
為什麼這樣做:HestiaCP 的每個網站屬於不同的 Linux 使用者。用 root 直接跑 WP-CLI,會留下 root 擁有的檔案,之後網站自己寫入就會失敗。所以先定義幾個小函式:自動找 WP-CLI(有的主機叫 wp,有的叫 wp-cli)、以檔案擁有者的身分執行、逐站迴圈,還有專門比版本號的 is_fixed。所有路徑變數都用雙引號包起來,路徑含空白也不會斷。最上面的 FIXED 是這顆 CVE 的修補門檻,LATEST 是撰文當天的最新版,之後 Gravity Forms 出了新版,改這兩行就好 🧱
# ㊀ 工具箱:同一個 SSH 連線貼一次就好,斷線後要重貼
SLUG="gravityforms"
FIXED="3.1.3" # 修補門檻:低於這版,就落在 CVE-2026-103039 的受影響範圍
LATEST="3.2.1" # 撰文當天(2026-10-31)的官方最新版,之後有新版請改這一行
# 找出 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" ]
}
is_fixed() { ! version_lt "$1" "$FIXED"; }
is_latest() { ! version_lt "$1" "$LATEST"; }
預期輸出:貼完沒有任何輸出就是成功。看到 ⚠️ 代表缺 WP-CLI 或 runuser,先補上再往下,不要硬跑。
驗證(確認版本判斷正確):貼完後跑下面這段,它只做版本判斷,不碰任何網站 🧪
for v in 3.1.2 3.1.3 3.1.10 3.2.0 3.2.1 3.0.9 2.9.31; do
if ! is_fixed "$v"; then
echo "$v 受影響"
elif ! is_latest "$v"; then
echo "$v 已過門檻,但不是最新"
else
echo "$v 已是最新"
fi
done
預期輸出(3.1.2 以前是「受影響」,3.1.3 到 3.2.0 是「已過門檻」,3.2.1 是「已是最新」):
3.1.2 受影響 3.1.3 已過門檻,但不是最新 3.1.10 已過門檻,但不是最新 3.2.0 已過門檻,但不是最新 3.2.1 已是最新 3.0.9 受影響 2.9.31 受影響
- 🟢 結果符合預期 👉 工具箱沒貼錯,往下進行。
- 🟠 出現 command not found 👉 函式沒貼成功,重新貼一次第一段。
- 🟡 3.1.10 被判成「比 3.2.1 舊」是刻意的:用 sort -V 逐段比數字,不是比字串,不然 3.1.10 會被誤判成比 3.1.3 還舊。
🧑🔧 DevOps 小知識:為什麼迴圈長成 3< <(find …) 這個樣子?
常見寫法 find | while read 有個陷阱:迴圈裡只要有指令會讀標準輸入,就會把清單吃掉一半,結果只處理到第一個網站。改成把 find 的輸出接到檔案描述符 3、用 read … <&3 讀,迴圈裡的指令就搶不到輸入;搭配 -print0 與 read -d ”,路徑含空白也安全。我另外拿三個假網站實測過,三個都有各印一行,沒有只處理到第一個 🔧
🔎 ㊁ 盤點版本:哪些站裝了它、現在哪一版?
為什麼這樣做:一台主機上有十個獨立 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="🔴 受影響"
elif ! is_latest "$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.1.2 狀態:active /home/admin/web/site-a.com/public_html 🟡 過了門檻但不是最新 💠 一般安裝 版本:3.1.3 狀態:active /home/bob/web/shop.example/public_html ⚪ 未安裝 💠 一般安裝 - - /home/bob/web/blog.example/public_html 🟢 已是最新 ✴️ Multisite 版本:3.2.1 狀態:active-network /home/bob/web/network.example/public_html
分支判斷邏輯:
- 🔴 受影響 👉 排入第六章的備份與更新,並接著做 ㊂ 到 ㊄,確認舊版時期有沒有被碰過。
- 🟡 過了門檻但不是最新 👉 這顆 CVE 的條件已解除,仍建議升到最新;如果舊版曾暴露一段時間,也建議做 ㊂ 到 ㊄。
- 🟢 已是最新 👉 版本條件解除,舊版若曾暴露,仍建議做 ㊄。
- 🟡 狀態是 inactive 👉 外掛已停用,入口暫時不在,仍建議更新或刪除,不要放著。
- ✴️ Multisite 出現 active-network 👉 外掛檔案全網共用,查主站一次就代表整個網路,更新也只做一次。
- 🟠 出現 ❓ 載入失敗 👉 先排查 PHP 或資料庫連線,不要直接對這站動手。路徑若像 public_html.bak 之類的備份副本,請人工確認哪一個才是正式站。
📝 ㊂ 找出入口:哪些表單踩中了條件?
為什麼這樣做:前面講過,這顆漏洞要被打到,站上得有用 Post Custom Field 的表單。大海撈針找攻擊字串太累,先弄清楚你家「有沒有這種門」更實際。這段做三件事:直接查資料庫,找出所有用了 Post Custom Field 的表單,並標出其中輸入類型像是 List 的;找出內容裡嵌了 Gravity Forms 短代碼或區塊的頁面;找出佈景主題或其他外掛的程式碼裡有沒有直接呼叫 gravity_form(。表單的設定存在 gf_form_meta 資料表的 display_meta 欄位(官方資料庫結構說明有列),欄位類型與輸入類型的設定名稱可參考官方 Field Object 文件。SQL 用 wp db prefix 取得資料表前綴,不寫死 wp_;Multisite 每個子站各有一套資料表,所以有獨立的 exposure_multisite。這段只讀不寫。最後一點要說明:我沒能查證內容範本在資料庫裡的欄位名稱,所以指令不替你判斷「有沒有啟用」,請照第三章 ㊄ 進編輯器看 🧐
exposure_one() {
local wp_path="$1" url="${2:-}" prefix sql
local -a site_arg=()
[ -z "$url" ] || site_arg=(--url="$url")
printf '\n===== %s %s =====\n' "$(site_domain "$wp_path")" "$url"
prefix="$(wpx "$wp_path" "${site_arg[@]}" db prefix)" || return 1
echo "--- ❶ 用了 Post Custom Field 的表單(has_list = 1 的優先看)---"
sql="$(cat <<EOF
SELECT m.form_id, f.title, f.is_active, f.is_trash,
(m.display_meta LIKE '%"inputType":"list"%'
OR m.display_meta LIKE '%inputType";s:4:"list"%') AS has_list
FROM ${prefix}gf_form_meta AS m
JOIN ${prefix}gf_form AS f ON f.id = m.form_id
WHERE m.display_meta LIKE '%post_custom_field%'
ORDER BY m.form_id;
EOF
)"
wpx "$wp_path" "${site_arg[@]}" db query "$sql" || true
echo "--- ❷ 內容裡嵌了 Gravity Forms 短代碼或區塊的頁面與文章 ---"
wpx "$wp_path" "${site_arg[@]}" post list --post_type=any --post_status=any \
--s='gravityform' --fields=ID,post_type,post_status,post_title --format=table || true
echo "--- ❸ 佈景主題與其他外掛的程式碼裡,直接呼叫 gravity_form( 的檔案 ---"
grep -rIlF --include='*.php' --exclude-dir='gravityforms*' -e 'gravity_form(' -- \
"${wp_path}/wp-content/themes" "${wp_path}/wp-content/plugins" \
"${wp_path}/wp-content/mu-plugins" 2> /dev/null || true
return 0
}
# Multisite:表單資料表每個子站各有一套,所以要逐子站查
exposure_multisite() {
local wp_path="$1" url
while IFS= read -r url <&3; do
exposure_one "$wp_path" "$url" || true
done 3< <(wpx "$wp_path" site list --field=url)
}
# 💠 單一網站
exposure_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站
each_site exposure_one
# ✴️ Multisite(對網路根目錄跑一次)
exposure_multisite "/home/admin/web/network.example.com/public_html"
預期輸出範例:
===== example.com ===== --- ❶ 用了 Post Custom Field 的表單(has_list = 1 的優先看)--- form_id title is_active is_trash has_list 3 來賓名單 1 0 1 5 聯絡我們 1 0 0 --- ❷ 內容裡嵌了 Gravity Forms 短代碼或區塊的頁面與文章 --- +----+-----------+-------------+------------+ | ID | post_type | post_status | post_title | +----+-----------+-------------+------------+ | 42 | page | publish | 來賓報名 | +----+-----------+-------------+------------+ --- ❸ 佈景主題與其他外掛的程式碼裡,直接呼叫 gravity_form( 的檔案 --- /home/admin/web/example.com/public_html/wp-content/themes/mytheme/page-rsvp.php
分支判斷邏輯:
- 🔴 ❶ 的 has_list 是 1,而且 is_active 是 1 👉 這張表單最可能符合入口條件,立刻進編輯器確認有沒有勾內容範本,更新是第一優先,㊃、㊄ 一定要做。
- 🟡 ❶ 有結果但 has_list 是 0 👉 用了 Post Custom Field,但輸入類型多半不是 List,依公告描述前提不成立,仍要更新。這個欄位是用文字比對判斷的,一張表單裡只要別的欄位出現 List 也會被標成 1,所以寧可多抓,不會漏抓。
- 🟡 is_trash 是 1 👉 表單在垃圾桶裡,風險較低,但我沒查到「垃圾桶裡的表單一定不能送出」的說明,建議永久刪除。
- 🟠 ❷ 或 ❸ 有結果 👉 這些表單被嵌在頁面或主題裡,是實際的曝光位置;❸ 有結果的話,停用外掛前要特別小心(第二章案例一)。
- 🟢 三項都沒結果 👉 沒有找到入口。但沒有嵌入頁面的表單也可能被直接送出,我沒查到相關說明,所以不要因此跳過更新。
- 🟠 ❶ 出現資料表不存在的錯誤 👉 前綴不一致、或這個網站根本沒裝過 Gravity Forms,先看 ㊁ 的盤點結果。
📊 ㊃ 日誌排查:看誰一直在敲「不該被敲」的門
為什麼這樣做:官方沒公開攻擊字串,標準存取日誌也不記錄 POST 的內容,只記請求行:方法、網址、狀態碼。所以日誌能回答的問題有限,我只抓四個有價值的結果特徵:哪些網址被 POST 最多(對照 ㊂ 找到的表單頁面,有沒有異常暴衝)、哪些來源 IP 送出最多 POST、有沒有 POST 打到不常見的 PHP 檔、有沒有人碰過 uploads 裡的 PHP 類檔案。HestiaCP 的日誌預設放在 /var/log/nginx/domains/,如果你的架構是 nginx 搭配 Apache,Apache 那一側的日誌在 /var/log/apache2/domains/,函式兩邊都讀,並用 zcat -f 一併讀輪替後的 .log.1 與 .log.2.gz。前面如果有 CDN,日誌裡看到的 IP 可能是 CDN 的 🎯
# 取出某網域「目前+輪替後(含 .gz)」的存取日誌;nginx 與 apache 兩套都讀
scan_domain_log() {
local domain="$1"
find /var/log/nginx/domains /var/log/apache2/domains -maxdepth 1 -type f \
-name "${domain}.log*" -print0 2> /dev/null \
| 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 "--- ❷ 送出 POST 最頻繁的來源 IP(前 10 名)---"
scan_domain_log "$domain" \
| awk -F'"' '$2 ~ /^POST /{ split($1, a, " "); print a[1] }' \
| sort | uniq -c | sort -rn | head -n 10
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
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
--- ❷ 送出 POST 最頻繁的來源 IP(前 10 名)---
398 203.0.113.5
41 198.51.100.1
--- ❸ 打到非常規 PHP 檔的 POST(最近 20 筆)---
203.0.113.7 - - [20/Oct/2026:10:00:01 +0800] "POST /wp-content/plugins/foo/bar.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
--- ❹ 任何人碰過 uploads 底下的 PHP 類檔案(最近 20 筆)---
203.0.113.7 - - [20/Oct/2026:10:00:03 +0800] "GET /wp-content/uploads/2026/10/x.php HTTP/1.1" 200 5 "-" "curl/8.5.0"
分支判斷邏輯:
- 🔴 ❹ 有命中 👉 uploads 裡有人執行過 PHP,視為高度可疑,直接進第六章 A,並同時做 ㊄ 的 ❻。
- 🔴 ❸ 有命中,來源是陌生 IP、User-Agent 像腳本工具 👉 記下 IP 與時間,做完 ㊄;若 ㊄ 也有異常,進第六章 A。
- 🟡 ❶ 的表單網址 POST 數量突然暴增,或 ❷ 有單一 IP 佔了絕大多數 👉 可能是正常流量,也可能是機器人在灌表單,要結合時間與 IP 判讀。我無法確認這顆漏洞在日誌裡的專屬特徵。
- 🟢 都沒命中 👉 只能說目前沒有直接證據,不能證明沒被打過:日誌可能已輪替、前面有 CDN,攻擊也可能沒走你查的這個日誌。繼續做 ㊄。
🕳️ ㊄ 帳號與檔案稽核:有沒有人留下東西
為什麼這樣做:這顆漏洞最直接的痕跡,是被塞進文章中繼資料的序列化物件,所以先查資料側:頁面範本欄位裡有沒有長得不像檔名的值、任何自訂欄位裡有沒有序列化物件的特徵。這兩個查詢直接用 SQL 讀原始資料,刻意避開 get_post_meta(),避免查的時候又幫對方還原一次。接著看管理員、最近註冊的帳號、外掛清單,以及檔案側:uploads 裡的 PHP、會自動載入的 mu-plugins、揭露前後被改過的 PHP,最後做檢查碼比對。掃描起點我設在 3.1.3 悄悄上架的 2026-10-01,因為從那天起,有心人就能拿新舊版本對照;想更保守就把 SINCE 往前調。資料量大的網站,REGEXP 查詢可能要跑幾十秒,建議挑離峰時間 🕵️♀️
# 掃描起點:3.1.3 悄悄上架的日子(2026-10-01)。想更保守就往前調
SINCE="2026-10-01"
# 資料側:直接用 SQL 讀原始資料,不經過 get_post_meta(),避免查的時候又反序列化一次
meta_audit_one() {
local wp_path="$1" url="${2:-}" prefix sql
local -a site_arg=()
[ -z "$url" ] || site_arg=(--url="$url")
printf '\n----- 資料表前綴與子站:%s -----\n' "${url:-(主站)}"
prefix="$(wpx "$wp_path" "${site_arg[@]}" db prefix)" || return 1
echo "--- ❶ 頁面範本欄位(_wp_page_template):值不像檔名的項目 ---"
sql="$(cat <<EOF
SELECT pm.meta_id, pm.post_id, p.post_type, p.post_status, p.post_date_gmt,
LEFT(pm.meta_value, 120) AS value_preview
FROM ${prefix}postmeta AS pm
JOIN ${prefix}posts AS p ON p.ID = pm.post_id
WHERE pm.meta_key = '_wp_page_template'
AND pm.meta_value NOT REGEXP '^[A-Za-z0-9_/.-]+\$'
ORDER BY pm.meta_id DESC
LIMIT 50;
EOF
)"
wpx "$wp_path" "${site_arg[@]}" db query "$sql" || true
echo "--- ❷ 任何自訂欄位裡出現「序列化物件」特徵(O:數字:\"類別名\")---"
sql="$(cat <<EOF
SELECT pm.meta_id, pm.post_id, pm.meta_key, p.post_type, p.post_status,
LEFT(pm.meta_value, 120) AS value_preview
FROM ${prefix}postmeta AS pm
JOIN ${prefix}posts AS p ON p.ID = pm.post_id
WHERE pm.meta_value REGEXP 'O:[0-9]+:"'
ORDER BY pm.meta_id DESC
LIMIT 50;
EOF
)"
wpx "$wp_path" "${site_arg[@]}" db query "$sql" || true
return 0
}
meta_audit_multisite() {
local wp_path="$1" url
while IFS= read -r url <&3; do
meta_audit_one "$wp_path" "$url" || true
done 3< <(wpx "$wp_path" site list --field=url)
}
audit_one() {
local wp_path="$1" opt
printf '\n===== %s(擁有者:%s)=====\n' "$wp_path" "$(site_owner "$wp_path")"
if wpx "$wp_path" core is-installed --network 2> /dev/null; then
meta_audit_multisite "$wp_path"
else
meta_audit_one "$wp_path"
fi
echo "--- ❸ 全部管理員 ---"
wpx "$wp_path" user list --role=administrator \
--fields=ID,user_login,user_email,user_registered --format=table
echo "--- ❹ 最近註冊的 10 個帳號(任何角色)---"
wpx "$wp_path" user list --orderby=registered --order=DESC --number=10 \
--fields=ID,user_login,user_email,user_registered,roles --format=table
echo "--- ❺ 外掛清單(有沒有你不認識的外掛)---"
wpx "$wp_path" plugin list --fields=name,status,version --format=table
echo "--- ❻ uploads 底下的 PHP 類檔案(正常網站應該是空的)---"
find "${wp_path}/wp-content/uploads" -type f \
\( -iname '*.php*' -o -iname '*.phtm*' -o -iname '*.pht' -o -iname '*.phar' \) \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
echo "--- ❼ mu-plugins 與 wp-content 根目錄的 PHP(會自動載入,後台外掛頁看不到)---"
find "${wp_path}/wp-content/mu-plugins" -maxdepth 2 -type f \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
find "${wp_path}/wp-content" -maxdepth 1 -type f -name '*.php' \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort
echo "--- ❽ ${SINCE} 之後被改過的 PHP(外掛正常更新也會出現,需人工比對)---"
find "${wp_path}/wp-content" -type f -name '*.php' -newermt "$SINCE" \
-not -path '*/cache/*' \
-printf '%TY-%Tm-%Td %TH:%TM %u %p\n' 2> /dev/null | sort | tail -n 100
echo "--- ❾ 排程工作(有沒有不認識的 hook)---"
wpx "$wp_path" cron event list --fields=hook,next_run_gmt,recurrence --format=table
echo "--- ❿ 檔案跟官方版本比對 ---"
wpx "$wp_path" core verify-checksums || true
if wpx "$wp_path" cli has-command gf > /dev/null 2>&1; then
wpx "$wp_path" gf tool verify-checksums || true
else
echo "(沒有裝 Gravity Forms CLI 附加元件,Gravity Forms 本體無法用官方檢查碼比對)"
fi
echo "--- ⓫ 幾個常被動手腳的網站設定 ---"
for opt in siteurl home admin_email users_can_register default_role; do
printf '%s = %s\n' "$opt" "$(wpx "$wp_path" option get "$opt")"
done
return 0
}
# 💠 單一網站
audit_one "/home/admin/web/example.com/public_html"
# ❇️ 獨立多站:逐站各自檢查
each_site audit_one
# ✴️ Multisite:帳號要多看一層「超級管理員」
wpx "/home/admin/web/network.example.com/public_html" super-admin list
預期輸出範例(正常的樣子,節錄):
===== /home/admin/web/example.com/public_html(擁有者:admin)===== ----- 資料表前綴與子站:(主站) ----- --- ❶ 頁面範本欄位(_wp_page_template):值不像檔名的項目 --- --- ❷ 任何自訂欄位裡出現「序列化物件」特徵(O:數字:"類別名")--- --- ❸ 全部管理員 --- +----+------------+-------------------+---------------------+ | ID | user_login | user_email | user_registered | +----+------------+-------------------+---------------------+ | 1 | admin | admin@example.com | 2024-01-15 08:00:00 | +----+------------+-------------------+---------------------+ --- ❻ uploads 底下的 PHP 類檔案(正常網站應該是空的)--- --- ❿ 檔案跟官方版本比對 --- Success: WordPress installation verifies against checksums. (沒有裝 Gravity Forms CLI 附加元件,Gravity Forms 本體無法用官方檢查碼比對)
如果有異常,❷ 會多出像這樣的列(欄位以 Tab 分隔,這裡的物件內容只是格式示範):
meta_id post_id meta_key post_type post_status value_preview
48211 903 rsvp_guests post draft O:8:"stdClass":0:{}
❶ 與 ❷ 沒有輸出,代表那一段沒查到東西,正常網站就是這樣。❿ 的第二行是因為沒裝官方 CLI 附加元件;有裝的話,會多一段 Gravity Forms 自己的檢查碼比對結果。付費外掛不在 WordPress.org,其他付費外掛在核心與外掛比對時常會出現無法驗證的提示,這很正常,實際文字依 WP-CLI 版本而異 🧾
分支判斷邏輯:
- 🔴 ❶ 出現 O:數字:”類別名” 或 a: 開頭的值,或 ❷ 在你沒預期的文章(尤其是 Gravity Forms 自動建立的文章)出現序列化物件 👉 這是這顆漏洞最直接的痕跡,先別動,截圖並保留資料,進第六章 A。注意有些外掛本來就會把序列化資料存進自訂欄位,要看類別名稱和文章來源判斷,看不懂就交給專業人員。
- 🔴 ❸❹ 出現 2026-10-01 之後註冊、你不認識的帳號 👉 高度可疑。陌生不等於惡意,可能是外包留的,先問清楚再處理。
- 🔴 ❻ 找到 PHP 類檔案,或 ❼ 出現不是你部署的檔案 👉 視為已中招,先別刪,保留證據,進第六章 A。
- 🟡 ❽ 揭露日前後被改過的 PHP 👉 外掛正常更新也會出現,要對照你自己的維運紀錄,別看到就當成入侵。
- 🟡 ❺ 與 ❾ 出現不認識的外掛或排程 hook 👉 先查那是哪個外掛、誰裝的,查不出來就當成可疑。
- 🟡 ❿ 出現 File was added 或檢查碼不符 👉 先確認是不是你自己放的客製檔案;不確定就當成可疑。
- 🔴 ⓫ 的 users_can_register 變成 1、default_role 變成 administrator,或 siteurl/home 被改成陌生網域 👉 網站設定被動過手腳,這類改動很少是正常維運造成的。
- 🟢 全部乾淨 👉 這一輪沒有發現明確跡象,仍建議做第六章的更新與第七章的加固,並多觀察一週。
📋 ㊅ 三環境速查表:把這一章濃縮成一張
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 工具箱 | 貼一次,整個 SSH 連線通用 | ||
| ② 盤點版本 | inventory_one 路徑 | each_site inventory_one | 查主站一次,狀態看 active-network |
| ③ 找出入口 | exposure_one 路徑 | each_site exposure_one | exposure_multisite 網路根目錄,逐子站查表單 |
| ④ 日誌排查 | 單一網域 | 掃所有網域日誌 | 共用網域日誌,再依 Host 回推子站 |
| ⑤ 帳號與檔案 | 資料/管理員/檔案 | 逐站各自檢查,不能拿 A 站代表 B 站 | 資料側自動逐子站,另加 super-admin list |
🛠️ 第六章:疑似中招、還不能更新、準備修補,三條路各走各的
前一章解決「怎麼判斷」,這一章才動手。主線固定是 備份 👉 預覽 👉 更新 👉 驗證;第五章若出現跡象,要先走 A 取證與止血。這一章的指令會改動系統狀態,所以每個動作後面都接著「怎麼確認真的生效」,以及失敗時的下一步。以下沿用第五章貼好的工具箱,沒貼的話請先回去貼一次 🧹
💾 ㊀ 三個共用零件:備份、刷新鹽值、停用外掛
為什麼這樣做:A、B、C 都會用到其中一兩個,所以先定義成函式,這一步只有定義,不會執行任何動作。備份檔放在 root 家目錄、權限 700,不放在網站目錄,避免被網址直接下載;備份除了資料庫,也把 Gravity Forms 外掛資料夾打包,遇到狀況時能拿來比對。鹽值(Salts)是 wp-config.php 裡那八組長字串,WordPress 用它們替登入 Cookie 簽章。wp config shuffle-salts 會把它們全部換新,所有已登入的人,包含可能握有 Cookie 的攻擊者,立刻被登出。代價是正常使用者包含你自己都要重新登入 🔑
你只改了某位住客的房號密碼,偷了房卡的人手上那張卡可能還刷得開門。重設加密金鑰,等於宣布「舊房卡今天起全部失效」,不管是誰拿著,都得回櫃台重新領。比單純改密碼徹底,也比較擾民,所以不是每次更新都要做 🛎️
那到底什麼狀況要刷?我的判斷依嚴重程度分三級:
| 你的狀況 | 要不要刷鹽值 | 理由 |
|---|---|---|
| 第五章 ㊃㊄ 出現明確跡象(uploads 有 PHP、陌生管理員、序列化物件) | 要,而且連管理員密碼一起重設 | 攻擊者可能已經拿到登入憑證,A 流程已內建 |
| 沒有跡象,但舊版曾公開暴露,而且 ㊂ 找到符合條件的表單 | 建議刷一次 | 成本只是大家重新登入一次,換得「萬一有人拿過憑證,現在作廢」 |
| 沒有跡象,也沒有符合條件的表單 | 不必 | 沒有理由登出所有人 |
# ㊀ 共用零件一:備份(資料庫+Gravity Forms 外掛資料夾),任何一步失敗就回傳失敗
BACKUP_DIR="${HOME}/wp-security-backup"
backup_one() {
local wp_path="$1" domain stamp db_file plugin_file
domain="$(site_domain "$wp_path")"
stamp="$(date +%Y%m%d-%H%M%S)"
db_file="${BACKUP_DIR}/${domain}_db_${stamp}.sql"
plugin_file="${BACKUP_DIR}/${domain}_${SLUG}_${stamp}.tar.gz"
install -d -m 700 -- "$BACKUP_DIR" || return 1
df -h -- "$BACKUP_DIR" | tail -n 1
( umask 077; wpx "$wp_path" db export - --single-transaction > "$db_file" )
if [ ! -s "$db_file" ]; then
echo "❌ 資料庫備份失敗或是空檔,停止,不進行任何變更"
return 1
fi
if ! tail -n 1 -- "$db_file" | grep -q 'Dump completed'; then
echo "⚠️ 備份檔結尾沒有 Dump completed 字樣,請人工確認檔案是否完整"
fi
tar -czf "$plugin_file" -C "${wp_path}/wp-content/plugins" "$SLUG" \
|| { echo "❌ 外掛資料夾備份失敗,停止"; return 1; }
chmod 600 -- "$plugin_file"
ls -lh -- "$db_file" "$plugin_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
}
# 停用 Gravity Forms(Multisite 連全網與各子站一起處理)
deactivate_one() {
local wp_path="$1" url status home_url code left=0
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)"
printf '%s:%s\n' "$SLUG" "${status:-未安裝}"
case "$status" in
active|active-network) left=1 ;;
esac
# 驗證二:首頁還打得開(佈景主題若直接呼叫了 Gravity Forms 的函式,停用後可能整站掛掉)
home_url="$(wpx "$wp_path" option get home)"
code="$(curl -sSL -o /dev/null -w '%{http_code}' --max-time 20 "${home_url%/}/" || true)"
case "$code" in
2*|3*) printf '首頁 HTTP %s,正常\n' "$code" ;;
*) printf '⚠️ 首頁 HTTP %s:停用後網站有問題,補救見下方分支判斷\n' "${code:-無回應}" ;;
esac
if [ "$left" -eq 1 ]; then
echo "❌ 仍是啟用狀態,請看上方輸出"
return 1
fi
return 0
}
預期輸出:貼完沒有任何輸出就是成功。三個函式(backup_one、rotate_salts_one、deactivate_one)要等到 A、B 或 C 才會被叫用,這時候的輸出範例如下:
/dev/vda1 80G 22G 54G 29% / -rw------- 1 root root 485M Oct 31 10:30 /root/wp-security-backup/example.com_db_20261031-103022.sql -rw------- 1 root root 18M Oct 31 10:30 /root/wp-security-backup/example.com_gravityforms_20261031-103022.tar.gz Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin Plugin 'gravityforms' deactivated. gravityforms:inactive 首頁 HTTP 200,正常
驗證與失敗分支:
- 🟢 備份:兩個檔案都在、大小合理、資料庫檔結尾有 Dump completed 👉 可以進下一步。這個結尾字樣是 mysqldump 與 MariaDB 的慣例輸出,若你的環境用別的工具匯出,字樣可能不同,腳本只會警告,不會擋你。
- 🟠 備份失敗或是空檔 👉 函式立刻停下,先查磁碟空間(已印出 df 結果)與資料庫權限,沒有備份不要往下。
- 🟢 鹽值:前後兩段雜湊不同 👉 已生效,你會被登出,重新登入即可。
- 🟠 鹽值出現 ❌「沒有改變」或指令本身失敗 👉 先確認 wp-config.php 的擁有者可寫;如果鹽值是定義在別的檔案而不是 wp-config.php 裡,這個指令改不到,要請維運人員處理。指令要連到 WordPress.org 的 API 取得新鹽值,主機連不出去也會失敗,先查網路。
- 🔴 刷完網站出現「建立資料庫連線時發生錯誤」 👉 用備份還原:cp -p “備份資料夾/網域_wp-config.php.時間” “網站路徑/wp-config.php”,備份檔名在上面的 ls 輸出旁邊的資料夾裡找。這份備份含資料庫密碼,確認一切正常後再決定要不要保留。
- 🟢 停用:狀態是 inactive,首頁 HTTP 為 2xx 或 3xx 👉 成功。Multisite 會連網路層與各子站一起處理。
- 🟠 停用後首頁不是 2xx/3xx 👉 佈景主題可能直接呼叫了 Gravity Forms 的函式(第二章案例一)。先別慌,用網站擁有者的身分執行 wpx “網站路徑” plugin activate gravityforms 恢復網站,再改走 B 的「頁面轉草稿」,並把這件事交給維護人員處理。重新啟用代表門又開了,所以要盡快排更新。
🚨 ㊁ A. 疑似中招:先留證據,再止血,最後才修補
為什麼這樣做:第五章出現明確跡象時,第一優先是保留現場,更新可以晚一步。順序是:資料庫快照與日誌、可疑資料與檔案 👉 停用外掛 👉 刷新鹽值 👉 管理員密碼重設並登出所有階段。先拍照存證,再關門,再讓所有舊憑證作廢,最後處理帳號。腳本裡有一道保險:停用沒有成功,就會直接中止,不會繼續往下換鹽值或重設密碼,免得入口還開著,你卻已經把自己鎖在門外。如果攻擊者已經能執行程式,他可能讀過 wp-config.php,所以結束時會提醒你接著換資料庫密碼 🧯
這支腳本會動到外掛、鹽值與帳號,請先確認路徑,而且只對有疑慮的那一站執行,不要整台主機無差別跑。新密碼會顯示在終端機並留在捲動紀錄裡,請在私密環境操作,用安全管道交給管理員本人,並請他登入後立刻自行更換,不要貼進聊天視窗或腳本。這裡也要主動提醒:取證資料夾裡有資料庫快照與 wp-config.php 的備份,內含個資與資料庫密碼,權限雖已設為 700/600,仍請妥善保管,用完不要隨手傳給不相干的人 🔐
# 前提:已貼過「工具箱」「共用零件」「deactivate_one」「meta_audit_one」。取證完成前,什麼都不要刪
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}/gf-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 /var/log/apache2/domains -maxdepth 1 -type f \
-name "${domain}.log*" -print0 2> /dev/null \
| tar -czf "${out}/logs_${stamp}.tar.gz" --null -T - 2> /dev/null || true
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
meta_audit_one "$wp_path" > "${out}/suspect-meta.txt" 2>&1 || 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"
install -m 600 -- "${wp_path}/wp-config.php" "${out}/wp-config.php.BAK.${stamp}"
echo "=== ❷ 隔離:停用 Gravity Forms ==="
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"
預期輸出範例:
=== ❷ 隔離:停用 Gravity Forms === Plugin 'gravityforms' deactivated. gravityforms:inactive 首頁 HTTP 200,正常 === ❸ 強制刷新金鑰與鹽值(含攻擊者手上的 Cookie 一併失效)=== Success: Shuffled the salt keys. ✅ example.com 鹽值已更新(3fa9c1d2e8b4 → 91c07a5be2d6),wp-config.php 權限:640 admin:admin === ❹ 管理員名單與密碼重設 === (列出管理員後詢問是否重設,選 y 會顯示每位管理員的新隨機密碼) ✅ example.com 止血完成,證據放在 /root/gf-incident-20261031-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 金鑰等放在資料庫或設定檔的憑證一併換掉。
- 🔴 更新前的備份可能已經含有後門,不能當成「乾淨的還原來源」;要還原請用事發前、已知乾淨的備份。
🔑 補一刀:換掉資料庫密碼,並讓 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 還原。
- 🟡 網站正在對外服務卻還沒完成這一步 👉 會有短暫的資料庫連線錯誤,屬預期,完成後即恢復。
- 🟡 Gravity Forms 的授權金鑰若寫在 wp-config.php 的 GF_LICENSE_KEY 或存在資料庫,也算被讀到的範圍。能不能重新產生金鑰、要怎麼處理,我沒有查到官方說明,請到官方帳號查看或聯絡官方支援。
🧯 ㊂ B. 短期應急:還沒確認中招,但現在還不能更新
🚨 短期應急 ≠ 正式修補。
停用外掛與下架表單頁面,只是縮短暴露時間。真正的修補仍然是更新到 3.1.3 以上,建議 3.2.1。我刻意沒有提供 nginx 擋規則:入口是一般的表單送出請求,沒有固定的網址特徵可以擋;而且常見的寫法是用 $request_body 在 if 裡比對內容,但在請求處理的早期階段,這個變數通常還是空的,這類條件多半擋不到東西,只會給人錯誤的安心感 🧱
選項一(最乾淨):停用外掛。直接叫用 ㊀ 貼好的 deactivate_one。Multisite 會連全網和各子站一起處理,最後驗證狀態與首頁 🧯
# 💠 單一網站(路徑換成你的;Multisite 請對網路根目錄跑一次) deactivate_one "/home/admin/web/example.com/public_html"
預期輸出範例:
Plugin 'gravityforms' deactivated. gravityforms:inactive 首頁 HTTP 200,正常
選項二(較弱):只把嵌了表單的頁面轉成草稿。適合選項一會讓佈景主題報錯的網站。頁面 ID 請拿第五章 ㊂ 的 ❷ 結果;我無法確認把頁面下架後,攻擊者是否仍能直接對表單的處理端點送出請求,所以這只能當最後手段,而且要盡快排更新。驗證指令確認狀態真的變成 draft 🧩
WP_PATH="/home/admin/web/example.com/public_html" # 把第五章 ㊂ 找到的、嵌了表單的頁面(以 ID 42 為例)轉成草稿 wpx "$WP_PATH" post update 42 --post_status=draft # 驗證:狀態要變成 draft wpx "$WP_PATH" post get 42 --field=post_status
預期輸出範例:
Success: Updated post 42. draft
- 🟢 選項一:狀態 inactive、首頁 2xx 或 3xx 👉 暫時安全,立刻排更新。
- 🟠 選項一:首頁不是 2xx/3xx 👉 見 ㊀ 的失敗分支,改走選項二,並交給維護人員。
- 🟡 選項二:只代表降低風險,不代表安全。表單若也被佈景主題直接呼叫(第五章 ㊂ 的 ❸ 有結果),轉草稿沒有用,只能走選項一。
- 🟡 要把頁面還原時,把 –post_status=draft 換成 –post_status=publish,驗證指令確認狀態回到 publish。
🚀 ㊃ C. 正式修補:備份 👉 預覽 👉 更新 👉 驗證
為什麼這樣做:Gravity Forms 的更新管道比一般外掛麻煩,所以腳本設計成三條路擇一:你提供官方 zip 就用 zip 覆蓋安裝;沒提供,就先看 WP-CLI 的更新欄位有沒有顯示新版,有的話走 plugin update(先預覽再確認);看不到新版時,再看有沒有裝官方 CLI 附加元件,有的話走 wp gf update。三條路都不通,腳本會明講,叫你去官方帳號下載 zip。每一次更新都要你輸入 y 才會動手,讀不到輸入(例如被排程呼叫)一律當成不同意,所以這支腳本必須有人在場。腳本用 heredoc 建立,結束標記用單引號包住,裡面的 $ 不會被提前展開,建好後立刻做語法檢查。千萬不要為了「保險」而降版,降回舊版等於把洞重新打開 📦
cat > /usr/local/sbin/gf-patch.sh <<'GF_PATCH_EOF'
#!/usr/bin/env bash
# gf-patch.sh:單一網站的 Gravity Forms 修補流程(備份 → 預覽 → 確認 → 更新 → 驗證)
# 用法:gf-patch.sh 網站使用者 網域 [官方 zip 的完整路徑]
set -euo pipefail
SLUG="gravityforms"
FIXED="3.1.3"
LATEST="3.2.1"
WPCLI="$(command -v wp || command -v wp-cli || true)"
if [ -z "$WPCLI" ]; then
echo "❌ 找不到 wp 或 wp-cli" >&2
exit 1
fi
if [ "$#" -lt 2 ] || [ "$#" -gt 3 ]; then
echo "用法:$0 網站使用者 網域 [官方 zip 的完整路徑]" >&2
exit 2
fi
SITE_USER="$1"
SITE_DOMAIN="$2"
ZIP_FILE="${3:-}"
[[ "$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" ]
}
get_ver() { wpx plugin get "$SLUG" --field=version 2> /dev/null || true; }
get_status() { wpx plugin get "$SLUG" --field=status 2> /dev/null || true; }
# 確認動作:只有明確輸入 y 才算同意;讀不到輸入(例如被排程呼叫)一律當成不同意
confirm() {
local ans=""
read -r -p "$1(y/N):" ans || true
[ "$ans" = "y" ]
}
cur_ver="$(get_ver)"
if [ -z "$cur_ver" ]; then
echo "⚪ ${SITE_DOMAIN} 沒有安裝 Gravity Forms,不需處理"
exit 0
fi
prev_status="$(get_status)"
printf '目前版本:%s(狀態:%s)\n' "$cur_ver" "${prev_status:--}"
if version_lt "$cur_ver" "$LATEST"; then
echo "🔴 ${SITE_DOMAIN} 版本低於 ${LATEST},開始處理"
# ❶ 備份:資料庫+外掛資料夾,備份失敗就停,不進行任何更新
BACKUP_DIR="${HOME}/wp-security-backup"
STAMP="$(date +%Y%m%d-%H%M%S)"
DB_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_db_${STAMP}.sql"
PLUGIN_FILE="${BACKUP_DIR}/${SITE_DOMAIN}_${SLUG}_${STAMP}.tar.gz"
install -d -m 700 -- "$BACKUP_DIR"
( umask 077; wpx db export - --single-transaction > "$DB_FILE" )
if [ ! -s "$DB_FILE" ]; then
echo "❌ 資料庫備份是空檔,停止,沒有進行任何更新" >&2
exit 1
fi
if ! tail -n 1 -- "$DB_FILE" | grep -q 'Dump completed'; then
echo "⚠️ 備份檔結尾沒有 Dump completed 字樣,請人工確認後再繼續"
fi
tar -czf "$PLUGIN_FILE" -C "${WP_PATH}/wp-content/plugins" "$SLUG"
chmod 600 -- "$PLUGIN_FILE"
echo "✅ 備份完成:${DB_FILE}"
echo "✅ 備份完成:${PLUGIN_FILE}"
# ❷ 更新:官方 zip → WP-CLI 看得到更新 → 官方 CLI 附加元件,三條路擇一
if [ -n "$ZIP_FILE" ]; then
if [ ! -f "$ZIP_FILE" ]; then
echo "❌ 找不到 ${ZIP_FILE}" >&2
exit 1
fi
if ! runuser -u "$SITE_USER" -- test -r "$ZIP_FILE"; then
echo "❌ 網站使用者 ${SITE_USER} 讀不到 ${ZIP_FILE},請把檔案放在 /tmp 再試" >&2
exit 1
fi
if confirm "確認要用 ${ZIP_FILE} 覆蓋安裝嗎?"; then
wpx plugin install "$ZIP_FILE" --force || echo "❌ 覆蓋安裝失敗,請看上方輸出"
else
echo "已略過更新"
fi
else
update_state="$(wpx plugin list --name="$SLUG" --field=update 2> /dev/null || true)"
if [ "$update_state" = "available" ]; then
echo "--- 預覽(不會真的更新)---"
wpx plugin update "$SLUG" --dry-run
if confirm "確認要更新嗎?"; then
wpx plugin update "$SLUG" || echo "❌ 更新指令失敗,請看上方輸出"
else
echo "已略過更新"
fi
elif wpx cli has-command gf > /dev/null 2>&1; then
echo "🟡 WP-CLI 看不到更新(多半是授權或更新通道的問題),改走官方 CLI 附加元件的 wp gf update"
if confirm "確認要執行 wp gf update 嗎?"; then
wpx gf update || echo "❌ wp gf update 失敗,請看上方輸出"
else
echo "已略過更新"
fi
else
echo "🟠 WP-CLI 看不到更新,也沒有裝官方 CLI 附加元件。"
echo " 請到 Gravity Forms 官方帳號下載最新 zip,放到 /tmp,再把路徑接在這支腳本的最後面重跑。"
fi
fi
fi
# ❸ 驗證:版本、啟用狀態、檔案完整性、首頁
echo "--- 驗證 ---"
fail=0
new_ver="$(get_ver)"
if [ -z "$new_ver" ]; then
echo "❌ 讀不到版本" >&2
fail=1
elif version_lt "$new_ver" "$FIXED"; then
echo "❌ ${SLUG}:${new_ver} 仍低於 ${FIXED},漏洞還在" >&2
fail=1
elif version_lt "$new_ver" "$LATEST"; then
echo "🟡 ${SLUG}:${new_ver} 已過 CVE 門檻,但比 ${LATEST} 舊"
else
echo "✅ ${SLUG}:${new_ver}"
fi
new_status="$(get_status)"
if [ -n "$prev_status" ] && [ "$new_status" != "$prev_status" ]; then
printf '❌ 外掛狀態從 %s 變成 %s,表單可能沒在運作,確認後再用 wp plugin activate 啟用\n' \
"$prev_status" "${new_status:--}" >&2
fail=1
fi
if wpx cli has-command gf > /dev/null 2>&1; then
wpx gf tool verify-checksums || { echo "🟠 官方檢查碼比對沒通過,請看上方輸出" >&2; fail=1; }
else
echo "(沒有裝官方 CLI 附加元件,略過 Gravity Forms 檢查碼比對)"
fi
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
exit "$fail"
GF_PATCH_EOF
chown root:root /usr/local/sbin/gf-patch.sh
chmod 750 /usr/local/sbin/gf-patch.sh
bash -n /usr/local/sbin/gf-patch.sh && echo "語法檢查通過"
驗證(確認腳本建好了):最後一行預期印出「語法檢查通過」。沒有這行,就代表 heredoc 內容有缺漏,用 ls -l /usr/local/sbin/gf-patch.sh 看檔案是否存在,再重新貼一次整段 🧪
使用方式:一次一站,先處理一站、確認前後台正常,再處理下一站。把第五章盤點出的 🔴 站,逐一用「使用者、網域」當參數跑:
# 授權有效、WP-CLI 看得到更新,或已裝官方 CLI 附加元件:兩個參數 /usr/local/sbin/gf-patch.sh admin example.com # WP-CLI 與 CLI 附加元件都走不通:先把官方 zip 放在 /tmp,把路徑接在最後面 # (把下面這行開頭的 # 拿掉再執行) # /usr/local/sbin/gf-patch.sh admin example.com /tmp/gravityforms.zip
預期輸出範例:
目前版本:3.1.2(狀態:active) 🔴 example.com 版本低於 3.2.1,開始處理 ✅ 備份完成:/root/wp-security-backup/example.com_db_20261031-111500.sql ✅ 備份完成:/root/wp-security-backup/example.com_gravityforms_20261031-111500.tar.gz --- 預覽(不會真的更新)--- (dry-run 的預覽表格,實際文字依 WP-CLI 版本而異) 確認要更新嗎?(y/N):y Success: Updated 1 of 1 plugins. --- 驗證 --- ✅ gravityforms:3.2.1 (沒有裝官方 CLI 附加元件,略過 Gravity Forms 檢查碼比對) Success: The cache was flushed. ✅ example.com:首頁 HTTP 200
- 🟢 看到最後的 ✅ 👉 這一站修補完成。接著手動清網站快取與 CDN 快取,並照第三章 ㊅ 把每張公開表單送出一次。
- 🟠 備份失敗 👉 腳本直接停下,沒有任何更新,先查磁碟空間與資料庫權限。
- 🟠 印出「WP-CLI 看不到更新,也沒有裝官方 CLI 附加元件」 👉 到官方帳號下載 zip,放在 /tmp,把路徑接在腳本最後面再跑一次。
- 🟡 印出「改走官方 CLI 附加元件的 wp gf update」 👉 這是預期的備援路線,授權金鑰要先設好(常數或後台)。
- 🔴 出現 ❌「仍低於 3.1.3,漏洞還在」 👉 更新根本沒生效,多半是授權或更新通道的問題。把輸出交給維運人員,不要反覆硬跑。
- 🔴 出現 ❌「外掛狀態從 active 變成 inactive」 👉 表單可能沒在運作,確認後用 wpx “網站路徑” plugin activate gravityforms 啟用,再回頭測表單。
- 🟠 出現「官方檢查碼比對沒通過」 👉 先確認是不是你自己放的客製檔案;不確定就視為可疑,回到 A。
- 🔴 出現 ❌「首頁 HTTP 500」 👉 立刻停用外掛(B 選項一),再用備份還原;之後把備份拿到測試站慢慢查,不要在正式站試錯。
- 🟡 預覽沒有列出可用更新 👉 不代表已是最新,以最後的版本判定為準;也要確認主機連得到官方更新伺服器。
🚨 驗證的侷限:
官方檢查碼比對只能證明 Gravity Forms 資料夾裡的檔案和官方發布版一致,檢查不到 mu-plugins、uploads、資料庫裡被塞入的物件,也看不出別的外掛有沒有被動過。它只是完整性檢查的其中一環,必須搭配第五章 ㊄ 的稽核。另外,如果這一站已經中招,更新前的備份本身就含有後門,不能當成乾淨的還原來源 🔐
修補完成後,還有三件事要收尾,缺一不可:
- ㊀ 重跑第五章 ㊄ 的稽核:這次的目的是確認更新後沒有新增異常,尤其是 ❶❷ 的序列化物件特徵。
- ㊁ 每張公開表單都送出一次:對照第二章最後那張表格,確認通知信、Entries、自動建立的文章都正常。
- ㊂ 回頭看有沒有符合條件的表單可以收斂:用不到的 Post Custom Field、不需要的內容範本,趁這個機會移除,第七章會再講。
| 情境 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| 疑似中招(A) | incident_one 路徑 | 只對有疑慮的站逐一執行 | 對網路根目錄跑一次,腳本會連子站一起停用 |
| 短期應急(B) | deactivate_one 路徑 | 逐站套用 | 對網路根目錄跑一次 |
| 正式修補(C) | gf-patch.sh 使用者 網域 | 一次一站,確認後再下一站 | 外掛檔案只有一份,更新一次即可,再逐子站驗證表單 |
🔥 一句話總結:「批次」不等於把所有東西塞進一條迴圈。真正安全的做法是先盤點、再備份、預覽,確認後逐站更新,最後驗證,而且每一步失敗就停下 🎯
🔒 第七章:補完洞之後,把第二道門也鎖上
更新是根本,加固是「萬一下一顆洞又來,讓損害小一點」。先講清楚:這一章的招式擋不住這顆 CVE 本身,它們擋的是物件被塞進去之後的下一步,屬於通用加固,不是官方要求。而且這一章的指令都會改動伺服器設定,每一個我都附了驗證與拆除方法 🛡️
🧱 加固一:把 PHP 的系統指令函式關掉
為什麼這樣做:這顆漏洞最壞的結果,是讓攻擊者在伺服器上執行程式。disable_functions 可以讓 PHP 無法呼叫 exec、system 這類直接跟作業系統溝通的函式,攻擊者就算拿到執行 PHP 的機會,能做的事也會少一截。清單刻意不含 curl_exec,因為 WordPress 核心雖然會退回其他傳輸方式,但許多外掛與第三方串接(付款、授權檢查、寄信服務)是直接呼叫 cURL 的,停掉常造成難以追查的故障;也不要寫 eval,它是 PHP 的語言結構,不是函式,寫進去不會有效果。備份、圖片處理類外掛偶爾會用到 exec,改完請實際測前後台 ⚠️
腳本的做法是「合併」,不是「覆蓋」:先把 php.ini 裡現有的 disable_functions 讀出來,和我們要的清單合併、去重再寫回去。這樣有些發行版預設就放在裡面的 pcntl_* 之類的函式不會被我們洗掉,重複跑幾次也不會疊加。流程是先備份 php.ini,用 sed -i -E 替換設定行,找不到那一行才用 tee -a 補上;設定檢查通過才重啟,沒過就自動還原。我拿四種狀況的假 php.ini 實測過:已有清單、只有被註解的範例行、完全沒有這一行、設定檢查失敗。前三種都正確合併,第四種完整還原,第二次執行結果不變 🔧
# 對主機上每一個 PHP-FPM 版本,把我們要的函式「合併」進現有清單(不覆蓋原本的、跑幾次都不會疊加)
harden_php_ini() {
local root="${1:-/etc/php}" want ini ver bak cur merged
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 -- "$ini" "$bak"
# 取出目前生效的那一行,和我們的清單合併、去重
cur="$(sed -n -E 's/^disable_functions[[:space:]]*=[[:space:]]*(.*)$/\1/p' "$ini" | head -n 1)"
merged="$(printf '%s,%s' "$cur" "$want" | tr ',' '\n' | sed 's/[[:space:]]//g' \
| awk 'NF && !seen[$0]++' | paste -sd, -)"
sed -i -E "s/^[;#]*[[:space:]]*disable_functions[[:space:]]*=.*/disable_functions = ${merged}/" "$ini"
# 檔案裡本來就沒有這一行的話,補在最後
if ! grep -q '^disable_functions' "$ini"; then
printf '\ndisable_functions = %s\n' "$merged" | tee -a "$ini" > /dev/null
fi
# 設定檢查通過才重啟;沒過就還原備份
if "php-fpm${ver}" -t > /dev/null 2>&1; then
systemctl restart "php${ver}-fpm"
printf 'PHP %s:已套用並重新啟動\n' "$ver"
else
cp -- "$bak" "$ini"
printf 'PHP %s:設定檢查沒過,已還原備份:%s\n' "$ver" "$bak"
fi
grep -H '^disable_functions' "$ini"
done 3< <(find "$root" -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 = pcntl_fork,exec,pcntl_alarm,passthru,shell_exec,system,proc_open,popen
最後那行 grep … pool.d/*.conf 沒有輸出,代表沒有個別網域的 pool 設定在覆蓋它,這是好事。出現「設定檢查沒過,已還原備份」代表設定沒套用、網站不受影響,把輸出貼給維運人員;要復原就把 .BAK.時間 備份檔蓋回 php.ini 再重啟 PHP-FPM。重啟 PHP-FPM 的瞬間,進行中的請求可能被中斷,請挑離峰時段 🧾
驗證加固真的有效:光看設定檔不夠,要實際從外面打一次。下面的探針用隨機檔名,內容只回報 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" <<'EOF'
<?php
echo function_exists('exec') ? 'EXEC-AVAILABLE' : 'EXEC-DISABLED';
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 -t 沒過時會自動撤回剛建的兩個檔案。規則用 ^~ 前綴,讓這個資料夾不再被其他 PHP 規則攔走。不同 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
tee "$http_inc" > /dev/null <<'EOF'
# 上傳資料夾只該放圖片與文件,PHP 類檔案一律回 403
location ^~ /wp-content/uploads/ {
location ~* \.(php|phtml|phar)$ {
return 403;
}
}
EOF
ln -sf "$http_inc" "$ssl_inc"
# 語法檢查通過才重新載入;沒過就把剛建的兩個檔案撤掉
if nginx -t; then
systemctl reload nginx
printf '已套用:%s\n' "$domain"
else
rm -f -- "$http_inc" "$ssl_inc"
printf 'nginx 設定檢查沒過,已撤回規則:%s\n' "$domain"
return 1
fi
}
# ⚠️ 拆除規則(會刪檔,只刪我們自己建的兩個檔案)
unblock_uploads_php() {
local owner="$1" domain="$2"
local conf_dir="/home/${owner}/conf/web/${domain}"
rm -f -- "${conf_dir}/nginx.conf_no_uploads_php" "${conf_dir}/nginx.ssl.conf_no_uploads_php"
if nginx -t; then
systemctl reload nginx
printf '已解除:%s\n' "$domain"
else
echo "nginx 設定檢查沒過,請先查原因,不要 reload"
return 1
fi
}
# 驗證:PHP 探針要 403,uploads 裡的一般圖片仍然是 200(探針用完立刻刪除)
check_uploads_block() {
local wp_path="$1" image_url="$2" domain name probe
domain="$(site_domain "$wp_path")"
name="__probe_$(head -c 8 /dev/urandom | od -An -tx1 | tr -d ' \n').php"
probe="${wp_path}/wp-content/uploads/${name}"
printf '<?php echo "PROBE-RAN";\n' > "$probe"
curl -sS -o /dev/null -w "uploads 內的 PHP 探針:HTTP %{http_code}\n" --max-time 20 \
"https://${domain}/wp-content/uploads/${name}" || true
rm -f -- "$probe"
[ -e "$probe" ] && printf '⚠️ 探針檔案還在,請手動刪除:%s\n' "$probe"
curl -sS -o /dev/null -w "一般圖片:HTTP %{http_code}\n" --max-time 20 "$image_url" || true
}
# 套用(把使用者與網域換成你的)
block_uploads_php "admin" "example.com"
check_uploads_block "/home/admin/web/example.com/public_html" \
"https://example.com/wp-content/uploads/你網站上任一張圖片.jpg"
預期輸出範例:
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 👉 規則影響到靜態檔,同樣先拆除。
- 🟠 nginx -t 報錯 👉 函式已自動撤回,請保留錯誤訊息給維運人員。
- 🟡 探針檔案還在 👉 手動刪掉,別留著。
🧑🔧 DevOps 小知識:open_basedir 不用你再全域設定
有些教學建議在 php.ini 全域寫死 open_basedir,但寫死某個網站的路徑,其他網站就全壞。HestiaCP 的 PHP-FPM 樣板本來就會為每個網域設好,用 grep -H open_basedir /etc/php/*/fpm/pool.d/*.conf 確認即可。它同樣擋不住這顆 CVE,只是縮小事後的傷害範圍,在同一台主機放了多個站的時候特別有用 🔒
📝 加固三:管好你自己的表單
這顆漏洞的前提是表單設定。就算更新了,下面這幾個習慣也能讓下一次出事時損害更小 🧼
- ⭐ 不需要建立文章的表單,就別用 Post Fields。這顆漏洞走的是「表單建立文章」那條路。只想收名單、收需求的表單,用一般欄位就好,資料只存在 Entries 裡,不會經過 create_post()。
- ⭐ 用不到的 Post Custom Field 與內容範本,直接拿掉。每一個多開的功能,都是多一扇門。
- ⭐ 用不到的表單直接刪除。下架活動、結束的報名表,連同嵌入它的頁面一起清掉,垃圾桶裡的也永久刪除。
- ⭐ 對公開表單加一道人機驗證。Gravity Forms 有 CAPTCHA 類欄位,可降低機器人自動送出的機會,但這不能取代更新【❓待確認:實際可用的驗證種類依版本而異】。
- ⭐ 保持授權有效,並搭配每日備份。這顆漏洞最大的麻煩,是授權過期的人拿不到修補。有效授權加上每日備份,才有退路。
- ⭐ 授權金鑰放哪裡,要想清楚。放在 wp-config.php 的 GF_LICENSE_KEY 常數方便 WP-CLI 維運,代價是這個檔案一旦被讀到,金鑰也一起外流。所以 wp-config.php 的權限請盡量收緊,並定期檢查。
- ⭐ 管理員的變動要有人知道。後台新增管理員、外掛被安裝,最好有通知機制。常見資安外掛有提供,可評估是否啟用【❓待確認:依你使用的外掛而定】。
🩵 荷包試算:要不要花錢買 WAF 或虛擬修補?
付費防火牆可以在你更新前先擋一層,但這顆洞的入口是一般的表單送出,能不能被擋得住,我沒有資料可以驗證。對多數站來說,把 Gravity Forms 授權維持有效、後台點一下更新,通常比月付防護方案划算。真的卡在相容性、短期無法更新時,再把付費防護當過渡手段,別反過來拿它當不更新的理由 💰
🤔 第八章:讀到快喘不過氣?常見焦慮與幾個通用心法
- 🌠 Q:我已經是 3.1.3 了,還需要升到 3.2.1 嗎?
A:就這顆 CVE 來說,3.1.3 已經過了受影響範圍。但 3.2.1 多了一項表單存取權限的修正,而且連續五個版本都帶著沒說明內容的安全強化,我的建議是升上去,並照第二章那張表測一遍 📦 - 🌟 Q:我只用 Gravity Forms 做聯絡表單,不建立文章,有事嗎?
A:🟡 依公告描述,有問題的是 create_post() 這條路,也就是「表單建立文章」的功能。純收信、純存 Entries 的表單,推論上不經過它。但我無法替你保證沒有別的路徑,更新還是要做 🧭 - ✨ Q:授權過期了,沒辦法更新怎麼辦?
A:官方說明過期後收不到包括安全更新在內的所有更新,也下載不到檔案。最乾淨的做法是續約後更新。來不及的話,走第六章 B 先停用外掛或下架表單,並把期限訂清楚,別讓「暫時」變成半年 🤗 - 🌠 Q:更新後我的表單壞了,可以降版嗎?
A:不建議。降版等於重新打開漏洞。先照第二章表格找出是哪項行為變更造成,或把那張表單暫時下架 🙅 - ✨ Q:看不懂那些指令,是不是沒救了?
A:不會。第三章全程用滑鼠就能完成最基本的處理,第五章以後是給有 SSH 權限的人看的補充 🔍 - 🌟 Q:資安外掛的防火牆說已經擋下了,我還需要更新嗎?
A:需要。我沒有資料可以驗證任何防火牆規則對這顆洞的效果,而且防火牆擋的是已知特徵,修補才是根本 🛡️
🧩 舉一反三:「安全版函式做好了,卻漏改一處」這類事故怎麼防
下面這些是通用防禦知識,不是本 CVE 的官方要求,但下次看到類似新聞能對號入座 🫡
- ⭐ 做了新的安全版函式,就要全站搜尋舊函式還有誰在用。這次就是外掛先有了加固版,卻有一個呼叫點沒換。維護自己的程式碼時,定期搜尋 unserialize 與 maybe_unserialize 是便宜又有效的檢查。
- ⭐ 同一家族的事故,通常會再來一次。2023 年與 2026 年都是 List 欄位加反序列化。看到某個外掛出過這類洞,往後幾年對它的更新要更積極。
- ⭐ 商業外掛的授權,是資安的一部分。授權過期等於放棄收修補,預算裡要把續約當成必要支出。
- ⭐ 更新日誌只寫一句話的時候,版本號就是你唯一的判斷依據。建立自己的版本盤點習慣(第五章 ㊁),比每次看新聞再追更有效。
- ⭐ 修補與事件應變是兩件事。舊版只要曾暴露在網路上,更新完還要查日誌、查資料、查帳號、查檔案,缺一不可。
🏆 第九章:這次到底該多緊張?事件綜合評估
| 評估維度(10 分制,數字越高代表風險或應對品質越正向) | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 9.8 | CVSS 官方評分。免登入、免互動、攻擊難度低;現實要打中還得有特定表單設定,要接管還得有物件鏈,所以是「立即處理」,不是「裝了就淪陷」 |
| 官方應變速度 | 7 | 修補版比公開揭露早 28 天是加分項;扣分在更新日誌只寫一句話、沒有 CVE 對照,而且是同一個地方的第二次事故 🟡 |
| 一般站長自救可行性 | 6 | 後台點「更新」就行,但要過三關:授權有效、找出踩中條件的表單、更新後逐張測表單 |
| DevOps 技術文件完整度 | 5 | 官方沒有公開程式差異,內容範本在資料庫的欄位名稱查不到,是否已被利用的資料也只能轉述,排查只能靠結果特徵 |
| 🏆 加權綜合建議 | 立即更新 | 最終建議:【今天內更新到 3.2.1;有 List 類型 Post Custom Field 表單的站,做第五章稽核;疑似中招走第六章 A;沒辦法更新的先走 B 並訂出期限】 |
🔥 這種漏洞最危險的時間點,是新舊版本對照已經不是祕密、你卻還沒更新的那幾天。條件嚴格不代表沒人會試,因為自動化工具就是專門去試「嚴格但成立」的組合。花五分鐘看一眼版本號,比事後花五天清後門便宜太多 🔢
📎 附錄 A:一頁式決策表(印出來貼在螢幕旁)
| 你看到的狀況 | 先做什麼 | 接著做什麼 |
|---|---|---|
| 版本 3.1.2(含)以前,沒有其他異常 | 備份 | 更新(C),逐張測表單,再做第五章稽核 |
| 版本 3.1.2(含)以前,而且有 List 類型 Post Custom Field 表單 | 備份,今天無法更新就走 B | 更新(C),做第五章 ㊃ ㊄,建議刷新鹽值 |
| 序列化物件特徵、陌生管理員、uploads 或 mu-plugins 出現異常 | 不要刪,走 A 取證與止血 | 換資料庫密碼,再走 C;有個資的網站通知內部資安與法務 |
| 更新按鈕沒出現 | 確認授權是否過期 | 續約後更新,或用官方帳號下載 zip,用 gf-patch.sh 的第三個參數安裝 |
| 更新後表單壞掉 | 對照第二章表格找原因 | 修正設定或把該表單下架,不要降版 |
| 更新後網站白畫面,或停用後首頁報錯 | B 選項一停用(若是停用造成的就重新啟用) | 用備份到測試站查原因,交給維運人員 |
📖 附錄 B:名詞小辭典
- 🔹 CVE:漏洞的國際編號,CVE-2026-103039 就是這次的編號。
- 🔹 CVSS:漏洞嚴重度評分,滿分 10 分,9.0 以上屬於 Critical。
- 🔹 CWE-502:不受信任資料的反序列化,也就是「照說明書組裝」這類弱點的分類編號。
- 🔹 反序列化(Unserialize):把打包成文字的資料還原成 PHP 的陣列或物件。
- 🔹 PHP 物件注入:讓程式還原出攻擊者指定的物件,本身不等於遠端程式碼執行。
- 🔹 POP Chain:把站上不同程式裡的零件接起來觸發危險動作的組合,又稱物件利用鏈。
- 🔹 Post Custom Field:Gravity Forms 的欄位類型之一,把訪客填的內容存成文章的自訂欄位。
- 🔹 Content Template:表單欄位上的「內容範本」功能,可以用範本組出要寫進文章的內容。
- 🔹 PoC:Proof of Concept,概念驗證,指能示範漏洞怎麼被利用的程式或步驟。
- 🔹 RCE:Remote Code Execution,遠端程式碼執行,也就是別人能在你的伺服器上跑他的程式。
- 🔹 SSVC:CISA 用來判斷漏洞處理優先順序的評估方式。
- 🔹 WP-CLI:WordPress 的命令列工具,用來在主機上管理網站。
- 🔹 鹽值(Salts):wp-config.php 裡八組長字串,用來簽章登入 Cookie。
📚 參考資料與延伸資源
- 📌 Gravity Forms 官方|更新日誌(3.1.1 至 3.2.1)
- 📌 Gravity Forms 官方|授權到期說明
- 📌 WordPress.org|Gravity Forms CLI Add-On
- 📌 GitHub|gravityforms/gravityformscli
- 📌 Gravity Forms 官方|資料庫結構說明
- 📌 Gravity Forms 官方|Field Object
- 📌 Wordfence|Gravity Forms <= 3.1.2 未登入 PHP 物件注入
- 📌 NVD|CVE-2026-103039
- 📌 CVE.org|CVE-2026-103039
- 📌 Bitdefender|Gravity Forms WordPress Plugin Found Vulnerable to PHP Object Injection(CVE-2023-28782)
- 📌 Heimdal Security|WordPress Plugin Gravity Forms Vulnerable(CVE-2023-28782)
- 📌 CERT-EU|Security Advisory 2023-032
- 📌 Gravity Forms 舊論壇|Fatal Error after upgrading to latest version
- 📌 Gravity Forms 舊論壇|Gravity Forms support for WP-CLI
- 📌 Gravity Forms 官方社群|WP-CLI license update not working
- 📌 WP-CLI 官方文件|wp plugin update
- 📌 WP-CLI 官方文件|wp plugin verify-checksums
- 📌 WP-CLI 官方文件|wp core verify-checksums
- 📌 WP-CLI 官方文件|wp config shuffle-salts
- 📌 WP-CLI 官方文件|wp user reset-password
- 📌 WP-CLI 官方文件|wp user session
- 📌 WP-CLI 官方文件|wp post list
- 📌 HestiaCP 官方文件|CLI 參考
- 📌 HestiaCP 論壇|Custom nginx settings per domain












