🕳️ 那個貼著「隱藏」標籤的上傳欄位,怎麼變成陌生人進出伺服器的側門?Gravity Forms 高危漏洞全解析(CVE-2026-84434/CVSS 9.8)
🔥 懶人包:
超過 100 萬 個網站在用的付費表單外掛 Gravity Forms,被抓到一個「不用登入、不用密碼」就能把檔案塞進伺服器的任意檔案上傳漏洞(CVE-2026-84434,CVSS 9.8 嚴重等級)。觸發條件很具體:表單裡要有一個「可見度設為 Hidden 的檔案上傳欄位」。沒有這種欄位,這條路就走不通;有的話,外掛前面的檢查會直接放行,後面的存檔動作還會把「本來該被退件」的檔案照收不誤。修補版 3.1.1 在 9 月 3 日就默默上線,漏洞細節卻拖到 9 月 18 日才公開,中間整整隔了 15 天。版本在 3.1.0.4 以下 的網站,今天就該處理 ⛑️
🧊 冷知識:Gravity Forms 是少數「不在 wordpress.org 目錄」的付費外掛
它沒有免費版,也沒有上架 WordPress 官方外掛目錄,更新要走廠商自己的授權管道。這件事在這次事件裡有兩個有趣的後果:第一,授權過期的站台,後台可能不會跳出更新提示;第二,用來驗證外掛檔案的官方指令 wp plugin verify-checksums 根本不適用,後面的技術章節會告訴你改用什麼 🧾
🌐 前往 Gravity Forms 官方更新日誌核對版本 [1]
內容目錄
🧐 第一章:一個「表單外掛」,怎麼替陌生人留了一扇側門?
先問你一個問題:如果社區大樓的管理員每天都認真拆包裹、檢查有沒有違禁品,可是大樓旁邊有一扇貼著「內部專用」的側門,警衛心想「這扇門只有自己人在走」,所以連看都不看,你會不會覺得哪裡怪怪的?這次 Gravity Forms 的漏洞,玩的就是這個心理盲點 🏢
你的網站是整棟大樓,Gravity Forms 則是負責收包裹(訪客上傳的檔案)的收發室。平常訪客把履歷、證件照、報價單從正門遞進來,管理員(驗證管線)會逐一拆開確認副檔名:PDF、PNG 可以進,PHP 程式檔一律退件。問題出在那扇「隱藏」側門:當表單裡的檔案上傳欄位被設成 Hidden,管理員誤以為這是內部通道,不必檢查,包裹直接放行。更糟的是,就算某個包裹在門口被判定「不合格」,它的收件登記單(暫存狀態)還是原封不動地被送到後面的搬運工手上(upload_file()),搬運工不會再問一次,就把包裹搬進了倉庫的公用區 🏭
官方對這個漏洞的白話說法是:「欄位驗證」跟「檔案存放」是兩條各自獨立的管線,兩邊對「這個檔案到底能不能收」的判斷沒有對齊。結果就是,被擋下來的檔案,在真正存進磁碟之前,沒有被重新檢查一次 🚪
🧊 冷知識:「隱藏」從來不等於「可信」
資安圈有句老話:凡是從瀏覽器傳回來的東西,包含畫面上看不到的隱藏欄位,通通可以被偽造。「隱藏」只代表畫面上不顯示,並不代表訪客送不出這個欄位的資料。開發者常拿隱藏欄位來夾帶流程參數,一旦後端把「它是隱藏的」誤當成「它是自己人」而放寬檢查,就成了經典的設計盲點,這次漏洞剛好是最標準的示範 🕶️
⏱️ 從悄悄補洞到公開細節,中間隔了 15 天
這起事件的時間軸有個蠻有意思的地方:補丁不是在公告之後才出現,而是早了兩個多禮拜就默默上線。我們把 CVE.org、WPScan 與官方更新日誌交叉比對後,整理成下面這張表 🗓️
| 日期 | 事件節點 | 詳細內容 |
|---|---|---|
| 2026-09-01 | 廠商收到通報 | CVE.org 記載這天是「廠商已通知」,CVE 編號同日預留,由 Wordfence 擔任 CNA(CVE 編號指派機構) |
| 2026-09-03 | 3.1.1 版釋出 | 官方更新日誌只寫「加入安全性增強」,並新增 gform_state_lifespan 濾鏡,沒有提到 CVE,也沒有點名是哪一類漏洞 |
| 2026-09-18 | 細節正式公開 | CVE.org 記為「已揭露」;WPScan 同日收錄,標示 3.1.1 以前受影響,CVSS 9.8 |
| 2026-09-19 | CVE 紀錄發布 | NVD 狀態仍是「已接收、待分析」;發現者署名為 0xd4rk5id3 |
這個節奏有點像房東先偷偷把鎖換掉,過了兩個禮拜才貼公告說「前陣子舊鑰匙其實能開門」。對已經更新的人這是好事;對還在用舊版的人,卻是一段沒有任何人提醒你的空窗期 🔑
📢 碎碎念:同一個外掛,一個月內第二個上傳漏洞
先跟你說一聲,網路上有些整理資料會把兩件事混成同一條時間軸,所以這裡特別分開講。8 月 9 日由 Wordfence Argus 發現、8 月 20 日推出 3.0.3 修補的,是前一個漏洞 CVE-2026-19513(CVSS 8.1,影響 3.0.2 以下)。它的成因是「多檔上傳的分塊雜湊被混用」,需要表單開啟「多檔上傳」才能觸發,跟這次「隱藏欄位」的 CVE-2026-84434 是兩個獨立的問題。只是兩個都出在上傳管線,也難怪資安圈這陣子的目光都聚在這裡 🔍
😨 講白了,這扇側門被推開之後會發生什麼事?
先講幾件目前已經證實的事,再往下推一層,最後才是「還沒發生,但邏輯上說得通」的情境。這樣你可以自己判斷該緊張到什麼程度 🎚️
- 🏮 ✅ 已確認事實:不用任何帳號就能觸發。攻擊者只要能連到一個公開的表單頁面,而且那張表單含有「可見度為 Hidden」的檔案上傳欄位,就有機會把副檔名不合規的檔案寫進伺服器。官方描述用的字眼是:上傳的檔案「可能是可執行的」,使遠端程式碼執行成為可能。
- 🏮 ✅ 已確認事實:修補版本是 3.1.1。WPScan 與 CVE.org 的資料一致:3.1.0.4(含)以前受影響。
- 🏮 🟡 合理推論:伺服器種類決定災情大小。Wordfence 在前一個漏洞的說明裡提過,Gravity Forms 會在暫存目錄放一個 .htaccess 擋 PHP 執行,但 Nginx 不讀這個檔案。照這個邏輯類推,Nginx 環境下這類檔案有機會被直接執行,Apache 環境則可能只剩儲存濫用或 XSS。不過這次官方沒有明講暫存目錄與執行條件,請把它當成推論。
- 🏮 🔴 假設情境:最壞的狀況是整站易主。如果攻擊者真的取得網頁伺服器的執行權,就能讀取 wp-config.php 裡的資料庫帳密,再植入後門、竄改資料,甚至橫向影響同一台主機上的其他網站。這是「假設攻擊成功」的推演,不是已經發生的事實。
- 🏮 【❓待確認】目前的利用狀況。我查到的公開資料顯示,這個 CVE 在 9 月 19 日還沒有被列入 CISA 的已知遭利用清單,但沒有找到可靠的來源可以證明「沒有公開的攻擊程式」或「沒有人在掃描」。細節既然已經公開,別把「還沒看到災情」當成安全的理由。
🎭 第二章:先別慌,看看你屬於哪一種情境
不同身分該做的事真的差很多。你可以先找到最像自己的那一種,再決定要花多少時間在這篇文章上 🌬️
🙋 我只是負責收表單、回信的網站小編
如果你平常的工作是看報名資料、回覆客戶詢問,完全不碰後台深層設定,那你只需要做三件事:確認版本、按更新、把這篇文章丟給懂技術的人。後面的技術深潛可以直接跳過,翻到第三章照著點就好,不會花你太多時間 💪
🩵 荷包試算:這次要花錢的地方,其實是「授權」
Gravity Forms 是付費外掛,更新是透過有效的授權金鑰取得的。也就是說,更新本身不用另外掏錢,但如果你的授權已經過期、卻沒人續約,後台可能看不到更新按鈕。至於「授權過期的站台,廠商會不會放行安全更新」,我沒查到官方說法 【❓待確認】。比起事後請人做鑑識、清後門的工時,續約費通常是比較划算的那一邊 💸
👨💻 我自己架站,手上還管著好幾個客戶的 WordPress
如果你同時顧著好幾個站,一個一個點後台實在太沒效率。你的主戰場是 SSH 與 WP-CLI,第五章與第六章會直接給你能貼上就跑的排查與修補指令。特別要記得的是:Gravity Forms 不在 wordpress.org,wp plugin update 能不能抓到新版,取決於授權金鑰有沒有設定好 🧰
🏢 我的網站會收履歷、證件或客戶資料
如果你的表單常收履歷、身分證影本、報價單這類檔案,這件事就不只是「更新一個外掛」而已。萬一確認有人利用過這個漏洞,上傳目錄裡的資料就要當成可能外洩來處理,還要考慮個資通報責任。建議同步啟動內部資安事件流程,把日誌與備份留存好,日後不管是配合調查還是跟保險公司對帳都用得上。此為資訊統整,非專業建議,重大決定前請諮詢真人專家 🩺
🧑🔧 DevOps 小知識:為什麼「只更新外掛」不夠?
更新能擋住「之後」的攻擊,卻清不掉「之前」已經進門的東西。如果攻擊者在你更新前,就已經把 PHP 檔案寫進 wp-content/uploads,或是建立了管理員帳號,更新完這些東西依然躺在原地。所以「更新」跟「排查」是兩件事,缺一不可,第五章會帶你一步一步查 🛡️
🚦 高風險場景模擬:那張「當年用條件邏輯留下來」的舊表單
一間小型工作室兩年前辦活動,做了一張報名表,中間為了配合條件邏輯,塞進一個被設成 Hidden 的檔案上傳欄位,活動結束後表單就再也沒有人打開過。網站主人的想法是「反正表單早就沒在用了」。但只要外掛還啟用著、那張表單還掛在某個公開頁面上,它就仍然是一扇對外開放的門。如果這時候授權又剛好過期、後台沒有跳出更新提示,同一個網站就同時踩中了「有隱藏欄位」「沒更新」「沒人盯」三個條件 🆘
🚥 畫重點:「很久沒用」不等於「沒有風險」。之前有人踩過類似的雷,問題不在最近有沒有人填表單,而在那個端點還開著。只要外掛啟用、表單還在公開頁面上,攻擊者就有機可乘 🙅♂️
🛟 第三章:五分鐘無痛自救指南(電腦小白也能跟著做)
不管你是哪一種情境,這一段建議每個人都先走一遍。全程不用敲任何指令,跟著畫面點就好 🤏
- ㊀ 先去哪裡看版本?
登入 WordPress 後台,點左側選單的「外掛」,再點「已安裝的外掛」。在清單裡找到名字叫 Gravity Forms 的那一項,名稱下方會顯示它目前的版本號碼 🔢 - ㊁ 看到版本號,怎麼判斷安不安全?
Gravity Forms 的版本號有時會有四段,例如 3.1.0.4。判斷方式是「看前三段」:3.1.0.4 或更舊(3.1.0.2、3.0.x、2.10.x 都算)代表還在受影響範圍;3.1.1 或更新就是已修補。要小心的是,3.1.0.4 尾巴有個 4,容易讓人誤以為它比 3.1.1 新,其實 3.1.0 比 3.1.1 小 ㊙️ - ㊂ 需要馬上更新嗎?
需要,而且今天內完成。直接在同一個畫面點「立即更新」,跑完後重新整理,確認版本已經是 3.1.1 以上。如果根本沒有出現更新按鈕,先去「表單」→「設定」看看授權金鑰欄位是不是空白或顯示過期;授權沒問題卻還是沒有更新,就請主機商或網站維護廠商用官方帳號下載新版,再從「外掛」→「安裝外掛」→「上傳外掛」手動覆蓋(介面名稱可能因版本略有不同)💪 - ㊃ 想知道自己有沒有那種「隱藏上傳欄位」?
到「表單」→「表單」,點進某張表單的編輯畫面,點一下裡面的「檔案上傳」欄位,切到「進階」分頁,找「可見度」這個選項。如果寫著 Hidden(隱藏),就是這次漏洞的射程範圍。不過就算每張表單都沒看到,也不代表可以不更新:表單可能不只一張,這也只是攻擊條件之一 🔍 - ㊄ 更新完,怎麼簡單檢查有沒有被動過手腳?
點「使用者」→「所有使用者」,用上方篩選切到「管理員」。看名單裡有沒有你或同事都不認識的名字、看起來像亂碼的帳號、或是用陌生信箱註冊的管理員。有的話,先別急著刪,截圖留存,再交給懂技術的人處理。你判斷不出來也沒關係,把截圖丟給網站維護的工程師或主機商就好,這不是你的問題,找人幫忙很正常 🙇 - ㊅ 不敢動手,或完全看不懂這些選單怎麼辦?
請把這篇文章網址,加上「CVE-2026-84434」這個編號,直接轉給你的主機商客服或網站維護廠商,並請他們「更新 Gravity Forms 到 3.1.1 以上,順便檢查 wp-content/uploads 底下有沒有 .php 檔案」。在沒有完整備份的情況下,請不要自己跑進主機底層刪除任何檔案❗
🫂 新手求助:卡關了,可以去哪裡問?
Gravity Forms 是付費外掛,有授權就有官方支援,可以到 官方開立支援單 [55]。想找人討論,可以去台灣的 WordPress 相關 Facebook 社團發問,記得把版本號與截圖一起附上,比較快得到回覆。如果你連「已安裝的外掛」都找不到,最快的方式就是把整篇文章轉傳給主機商,說「我的外掛有嚴重漏洞,麻煩幫我更新並檢查」,這件事本來就不該由網站主一個人扛 🤗
🤿 第四章:技術深潛篇:這道側門的底層到底哪裡沒鎖好
接下來這段是給想知道「為什麼會這樣」的人看的。只想補洞的話,前面三章其實已經夠用;如果你也好奇這種等級的漏洞底層長什麼樣子,我們繼續往下拆 ⚔️
| 評估指標 | 具體資訊 |
|---|---|
| 外掛名稱 | Gravity Forms(Rocketgenius,商業外掛) |
| CVE 編號 | CVE-2026-84434 |
| CVSS 評分 | 9.8(Critical,由 CNA:Wordfence 給出) |
| CVSS 向量 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| 受影響版本 | <= 3.1.0.4 |
| 安全版本 | >= 3.1.1(2026-09-03 釋出) |
| 漏洞類型 | CWE-434:不受限制上傳危險類型檔案 |
| 涉及函式 | upload_file() |
| 觸發條件 | 公開表單含「可見度為 Hidden」的檔案上傳欄位 |
🧑🔧 DevOps 小知識:9.8 分從哪來?NVD 還沒表態
CVSS 向量裡的 PR:N 與 UI:N 代表不需要權限、也不需要使用者互動,所以直接拿到嚴重等級。要留意的是,截至 9 月 19 日,NVD 的狀態仍是「已接收、待分析」,這個 9.8 是 CNA(Wordfence)給的分數。另外有整理資料提到 Patchstack 給了 10.0,我沒能查證,先標示為 【❓待確認】,實務上請以自己的環境風險為準,不要因為分數差 0.2 就改變處理順序 🙅
問題的核心在於兩條管線沒對齊。表單送出時,「欄位驗證管線」會檢查副檔名;但當欄位被設成 Hidden,驗證會被略過。而被判定為拒絕的檔案,它原本完整的上傳狀態,後來仍會被交給 upload_file() 做最終存放,這一步沒有再驗證一次。兩個小小的疏漏疊在一起,就變成「該擋的檔案,最後還是被寫進磁碟」 🧩
🟡 合理推論:3.1.1 的更新日誌除了「安全性增強」,還新增了一個跟表單「狀態(state)」有關的濾鏡 gform_state_lifespan。官方文件說明,Gravity Forms 會替每張表單附上一個隱藏的狀態值,用來防止送出的內容被竄改、也避免過期的舊表單被接受,預設有效期是 2 天。這次修補會不會跟這套機制有關,官方沒有明講,所以只能當作線索,不能當作結論 🕵️
🎯 攻擊者概念上要走幾步?(僅為防禦說明,不含可執行細節)
- 🏮 第一步:找到目標。透過爬蟲或掃描工具,找出安裝了 Gravity Forms、而且公開表單含有隱藏上傳欄位的網站。
- 🏮 第二步:送出一個不合規的檔案。利用驗證會被略過的特性,把副檔名本該被擋下的檔案送進隱藏欄位。
- 🏮 第三步:靠伺服器設定決定成敗。檔案被寫進公開可存取的暫存位置後,能不能被執行,取決於伺服器是否禁止該目錄執行 PHP。
🧊 冷知識:.htaccess 是貼給 Apache 保全看的告示牌
Gravity Forms 安裝時會在上傳資料夾放一個 .htaccess,用來禁止 PHP 被執行。這招對 Apache 有效,因為 Apache 會乖乖讀這個檔案;但 Nginx 不讀 .htaccess,等於告示牌貼在保全看不懂的位置。至於 HestiaCP 這種「Nginx 在前、Apache 在後」的架構,PHP 實際是交給 Apache 處理,情況又不太一樣,是否有效請實測,不要憑印象判斷 🪧
🕵️♀️ 第五章:資安排查 Checklist:DevOps 該怎麼確認自己有沒有已經中招
🤖 重要提醒:以下指令與排查流程由 Gemini、Meta AI、DeepSeek 產生,Claude 複查修正
雖然已對照 WP-CLI 與 Gravity Forms 官方文件,統一改寫成路徑雙引號包覆、find -print0 安全迴圈等寫法,並且用語法檢查工具跑過一輪,但仍可能因伺服器環境、權限設定或外掛版本差異而產生疏漏。正式環境的高風險操作,請務必由熟悉該站架構的專業人員做最後確認,切勿直接複製貼上就按下 Enter 🧙
💡 新手避坑指南:先看這三件事再貼指令
① 範例路徑 /var/www/example.com/public_html 請換成你的真實路徑;HestiaCP 或 cPanel 的網站通常在 /home/使用者/ 底下,掃描根目錄請相應改成 /home。② 單一網站的範例假設你已經是該站的檔案擁有者身分,不要用 root 直接跑 wp;多站迴圈則是用 sudo -u 自動切換成擁有者。③ 所有路徑變數都用雙引號包住,請不要自行刪除,避免路徑有空格時指令斷裂 ⛓️💥
這一節依序走過環境判斷 👉 盤點 👉 Log 排查 👉 後門檢查 👉 加固五個關卡,每一關都拆成「決策原理 👉 指令 👉 預期輸出 👉 分支判斷」,並分別涵蓋 💠 單一網站、❇️ 獨立多站、✴️ Multisite 三種架構 📜
🔥 一句話先記住:「版本已經是 3.1.1」只代表門鎖換新了,不等於「這段期間沒有人進來過」。舊版暴露了多久,就該回頭查多久 🧯
🧭 ㊀ 環境判斷:你在管一個 WordPress,還是一整台 WordPress 伺服器?
決策原理:「一台主機上有 10 個獨立的 WordPress」跟「一個 Multisite 裡有 10 個子站」外表很像,指令卻完全不同:前者要逐一找出各自的 wp-config.php,後者才適用 wp site list。判斷錯了,後面的備份與更新範圍都會跑偏 🤔
💠 單一網站與 ✴️ Multisite:先確認 WP-CLI 站得對位置
WP_PATH="/var/www/example.com/public_html"
if wp --path="$WP_PATH" core is-installed; then
echo "OK: WordPress installation detected."
grep -E "MULTISITE|SUBDOMAIN_INSTALL|DOMAIN_CURRENT_SITE|PATH_CURRENT_SITE" "$WP_PATH/wp-config.php" || echo "No multisite constants found."
else
echo "ERROR: WordPress installation not detected."
fi
預期輸出範例:
OK: WordPress installation detected. No multisite constants found.
分支判斷邏輯:
- ⭕ 出現 OK 且找不到 multisite 常數 👉 單一網站,進入盤點
- ⭕ 出現 OK,且 grep 找到 MULTISITE 相關常數 👉 Multisite,後續以整個 Network 為單位處理
- 🟠 出現 ERROR 👉 先停止,確認路徑與執行身分,不要對錯誤的目錄動手
❇️ 獨立多站:從 wp-config.php 建立清單
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
SITE_PATH="$(dirname -- "$CONFIG")"
OWNER="$(stat -c '%U' -- "$CONFIG")"
if sudo -u "$OWNER" -- wp --path="$SITE_PATH" core is-installed </dev/null 2>/dev/null; then
printf 'WordPress: %s (owner: %s)\n' "$SITE_PATH" "$OWNER"
fi
done
預期輸出範例:
WordPress: /var/www/site-a/public_html (owner: site-a) WordPress: /var/www/site-b/public_html (owner: site-b)
分支判斷邏輯:每個路徑都能通過,才算建立好獨立多站清單;找到 wp-config.php 卻無法載入,多半是 PHP、權限或路徑問題,先排除再往下;同一個站找到兩份設定檔(例如備份或測試副本),要先人工確認哪個才是正式環境 🗂️
✴️ Multisite:確認後才列出子站
WP_PATH="/var/www/network/public_html" wp --path="$WP_PATH" site list --fields=blog_id,url --format=table
預期輸出範例:
blog_id url 1 https://example.com/ 2 https://shop.example.com/
🔎 ㊁ 盤點:哪些站裝了它?現在是哪一版?
決策原理:版本號有四段,人眼很容易誤判,所以交給 sort -V 比對。腳本裡的 3.1.1 是「這次的修補門檻」,日後廠商若釋出更高版本,仍以官方最新版為準 📊
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
FIXED="3.1.1"
wp --path="$WP_PATH" plugin get "gravityforms" --fields=name,status,version,update,update_version
CURRENT="$(wp --path="$WP_PATH" plugin get "gravityforms" --field=version)"
if [ "$(printf '%s\n%s\n' "$FIXED" "$CURRENT" | sort -V | head -n 1)" = "$FIXED" ]; then
echo "OK: $CURRENT >= $FIXED"
else
echo "VULNERABLE: $CURRENT < $FIXED"
fi
預期輸出範例:
name: gravityforms status: active version: 3.1.0.4 update: available update_version: 3.1.1 VULNERABLE: 3.1.0.4 < 3.1.1
分支判斷邏輯:
- 🔴 出現 VULNERABLE 👉 視為受影響,進入第六章
- 🟢 出現 OK 👉 版本條件已解除,但舊版暴露期間仍要繼續做 Log 與後門排查
- 🟠 版本落後,但 update 欄位顯示 none 👉 高度懷疑授權金鑰未設定或已過期,先處理授權,再談更新
- 🟠 找不到外掛 👉 確認資料夾名稱或是否查錯站,不代表安全
❇️ 獨立多站:逐站盤點,直接標出風險
SEARCH_ROOT="/var/www"
FIXED="3.1.1"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
SITE_PATH="$(dirname -- "$CONFIG")"
OWNER="$(stat -c '%U' -- "$CONFIG")"
if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" core is-installed </dev/null 2>/dev/null; then
continue
fi
VERSION="$(sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin get "gravityforms" --field=version </dev/null 2>/dev/null || true)"
if [ -z "$VERSION" ]; then
continue
fi
if [ "$(printf '%s\n%s\n' "$FIXED" "$VERSION" | sort -V | head -n 1)" = "$FIXED" ]; then
STATE="OK"
else
STATE="VULNERABLE"
fi
printf '%s | %s | owner=%s | version=%s\n' "$STATE" "$SITE_PATH" "$OWNER" "$VERSION"
done
預期輸出範例:
OK | /var/www/site-a/public_html | owner=site-a | version=3.1.1 VULNERABLE | /var/www/site-b/public_html | owner=site-b | version=3.1.0.4
分支判斷邏輯:這份輸出就是你的第一張「風險地圖」。標 VULNERABLE 的站優先進第六章;標 OK 的站也別直接放過,還是要查一遍舊版期間有沒有被動過手腳 🗺️
✴️ Multisite:外掛檔案全網共用,只需盤點一次
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" plugin get "gravityforms" --fields=name,status,version,update,update_version
if wp --path="$WP_PATH" plugin is-active "gravityforms" --network; then
echo "Network activated."
else
echo "Not network activated (may be active on individual sites)."
fi
分支判斷邏輯:顯示 Network activated,處理範圍就是整個 Network;顯示 Not network activated,代表只有部分子站啟用,之後要用 –url 逐站確認 🧭
📊 ㊂ Log 日誌排查:找「路徑+回應碼+來源」的組合
決策原理:官方沒有公開確切的攻擊端點,所以下面這些屬於 🟡 依漏洞類型推導的通用特徵,不是官方證實的攻擊指紋。最有價值的線索是:有沒有人成功請求過 uploads 底下的 PHP 類型檔案。另外,存取日誌看不到 POST 內容,所以只能靠「路徑與次數」判斷 🎯
💠 單一網站 與 ✴️ Multisite:先確認日誌在哪裡,再搜尋
ACCESS_LOG="/var/log/nginx/access.log"
if [ -f "$ACCESS_LOG" ]; then
echo "=== ❶ uploads 內可執行類型檔案的請求(回應 2xx 最可疑)==="
grep -aEi '/wp-content/uploads/[^ "?]*\.(php|phtml|phar|pht|php[0-9])' "$ACCESS_LOG" |
awk '$9 ~ /^2/ {print $1, $4, $6, $7, $9}' | tail -n 50
echo "=== ❷ gf_page=upload 請求量最大的來源 IP(🟡 非官方證實特徵)==="
grep -a "gf_page=upload" "$ACCESS_LOG" |
awk '{print $1}' | sort | uniq -c | sort -rn | head -n 20
else
echo "LOG NOT FOUND: $ACCESS_LOG"
fi
預期輸出範例:
203.0.113.7 [18/Sep/2026:14:20:12 "GET /wp-content/uploads/gravity_forms/tmp/example.php 200
分支判斷邏輯:
- 🟢 ❶ 沒有輸出 👉 沒有直接證據,但仍要往下做後門檢查
- 🟡 ❷ 出現單一 IP 大量請求 👉 可能是掃描行為,提高警覺,比對同時間有沒有新檔案或新帳號
- 🔴 ❶ 出現 2xx 的 PHP 請求 👉 高度懷疑 Web Shell 已被執行,直接進入第六章「A. 已中招」流程
- 🟠 出現 LOG NOT FOUND 👉 這不是失敗,而是提醒你先找出實際的虛擬主機日誌路徑
❇️ 獨立多站:掃描所有日誌,含已輪替壓縮的檔案
LOG_ROOT="/var/log"
find "$LOG_ROOT" -type f \( -name "*.log" -o -name "*.log.*" \) -print0 |
while IFS= read -r -d '' LOG; do
MATCHES="$(zgrep -aEi '/wp-content/uploads/[^ "?]*\.(php|phtml|phar|pht|php[0-9])' "$LOG" 2>/dev/null |
awk '$9 ~ /^2/ {print $1, $4, $6, $7, $9}' | tail -n 30 || true)"
if [ -n "$MATCHES" ]; then
printf '\n===== %s =====\n' "$LOG"
printf '%s\n' "$MATCHES"
fi
done
分支判斷邏輯:某個日誌出現命中,就依檔名或虛擬主機設定回推是哪一站,把該站列為優先鑑識對象;全部沒命中只代表風險降低,不能證明沒被摸過,因為日誌可能已輪替、遺失,或流量根本先經過 CDN 或 WAF ♻️
🫂 新手求助:找不到日誌?
HestiaCP 的網站日誌通常在 /var/log/nginx/domains/ 與 /var/log/apache2/domains/ 底下,檔名是網域名稱,不含 access 字樣,上面的迴圈已經用比較寬鬆的檔名比對來涵蓋它。找不到就請主機商提供該網站近 30 天的存取日誌 📚
🕳️ ㊃ 後門檢查點:檔案完整性不能只看外表
決策原理:這個漏洞的直接後果是「有檔案被寫進 uploads」,所以第一站就是那裡;接著才是 mu-plugins(放在這裡的 PHP 不需要啟用就會自動載入)、近期修改的 PHP 檔案,以及新建立的帳號。正常情況下,uploads 裡不該有任何 PHP 類型檔案 🕵️
💠 單一網站
WP_PATH="/var/www/example.com/public_html"
UPLOADS="$WP_PATH/wp-content/uploads"
MU_PLUGINS="$WP_PATH/wp-content/mu-plugins"
echo "=== ❶ uploads 內不該出現的可執行類型檔案 ==="
find "$UPLOADS" -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.pht" \) -ls
echo "=== ❷ Gravity Forms 資料夾近 30 天新增的檔案 ==="
if [ -d "$UPLOADS/gravity_forms" ]; then
find "$UPLOADS/gravity_forms" -type f -mtime -30 -ls
else
echo "NOT FOUND: $UPLOADS/gravity_forms"
fi
echo "=== ❸ mu-plugins 內的 PHP 檔案 ==="
if [ -d "$MU_PLUGINS" ]; then
find "$MU_PLUGINS" -maxdepth 2 -type f -iname "*.php" -print
else
echo "MU-PLUGINS DIRECTORY NOT FOUND: $MU_PLUGINS"
fi
echo "=== ❹ 近 14 天被修改過的 PHP(外掛更新本身也會改檔,請對照時間)==="
find "$WP_PATH/wp-content" -type f -name "*.php" -mtime -14 -ls | head -n 100
echo "=== ❺ 最近註冊的 10 個帳號與全部管理員 ==="
wp --path="$WP_PATH" user list --orderby=user_registered --order=DESC --number=10 --fields=ID,user_login,user_email,roles,user_registered --format=table
wp --path="$WP_PATH" user list --role=administrator --fields=ID,user_login,user_email,user_registered --format=table
預期輸出範例:
=== ❶ uploads 內不該出現的可執行類型檔案 === (沒有輸出,代表正常) === ❸ mu-plugins 內的 PHP 檔案 === MU-PLUGINS DIRECTORY NOT FOUND: /var/www/example.com/public_html/wp-content/mu-plugins
分支判斷邏輯:
- 🔴 ❶ 或 ❷ 出現非你部署的 PHP 檔案,尤其是檔名像亂數字串 👉 不要直接刪除,先保留時間、權限、雜湊與副本供鑑識,進入第六章「A. 已中招」
- 🔴 ❺ 出現註冊時間落在可疑期間的陌生管理員 👉 高度可疑,先降權保留證據,不要急著刪
- 🟡 ❹ 出現近期修改的 PHP,但時間剛好是你更新外掛的時候 👉 多半是正常更新,比對時間即可
- 🟡 ❶ 只出現 index.php,內容是「Silence is golden」 👉 少數外掛會放這種空白防護檔,屬於誤報,人工確認內容即可
- 🟢 全部乾淨 👉 這一輪沒有發現異常,但「陌生帳號」不等於「惡意帳號」,先問過維運商再判斷
❇️ 獨立多站:每站各自檢查,結果要帶上網站路徑
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
SITE_PATH="$(dirname -- "$CONFIG")"
UPLOADS="$SITE_PATH/wp-content/uploads"
if [ ! -d "$UPLOADS" ]; then
continue
fi
HITS="$(find "$UPLOADS" -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.pht" \) -print)"
if [ -n "$HITS" ]; then
printf '\n===== SUSPICIOUS FILES: %s =====\n' "$SITE_PATH"
printf '%s\n' "$HITS"
fi
done
分支判斷邏輯:每個站獨立判斷,別看到 A 站乾淨就把 B、C 站一起視為乾淨;有命中的站優先進入事件調查,其餘站繼續走一般流程 🧭
✴️ Multisite:檔案屬於整個 Network,帳號要分兩層看
WP_PATH="/var/www/network/public_html"
UPLOADS="$WP_PATH/wp-content/uploads"
find "$UPLOADS" -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.pht" \) -ls
wp --path="$WP_PATH" super-admin list
wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
printf '\n===== %s =====\n' "$SITE_URL"
wp --path="$WP_PATH" --url="$SITE_URL" user list --role=administrator --fields=ID,user_login,user_email,user_registered --format=table </dev/null
done
分支判斷邏輯:子站的檔案都放在同一個 uploads 底下,任何一個命中,就要把整個 Network 納入事件範圍;發現陌生帳號時,先分清楚它是超級管理員還是單一子站的管理員,再決定處理方式 🧾
🚨 畫重點:Checksum 通過,不等於整站乾淨。
Gravity Forms 是商業外掛,wp plugin verify-checksums 依賴 wordpress.org 的資料,對它不適用;wp core verify-checksums 只檢查 WordPress 核心檔案,看不到 wp-content。攻擊者寫進 uploads 的檔案、資料庫裡的帳號,都不在它們的檢查範圍內,所以本節的 find 與帳號檢查一項都不能少 🔐
🔧 ㊄ 加固檢查:就算修好了,也把第二道門一起鎖上
決策原理:這個漏洞最根本的教訓是「別讓上傳目錄有機會執行程式」。加固要從三個方向收斂:伺服器層禁止 PHP 執行、PHP 層停用高風險函式、設定檔權限最小化。另外,考量到漏洞嚴重程度,如果舊版曾長時間暴露,建議用 wp config shuffle-salts 強制重置 wp-config.php 裡那八組長字串鹽值,一次把所有現存的登入憑證作廢 🔒
🧊 冷知識:WordPress 的「鹽值」其實是八組隨機字串
wp-config.php 裡藏著四組金鑰(Key)加四組鹽值(Salt),負責替登入 Cookie 加密。重新產生之後,所有已登入的使用者,包含可能偷到登入狀態的攻擊者,都會被強制登出。這招在懷疑有人偷了鑰匙的時候特別好用,比逐一改密碼更快一步 🗝️
💠 單一網站:刷新鹽值(備份放在網站根目錄之外)、收緊權限、停用高風險函式
WP_PATH="/var/www/example.com/public_html"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
echo "=== ❶ 備份 wp-config.php(放在網站根目錄之外,避免被直接下載)==="
cp -- "$WP_PATH/wp-config.php" "$BACKUP_DIR/wp-config.php.${STAMP}"
chmod 600 -- "$BACKUP_DIR/wp-config.php.${STAMP}"
echo "=== ❷ 強制刷新鹽值,讓所有現存登入失效 ==="
wp --path="$WP_PATH" config shuffle-salts
echo "=== ❸ 檢查 wp-config.php 權限與擁有者 ==="
stat -c '%a %U:%G %n' -- "$WP_PATH/wp-config.php"
停用高風險 PHP 函式(冪等替換,重複執行也不會疊加設定;版本號請換成你的 PHP 版本):
PHP_VER="8.4"
PHP_INI="/etc/php/${PHP_VER}/fpm/php.ini"
# ❶ 備份設定檔
sudo cp -- "$PHP_INI" "$PHP_INI.BAK.$(date +%F_%H%M%S)"
# ❷ 停用高風險的函式
sudo sed -i -E 's/^[;#]*[[:space:]]*disable_functions[[:space:]]*=.*/disable_functions = exec,passthru,shell_exec,system,proc_open,popen/' "$PHP_INI"
# ❸ 若原本完全沒有這一行,補上
if ! grep -qE '^disable_functions' "$PHP_INI"; then
echo 'disable_functions = exec,passthru,shell_exec,system,proc_open,popen' | sudo tee -a "$PHP_INI" > /dev/null
fi
# ❹ 重載 php-fpm
sudo systemctl restart "php${PHP_VER}-fpm"
# ❺ 確認設定檔內容(php -i 讀的是 CLI 的 ini,不能代表 FPM,所以直接看 FPM 的 ini)
grep -E '^disable_functions' "$PHP_INI"
說明:⚠️ 為了安全性,建議停用 exec,passthru,shell_exec,system,proc_open,popen 這些高風險函式。若因業務需求必須保留(部分備份、影像處理外掛會用到),請再三確認必要性,並搭配額外的隔離措施 🛡️
預期輸出範例:
Success: Shuffled the salt keys. 640 site-a:www-data /var/www/example.com/public_html/wp-config.php disable_functions = exec,passthru,shell_exec,system,proc_open,popen
分支判斷邏輯:
- 🟢 三行輸出都符合預期 👉 加固完成
- 🟠 shuffle-salts 回報無法寫入 👉 wp-config.php 的擁有者或權限不對,請用該站的擁有者身分重跑
- 🟠 wp-config.php 權限大於 640 👉 收緊成 640 或 600;但收緊前先確認 PHP-FPM 的執行身分讀得到它,否則網站會直接白畫面
- 🟡 HestiaCP 等每個網站有獨立 FPM 站台設定檔的環境 👉 站台設定可能覆蓋全域的 disable_functions,請實測而不是只看 ini
- 🟡 刷新鹽值的代價是全站使用者都要重新登入 👉 若尚無入侵跡象,這屬於「保險動作」,可排在維護窗口進行
❇️ 獨立多站:逐站刷新,備份一律放在集中的安全目錄
SEARCH_ROOT="/var/www"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
SITE_PATH="$(dirname -- "$CONFIG")"
OWNER="$(stat -c '%U' -- "$CONFIG")"
SITE_NAME="$(printf '%s' "$SITE_PATH" | sed 's#^/##; s#/#_#g')"
if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" core is-installed </dev/null 2>/dev/null; then
continue
fi
printf '=== Shuffle salts: %s ===\n' "$SITE_PATH"
sudo cp -- "$CONFIG" "$BACKUP_DIR/${SITE_NAME}_wp-config.php.${STAMP}"
sudo chmod 600 -- "$BACKUP_DIR/${SITE_NAME}_wp-config.php.${STAMP}"
sudo -u "$OWNER" -- wp --path="$SITE_PATH" config shuffle-salts </dev/null
sleep 3
done
分支判斷邏輯:檔名用完整路徑轉換而來(例如 var_www_site-a_public_html),是為了避免每個站的資料夾都叫 public_html,備份互相覆蓋;任何一站執行失敗,就先暫停該站,不要繼續擴大變更範圍 ⏸️
✴️ Multisite:鹽值與設定檔屬於同一個 installation,一次處理即可
WP_PATH="/var/www/network/public_html"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
cp -- "$WP_PATH/wp-config.php" "$BACKUP_DIR/wp-config.php.${STAMP}"
chmod 600 -- "$BACKUP_DIR/wp-config.php.${STAMP}"
wp --path="$WP_PATH" config shuffle-salts
最後,把「上傳目錄禁止執行 PHP」這件最重要的加固,做成伺服器層規則。這一段同時是第六章「B. 短期應急」的核心,細節放在下面統一講 🧱
📋 ㊅ 第五章速查表:三種架構的排查地圖
| 階段 | 💠 單一網站 | ❇️ 獨立多站 | ✴️ Multisite |
|---|---|---|---|
| ① 環境判斷 | wp core is-installed | 逐站 wp-config.php | wp site list |
| ② 盤點(版本) | plugin get + sort -V | 迴圈逐站標出 VULNERABLE | Network 查一次+–network |
| ③ Log 排查 | 單一存取日誌 | 逐檔掃描含輪替日誌 | 共用日誌+URL 回推子站 |
| ④ 後門檢查 | uploads+mu-plugins+帳號 | 逐站 uploads | Network uploads+分層帳號 |
| ⑤ 加固檢查 | 鹽值+權限+函式 | 逐站鹽值、備份分開存 | Network 鹽值一次 |
🤔 讀到快喘不過氣?常見焦慮 QA 大解密
看到這裡,如果你是行銷小編或老闆,可能已經覺得頭昏眼花。別緊張,我們整理了幾個最常被問到的問題,幫你壓壓驚 🍵
- 🌠 Q:按了更新,表單會不會壞掉?
A:3.1.1 除了安全性增強,官方更新日誌還提到:匯出資料頁面新增了狀態選項、後台頁面調整了樣式範圍、授權檢查改為先讀 GF_LICENSE_KEY 常數、MCP 整合改用官方轉接套件。有用到這些功能的站,更新後請特別測試。保險起見,動手前先備份,並在測試環境跑過一次表單送出 💾 - 🌟 Q:我的表單都沒有隱藏上傳欄位,可以不更新嗎?
A:不建議。表單可能不只一張、可能有人改過設定,而且這只是已知的觸發條件。更新的成本很低,不更新的成本可能是整站 🛑 - ✨ Q:外包廠商說「那個表單早就沒人用,不用管它」,該信嗎?
A:千萬別信這句話!漏洞不在乎有沒有人填表單,只要外掛啟用、表單還在公開頁面上,攻擊者就能嘗試。請堅定地要求廠商協助更新 💣 - ⭐ Q:我完全看不懂指令,是不是就沒救了?
A:完全不會。第三章從頭到尾不需要碰指令,後面的技術內容是給有 SSH 權限、想深入排查的人看的補充 🤗
🛠️ 第六章:修不了就先擋:短期應急與正式修補三部曲
前面解決的是「怎麼判斷」,這一章才是真正的維運 Runbook。不管你屬於哪種情境,流程都是同一套:判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證,每個階段完成才能進下一階段,正式環境升級外掛前,務必先在測試環境備份並驗證,避免網站崩潰 🧹
| 階段 | 目的 | 核心動作 |
|---|---|---|
| ① 判斷環境 | 避免操作錯網站 | 單站/獨立多站/Multisite |
| ② 盤點 | 知道誰受影響 | 版本比對,標出 VULNERABLE |
| ③ 備份 | 保留回復點 | 資料庫+wp-content |
| ④ Dry-run | 先看更新會做什麼 | –dry-run |
| ⑤ 更新 | 關閉漏洞 | 升級至 3.1.1 或更新版本 |
| ⑥ 驗證 | 確認修補真的完成 | 版本+核心檔案+功能+日誌+uploads |
🚨 ㊀ A. 已中招/疑似中招:先止血再修補
決策原理:一旦第五章出現明確跡象(uploads 裡的 PHP 檔案、2xx 的可疑請求、陌生管理員),首要目標是「切斷觸發點+保留證據+隔離後門」,而不是急著更新。最大的敵人不是漏洞本身,而是在沒留證據的情況下直接清理,反而把最有價值的線索一起刪掉 🧯
💠 單一網站:先建立證據,再止血
WP_PATH="/var/www/example.com/public_html"
STAMP="$(date +%Y%m%d_%H%M%S)"
INCIDENT_DIR="$HOME/gf-incident-${STAMP}"
mkdir -p -- "$INCIDENT_DIR"
chmod 700 -- "$INCIDENT_DIR"
# ❶ 先留證據:版本、使用者名單、uploads 內可執行類型檔案的時間與雜湊
{
date -Is
wp --path="$WP_PATH" core version
wp --path="$WP_PATH" plugin get "gravityforms" --fields=name,status,version,update,update_version
} > "$INCIDENT_DIR/summary.txt" 2>&1
wp --path="$WP_PATH" user list --fields=ID,user_login,user_email,roles,user_registered --format=csv > "$INCIDENT_DIR/users.csv"
find "$WP_PATH/wp-content/uploads" -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.pht" \) \
-printf '%TY-%Tm-%Td %TH:%TM:%TS %u:%g %m %p\n' > "$INCIDENT_DIR/uploads-executables.txt"
find "$WP_PATH/wp-content/uploads" -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.pht" \) \
-exec sha256sum -- {} + > "$INCIDENT_DIR/uploads-executables.sha256"
# ❷ 日誌與資料庫快照
cp -p -- /var/log/nginx/access.log "$INCIDENT_DIR/" 2>/dev/null || echo "access.log 不在預設位置,請手動保存"
cp -p -- /var/log/nginx/error.log "$INCIDENT_DIR/" 2>/dev/null || echo "error.log 不在預設位置,請手動保存"
wp --path="$WP_PATH" db export "$INCIDENT_DIR/db_snapshot.sql"
# ❸ 切斷觸發點:停用外掛
wp --path="$WP_PATH" plugin deactivate "gravityforms"
echo "Incident evidence: $INCIDENT_DIR"
預期輸出範例:
Plugin 'gravityforms' deactivated. Success: Deactivated 1 of 1 plugins. Incident evidence: /home/admin/gf-incident-20260919_143000
分支判斷邏輯:證據已建立、外掛已停用 👉 接續下方隔離與重置;如果是企業、電商或會員制網站,同步通知資安或維運負責人,並把這個資料夾放在受保護的位置 📦
隔離可疑檔案。預設只會列出清單,不會動任何檔案;確認清單沒有誤傷之後,把 CONFIRM_QUARANTINE 改成 yes 再重跑這一段(移動使用 sudo,因為 uploads 底下的檔案通常屬於網頁伺服器帳號):
WP_PATH="/var/www/example.com/public_html"
CONFIRM_QUARANTINE="no"
QUARANTINE_DIR="$INCIDENT_DIR/quarantine"
mkdir -p -- "$QUARANTINE_DIR"
chmod 700 -- "$QUARANTINE_DIR"
find "$WP_PATH/wp-content/uploads" -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.pht" \) -print0 |
while IFS= read -r -d '' SUSPECT; do
if [ "$CONFIRM_QUARANTINE" = "yes" ]; then
printf 'Quarantine: %s\n' "$SUSPECT"
sudo mv --backup=numbered -- "$SUSPECT" "$QUARANTINE_DIR/"
else
printf 'Candidate (not moved): %s\n' "$SUSPECT"
fi
done
重置登入憑證:先把 wp-config.php 備份到網站根目錄之外,再用 wp config shuffle-salts 強制刷新那八組長字串,讓包含攻擊者在內的所有登入狀態立刻失效,最後逐一重設管理員密碼:
WP_PATH="/var/www/example.com/public_html"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
cp -- "$WP_PATH/wp-config.php" "$BACKUP_DIR/wp-config.php.${STAMP}"
chmod 600 -- "$BACKUP_DIR/wp-config.php.${STAMP}"
wp --path="$WP_PATH" config shuffle-salts
wp --path="$WP_PATH" user list --role=administrator --field=user_login |
while IFS= read -r LOGIN; do
printf 'Resetting password for: %s\n' "$LOGIN"
wp --path="$WP_PATH" user reset-password "$LOGIN" --skip-email </dev/null
done
分支判斷邏輯:全部成功 👉 進入下方後門清除與 C 節正式修補;任一步驟失敗(例如檔案權限不足)👉 先解決權限,不要略過這一步直接更新,否則同一條入侵路徑可能再次被利用。另外,重設密碼後,合法管理員要走「忘記密碼」流程取得新密碼,這是預期中的結果,請先跟團隊說一聲 🔁
陌生管理員:先降權保留證據,不要急著刪(刪除是不可逆操作,請在鑑識完成、確認 ID 無誤後再處理):
WP_PATH="/var/www/example.com/public_html" SUSPICIOUS_ID="42" # 請換成你在 users.csv 裡確認過的 ID wp --path="$WP_PATH" user set-role "$SUSPICIOUS_ID" subscriber
🚨 畫重點:如果你確認 Web Shell 曾被執行,就當作 wp-config.php 已經外洩。
之前有人只換了 WordPress 的鹽值,卻沒換資料庫密碼,攻擊者手上那份舊的資料庫帳密照樣能連線。鹽值只能作廢「登入狀態」,作廢不了「資料庫密碼」。下面這段是破壞性操作,執行前請先確認資料庫帳號的主機欄位是 localhost;若使用 HestiaCP 等面板,建議改由面板換密碼,以免面板與設定檔不同步 🙅♂️
WP_PATH="/var/www/example.com/public_html"
DB_USER="$(wp --path="$WP_PATH" config get DB_USER)"
NEW_DB_PASS="$(openssl rand -hex 24)"
sudo mysql -e "ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${NEW_DB_PASS}';" && \
wp --path="$WP_PATH" config set DB_PASSWORD "$NEW_DB_PASS" && \
wp --path="$WP_PATH" db query "SELECT 1;"
❇️ 獨立多站:只隔離有證據的站,避免整台 VPS 一起誤傷
INCIDENT_PATH="/var/www/site-b/public_html"
INCIDENT_OWNER="$(stat -c '%U' -- "$INCIDENT_PATH/wp-config.php")"
if sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" core is-installed; then
echo "Incident target confirmed: $INCIDENT_PATH (owner: $INCIDENT_OWNER)"
sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" plugin deactivate "gravityforms"
sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" config shuffle-salts
else
echo "ERROR: target is not a WordPress installation."
fi
分支判斷邏輯:路徑與擁有者確認無誤 👉 停用與刷新鹽值之後,把上方單一網站流程中的證據保存與隔離段落,逐段套用到這一站(WP_PATH 換成 INCIDENT_PATH,wp 前面加上 sudo -u 擁有者);不要把整個 /var/www 當成單一網站處理,也別因為其他站看起來正常就跳過第五章的排查 🧭
✴️ Multisite:先把事件視為 Network 級問題
WP_PATH="/var/www/network/public_html"
if wp --path="$WP_PATH" plugin is-active "gravityforms" --network; then
wp --path="$WP_PATH" plugin deactivate "gravityforms" --network
else
wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
wp --path="$WP_PATH" --url="$SITE_URL" plugin deactivate "gravityforms" </dev/null
done
fi
wp --path="$WP_PATH" config shuffle-salts
wp --path="$WP_PATH" super-admin list
分支判斷邏輯:任一子站出現入侵跡象,就對整個 Network 執行證據保存、uploads 隔離與鹽值刷新,因為外掛檔案、鹽值與 uploads 都是共用的;接著逐一檢查各子站的管理員,別漏掉權限較低、卻有發文能力的帳號 🔍
🧯 ㊁ B. 短期應急:還沒確認中招,但現在還不能立刻更新
🚨 短期應急 ≠ 正式修補。
停用外掛、禁止 uploads 執行 PHP,都只是縮短暴露時間的緩衝措施,真正的修補仍然是升級到 3.1.1 或更新版本 🧱
💠 單一網站:能停用就先停用
WP_PATH="/var/www/example.com/public_html" wp --path="$WP_PATH" plugin deactivate "gravityforms"
預期輸出範例:
Plugin 'gravityforms' deactivated. Success: Deactivated 1 of 1 plugins.
分支判斷邏輯:網站目前不靠 Gravity Forms 收單 👉 直接停用,最乾淨;表單是核心流程、不能中斷 👉 改用下面的伺服器層規則,並儘快排入正式修補 💪
Nginx 環境:把規則放進獨立檔案,再引入網站設定。順序很重要:Nginx 的正規表達式 location 是「先比對到先生效」,所以這條規則必須放在處理 PHP 的那個 location 區塊之前,否則會被搶先攔截而失效:
sudo tee /etc/nginx/snippets/wp-uploads-no-php.conf > /dev/null <<'EOF'
# CVE-2026-84434 短期緩解:uploads 目錄一律不執行 PHP
location ~* ^/wp-content/uploads/.*\.(php|phtml|phar|pht|php[0-9])$ {
return 403;
}
EOF
# 接著在該網站的 server { } 區塊中,location ~ \.php$ 之前,加入這一行:
# include snippets/wp-uploads-no-php.conf;
sudo nginx -t && sudo systemctl reload nginx
驗證規則真的生效(建立一個只會印出一行字的測試檔,測完立刻刪除;請把網址換成你的網站):
WP_PATH="/var/www/example.com/public_html"
SITE_URL="https://example.com"
TEST_FILE="$WP_PATH/wp-content/uploads/nophp-test.php"
printf '<?php echo "PHP_EXECUTED";\n' > "$TEST_FILE"
curl -s -o /dev/null -w "%{http_code}\n" "$SITE_URL/wp-content/uploads/nophp-test.php"
rm -f -- "$TEST_FILE"
預期輸出範例:
403
分支判斷邏輯:看到 403 👉 規則生效;看到 200 👉 規則被前面的 PHP location 搶先處理,調整 include 位置;看到 404 👉 檔案沒建成功,多半是路徑或權限問題。另外,極少數外掛會把 PHP 放在 uploads 底下供直接存取,套用後若有功能異常,請先確認是不是這個原因 🔮
Apache 環境:在 uploads 底下的 .htaccess 追加規則,先檢查標記避免重複寫入,也不會覆蓋原本的內容:
WP_PATH="/var/www/example.com/public_html"
HTACCESS="$WP_PATH/wp-content/uploads/.htaccess"
if ! grep -qs "CVE-2026-84434" "$HTACCESS"; then
cat <<'EOF' >> "$HTACCESS"
# CVE-2026-84434 temporary mitigation
<FilesMatch "(?i)\.(php|phtml|phar|pht|php[0-9])$">
Require all denied
</FilesMatch>
EOF
fi
❇️ 獨立多站:逐站應急,別一次停掉整台 VPS
INCIDENT_PATH="/var/www/site-b/public_html" INCIDENT_OWNER="$(stat -c '%U' -- "$INCIDENT_PATH/wp-config.php")" sudo -u "$INCIDENT_OWNER" -- wp --path="$INCIDENT_PATH" plugin deactivate "gravityforms"
分支判斷邏輯:這個指令只影響 Site B;要批次處理多個高風險站時,先跑第五章的盤點迴圈,把標 VULNERABLE 的站篩成清單,再逐一處理,不要無差別對整個 /var/www 下手 📋
✴️ Multisite:先確認是不是 Network Activated
WP_PATH="/var/www/network/public_html"
if wp --path="$WP_PATH" plugin is-active "gravityforms" --network; then
echo "Plugin is Network Activated."
else
echo "Plugin is not Network Activated."
fi
分支判斷邏輯:是 Network Activated,就把整個 Network 視為影響範圍;只是部分子站各自啟用,才適合用 –url 針對個別子站停用 🔀
✅ 為什麼停用就能擋?
這條攻擊路徑靠的是 Gravity Forms 的上傳處理程式,外掛一停用,這段程式就不會被載入,是目前最乾淨、風險最低的緩解方式。只是它擋得住新的攻擊,清不掉已經寫進去的檔案,所以還是要搭配第五章的排查 🦸
㊂ C. 正式修補:判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證
🧑🔧 DevOps 小知識:商業外掛的更新,有三條路
① wp plugin update gravityforms:要先有有效的授權金鑰,否則抓不到新版。② 官方的 CLI 附加元件(wp plugin install gravityformscli –activate)提供 wp gf update 等指令,同樣需要授權。③ 從官方帳號後台下載 ZIP,再用 wp plugin install “$GF_ZIP” –force 覆蓋。授權金鑰建議寫在 wp-config.php 的 GF_LICENSE_KEY 常數(3.1.1 起,外掛會優先讀這個常數),不要用 wp gf license update 金鑰 直接打在指令列,因為金鑰會留在 shell 歷史紀錄裡 🔑
🩵 荷包試算:這幾道防線要花多少錢?
伺服器層設定、停用外掛、刷新鹽值本質上都是免費操作,有 SSH 權限就能自己動手。真正會花到錢的是「已經中招之後」:請資安顧問鑑識、清後門的工時,加上授權過期補繳的續約費。這筆帳怎麼算,都是現在花半小時走完這套流程比較划算 💸
① 判斷環境、② 盤點
回到第五章「㊀ 環境判斷」與「㊁ 盤點」,確認自己的架構、也確認哪些站落在 VULNERABLE,把結果列成清單,不要在盤點迴圈裡直接更新 📝
③ 備份(Backup)
決策原理:更新主要改的是外掛檔案,但網站設定、資料庫與媒體檔案仍可能需要回復,所以至少要有資料庫與 wp-content 的備份。備份遵循「3-2-1 原則」:3 份副本、2 種儲存媒介、1 份異地。備份檔案集中放在 $HOME/wp-security-backup,並把目錄權限收緊,因為資料庫備份裡有完整的會員與表單資料 💾
💠 單一網站:
WP_PATH="/var/www/example.com/public_html"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
wp --path="$WP_PATH" db export "$BACKUP_DIR/site_pre_gf_update_${STAMP}.sql"
tar -czf "$BACKUP_DIR/site_wp-content_pre_gf_update_${STAMP}.tar.gz" \
-C "$WP_PATH" "wp-content"
ls -lh -- "$BACKUP_DIR"/*"${STAMP}"*
分支判斷邏輯:SQL 與檔案備份都存在 👉 進 Dry-run;任一備份失敗 👉 停止,不進入正式更新,先排除磁碟空間或權限問題 🛑
❇️ 獨立多站:資料庫用 wp db export – 直接輸出到標準輸出、由目前使用者寫入備份目錄,避免站台擁有者對共用備份目錄沒有寫入權限而失敗:
SEARCH_ROOT="/var/www"
BACKUP_ROOT="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p -- "$BACKUP_ROOT"
chmod 700 -- "$BACKUP_ROOT"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
SITE_PATH="$(dirname -- "$CONFIG")"
OWNER="$(stat -c '%U' -- "$CONFIG")"
SITE_NAME="$(printf '%s' "$SITE_PATH" | sed 's#^/##; s#/#_#g')"
DB_FILE="$BACKUP_ROOT/${SITE_NAME}_db_${STAMP}.sql.gz"
FILES_TAR="$BACKUP_ROOT/${SITE_NAME}_wp-content_${STAMP}.tar.gz"
if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" core is-installed </dev/null 2>/dev/null; then
continue
fi
printf '=== Backup: %s ===\n' "$SITE_PATH"
sudo -u "$OWNER" -- wp --path="$SITE_PATH" db export - </dev/null | gzip > "$DB_FILE"
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
printf 'FAILED (database): %s\n' "$SITE_PATH"
continue
fi
sudo tar -czf "$FILES_TAR" -C "$SITE_PATH" "wp-content"
printf 'Backup done: %s\n' "$SITE_NAME"
sleep 3
done
預期輸出範例:
=== Backup: /var/www/site-a/public_html === Backup done: var_www_site-a_public_html
分支判斷邏輯:某一站備份失敗,就把該站標記為未完成,不要因為其他站成功就直接進入批次更新;每站的備份檔名都含完整路徑,不會互相覆蓋 ⛔
✴️ Multisite:整個 Network 共用一個資料庫,只需匯出一次:
WP_PATH="/var/www/network/public_html"
BACKUP_DIR="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"
mkdir -p -- "$BACKUP_DIR"
chmod 700 -- "$BACKUP_DIR"
wp --path="$WP_PATH" db export "$BACKUP_DIR/multisite_pre_gf_update_${STAMP}.sql"
tar -czf "$BACKUP_DIR/multisite_wp-content_pre_gf_update_${STAMP}.tar.gz" \
-C "$WP_PATH" "wp-content"
🚨 畫重點:不要在 Multisite 的每個網址上各跑一次 wp db export。
這只是把同一個 Network 資料庫重複匯出多次,既浪費空間,也沒有增加實質的備份價值 🗄️
④ Dry-run(正式修改前先預覽)
💠 單一網站:
WP_PATH="/var/www/example.com/public_html" wp --path="$WP_PATH" plugin update "gravityforms" --dry-run
預期輸出範例(實際文字依 WP-CLI 版本略有不同):
Available plugin updates: +---------------+--------+---------+----------------+ | name | status | version | update_version | +---------------+--------+---------+----------------+ | gravityforms | active | 3.1.0.4 | 3.1.1 | +---------------+--------+---------+----------------+
分支判斷邏輯:看到目標版本 👉 可以正式更新;沒有列出更新,但目前版本仍 ≤ 3.1.0.4 👉 高度懷疑授權金鑰未設定或已過期,先設定好 GF_LICENSE_KEY,或改走 ZIP 手動覆蓋,不要因為「沒有更新」就認定網站安全 🔮
❇️ 獨立多站:每站各自 Dry-run
SEARCH_ROOT="/var/www"
find "$SEARCH_ROOT" -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' CONFIG; do
SITE_PATH="$(dirname -- "$CONFIG")"
OWNER="$(stat -c '%U' -- "$CONFIG")"
if ! sudo -u "$OWNER" -- wp --path="$SITE_PATH" core is-installed </dev/null 2>/dev/null; then
continue
fi
VERSION="$(sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin get "gravityforms" --field=version </dev/null 2>/dev/null || true)"
if [ -n "$VERSION" ]; then
printf '\n===== %s | current=%s =====\n' "$SITE_PATH" "$VERSION"
sudo -u "$OWNER" -- wp --path="$SITE_PATH" plugin update "gravityforms" --dry-run </dev/null
fi
done
✴️ Multisite:外掛檔案屬於同一份 installation,Dry-run 一次即可代表整個 Network:
WP_PATH="/var/www/network/public_html" wp --path="$WP_PATH" plugin update "gravityforms" --dry-run
⑤ 更新(備份與 Dry-run 都 OK 才真正修改)
💠 單一網站:先停用、再更新、再啟用,換鎖的時候把門先關上
WP_PATH="/var/www/example.com/public_html" wp --path="$WP_PATH" plugin deactivate "gravityforms" wp --path="$WP_PATH" plugin update "gravityforms" wp --path="$WP_PATH" plugin activate "gravityforms" wp --path="$WP_PATH" plugin get "gravityforms" --fields=name,status,version,update,update_version
若走 ZIP 手動覆蓋(檔名請換成你從官方帳號後台實際下載的檔案,只從官方管道下載):
WP_PATH="/var/www/example.com/public_html"
GF_ZIP="$HOME/gravityforms_3.1.1.zip"
if [ -f "$GF_ZIP" ]; then
wp --path="$WP_PATH" plugin install "$GF_ZIP" --force --activate
else
echo "ZIP NOT FOUND: $GF_ZIP"
fi
預期輸出範例:
Plugin 'gravityforms' deactivated. Success: Updated 1 of 1 plugins. Plugin 'gravityforms' activated. name: gravityforms status: active version: 3.1.1
分支判斷邏輯:更新成功 👉 立即進版本驗證;更新失敗 👉 不要反覆執行,先看錯誤訊息,確認授權、檔案權限與磁碟空間 ⚠️
❇️ 獨立多站:一站一個完整閉環
WP_PATH="/var/www/site-a/public_html" OWNER="$(stat -c '%U' -- "$WP_PATH/wp-config.php")" sudo -u "$OWNER" -- wp --path="$WP_PATH" plugin deactivate "gravityforms" sudo -u "$OWNER" -- wp --path="$WP_PATH" plugin update "gravityforms" sudo -u "$OWNER" -- wp --path="$WP_PATH" plugin activate "gravityforms" sudo -u "$OWNER" -- wp --path="$WP_PATH" plugin get "gravityforms" --fields=name,status,version,update,update_version
🧑🔧 多站環境的實務原則:一次只處理一個網站,至少在第一批部署時如此。先完成一站的「備份 👉 更新 👉 驗證」,確認沒有 PHP Fatal Error、白畫面、登入問題或表單異常,再處理下一站。這比一個巨大的迴圈從頭跑到尾更容易控制風險,出事也好定位是哪一站的問題 🎛️
✴️ Multisite:外掛檔案只更新一次,再逐站確認
WP_PATH="/var/www/network/public_html"
wp --path="$WP_PATH" plugin update "gravityforms"
wp --path="$WP_PATH" plugin get "gravityforms" --fields=name,status,version,update,update_version
wp --path="$WP_PATH" site list --field=url |
while IFS= read -r SITE_URL; do
printf '\n===== %s =====\n' "$SITE_URL"
wp --path="$WP_PATH" --url="$SITE_URL" plugin get "gravityforms" --fields=name,status,version </dev/null
done
分支判斷邏輯:外掛版本只需更新一次;子站層則逐一確認是否正常載入,任一子站出現錯誤 👉 暫停批次流程,先處理該子站,不要繼續擴大變更範圍 🛑
⑥ 驗證(更新成功 ≠ 工作完成)
正式更新之後,至少要過四層驗證。三種環境的差異只在「一次執行」還是「逐站執行」,項目本身是一樣的:
① 版本驗證
WP_PATH="/var/www/example.com/public_html"
FIXED="3.1.1"
CURRENT="$(wp --path="$WP_PATH" plugin get "gravityforms" --field=version)"
if [ "$(printf '%s\n%s\n' "$FIXED" "$CURRENT" | sort -V | head -n 1)" = "$FIXED" ]; then
echo "OK: $CURRENT >= $FIXED"
else
echo "VULNERABLE: $CURRENT < $FIXED"
fi
② 檔案完整性驗證
WP_PATH="/var/www/example.com/public_html" wp --path="$WP_PATH" core verify-checksums # 需要已安裝官方 CLI 附加元件(gravityformscli),用來比對 Gravity Forms 自己的檔案 wp --path="$WP_PATH" gf tool verify-checksums
分支判斷邏輯:第二個指令若回報 ‘gf’ is not a registered wp command,代表沒裝 CLI 附加元件,不是網站有問題;此時可改為向官方帳號重新下載同版本 ZIP,做檔案比對。wp plugin verify-checksums 不適用於這支商業外掛,不要用它的結果當依據 🔍
③ 前後台功能
WP_PATH="/var/www/example.com/public_html"
SITE_URL="https://example.com"
curl -s -o /dev/null -w "%{http_code}\n" "$SITE_URL/"
curl -s -o /dev/null -w "%{http_code}\n" "$SITE_URL/wp-admin/"
wp --path="$WP_PATH" plugin list --status=active --fields=name,version | grep -i "gravityforms"
- ☑️ 到前台實際用一張表單,上傳一個 PNG 或 PDF,確認能送出並收到成功提示
- ☑️ 後台「表單」→「Entries」確認舊資料完整顯示
- ☑️ PHP error log 沒有突然出現大量 Fatal Error
- ☑️ Cache/CDN 沒有持續回傳舊內容
④ 更新後再查一次日誌與 uploads
ACCESS_LOG="/var/log/nginx/access.log"
WP_PATH="/var/www/example.com/public_html"
if [ -f "$ACCESS_LOG" ]; then
grep -aEi '/wp-content/uploads/[^ "?]*\.(php|phtml|phar|pht|php[0-9])' "$ACCESS_LOG" |
awk '$9 ~ /^2/ {print $1, $4, $6, $7, $9}' | tail -n 50
fi
find "$WP_PATH/wp-content/uploads" -type f \( -iname "*.php" -o -iname "*.php[0-9]" -o -iname "*.phtml" -o -iname "*.phar" -o -iname "*.pht" \) -ls
分支判斷邏輯:更新後仍持續出現可疑請求,或 uploads 又冒出新的 PHP 檔案 👉 別把它當成「已修好所以沒事」,回到「A. 已中招」流程重新處理,而不是單純再更新一次外掛就結案 🔁
🔥 一句話總結:真正安全的批次維運,是先盤點、再備份、再 Dry-run,確認無誤後才逐批更新,最後驗證。環境判斷錯誤比指令寫錯更致命,這套 判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證 的閉環,換成下一次別的外掛、別的 CVE,依然可以直接套用 🦸
🕵️♀️ 舉一反三:任意檔案上傳這一整類漏洞的通用防禦心法
這次的 CVE-2026-84434 屬於典型的 CWE-434。以下是這一整類漏洞的通用防禦招式,不是本 CVE 的官方要求,但值得記在腦子裡,下次遇到不同外掛的同類問題也用得上 🧩
- 🛡️ 讓上傳目錄「即使被寫入,也無法執行」。在 Nginx 或 Apache 設定裡,明確禁止 uploads 底下的 PHP 類型檔案被執行。這是最有效的一層,因為它不依賴任何外掛有沒有寫對。
- 🛡️ 檔名由伺服器決定,副檔名由白名單決定。上傳處理不要相信客戶端傳來的檔名與欄位屬性,改由後端產生隨機檔名,再附上白名單內的副檔名,就算驗證被繞過,檔案也很難變成可執行的樣子。
- 🛡️ 驗證與儲存必須共用同一份判斷結果。這次漏洞的本質,就是「被拒絕的狀態」還能流進儲存階段。好的設計是一旦被判定無效,該物件就應該立刻作廢,不再往下傳。
🏆 第七章:事件綜合評估(10 分制,數字越高代表風險或應對品質越正向)
| 評估維度 | 分數 | 一句話評語 |
|---|---|---|
| 漏洞嚴重程度(越高代表風險越大) | 9.8 | 不用登入、不用互動就能觸發,但需要表單含隱藏上傳欄位,條件雖窄,卻是自動化掃描很容易檢查的特徵 |
| 官方應變速度 | 8 | 通報後兩天就釋出 3.1.1,速度很快;扣分在更新日誌只寫「安全性增強」,公告又晚了 15 天 |
| 一般站長自救可行性 | 7 | 點更新就能解,但這是商業外掛,授權過期的站台要多繞一步 |
| DevOps 技術文件完整度 | 7 | 成因與觸發條件清楚,但攻擊端點、暫存路徑官方未公開,NVD 分析也還沒完成 |
| 🏆 加權綜合建議 | 立即行動 | 最終建議:【今天內完成更新,並順手檢查 uploads 有沒有 PHP 檔案】 |
🔥 這起事件提醒我們一件事:漏洞不一定藏在複雜的核心系統裡,有時候只是一個「以為不必檢查的隱藏欄位」。Gravity Forms 本身是一支成熟的外掛,真正的問題是,只要它還啟用著、版本又落在受影響範圍內,那扇側門就一直開著。與其等哪天發現 uploads 裡多了不認識的檔案,不如現在花五分鐘,打開後台看一眼版本號碼 🔢
📚 參考資料與延伸資源
- 📌 NVD:CVE-2026-84434 官方詳情頁 [63]
- 📌 CVE.org:CVE-2026-84434 官方紀錄 [64]
- 📌 Wordfence Threat Intelligence:Gravity Forms <= 3.1.0.4 漏洞紀錄 [65]
- 📌 Patchstack:Gravity Forms 漏洞資料庫紀錄 [66]
- 📌 WPScan:Gravity Forms 歷史漏洞清單 [67]
- 📌 VulDB:CVE-2026-84434 技術分析 [68]
- 📌 Wordfence Blog:前一個漏洞 CVE-2026-19513 的技術分析(含 .htaccess 與 Nginx 差異說明) [69]
- 📌 Gravity Forms 官方更新日誌 [1]
- 📌 Gravity Forms 文件:gform_state_lifespan 濾鏡 [70]
- 📌 Gravity Forms 文件:用 WP-CLI 管理 Gravity Forms 與附加元件 [71]
- 📌 Gravity Forms CLI 附加元件(含 gf tool verify-checksums 說明) [72]









