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

🚨表單裡的下拉選單,居然是駭客的後門?60萬站受影響 Forminator 高危漏洞全解析(CVE-2026-15748/CVSS 9.8)

🕳️ 表單裡那顆「選你要問什麼」的下拉選單,居然能被拿去開後門?Forminator Forms 高危漏洞全解析(CVE-2026-15748/CVSS 9.8)

🔥 懶人包:
裝機量超過 60 萬 的 WordPress 表單外掛 Forminator Forms,被抓到一個完全不用登入就能觸發的任意檔案上傳漏洞(CVE-2026-15748,CVSS 9.8 嚴重等級)。只要你的表單同時有「上傳檔案」跟「下拉選單」這兩個欄位,攻擊者就能假造下拉選單送出的資料,偷渡一份假的上傳設定,繞過外掛原本用來擋 PHP 檔案的黑名單,把一支可執行的後門程式直接寫進你的網站。官方已在 1.56.2 版修補,凡是還停在 1.56.1 以下的網站,建議把這件事排進今天的待辦事項最前面 ⏰

🌐 前往 WordPress.org 外掛頁面確認目前版本 [1]

[2]


🟦 🟦 🟦

內容目錄

😨 漏洞背景:一個「你想詢問什麼」的欄位,怎麼會變成整站淪陷的破口?

先問你一個問題:你有沒有填過那種「請選擇您想詢問的項目」的下拉選單,旁邊還順手附上一個「上傳收據或截圖」的按鈕?這種組合幾乎是聯絡表單的標準配備——想客訴的人選「客訴」、想詢價的人選「詢價」,順便把發票拍照上傳。這幾乎是最無害、最日常的表單設計,偏偏這次的漏洞,就是專門挑上這種最平凡的組合下手 🧋

💡 白話文教室:把 Forminator 想成手搖飲料店的點餐機
店員櫃檯有一台自動點餐機,客人選「珍珠奶茶」(下拉選單),也可以順手把折價券照片上傳給店員核對(上傳欄位)。機器背後有一份「禁止夾帶清單」,寫著:只要備註欄裡出現 PHP 這種「可以自己執行指令的紙條」,一律擋下不處理。
問題出在,點餐機還悄悄相信一件事:只要客人在「口味備註」欄位裡多打幾個字,機器就會把這幾個字當成「內部設定指令」直接執行,而不是單純的備註文字。攻擊者發現了這個漏洞,就假裝自己在備註欄裡寫飲料甜度,其實塞的是一整套「請把我的附件當成可執行檔案處理」的偽造指令。機器信了,禁止清單也被繞過,一支就像遙控器可以遠端下指令的檔案,就這樣被端進了廚房 🧃

🧊 冷知識:擋 PHP 執行的 .htaccess,其實是 1993 年就有的老古董
這次事件裡反覆出現的救命稻草 .htaccess,其實可以追溯到 1993 年的 NCSA HTTPd(Apache 的前身),當年的設計目的單純只是想在特定資料夾做「目錄層級的存取管控」。三十幾年後的今天,它搖身一變成了擋住惡意 PHP 檔案執行的最後一道門神——只不過這次的漏洞剛好利用了「門神有時候根本沒被叫來站崗」這個時間差 🚪

整起事件的時間軸其實走得算快,攻防雙方幾乎是在搶時間,先把已經查證過的節點攤開來看:

日期 事件節點 詳細內容
2026-07-11 漏洞通報 化名 daroo 的資安研究員透過 Wordfence 漏洞懸賞計畫回報這個未授權任意檔案上傳漏洞
2026-07-14 驗證與通報開發商 Wordfence 完成漏洞驗證,將完整技術細節交給外掛開發商 WPMU DEV
2026-07-31 官方修補上線 WPMU DEV 發布 1.56.2 安全版本,正式修掉偽造欄位設定與副檔名繞過這兩個問題
2026-08-17 公開揭露 Wordfence 發布完整技術分析部落格文章,多家資安媒體同步報導
2026-08-18 CVE 正式公開 NVD/CVE.org 正式收錄 CVE-2026-15748,CVSS 評為 9.8(Critical)

從通報到修補只花了 20 天,這速度在資安圈算相當有誠意。但真正該讓你在意的其實是修補「之後」發生的事:根據 SecurityWeek 的推估,漏洞公開揭露的當下,全球還有超過 30 萬個網站仍在使用有漏洞的舊版本——這代表就算官方早就把鑰匙換好了,還是有一大半的人家門根本沒去鎖 🔓

📿 碎碎念:CVSS 9.8 到底有多嚴重?
資安圈的 CVSS 滿分是 10.0,能拿到 9 分以上的漏洞,通常代表「不用密碼、不用你點任何連結、攻擊者坐在自己家裡就能遠端得手」。這次的漏洞正好符合這個組合——不需要登入、不需要管理員做任何動作,光是網站對外開著,就有機會被摸走 🎯

值得慶幸的是,截至報告撰寫時,這個漏洞尚未被美國網路安全與基礎設施安全局(CISA)列入「已知遭利用漏洞」(KEV)清單,代表目前還沒有公認的大規模自動化攻擊潮。但根據 EPSS(漏洞被利用機率評分)的資料,這顆漏洞的被利用機率已經從 1.2% 一路爬升到 4.6%,落在所有已知漏洞的前 91% 百分位——白話講就是:目前雖然還沒炸,但引信已經點著了 🧨


🩺 🩺 🩺

🩺 你的網站屬於哪一種?先做個簡單的檢傷分級

看到這裡別急著慌,不是每個裝了 Forminator 的網站都要立刻拉警報。你可以先花一分鐘,對照看看自己比較接近下面哪一種情境,再決定接下來要花多少力氣處理 🏥

🙋 我只是負責顧網站、發文章的一般管理者

如果你平常的工作是寫文章、上架商品、回覆客服,完全不碰程式碼跟伺服器,那你要做的事其實很單純:確認版本、按更新、必要時先把表單關掉。你不需要理解後面 DevOps 那段技術深潛,直接跳到下一章「五分鐘自救指南」照著點就好,這件事真的不需要懂程式 💪

👨‍💻 我自己架站,或是幫好幾個客戶管理 WordPress

如果你身兼數職,同時顧著好幾個網站,光靠手動一個一個點後台更新太沒效率。這種情況下,用 WP-CLI 批次盤點所有站台的版本會實際很多,後面的技術深潛篇會直接給你可以貼上就用的排查指令 👇

🩵 荷包試算:這次不用花錢,但要花時間
Forminator 本身有免費版跟付費 Pro 版,但這次的漏洞是免費核心程式碼的問題,跟你有沒有付費完全無關——不管你用哪個版本,一樣要更新。真正會花到成本的地方,是如果拖到真的中招之後,得花時間(甚至請人)做鑑識與清理,那筆帳怎麼算都不划算,現在花十分鐘遠比事後花好幾天便宜 💸

🏢 我的網站有會員資料,或是有在線上收單

如果你的網站牽涉到會員個資或金流交易,這件事就不只是「更新一個外掛」這麼簡單——它同時牽涉到資料外洩的通報責任。一旦網站真的被植入後門,企業面對的往往不只是商譽受損,還可能有法規究責的問題。除了更新與重置密碼之外,建議同步啟動內部的資安應變流程,把稽核紀錄留存下來 🩹

🚨 這種情境最容易被忽略:自己客製過上傳資料夾的網站

🔴 假設情境模擬:把上傳檔案集中管理,卻忘了幫新家裝監視器
有些網站管理員為了整理方便,會把 Forminator 的檔案上傳位置改到一個自訂的資料夾(而不是用外掛預設的路徑)。問題是,外掛原本會自動幫預設資料夾裝上「禁止執行 PHP」的保護鎖,但如果這個自訂資料夾是在使用者前台送出表單時才臨時建立的,負責掛保護鎖的小幫手函式當時剛好不在場——就像總公司規定新分店開幕要裝監視器,但只有總部人員親自到場才會安裝,半夜自己偷偷開的分店,監視器永遠裝不上去。這種情況下,一旦漏洞被觸發,等於連最後一道防線都是空的 🕳️

🚨 畫重點!千萬別踩雷:如果你曾經在 Forminator 設定裡動過「自訂檔案上傳儲存根目錄」這個選項,請把它列為優先排查對象,因為這個情境的風險明顯比預設路徑更高 🪤

[45]


🟢 🟢 🟢

🛟 五分鐘無痛自救指南:不用懂技術,跟著點就好

接下來這段不管你是哪一種情境,都建議先跑一遍——完全不用碰任何指令,跟著滑鼠點就好 🖱️

  • 先去哪裡確認有沒有裝這個外掛?
    登入你的 WordPress 後台,點左側選單的「外掛(Plugins)」,再點「已安裝外掛(Installed Plugins)」。在清單裡找名字叫 Forminator Forms(後面可能還帶著 – Contact Form, Payment Form & Custom Form Builder 字樣)的那一項,它右側會標示目前的版本號碼 🔢
  • 看到版本號,怎麼判斷自己安不安全?
    如果版本號是 1.56.1 或更舊(例如 1.56.0、1.55.x 等等),代表你目前正暴露在風險裡;如果已經顯示 1.56.2 或更新,可以先鬆一口氣。判斷標準就是這一個數字 ㊙️
  • 需不需要馬上更新?
    需要,而且建議排在今天代辦事項的最前面。直接在同一個畫面點「立即更新(Update Now)」按鈕,等它跑完,重新整理頁面確認版本號已經變成 1.56.2 以上即可 💪
  • 更新會不會讓網站壞掉?
    一般來說不會,這類安全性小版號更新通常不動資料庫結構。但如果你的表單有接第三方服務(像是 Google Sheets、CRM 系統),建議更新後找一個表單實際送出一次測試,確認收件跟版面都正常 🧪
  • 如果按了更新按鈕卻沒反應,或是根本不敢按怎麼辦?
    不需要自己硬著頭皮處理,直接把這篇報告轉傳給你的網站維護工程師、外包商,或是主機代管服務商,跟他們說「Forminator 外掛有一個 CVE-2026-15748 的嚴重漏洞,麻煩優先協助更新並檢查」,多數台灣主機商都有提供這類技術支援,找人幫忙是很正常的事 🙇
  • 如果暫時真的沒辦法更新,該怎麼辦?
    既然這個漏洞需要「同時有檔案上傳+下拉選單」的表單才會被觸發,你可以先登入後台,把符合這個組合的表單暫時設為「草稿」或直接下架,等確定能安全更新後再重新啟用。也可以考慮先把整個 Forminator 外掛「停用(Deactivate)」,這樣能百分之百截斷這次的攻擊路徑 🤔

🫂 新手求助:完全看不懂「後台」「外掛」這些詞怎麼辦?
完全沒關係,這篇文章本來就不是要你變成資安專家。如果你連「已安裝外掛」這個選單都找不到,最快的方式就是把整篇文章的網址直接轉傳給你的主機商客服或網站外包商,跟他們說「我這個外掛有嚴重漏洞『CVE-2026-15748』,麻煩幫我更新並檢查」,這件事本來就不該是每個網站主自己一個人扛 🤗


🔴 🔴 🔴

🔬 技術深潛篇:從「備註欄」到「遠端執行」,中間到底斷了幾道防線?

接下來這段是給想知道「為什麼會這樣」的人看的——如果你只想知道怎麼補洞,上一章其實已經夠用了。但如果你也好奇這種等級的漏洞底層長什麼樣子,我們繼續往下拆 ⚔️

評估指標 具體資訊
外掛名稱 Forminator Forms – Contact Form, Payment Form & Custom Form Builder
開發廠商 WPMU DEV
CVE 編號 CVE-2026-15748
CVSS 評分 9.8(Critical)
CVSS 向量 CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
受影響版本 <= 1.56.1
安全版本 >= 1.56.2(2026-07-31 釋出)
漏洞類型 未授權任意檔案上傳/CWE-434(Unrestricted Upload of File with Dangerous Type)

🧊 冷知識:這已經不是 Forminator 第一次「栽在同一件事」上
早在 2023 年,這款外掛就曾爆出過 CVE-2023-4596,一樣是 CVSS 9.8 的未授權任意檔案上傳漏洞(當時的成因是驗證動作發生在檔案已經寫入之後,等於門鎖裝反了)。三年後的今天,問題以不同的變體再次出現,這提醒我們:修補單一邏輯漏洞往往不夠,真正該做的是重新檢視整條信任鏈 🔁

問題的根源不是單一段程式碼寫錯,而是三道弱點串成的一條完整攻擊鏈。以下依照多份技術分析交叉比對後的合理推論整理,實際程式碼細節請以官方原始碼與 Wordfence 技術披露為準:

🧩 弱點一:下拉選單送來的巢狀資料,沒有經過清洗就被信任

Forminator 在處理表單提交時,負責整理各欄位資料的方法(概念上對應 set_field_data() / prepare_fields_info())會逐一走訪每個已儲存的欄位。當攻擊者送出的 Select 欄位資料是一包帶有特定旗標鍵值的巢狀陣列時,這包資料會被整包原封不動附加進後續處理流程,而且是在任何逐欄位驗證之前就發生。🟡(基於公開技術分析交叉比對的合理推論)

🧩 弱點二:上傳處理程序,直接信任這包偽造資料裡的欄位設定

接下來負責處理上傳的程序(概念上對應 process_uploads())會走訪這批欄位資料,信任其中偽造的 field_type == ‘upload’,用偽造的欄位名稱去讀取真正的檔案內容,並把整包偽造的欄位設定當成「受信任的設定」,直接傳給真正負責寫檔案的核心函式。這一步等於是讓攻擊者自己決定了「這次上傳要用哪一套規則來檢查」🟡(同上)

🧩 弱點三:黑名單比對「一模一樣的字」,正則引擎卻認得「變化版」

💡 白話文教室:餐廳的過敏原申報表,只認得一模一樣的字
想像一家餐廳的過敏原黑名單上寫著「花生」兩個字,前台核對訂單時,只會逐字比對「這裡有沒有出現『花生』兩個字」。攻擊者知道規則後,故意在訂單上寫成「花(生)」——前台核對系統看不出這是同一種東西,蓋章放行了。但後廚實際切菜、烹調時使用的機器,看到括號還是會直接判讀成「花生」,過敏原照樣被端上桌。這正是這次漏洞裡黑名單被繞過的關鍵:擋 PHP 的清單只比對「php」這個精確字串,但 WordPress 底層真正判斷副檔名的函式,把清單裡的鍵值當成正則表達式解讀,於是 ph(p) 這種帶括號的寫法,一邊騙過了黑名單的字串比對,另一邊卻仍然被判讀成合法的 .php 副檔名 🥜

攻擊者只需要把偽造的 additional-type 設為 ph(p)|text/x-php 這樣的字串,就能讓可執行的 PHP 檔案順利通過驗證、被寫入磁碟。✅(此為多份技術披露一致確認的核心繞過手法)

🧩 隱藏的第四道破口:只有「總部人員到場」才會裝的保護鎖

如果攻擊真的走到「上傳成功」這一步,接下來還有一道潛在防線:外掛預設會在自己的上傳目錄裡自動生成一個禁止執行 PHP 的 .htaccess 檔案。但如果管理員曾經設定過「自訂檔案上傳儲存根目錄」,這個自訂目錄在前台請求時才第一次被建立的情況下,可能不會自動長出這個保護檔——因為負責寫入 .htaccess 的輔助函式,設計上只有在後台載入時才會被引入。🟡(基於公開原始碼行為分析的合理推論)

🧑‍🔧 DevOps 小知識:.htaccess 在 Nginx 環境下根本不會被讀取
就算保護檔真的有被正確生成,這道防線也只對 Apache 有效。如果你的伺服器跑的是 Nginx,.htaccess 從頭到尾都不會被解析——這代表 Nginx 環境的站台,必須額外在 server 設定裡手動加上等效的 location 阻擋規則,光靠外掛自動生成的保護檔是完全沒用的 ⚙️

[46]


🟧 🟧 🟧

🎯 三步驟攻擊鏈:從送出表單到拿到執行權,攻擊者只做了這三件事

  • 🏮 第一步:找到符合條件的公開表單。攻擊者不需要登入,只要找到網站上任何一個公開、已發布的表單,同時包含「File Upload」與「Select」這兩個欄位即可。
  • 🏮 第二步:偽造 Select 欄位的送出資料。透過腳本,把 Select 欄位的值包裝成一包帶有 return 旗標、偽造 field_type=upload、偽造 additional-type=ph(p)|text/x-php 的巢狀資料,向表單提交端點(通常是 admin-ajax.php 或 REST API 端點)送出。
  • 🏮 第三步:請求剛剛上傳成功的檔案,觸發執行。根據公開的原始碼分析,成功寫入後的檔案路徑會落在類似 wp-content/uploads/forminator/<表單ID>_<雜湊值>/uploads/<12碼>-shell.php 這樣的結構。若該目錄缺乏禁止執行 PHP 的保護,攻擊者只需要用瀏覽器直接開啟這個網址,伺服器就會乖乖執行裡面的程式碼。
評估項目 等級 說明
機密性影響 🔴 高 可讀取網站檔案與資料庫內容
完整性影響 🔴 高 可植入後門、竄改網站內容
可用性影響 🔴 高 可導致網站無法運作或被完全接管
利用複雜度 🟢 低 無需登入、無需任何使用者互動
觸發前提 🟡 中 表單須同時具備 File Upload 與 Select 欄位,但這是相當常見的欄位組合
整體嚴重性 🔴 嚴重 CVSS 9.8,可未授權遠端執行

[47]


🕵️ 🕵️ 🕵️

🕵️ DevOps 排查 Checklist:三種架構,各自要怎麼揪出問題

🤖 重要提醒:以下指令與排查流程由 Gemini 產生、Claude 複查
雖然已盡力對照常見實務經驗與 WP-CLI 官方文件,但仍可能因伺服器環境、權限設定或外掛版本差異而產生疏漏。正式環境(尤其是正在營業中的電商站)的高風險操作——包含更新、停用、刪除檔案、變更權限或執行批次腳本——請務必由熟悉該站架構的專業人員進行最後確認與執行,切勿直接複製貼上後就按下 Enter 🧙

如果你自己架 VPS、同時管理多個 WordPress,這一節最重要的不是背指令,而是先搞清楚自己到底是哪一種架構。搞錯架構,後面的批次指令就算語法完全正確,也可能處理錯對象。整個流程遵循環境判斷 👉 盤點 👉 Log 排查 👉 後門檢查 👉 加固檢查五個關卡,並涵蓋單一網站、獨立多站、Multisite 三種環境 📜

🧭 ㊀ 環境判斷:你在管一個網站,還是一整台伺服器?

決策原理:「一台伺服器上有好幾個 WordPress」跟「一個 WordPress Multisite 底下有好幾個 Site」看起來很像,實際上是兩種完全不同的架構,判斷錯誤會讓後續盤點跟更新指令用錯對象 🤔

💡 新手避坑指南:關於路徑與指令替換
下面指令中會出現 📂 /var/www/example.com/public_html 這類路徑。如果你用的是 Cloudways,路徑通常含 public_html;cPanel 通常在 /home/你的帳號/public_html。複製貼上前,請務必把這些變數換成你主機的真實環境,且指令中的引號包覆(如 “$WP_PATH”)已經過測試,請勿自行刪除,避免路徑含空格導致指令斷裂 ⚠️

💠 單一網站

WP_PATH="/var/www/example.com/public_html"

if wp --path="$WP_PATH" core is-installed; then
    echo "OK: 這裡確實是一個 WordPress 安裝目錄。"
else
    echo "ERROR: 這個路徑載入不了 WordPress,請先確認路徑是否正確。"
fi

grep -E "define\s*\(\s*['\"]MULTISITE['\"]" "$WP_PATH/wp-config.php" \
    && echo "→ 這是 Multisite" \
    || echo "→ 不是 Multisite,屬於單站或獨立多站"

預期輸出範例:

OK: 這裡確實是一個 WordPress 安裝目錄。
→ 不是 Multisite,屬於單站或獨立多站

分支判斷邏輯:

  • ⭕ 顯示 OK 且非 Multisite 👉 走 💠 單一網站流程
  • 🟠 顯示 ERROR 👉 先停止,確認是不是路徑打錯了
  • 🟡 grep 找到 define(‘MULTISITE’, true) 👉 進入 ✴️ Multisite 分支

❇️ 獨立多站

決策原理:獨立多站的外觀跟 Multisite 相似(都像是「好幾個網站」),但每個網站有自己獨立的 wp-config.php 與資料庫。這裡用 find … -print0 搭配 while read -r -d ” 的安全寫法,避免路徑裡如果含有空格會讓迴圈跑錯對象 🛡️

SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -maxdepth 4 -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
    path="$(dirname -- "$config")"
    owner="$(stat -c '%U' -- "$config")"

    if sudo -u "$owner" -- wp --path="$path" core is-installed 2>/dev/null; then
        printf 'WordPress: %s (owner: %s)\n' "$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 卻載入不了 👉 先確認檔案權限,不要直接更新 🗂️

✴️ Multisite

WP_PATH="/var/www/network/public_html"

if wp --path="$WP_PATH" core is-installed --network 2>/dev/null; then
    echo "✴️ 這是一個 Multisite 網路"
    wp --path="$WP_PATH" site list --fields=blog_id,url --format=table
else
    echo "💠 不是 Multisite(單站或獨立多站)"
fi

分支判斷邏輯:能列出多個 Site 👉 進入 Multisite 流程,並記得 Plugin 檔案在 Network 中是共用的,不用逐站處理 🧩


🔎 🔎 🔎

🔎 ㊁ 盤點:先搞清楚哪些站裝了、版本多少

決策原理:不要把版本號寫死在腳本裡,讓 WP-CLI 直接動態抓取,版本不明時一律套用保守防禦原則視為受影響 📊

💠 單一網站

WP_PATH="/var/www/example.com/public_html"

wp --path="$WP_PATH" plugin get forminator \
    --fields=name,status,version,update,update_version

預期輸出範例:

name: forminator
status: active
version: 1.56.1
update: available
update_version: 1.56.2

❇️ 獨立多站

SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -maxdepth 4 -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
    path="$(dirname -- "$config")"
    owner="$(stat -c '%U' -- "$config")"

    version="$(sudo -u "$owner" -- wp --path="$path" \
        plugin get forminator --field=version 2>/dev/null || true)"

    if [ -n "$version" ]; then
        printf 'Site: %s | Owner: %s | Forminator: %s\n' "$path" "$owner" "$version"
    fi
done

預期輸出範例:

Site: /var/www/site-a/public_html | Owner: site-a | Forminator: 1.56.1
Site: /var/www/site-b/public_html | Owner: site-b | Forminator: 1.56.2

✴️ Multisite

WP_PATH="/var/www/network/public_html"

wp --path="$WP_PATH" plugin get forminator \
    --fields=name,status,version,update,update_version

wp --path="$WP_PATH" plugin is-active forminator --network
echo "Network activated exit code: $?"

分支判斷邏輯:Exit code 0 代表 Network Activated,處理範圍要以整個 Network 為單位;非 0 則代表只在部分 Site 啟用,需搭配 –url 逐站確認 🔀

[48]


🟥 🟥 🟥

📊 ㊂ Log 日誌排查:找「異常參數+PHP 檔案存取」的組合

決策原理:攻擊鏈路利用的是外掛合法的表單提交端點,特徵比對型 WAF 未必能第一時間攔截。真正有價值的線索是「Select 欄位夾帶異常參數」搭配「上傳目錄裡出現 PHP 檔案存取」這兩者的時間相近性 🎯

ACCESS_LOG="/var/log/nginx/access.log"

if [ -f "$ACCESS_LOG" ]; then
    # ❶ 搜尋 Forminator 相關請求中夾帶異常欄位參數
    grep -iE "forminator" "$ACCESS_LOG" | \
        grep -iE "select-|additional-type|field_type" | tail -n 100

    # ❷ 搜尋直接請求上傳目錄裡 PHP 檔案的紀錄
    grep -E "uploads/forminator/.*\.php" "$ACCESS_LOG" | tail -n 100
else
    echo "LOG NOT FOUND: $ACCESS_LOG,請先確認你的 Web Server 實際日誌路徑。"
fi

預期輸出範例:

203.0.113.42 - - [15/Aug/2026:03:12:05 +0800] "GET /wp-content/uploads/forminator/4_fa2ad53c/uploads/cX3fRjMDDu03-shell.php?c=id HTTP/1.1" 200 45

分支判斷邏輯:

  • 🔴 出現對 forminator/…/*.php200 回應 👉 高度疑似已被成功執行,立即進入下一章「A. 已中招」流程
  • 🟡 出現 403404 👉 檔案可能上傳成功但被伺服器規則擋下,仍需徹底清理並更新
  • 🟢 無明顯異常 👉 不代表安全,繼續進行後門檢查

🟣 🟣 🟣

🕳️ ㊃ 後門檢查點:上傳目錄裡不該出現 PHP

💠 單一網站

WP_PATH="/var/www/example.com/public_html"

# ❶ Forminator 上傳目錄裡正常只該有圖片、PDF,不該有 PHP
find "$WP_PATH/wp-content/uploads/forminator" -type f -iname "*.php" -print 2>/dev/null

# ❷ mu-plugins 目錄(正常應為空,或只有你認得的檔案)
find "$WP_PATH/wp-content/mu-plugins" -maxdepth 2 -type f -iname "*.php" -print 2>/dev/null

# ❸ 最近 15 天內修改過的 PHP 檔案
find "$WP_PATH/wp-content" -type f -iname "*.php" -mtime -15 -ls 2>/dev/null | head -n 50

資料庫端的異常管理員排查,用 mktemp 建立獨立的 SQL 暫存檔,避免多層引號互相打架:

WP_PATH="/var/www/example.com/public_html"
QUERY_FILE="$(mktemp)"

cat > "$QUERY_FILE" <<'EOF'
SELECT ID, user_login, user_email, user_registered
FROM wp_users
ORDER BY user_registered DESC
LIMIT 20;
EOF

wp --path="$WP_PATH" db query < "$QUERY_FILE"

rm -f -- "$QUERY_FILE"

wp --path="$WP_PATH" user list --role=administrator \
    --fields=ID,user_login,user_email,user_registered --format=table

預期輸出範例:

# 若發現異常檔案:
/var/www/example.com/public_html/wp-content/uploads/forminator/4_fa2ad53c/uploads/cX3fRjMDDu03-shell.php

# 若管理員名單正常:
+----+------------+---------------------+---------------------+
| ID | user_login | user_email          | user_registered     |
+----+------------+---------------------+---------------------+
| 1  | admin      | admin@example.com   | 2025-01-15 08:30:00 |
+----+------------+---------------------+---------------------+

分支判斷邏輯:

  • 🔴 上傳目錄或 mu-plugins 出現不明 PHP 檔案 👉 高度疑似已中招,立即進入下一章「A. 已中招」流程
  • 🟡 出現近期建立的陌生管理員帳號 👉 立即列入調查對象,先不要直接刪除,保留紀錄
  • 🟢 皆無異常 👉 仍需完成後續更新與加固檢查

❇️ 獨立多站

SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -maxdepth 4 -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
    path="$(dirname -- "$config")"
    owner="$(stat -c '%U' -- "$config")"

    hits="$(find "$path/wp-content/uploads/forminator" \
        -type f -iname "*.php" -print 2>/dev/null)"

    if [ -n "$hits" ]; then
        printf '\n===== SUSPICIOUS: %s (owner: %s) =====\n' "$path" "$owner"
        printf '%s\n' "$hits"
    fi
done

分支判斷邏輯:每站獨立判斷,發現命中的站優先進入事件調查,其餘站繼續走一般盤點流程,不要因為一站乾淨就假設全部都沒事 🧭

✴️ Multisite

WP_PATH="/var/www/network/public_html"

find "$WP_PATH/wp-content/mu-plugins" -maxdepth 2 -type f -iname "*.php" -print 2>/dev/null

find "$WP_PATH/wp-content/uploads" -path "*forminator*" -type f -iname "*.php" -print 2>/dev/null

分支判斷邏輯:發現可疑檔案 👉 整個 Network 都要納入事件調查範圍,因為 mu-plugins 與外掛檔案都是 Network 共用資源 🌐

[49]


🔧 🔧 🔧

🔧 ㊄ 加固檢查:更新是治本,加固是多一道保險

決策原理:即使外掛已修補,補上伺服器層的防護能讓未來同類型漏洞的殺傷力大幅降低——這是最後一道防線,就算應用層被繞過,上傳的檔案也無法被執行 🔒

先確認並補齊 Forminator 上傳目錄的 .htaccess(Apache 環境適用):

WP_PATH="/var/www/example.com/public_html"
UPLOAD_DIR="$WP_PATH/wp-content/uploads/forminator"

if [ -d "$UPLOAD_DIR" ]; then
    cat > "$UPLOAD_DIR/.htaccess" <<'EOF'
<FilesMatch "\.(php|php3|php4|php5|php7|phtml|pht)$">
  SetHandler none
</FilesMatch>
RemoveHandler .php .php3 .php4 .php5 .php7 .phtml .pht
RemoveType .php .php3 .php4 .php5 .php7 .phtml .pht
Options -ExecCGI
AddType text/plain .php .php3 .php4 .php5 .php7 .phtml .pht
EOF
    cat "$UPLOAD_DIR/.htaccess"
else
    echo "找不到 $UPLOAD_DIR,可能你使用的是自訂上傳根目錄,請自行確認實際路徑。"
fi

🧑‍🔧 DevOps 小知識:Nginx 環境請額外補上這段規則
前面技術深潛篇提過,.htaccess 在 Nginx 底下完全不會被讀取。以下規則先寫成獨立的 snippet 檔案,實際套用時請手動把 include 這一行加進你自己的 server 區塊——自動改寫既有的 vhost 設定檔風險太高,建議人工確認後再套用 🕵️‍♀️

sudo mkdir -p /etc/nginx/snippets

sudo tee /etc/nginx/snippets/block-forminator-php.conf > /dev/null <<'EOF'
location ~* /wp-content/uploads/forminator/.*\.(php|php3|php4|php5|php7|phtml|pht)$ {
    deny all;
    return 404;
}
location ~* /wp-content/uploads/.*\.(php|php3|php4|php5|php7|phtml|pht)$ {
    deny all;
    return 404;
}
EOF

# ⚠️ 請自行在 server 區塊內加入:include /etc/nginx/snippets/block-forminator-php.conf;
sudo nginx -t && sudo systemctl reload nginx

PHP 層面收斂高風險函式(範例採冪等替換寫法,重複執行也不會壞):

# ❶ 備份設定檔
PHP_INI="/etc/php/8.4/fpm/php.ini"
sudo cp "$PHP_INI" "$PHP_INI.BAK.$(date +%F_%H%M%S)"

# ❷ 停用高風險函式
sudo sed -i -E 's/^[;#]*\s*disable_functions\s*=.*/disable_functions = exec,passthru,shell_exec,system,proc_open,popen/' "$PHP_INI"

# ❸ 改完重載 PHP-FPM
sudo systemctl restart php8.4-fpm

# ❹ 確認生效
php -i | grep disable_functions

檔案權限最小化:

WP_PATH="/var/www/example.com/public_html"

find "$WP_PATH" -type d -exec chmod 755 {} \;
find "$WP_PATH" -type f -exec chmod 644 {} \;
chmod 600 "$WP_PATH/wp-config.php"

ls -la "$WP_PATH/wp-config.php"

[50]


🧯 🧯 🧯

🧯 修不了就先擋:短期應急與正式修補三部曲

🔥 這裡最重要的閉環:判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證。不要把「更新成功」當成整件事結束 🙅

🚨 A. 已中招/疑似中招:先止血、留證據,再修補

決策原理:一旦第五章排查發現明確跡象(上傳目錄有可疑 PHP、mu-plugins 有陌生檔案、日誌出現成功執行的紀錄),優先目標是「切斷攻擊鏈+保留證據+隔離後門」,而不是急著更新——直接刪除或更新反而會把最有價值的鑑識線索一起銷毀 🛟

步驟 ❶:立即隔離

WP_PATH="/var/www/example.com/public_html"

wp --path="$WP_PATH" plugin deactivate forminator

wp --path="$WP_PATH" plugin get forminator --fields=name,status

步驟 ❷:緊急取證(先留證據再動手)

WP_PATH="/var/www/example.com/public_html"
STAMP="$(date +%Y%m%d_%H%M%S)"
INCIDENT_DIR="$HOME/forminator-incident-${STAMP}"

mkdir -p "$INCIDENT_DIR"

# ❷-❶ 打包伺服器日誌
tar -czf "$INCIDENT_DIR/server-logs.tar.gz" \
    /var/log/nginx/access.log* /var/log/nginx/error.log* 2>/dev/null

# ❷-❷ 打包 mu-plugins(若存在)
if [ -d "$WP_PATH/wp-content/mu-plugins" ]; then
    tar -czf "$INCIDENT_DIR/mu-plugins.tar.gz" -C "$WP_PATH/wp-content" "mu-plugins"
fi

# ❷-❸ 打包 Forminator 上傳目錄(若存在)
if [ -d "$WP_PATH/wp-content/uploads/forminator" ]; then
    tar -czf "$INCIDENT_DIR/forminator-uploads.tar.gz" \
        -C "$WP_PATH/wp-content/uploads" "forminator"
fi

# ❷-❹ 匯出資料庫快照
wp --path="$WP_PATH" db export "$INCIDENT_DIR/db-snapshot.sql"

# ❷-❺ 建立 PHP 檔案清單與 hash 紀錄
find "$WP_PATH/wp-content" -type f -iname "*.php" -exec md5sum {} \; \
    > "$INCIDENT_DIR/php-files-md5.txt" 2>/dev/null

echo "取證完成,檔案位於:$INCIDENT_DIR"
ls -la "$INCIDENT_DIR"

步驟 ❸:暫時性 Hotfix(隔離可疑檔案、刷新鹽值、重設密碼)

WP_PATH="/var/www/example.com/public_html"
STAMP="$(date +%Y%m%d_%H%M%S)"
QUARANTINE_DIR="$HOME/forminator-quarantine-${STAMP}"

mkdir -p "$QUARANTINE_DIR"

# ❸-❶ 隔離可疑檔案(不要直接刪除,先移到隔離區保留證據)
find "$WP_PATH/wp-content/uploads/forminator" -type f -iname "*.php" -print0 |
while IFS= read -r -d '' f; do
    printf '隔離:%s\n' "$f"
    chmod 000 -- "$f"
    mv -- "$f" "$QUARANTINE_DIR/"
done

echo "隔離檔案數量:$(ls -1 "$QUARANTINE_DIR" | wc -l)"

# ❸-❷ 備份 wp-config.php,並強制刷新鹽值(Salts),讓所有現有登入 Session 立即失效
cp "$WP_PATH/wp-config.php" "$WP_PATH/wp-config.php.BAK.${STAMP}"
wp --path="$WP_PATH" config shuffle-salts

# ❸-❸ 重設所有管理員密碼,並逐一銷毀他們目前的登入 Session
wp --path="$WP_PATH" user list --role=administrator --field=user_login |
while IFS= read -r login; do
    echo "重設密碼並登出:$login"
    wp --path="$WP_PATH" user update "$login" --user_pass="$(openssl rand -base64 32)"
    wp --path="$WP_PATH" user session destroy "$login" --all
done

🧑‍🔧 DevOps 小知識:為什麼「鹽值」跟「密碼」要一起處理?
早期的 WordPress 沒有這種防護機制,直到 2008 年發布的 2.5 版才引入第一批安全金鑰,2.6 版正式補齊了完整的四組鹽值。這些字串被用來混淆登入 Cookie 的雜湊運算——只要重新洗牌一次,攻擊者手上任何已經竊取到的登入憑證,會瞬間全部失效,這是應急處置中最快、最有效的止血手段之一 🧂

預期輸出範例:

隔離檔案數量:1
重設密碼並登出:admin
Success: Updated user 'admin'.
Success: Destroyed all sessions.

分支判斷邏輯:隔離與重置動作全部成功 👉 進入下方「C. 正式修補」流程;任一步驟失敗(例如檔案權限不足)👉 先解決權限問題,不要略過直接更新外掛版本,否則同一個入侵路徑可能再度被利用 🔁


🟡 🟡 🟡

🩹 B. 短期應急:尚未確認中招,但暫時無法立刻更新

🚨 短期應急 ≠ 正式修補。停用外掛與伺服器層防護規則都只是緩衝措施,目標是縮短暴露時間,真正的修補仍然是把 Forminator 升級到 1.56.2 以上 🧱

WP_PATH="/var/www/example.com/public_html"

# 若業務允許,先停用整個外掛,百分之百截斷這次的攻擊路徑
wp --path="$WP_PATH" plugin deactivate forminator

若無法整個停用(例如網站核心流程依賴表單功能),至少先套用第五章「加固檢查」提到的 Nginx/.htaccess 阻擋規則,並在後台把同時含有 File Upload 跟 Select 欄位的表單先暫時下架 🩹

[51]


🚀 🚀 🚀

🚀 C. 正式修補:判斷環境 👉 盤點 👉 備份 👉 Dry-run 👉 更新 👉 驗證

⚠️ 安全提醒:在正式環境升級外掛前,務必先於測試環境備份並驗證,避免網站崩潰。多站環境建議先挑一台流量最小的站點驗證後,再批次推送 🧪

① 備份(遵循 3-2-1 原則:至少 3 份備份、2 種不同媒介、1 份異地存放)

💠 單一網站

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"

wp --path="$WP_PATH" db export \
    "$BACKUP_DIR/site_pre_forminator_update_${STAMP}.sql"

tar -czf "$BACKUP_DIR/site_wp-content_pre_forminator_update_${STAMP}.tar.gz" \
    -C "$WP_PATH" "wp-content"

ls -lh "$BACKUP_DIR"

❇️ 獨立多站(逐站備份,檔名含站名避免互相覆蓋,加入緩衝)

SEARCH_ROOT="/var/www"
BACKUP_ROOT="$HOME/wp-security-backup"
STAMP="$(date +%Y%m%d_%H%M%S)"

mkdir -p "$BACKUP_ROOT"

find "$SEARCH_ROOT" -maxdepth 4 -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
    path="$(dirname -- "$config")"
    owner="$(stat -c '%U' -- "$config")"
    site_name="$(basename -- "$path")"

    if ! sudo -u "$owner" -- wp --path="$path" core is-installed 2>/dev/null; then
        continue
    fi

    echo "=== 備份 $site_name ==="

    sudo -u "$owner" -- wp --path="$path" db export \
        "$BACKUP_ROOT/${site_name}_pre_forminator_update_${STAMP}.sql"

    tar -czf "$BACKUP_ROOT/${site_name}_wp-content_${STAMP}.tar.gz" \
        -C "$path" "wp-content"

    sleep 2
done

⚠️ 多站批次風險提醒:不要用一個巨大迴圈一次跑完所有網站沒有任何延遲。正確做法是「逐站完成備份 👉 更新 👉 驗證後再進行下一站」,並加入 sleep 緩衝,避免伺服器 I/O 瞬間爆增 🐢

✴️ Multisite

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"

wp --path="$WP_PATH" db export \
    "$BACKUP_DIR/network_pre_forminator_update_${STAMP}.sql"

tar -czf "$BACKUP_DIR/network_wp-content_${STAMP}.tar.gz" \
    -C "$WP_PATH" "wp-content"

echo "Multisite 備份完成(注意:db export 匯出的是整個 Network 共用資料庫,不是單一 Site)"

② Dry-run

WP_PATH="/var/www/example.com/public_html"

wp --path="$WP_PATH" plugin update forminator --dry-run

預期輸出(有更新):

Plugin forminator 1.56.1 will be updated to 1.56.2.

③ 更新(標準流程:停用 👉 更新 👉 重新啟用)

💠 單一網站

WP_PATH="/var/www/example.com/public_html"

wp --path="$WP_PATH" plugin deactivate forminator && \
wp --path="$WP_PATH" plugin update forminator && \
wp --path="$WP_PATH" plugin activate forminator

wp --path="$WP_PATH" plugin get forminator --fields=name,status,version

❇️ 獨立多站(一站一個完整閉環)

SEARCH_ROOT="/var/www"

find "$SEARCH_ROOT" -maxdepth 4 -type f -name "wp-config.php" -print0 |
while IFS= read -r -d '' config; do
    path="$(dirname -- "$config")"
    owner="$(stat -c '%U' -- "$config")"

    echo "=== 更新 $path ==="

    sudo -u "$owner" -- wp --path="$path" plugin deactivate forminator && \
    sudo -u "$owner" -- wp --path="$path" plugin update forminator && \
    sudo -u "$owner" -- wp --path="$path" plugin activate forminator

    sudo -u "$owner" -- wp --path="$path" plugin get forminator --fields=name,version

    sleep 3
done

✴️ Multisite(Plugin 檔案共用,更新一次即可)

WP_PATH="/var/www/network/public_html"

wp --path="$WP_PATH" plugin deactivate forminator --network
wp --path="$WP_PATH" plugin update forminator
wp --path="$WP_PATH" plugin activate forminator --network

wp --path="$WP_PATH" plugin get forminator --fields=name,version,network

④ 驗證(三層驗證,缺一不可)

第一層:版本驗證

WP_PATH="/var/www/example.com/public_html"
wp --path="$WP_PATH" plugin get forminator --fields=name,status,version

第二層:檔案完整性(Checksum)驗證

WP_PATH="/var/www/example.com/public_html"

wp --path="$WP_PATH" plugin verify-checksums forminator
wp --path="$WP_PATH" core verify-checksums

⚠️ Checksum 驗證的侷限:這個指令只能證明「檔案內容與官方發布版本一致」,無法證明「整站完全沒有被植入後門」。攻擊者可能把後門藏在 mu-plugins、其他上傳目錄或資料庫裡,這些都不在 Checksum 驗證範圍內,仍需搭配第五章的後門排查 🔍

第三層:前後台功能與殘留檔案確認

WP_PATH="/var/www/example.com/public_html"

# 前台表單頁面是否正常
curl -sS -o /dev/null -w "%{http_code}\n" "https://example.com/contact/"

# 外掛啟用狀態
wp --path="$WP_PATH" plugin is-active forminator && echo "外掛啟用中"

# 上傳目錄是否仍有 PHP 殘留(正確答案應該是 0)
find "$WP_PATH/wp-content/uploads/forminator" -type f -iname "*.php" 2>/dev/null | wc -l

分支判斷邏輯:版本 ≥ 1.56.2、Checksum 通過、前台回傳 200、殘留 PHP 數量為 0 👉 修補完成;任一項未通過 👉 回到對應章節重新排查,不要單憑「版本更新成功」就認定網站已經安全 🔁

📋 三種架構操作手冊總表(照表操課)

階段 💠 單一網站 ❇️ 獨立多站 ✴️ Multisite
判斷環境 wp core is-installed + grep MULTISITE find … -print0 逐一檢查 wp core is-installed –network
盤點 plugin get forminator 逐站 –path + 切換 owner Network Plugin + –network
備份 db export + tar wp-content 逐站備份,檔名含站名,加 sleep 2 整個 Network DB + tar wp-content
更新 deactivate → update → activate 逐站三步驟,完成一站再下一站 檔案共用,更新一次即可
驗證 版本 + checksum + 前台測試 逐站版本檢查 + 功能測試 Network 驗證 + 各 Site 抽樣測試

🧠 🧠 🧠

🧠 舉一反三:從這次事件學到的通用防禦知識

以下屬於「未授權任意檔案上傳」這類漏洞的通用防禦知識,並非本 CVE 官方要求,但看完前面的攻擊鏈之後,你應該會發現這些原則其實都在呼應同一個道理:永遠不要相信使用者送來的資料,哪怕它看起來只是個備註欄 🔪

  • 白名單取代黑名單:永遠使用「允許清單」而非「封鎖清單」驗證檔案類型。只允許 .jpg.png.pdf 等明確安全的副檔名,而不是試圖列出所有危險的副檔名——黑名單永遠有繞過的可能,這次的 ph(p) 就是活生生的例子。
  • 儲存與執行必須分離:無論應用層驗證多嚴謹,都應該在伺服器層面對上傳目錄設定「禁止執行 PHP」的規則。這是最後一道防線——即使應用層被繞過,上傳的檔案也無法被執行。
  • 前端送來的欄位設定,一律視為不可信:這次的核心破口是「Select 欄位能偷渡 Upload 欄位的設定」,說到底就是信任邊界失守。後端應該以資料庫或設定檔中的官方版本為準,重新組合資料,而不是直接沿用客戶端送來的設定。
  • 物件儲存隔離:對於中大型網站,可以考慮把上傳檔案透過外掛轉存到獨立的物件儲存服務(如 S3),並由獨立網域提供存取,讓應用伺服器與靜態檔案完全分離,從架構上就拔除遠端執行的可能性。

🏁 🏁 🏁

🏆 事件綜合評估(10 分制,數字越高代表風險或應對品質越正向)

評估維度 分數 一句話評語
漏洞嚴重程度(越高代表風險越大) 9.8 不用登入就能觸發,且觸發條件是最常見的欄位組合,防禦難度不低
官方應變速度 8 通報後約 20 天完成修補並釋出安全版本,反應算快
一般站長自救可行性 9 點更新按鈕就能解決,操作門檻不高但急迫度極高
DevOps 技術文件完整度 8 攻擊鏈路、CVSS 向量與三種架構的排查指令齊全,方便直接落地部署
🏆 加權綜合建議 立即行動 最終建議:【今天內完成更新,不要拖到明天】

🔥 這起事件其實給了所有網站主一個很好的提醒:漏洞不一定藏在你最常用的核心功能裡,有時候反而藏在一個「你以為只是讓客人選選口味」的小小下拉選單背後。與其等真的出事才發現不對勁,不如現在就花五分鐘,打開後台看一眼版本號碼 🔢

[52]


📎 📎 📎

📚 參考資料與延伸資源

列印本文 [60] 👨‍👩‍👧‍👦 45 次瀏覽