這份清單整理自一次實際的 PHP 升級與修補。PHP 官方在 2026-10-21 公布 php2shell 漏洞(CVE-2026-109618/109619,CVSS 9.2),我們順著修補一路檢查了幾個正式站,把真的遇到的狀況用白話寫下來。如果你的系統也有類似的用法,請特別注意。
怎麼用這份清單每個情境都分成五段:白話說明、攻擊者會怎麼做、怎麼自己檢查、怎麼修,以及替代做法:一時沒辦法照標準修法做的時候,副作用最小的做法。檢查指令大多是唯讀的,可以直接在自己的主機或電腦上跑。網址、主機名稱都已經拿掉,改用 你的網站 代替。
一、PHP 本身的設定與版本
這一類跟程式寫得好不好無關,裝好 PHP 就有。
01
上傳進度被寫進 session(php2shell)
高上傳大檔案時,PHP 可以一邊收檔、一邊把「已經收到多少 bytes」寫進 session,讓網頁另外發請求來查進度、畫進度條。php2shell 攻擊的就是這個「寫進 session」的步驟。
- 攻擊者
送出一個特製的上傳請求,讓 PHP 在寫 session 時寫進攻擊者控制的內容,最後可以在主機上執行指令。網站根本不需要有上傳功能,只要 PHP 有開這個設定就會中。Apache mod_php 的環境已經有公開的攻擊示範。
- 檢查
php -i | grep session.upload_progress.enabled
# 看到 On 或 1 就是有開(PHP 預設是開的)
php-fpm、Apache、命令列各自讀不同的 php.ini,每一份都要看。
- 修法
- 最好:升級到 8.5.12、8.4.27、8.3.36 或 8.2.35 以上。
- 暫時不能升級:在 php.ini 或 FPM pool 設定
session.upload_progress.enabled = 0,然後重啟所有 PHP worker。 - 需要進度條的話,改用瀏覽器端計算(
xhr.upload.onprogress),不需要伺服器幫忙。
- 替代做法
需要上傳進度條時,改由瀏覽器自己計算,伺服器完全不用設定,也不碰 session:
const xhr = new XMLHttpRequest();
xhr.upload.onprogress = e => bar.value = e.loaded / e.total * 100;
xhr.open('POST', '/upload'); xhr.send(formData);注意:fetch() 目前拿不到上傳進度,要用 XMLHttpRequest;用 jQuery 或 axios 的話,它們也有對應的 progress 參數。副作用:只動前端,後端上傳程式不用改。
這次遇到:所有主機都是預設開啟。查過程式碼,沒有任何功能用到它,所以關掉後使用者不會察覺任何差別。檔案上傳本身(file_uploads)是另一個設定,不受影響。
02
PHP 版本已停止支援
高PHP 每個版本只維護幾年。過了期限,之後再發現漏洞,官方也不會出修補版。
- 攻擊者
等新漏洞公布,直接拿去打還在用舊版的網站。舊版永遠補不起來,等於每個新漏洞都有效。
- 檢查
php -v
curl -sI https://你的網站/ | grep -i x-powered-by
對照 php.net 的「Supported Versions」頁。這次 8.1 以下只能自己移植修補。
- 修法
- 排程升級。先比對兩個版本的語言差異、掃描需要改的程式碼位置,再升級。
- 短期升不上去:套用 01 的設定緩解,並把主機放到防火牆或 WAF 後面,只開必要的入口。
- 替代做法
不要直接在原主機上升級。先用容器把新版 PHP 開在旁邊,接資料庫副本,新舊兩版打同一批網址比對;確認一樣了再把流量切過去,舊版保留著,隨時可以切回來。跨好幾個大版本時,一次只升一個大版本(例如 8.1 → 8.2 → 8.4),每一步都比對。副作用:多花一點測試時間,正式站停機時間接近零。
這次遇到:5.6、7.0、8.1 都還有正式站在跑。容器部署的站已升到 8.4.27;舊主機只能先用設定緩解。
11
記憶體與上傳大小沒有上限
中memory_limit = -1 代表單一請求可以用掉主機全部記憶體;post_max_size 設很大,代表一個請求可以塞很多資料進來。
- 攻擊者
送出超大或特製的請求,一個就能把主機記憶體吃光,整台機器上的服務一起掛掉。這次官方的緩解建議裡,也有提到要把這兩個值收緊。
- 檢查
php -i | grep -E 'memory_limit|post_max_size|upload_max_filesize'
- 修法
memory_limit 設成實際需要的值,例如 256M。post_max_size 依實際上傳需求設定。先記下原值;如果原本就設得比較大,是因為有功能真的需要,就不要隨便調小。- 前面有 nginx 的話,也要用
client_max_body_size 限制大小。
- 替代做法
不要全站一起調小。整體設一個合理的上限,只有真的需要大檔案的那一兩個上傳網址,在 nginx 針對該路徑放大;或開一個獨立的 FPM pool 給它,用不同的設定:
client_max_body_size 8m; # 全站
location /api/upload/video { client_max_body_size 200m; } # 只放大這一條副作用:要先盤點哪些網址真的需要大檔案;調小之後,觀察 log 一週,看有沒有 413 或記憶體不足的錯誤。
08
回應標頭直接寫出 PHP 版本
中網站回應裡帶著 X-Powered-By: PHP/8.1.34,等於公開告訴所有人「我用的是哪一版」。
- 攻擊者
用掃描器整批掃網路,挑出版本號落在漏洞範圍內的網站優先攻擊。漏洞公布後幾小時內就會開始掃。
- 檢查
curl -sI https://你的網站/ | grep -i -E 'x-powered-by|server'
- 修法
php.ini 設 expose_php = Off;nginx 設 server_tokens off。這不是真正的修補,只是不讓自己被優先挑中。
- 替代做法
不想動 PHP 設定的話,在前面的 nginx 拿掉標頭就好:
fastcgi_hide_header X-Powered-By; # php-fpm
proxy_hide_header X-Powered-By; # 反向代理
副作用:沒有。只是讓回應少一行標頭。
這次遇到:每個站都有。升級後已經隱藏。
12
以為改了設定,其實沒生效
中設定改完,網站還是用舊的設定在跑,自己卻以為已經修好了。
- 常見原因
- 用
ini_set() 或 .user.ini 關 upload_progress:攻擊在程式執行之前就發生了,這兩種方式來不及擋,官方明確說不能用。 - 改了 php.ini 但沒重啟 php-fpm/Apache:舊的 worker 還在用舊設定。
- 改到命令列那份 php.ini,網站讀的是 fpm 那份。
- 設定檔改錯字,重啟失敗,服務直接停掉。
- 檢查
改完一定要從「網站實際在跑的那個 PHP」讀回來:
php-fpm -t # 先檢查設定檔語法
# 在容器內查:
docker exec <容器> php -r 'var_dump(ini_get("session.upload_progress.enabled"));'- 修法
修改前先備份,改完先跑 php-fpm -t/apache2ctl -t,通過了才 reload;失敗就自動還原,而且不重啟。
- 替代做法
把整個流程寫成一支腳本,每次都照同樣的順序做:備份 → 修改 → 檢查語法(php-fpm -t、apache2ctl -t)→ 平滑重啟(reload、graceful,不會中斷正在處理的連線)→ 從實際在跑的 PHP 讀回設定 → 失敗就自動還原備份。副作用:沒有停機;最壞的情況是還原回原狀。
03
頁面要登入,背後的 API 不用
高後台的會員列表頁面有擋登入,但頁面載入資料時呼叫的 API 網址沒有擋。不經過頁面、直接打 API 網址,資料一樣拿得到。
- 攻擊者
打開瀏覽器開發者工具,看後台頁面呼叫了哪些網址,再登出後直接打。這次實測:不用登入就能下載整份會員 CSV,還能新增、修改、刪除會員、停權、套用選單。
- 檢查
# 登出狀態,直接打後台用的 API
curl -s -o /dev/null -w "%{http_code}\n" https://你的網站/後台API/匯出
# 應該是 401 或 302 導到登入頁;回 200 就是有洞GET 和 POST 都要試,「讀」有擋不代表「寫」有擋。
- 修法
- 登入檢查掛在整組路由上(框架的 filter/middleware),不要靠每支程式自己檢查。
- 沒登入時,API 回 401,頁面導到登入頁。
- 修之前先查清楚有哪些頁面在呼叫這些 API,避免把正常使用者也擋掉。
- 替代做法
先「只記錄、不擋」:在整組路由加上檢查,但第一週只把沒登入的呼叫寫進 log,不回 401。看完 log,確定沒有正常功能是在沒登入的狀態下呼叫的,再切成真的擋。檢查掛在路由群組,不改每支 controller,要拿掉時只要改一行。副作用:觀察期那一週破口還在,資料很敏感的話就直接擋,並準備好一鍵回滾。
這次遇到:會員資料 API 讀寫都不用登入。修好後,未登入一律回 401;用資料庫副本比對,登入後的 22 個網址結果跟修改前完全一樣。
04
除錯、測試頁留在正式站
高開發時寫來看錯誤、測資料庫連線、修資料的小程式,上線時沒刪掉。
- 攻擊者
掃常見檔名,例如 test_db.php、phpinfo.php、error_viewer.php、fix_errors.php。這些頁面常會顯示資料庫帳密、錯誤 log,甚至可以直接改資料。有些還留著 ?allow_remote=1 這種「開放遠端看」的參數。
- 檢查
find . -maxdepth 2 -name '*.php' | grep -i -E 'test|debug|phpinfo|fix|error_view|backup'
curl -s -o /dev/null -w "%{http_code}\n" https://你的網站/test_db.php- 修法
從部署內容中移除;一時刪不掉,就在網站設定裡對這些路徑一律回 403。
- 替代做法
先不刪檔,在網站設定加一條規則讓這些路徑回 403;之後要用,再拿掉規則就好。真的需要在正式站除錯時,只允許公司 IP,或透過 SSH tunnel 從本機開。
# nginx
location ~ ^/(test_db|fix_errors|error_viewer|phpinfo)\.php$ { return 403; }副作用:不動程式,規則隨時可以拿掉。
這次遇到:3 支除錯頁對外可開,升級後已改成回 403。
05
上傳目錄可以寫入也可以執行
高為了讓上傳「不會出權限錯誤」,部署時把目錄設成 chmod 777。結果任何人都能寫,而且放進去的 .php 檔還能被執行。
- 攻擊者
只要找到一個上傳點,或任何能寫檔的漏洞,就上傳一支 .php 後門(web shell),之後從瀏覽器就能控制主機。
- 檢查
find . -type d -perm -0002 -not -path './vendor/*' # 所有人都能寫的目錄
find uploads writable -name '*.php' 2>/dev/null # 上傳目錄裡不該有 .php
- 修法
- 目錄只給網站執行身分(例如 www-data)寫入權限,不要開 777。
- 上傳目錄設成禁止執行 PHP,或乾脆放在網站根目錄之外。
- 上傳檔名改用系統產生,不要沿用使用者給的檔名和副檔名。
- 替代做法
上傳程式不用改,在網站設定讓上傳目錄「不執行 PHP」就好;權限從 777 改成「擁有者是網站身分、權限 755/644」,網站照樣可以寫檔。
# nginx
location ~ ^/(uploads|writable)/.*\.php$ { deny all; }
# Apache(放在上傳目錄的 .htaccess 或 vhost)
php_admin_flag engine off副作用:如果真的有功能把 .php 放在上傳目錄裡執行,那個功能會壞掉,但那本來就是後門的做法。
這次遇到:舊的部署設定每次都把可寫目錄設成 0777。改成用容器啟動時把目錄擁有者改成網站身分,拿掉 777。
06
正式站開著開發模式
中框架的環境設定(例如 CI_ENVIRONMENT、APP_ENV、APP_DEBUG)在正式站還是 development。
- 攻擊者
故意弄出錯誤,例如打一個不存在的網址、送奇怪的參數,錯誤頁就會顯示完整程式路徑、程式碼片段、SQL,有時還有環境變數。這些都是下一步攻擊的地圖。
- 檢查
curl -s https://你的網站/不存在的頁面-xyz | grep -i -E 'stack|trace|\.php|exception'
- 修法
正式站設成 production,並在 php.ini 設 display_errors = Off、log_errors = On:錯誤寫進 log,不要顯示給使用者看。
- 替代做法
只改環境變數,不改程式。需要看錯誤時去看 log 檔(writable/logs/、docker logs),不要開在畫面上。要讓開發者在正式站看細節,可以在 log 裡記一個錯誤編號,畫面上只顯示這個編號。副作用:開發者要改成看 log 找錯誤。
這次遇到:有一個站的正式環境設成 development,404 頁會回傳 stack trace。已改成 production。
07
錯誤頁洩漏伺服器路徑
中程式本身有 bug,每次呼叫都噴 Fatal error,錯誤訊息裡帶著伺服器上的完整檔案路徑。
- 攻擊者
從路徑知道主機的目錄結構、框架和版本,配合其他漏洞時,就知道該讀哪個檔、往哪裡寫。
- 檢查
用無效的參數打每一支公開 API,看回應裡有沒有 Fatal error、/var/www/、/app/ 這類字樣。
- 修法
修 bug,並套用 06 的設定。這次的原因是程式和框架新版的方法簽名不合,升級之後就修好了:無效的 token 現在回 404 和一句中文說明。
- 替代做法
在框架加一個全域的錯誤處理:記完整的錯誤到 log,畫面只回固定格式,例如 {"success":false,"message":"系統錯誤","id":"錯誤編號"}。原本的 bug 還沒修好之前,這樣做也能先擋住洩漏。副作用:前端看到的錯誤訊息會變簡單,要靠編號回頭查 log。
09
帳號密碼寫死在程式裡
中程式裡直接寫著 admin/admin123 這類帳密。即使現在沒有頁面在用,它還是存在 git 紀錄裡。
- 攻擊者
只要原始碼外洩(例如 .git 資料夾被公開,或 repo 權限設錯),就能直接拿來登入。或者以後有人把這個驗證又掛回某個路由,就等於開了一扇預設密碼的門。
- 檢查
grep -rn -i -E "password\s*=\s*['\"]|admin123|123456" app/ config/ --include=*.php
- 修法
刪掉那段程式,帳密一律改從環境變數讀。如果這組密碼別的地方也用過,要一併更換。
- 替代做法
分兩步走,避免改的當下就把人鎖在外面:第一步,把寫死的值搬到環境變數,值先保持一樣,上線確認正常;第二步,再換成新密碼。功能根本沒在用的話,直接刪掉最乾淨。副作用:第一步完成前,舊密碼還在 git 紀錄裡,所以第二步一定要做。
這次遇到:一個沒在用的 Basic Auth 過濾器寫死了 admin123,已刪除;用舊帳密打 API 確認回 401。
10
測試站對外公開、沒有密碼
中測試分支部署成一個對外網址,方便大家看,但沒有加任何保護。
- 攻擊者
測試站通常版本最舊、設定最鬆,有時還連著正式資料庫。攻擊者會從 DNS 紀錄或憑證紀錄找到 test.、dev.、staging. 這類網址,先從這裡進來。
- 檢查
列出所有對外的子網域,逐一確認:不開就看不到的站有沒有加密碼?它連的是哪一個資料庫?
- 修法
不用時就停掉;需要時加 Basic Auth 或限制來源 IP,而且一定要用獨立的測試資料庫。
- 替代做法
不用停站,在反向代理或 Cloudflare Access 這一層加上 Basic Auth 或 IP 白名單,測試站本身的程式完全不用改。副作用:看測試站的人要多輸入一次密碼。
13
升級本身把功能弄壞
中為了修漏洞而升級,結果某個功能壞了,而且可能過幾天才被使用者發現。
- 常見原因
- 新版 PHP 改了函式的預設值或棄用參數,例如 CSV 相關函式的 escape 參數。
- 框架新版改了讀取 cookie 和請求資料的方式。
- 程式的方法和框架新版的同名方法簽名衝突,直接 Fatal error。
- 部署方式跟著改了,例如目錄權限、啟動指令。
- 做法
每一層都要和「改之前」比,不是只看有沒有通過:
- 升級前先查官方文件,列出兩個版本的差異,用 PHPCompatibility 掃出要改的程式位置。
- 在還沒改的程式上先跑一次單元測試,當作基準。
- 用資料庫副本,同時跑新舊兩版,打同一批網址比對回應。
- 上線後跑 smoke 測試和基準比對,並觀察 log。
- 最後列出「最可能壞掉的地方」,請人登入實際操作一次。
- 替代做法
升級和功能修改分開提交、分開上線,出問題時才知道是哪一個造成的。上線時用新容器取代舊容器(藍綠部署),舊版本保留著,回滾只要一行指令。副作用:要多維護一個部署設定,換來的是幾分鐘內就能退回原狀。
這次遇到:測試抓到 1 個因框架改版造成的失敗,已修。新舊版比對 27 個網址,有 4 個不同,都是預期中的修正(原本會壞掉的 API 變正常、錯誤頁不再洩漏)。
五分鐘自我檢查curl -sI https://你的網站/ | grep -i x-powered-by # 08 版本號有沒有露出來
php -i | grep -E 'upload_progress.enabled|memory_limit|display_errors' # 01 11 06
curl -s -o /dev/null -w "%{http_code}\n" https://你的網站/test_db.php # 04 除錯頁
curl -s -o /dev/null -w "%{http_code}\n" https://你的網站/.git/config # 原始碼外洩
# 03:登出狀態下,把後台頁面呼叫的 API 逐一打一次