Wordpress

WordPress uploads 資料夾能執行 PHP 嗎?權限設對後門還是進得來

多數人以為,只要把 WordPress 上傳資料夾(uploads)的權限鎖到最嚴,網站就安全了。目錄設定 755、檔案設定 644,資安檢查清單打勾,然後呢?後門還是照樣進得來,因為「誰能讀、誰能寫」跟「這個檔案會不會被伺服器當成程式碼執行」,根本是兩件互不相干的事,權限位元從頭到尾都管不到後者。

uploads 資料夾之所以存在,只是為了讓 WordPress 把使用者上傳或系統產生的媒體檔案(圖片、PDF、影片、暫存檔)放進去,供瀏覽器讀取、顯示;它從來不需要被賦予「執行程式碼」這個能力。可是外掛上傳功能的驗證漏洞一年一年沒斷過,攻擊者早就把 uploads 當成寫入惡意 PHP 檔案的固定落點。真正能擋住最後一步的,不是把權限再收緊一次,而是在伺服器層直接切斷「這個目錄裡的 .php 會不會被執行」這條規則。

上傳資料夾天生就不需要執行 PHP 的能力

先把兩件常被混在一起的事分開講。「可寫入」講的是伺服器程序能不能把資料寫進某個目錄;「可執行」講的是伺服器收到一個請求時,會不會把對應的檔案丟進某個直譯器(例如 PHP 引擎)去跑。這兩層設定各自獨立,一個管讀寫權限,一個管處理方式,完全不會互相牽動。

可寫入由檔案權限位元控管、可執行由伺服器處理常式控管,兩層各自獨立,權限鎖再嚴也管不到 uploads 裡的 .php 會不會被執行
可寫入和可執行分屬兩層,權限位元只管誰能把檔案寫進來,管不到 uploads 裡的 .php 會不會被伺服器當成程式碼執行。

uploads 目錄之所以需要能寫入,純粹是因為 WordPress 要讓使用者上傳的圖片、PDF、影片,或系統自動產生的縮圖、暫存檔案能夠落地,再讓瀏覽器讀取、顯示出來。從頭到尾,這個目錄唯一的工作就是存放媒體檔案供讀取,沒有任何情境需要它被伺服器當成程式碼執行。WordPress 官方開發手冊在說明 wp-content 目錄的權限設計時,也只提到這個目錄需要能被使用者帳號與網頁伺服器程序寫入,完全沒有提到「可執行」,官方語境裡,這兩者本來就是分開處理的概念。

不少人以為把 uploads 目錄權限調到最嚴(目錄 755、檔案 644,這也是官方建議的標準值)就等於關上了這個破口。這個判斷只對了一半,權限確實能限制誰能寫入這個目錄,但完全管不到已經寫進去的 .php 檔案會不會在有人直接請求它的網址時被伺服器執行。真正決定後門能不能被引爆的,是後面這一層執行規則,不是檔案權限。

檔案權限管讀寫,執行與否是另一層開關

伺服器怎麼決定「這個副檔名的檔案該怎麼處理」,靠的是依副檔名指派的處理常式(handler)。以 Apache 或 Nginx 為例,只要一個請求打到副檔名是 .php 結尾的檔案,預設就會被丟進 PHP 解析引擎執行;這個指派規則設定在伺服器設定層,Apache 寫在 FilesMatch 區塊、Nginx 寫在 location 區塊,跟檔案本身的 rwx 權限位元完全無關。

這也是為什麼「明明權限都設對了,還是中後門」的情況會反覆發生。就算把上傳目錄權限鎖到最嚴,目錄 755、檔案 644,只要這個目錄仍然被「.php 一律交給 PHP 執行」的規則覆蓋,攻擊者放進去的 .php 檔案照樣會被執行,權限設定在這裡完全幫不上忙。OWASP 的 File Upload Cheat Sheet 也把這兩件事分開處理,文件建議依最小權限原則設定檔案系統權限,若檔案真的需要被執行,則要求在執行前先掃描,確保沒有夾帶巨集或隱藏指令碼。也就是說,OWASP 把「檔案系統存取權限」與「是否該允許執行」列成兩個各自要單獨檢查的控制項,而且對「執行」直接訂出更高的標準。同一份文件講到檔案儲存位置時,也建議把檔案存在網站根目錄之外,或即使存在根目錄內也只給寫入權限,同樣是把寫入與執行分開列。

後門偏好躲在上傳資料夾的三個原因

uploads 資料夾會變成後門固定藏匿的位置,不是巧合,第一個原因很單純,它是 WordPress 運作上少數「一定要可寫入」的目錄之一。核心程式、佈景主題、大多數外掛檔案在正常情況下都不該被外部寫入,可寫入的目錄自然就成了攻擊者的首選。

第二個原因跟人的注意力有關。uploads 裡塞滿看起來正常的圖片、PDF、影片,管理者與掃描工具的目光多半放在核心、外掛、佈景主題的程式檔案上,逐一檢查媒體目錄裡每一個檔案的頻率反而偏低。一支偽裝成 .jpg、或藏在深層子資料夾裡的 .php 檔案,很容易就這樣被忽略掉。

第三個原因,也是最常見的破口,藏在外掛的上傳功能裡。

外掛的檔案上傳功能是最常見的破口

這類漏洞在資安分類裡有正式名字,CWE-434,不受限的危險類型檔案上傳。當外掛開放使用者上傳檔案(留言附件、表單附件、頭像圖片、匯入功能)時,若只靠副檔名黑名單擋,或只信任使用者送出的 Content-Type 標頭,問題就來了。這個標頭本身可以被偽造,副檔名黑名單也擋不住刻意繞過的命名方式,攻擊者只要讓偽裝過的 .php 檔案通過檢查,就能直接把它落地到 wp-content/uploads。

這不是理論推演,而是 WordPress 外掛生態裡反覆出現的漏洞模式。OWASP 的 File Upload Cheat Sheet 把這類威脅列在檔案上傳威脅一節,具體點出幾種常見的繞過手法,驗證檢查必須在檔案名稱解碼之後才進行,否則像雙重副檔名(例如 .jpg.php)或空位元組(例如 .php%00.jpg,讓系統誤判 .jpg 是副檔名,實際執行的卻是 .php)都能讓檢查形同虛設。以下兩起真實案例,弱點分類正好都對應同一個 CWE-434。

就連 WordPress 核心也曾出現能寫入可執行檔案的漏洞

即使核心與外掛都保持在最新版本,這件事也不是理論上的假設。以下兩起都是官方紀錄在案、可查證的真實案例。

  1. CVE-2024-31210,出在外掛安裝上傳功能。管理員在後台外掛的安裝外掛畫面上傳一個非 zip 格式的檔案時,如果安裝流程要求輸入 FTP 憑證才能把檔案搬到目標位置(搬到 uploads 目錄之外),這個檔案在搬移前會暫時留在媒體庫,也就是留在 uploads 路徑底下。當一個網站同時符合兩個條件,一是 DISALLOW_FILE_EDIT 設為 true,二是安裝外掛時要求輸入 FTP 憑證,就會出現一條原本不該存在的 PHP 程式碼執行管道,讓原本無法直接執行任意 PHP 的管理員帳號取得遠端程式碼執行的能力。官方安全公告 CVSS 3.1 評分 7.6,屬於高風險等級,弱點分類同樣是 CWE-434。WordPress 已在 2024 年 1 月 30 日發布的 6.4.3 版修補,並回溯修補到 4.1.40 以上所有次版本。
  2. CVE-2026-63030,是 2026 年 7 月才發生的近期案例。WordPress 核心的 REST API 批次端點存在邏輯缺陷,搭配另一個 SQL 注入漏洞 CVE-2026-60137(出在 WP_Query 的 author__not_in 參數),兩者串連之後可以達成完整的遠端程式碼執行。官方 CVSS 3.1 評分 9.8,屬於危急等級。這個漏洞已被美國網路安全暨基礎設施安全局(CISA)在 2026 年 7 月 21 日正式列入已知被利用漏洞目錄,代表攻擊者當下正在實際利用這個漏洞,不只是紙上談兵的風險評估。受影響版本涵蓋 6.9.0 到 6.9.4、以及 7.0.0 到 7.0.1,WordPress 已在 6.9.5、7.0.2、7.1 beta2 修補完成。

這兩個案例合起來說明一件事,即使核心與外掛都保持更新,任何一次新漏洞從被發現到修補上線之間都存在空窗期。空窗期裡,攻擊者能不能把可執行的程式碼寫進網站,uploads 目錄往往是第一個測試落點,而就算檔案真的被寫進來,伺服器也不執行它,這道規則正是在空窗期裡唯一還能擋住攻擊鏈最後一步的防線。

後門攻擊鏈從外掛上傳漏洞、惡意 .php 落地 uploads 到被直接請求執行,阻擋執行規則在最後一步讓伺服器回應 403、不執行檔案
阻擋 uploads 執行 PHP 不會擋下檔案被上傳進來,但會在攻擊鏈最後一步把請求擋成 403,讓後門無法被引爆。

在 Apache 環境用 .htaccess 擋下執行請求

多數台灣的共享主機、虛擬主機用的都是 Apache,這也是第一種、也是最多人適用的做法。作法是在 wp-content/uploads 目錄裡新增或編輯既有的一份 .htaccess 檔案,加入指令讓伺服器對所有 .php 結尾的請求一律回應拒絕存取,而不是把它交給 PHP 處理常式執行。

這一步不需要動到網站根目錄的 .htaccess,那份檔案控制的是固定網址結構,改壞了會讓整站掛掉。只要在 uploads 這一層新增一份獨立的 .htaccess,風險範圍就小很多;動手之前,先把現有的設定備份一份,萬一套用後出現異常也能立刻退回。

Require all denied 搭配 FilesMatch

Apache 2.4 起,官方建議的存取控制語法是 Require 系列指令,用來取代舊版的 Order 與 Deny from all 寫法。具體做法是用 FilesMatch 把規則的作用範圍限定在 .php 結尾的請求上,裡面放一行 Require all denied,寫法如下:

<FilesMatch "\.php$">
    Require all denied
</FilesMatch>Code language: Apache (apache)

這樣寫的效果是,圖片、PDF、影片等其他副檔名完全不受影響,仍然可以正常上傳與顯示,只有直接對 .php 檔案發出的請求會被擋下,回應 403。Apache 官方文件對 Require all denied 的定義很直接,就是無條件拒絕存取。這個指令屬於 mod_authz_core 模組,可以用在 directory 區塊或 .htaccess 裡。

主機用舊版 Apache 出現 500 錯誤時的替代寫法

極少數還在用 Apache 2.2、或主機沒有啟用 mod_authz_core 模組的環境,Require 語法不會被辨識,套用後會直接跳出 500 內部伺服器錯誤。Apache 官方文件本身就說明了這個版本沿革,mod_authz_core 是 Apache 2.4 才新增的模組,用來取代 2.2 版原本由 mod_access 模組提供的 Order、Deny 存取控制語法。

遇到這種情況,改用舊版寫法就能達到相同效果:

<FilesMatch "\.php$">
    Order Deny,Allow
    Deny from all
</FilesMatch>Code language: Apache (apache)

兩種寫法二選一即可,不要在同一個 FilesMatch 區塊裡同時疊加新舊兩種指令,部分主機環境疊加寫法會互相衝突,反而再次觸發 500 錯誤。

在 Nginx 環境要把拒絕規則寫在 PHP 位置區塊前面

Nginx 不讀取 .htaccess,沒辦法沿用 Apache 那套做法,必須直接編輯 Nginx 的網站設定檔,在 server 區塊裡新增一個 location 區塊,把 uploads 目錄內對 .php 的請求擋掉。

但這裡真正的重點不是有沒有加規則,而是加在設定檔的哪個位置,這是這個做法最容易失敗、卻最少人提醒的地方。

Nginx 位置區塊命中順序在前的那一條,不是最精確的那條

多數 WordPress 網站的 Nginx 設定裡,通常都已經有一段通用的 PHP 位置區塊,把所有 .php 結尾的請求轉給 PHP-FPM 處理,大致像這樣:

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
}Code language: Nginx (nginx)

如果只是把擋掉 uploads 內 PHP 的新規則加在這段通用規則的後面,效果會等於沒加。

原因在於 Nginx 官方文件對 location 比對順序的定義,正規表示式類型的 location,是依設定檔裡出現的先後順序逐一比對,一旦命中第一個相符的規則就停止比對,套用該區塊的設定,不會自動挑選寫得比較精確、或比較晚出現的那一條。也就是說,請求早就被前面那段通用的 PHP 規則接走,送進 PHP-FPM 執行了,後面才新增的拒絕規則形同虛設。

正確做法是把拒絕 uploads 內 PHP 執行這個更精確的 location 規則,寫在通用 PHP 位置區塊之前:

location ~* /wp-content/uploads/.*\.php$ {
    deny all;
}

location ~ \.php$ {
    fastcgi_pass unix:/run/php/php-fpm.sock;
    fastcgi_index index.php;
    include fastcgi_params;
}Code language: Nginx (nginx)

改完設定檔之後要重新載入 Nginx(reload)才會生效,直接存檔並不會讓正在運作中的服務套用新規則。

Apache 在 uploads 放 .htaccess 用 FilesMatch 搭 Require all denied,Nginx 改設定檔加 location 且要寫在通用 PHP 區塊之前,兩種做法效果相同
Apache 靠 uploads 目錄的 .htaccess、Nginx 靠設定檔的 location,二選一即可,Nginx 的拒絕規則務必寫在通用 PHP 區塊之前才有效。

資安外掛的一鍵切換,本質是代寫同一份規則

不想自己動手改設定檔,許多常見的 WordPress 資安外掛,設定選單裡也都有類似阻擋 uploads 目錄執行 PHP 的開關。勾選之後,外掛會自動幫網站在 uploads 目錄寫入對應的 .htaccess(Apache 環境),效果跟前面手動寫的規則完全相同,外掛只是把操作介面化,幫你按下那個開關而已。

外掛寫入的規則檔案本身,是留在伺服器上的靜態設定。一旦寫進去就會持續生效,真正決定這道防護存不存在的,是那份 .htaccess(或對應的伺服器設定)有沒有留著,而不是外掛有沒有保持啟用或更新。就算哪天停用甚至移除了那支外掛,只要沒動過那份設定檔,規則依然有效;反過來,如果哪次手動清理設定檔時不小心刪掉了那幾行,防護也會跟著消失,跟外掛狀態無關。無論是用外掛按一鍵切換,還是照前面的方法手動寫,最終效果一樣,依自己的熟悉程度二選一即可。

設定完成後,要用測試檔實際驗證是否生效

規則寫完不能只靠畫面沒報錯就當作設定成功,一定要實際測試。具體做法是先備份現有的 .htaccess(或 Nginx 設定),接著在 uploads 目錄裡放一個內容極簡單、只會輸出一行文字的 .php 測試檔,直接用瀏覽器網址請求它。

預期結果是收到 403 Forbidden,這正是 Apache Require all denied 的標準回應,或是 Nginx 對應的拒絕回應。畫面不會顯示這支測試檔案執行後該輸出的內容,也不能顯示這支 .php 檔案的原始碼文字本身。如果瀏覽器直接顯示了 PHP 原始碼,代表用錯了方法,只是把 PHP 引擎關掉,沒有真的擋下請求;讓原始碼外洩,本身也是另一個資安問題。同時也要確認網站原本正常的圖片、文件上傳與前台顯示都不受影響。測試完成之後,記得把這支測試檔刪除,不要留在正式站上。

放測試 .php 到 uploads 後用瀏覽器請求,回應 403 代表規則生效,顯示原始碼或執行輸出都代表設定沒做對
放一支測試 .php 直接請求,收到 403 才算成功;顯示原始碼或跑出結果,都代表方法用錯或規則沒真的生效。

出現空白頁或伺服器錯誤,代表寫法跟主機不相容

如果套用規則後網站出現異常,不限於 500 錯誤,也可能是完全無法連線、或後台打不開,這裡講的是通用的除錯順序,不限於 Apache 環境。第一步永遠是先把剛剛加入的那幾行規則退回,確認網站恢復正常。

接著再依前面依伺服器類型列出的替代寫法,逐一重新測試。不要在同一份設定檔裡同時保留兩種可能互相衝突的寫法邊試邊猜,那樣真的出問題時,很難判斷是哪一段造成的。

這道規則擋得住執行,但擋不住檔案被放進來

阻擋 uploads 目錄執行 PHP,是攻擊鏈的最後一道閘門,不是唯一的防線。它不會阻止惡意檔案被上傳進來,那要靠外掛本身的漏洞修補、上傳功能的檔案驗證機制去擋;它也不會清除網站裡已經存在的後門,那是另一套排查後門的工作。

它做的事情很單純,就算前面所有防線都被突破,真的有一支惡意的 .php 檔案被寫進了 uploads 目錄,伺服器層級也不會把它當程式碼執行,讓整條攻擊鏈在最後一步斷掉。前面提到連 WordPress 核心自己都在 2026 年 7 月出現過被 CISA 列入已知被利用漏洞目錄的重大漏洞,正說明隨時可能有新漏洞才是常態。

正因為如此,這道設定該被當成幾乎零成本、幾分鐘就能做完的基本加固,而不是拿來取代保持 WordPress 核心、佈景主題、外掛隨時更新這個更根本的防守。這道規則能做的,就是在漏洞被修補之前,先把攻擊者能造成的最終傷害鎖死在檔案能被放進來、但不能被執行這一步。

上傳資料夾要不要能寫入,答案從一開始就沒得選;會不會被拿來執行,答案完全在你手上。設定這道規則不需要等到中招才想起來,現在就能動手。

資料來源
  1. Hardening WordPress — WordPress
  2. File Upload - OWASP Cheat Sheet Series — OWASP
  3. mod_authz_core - Apache HTTP Server Version 2.4 — Apache
  4. Module ngx_http_core_module — Nginx
  5. CVE-2024-31210 security advisory (plugin installer PHP file upload bypass) — WordPress
  6. CVE-2026-63030 security advisory (REST API batch-route confusion / SQL injection RCE) — WordPress
  7. WordPress 7.0.2 Security Release — WordPress
  8. Known Exploited Vulnerabilities Catalog — CISA