Learning Objectives
- 說明網站安全的基本原則:不相信使用者輸入、輸入時驗證、輸出時跳脫、最小權限
- 說明 XSS 如何發生,並在 HTML 內容與屬性中用 htmlspecialchars 跳脫
- 說明 CSRF 如何發生,用存在 Session 的隨機 token 與 hash_equals 防止
- 用 password_hash、password_verify、password_needs_rehash 保存與驗證密碼,說明為什麼不用 md5、sha1
- 安全地處理檔案路徑與上傳:basename、副檔名白名單、上傳檔放在不能執行的位置
- 設定正式環境不顯示錯誤、Session Cookie 的安全選項,並說明為什麼要使用仍有安全更新的 PHP 版本
網站一上線,就會有人來試
網站放上網路之後,來的不只是正常的使用者,還有自動掃描程式:它們每天對網路上的網站送出大量請求, 試著在表單裡塞進 HTML、SQL、奇怪的路徑,看看哪個網站會出錯。安全性不是最後才加的功能,而是寫每一行程式時的習慣。 本課整理 PHP 網站最常見的幾種攻擊,以及對應的寫法。先記住四個原則:
| 原則 | 意思 |
|---|---|
| 不相信使用者的輸入 | $_GET、$_POST、$_COOKIE、上傳的檔名,甚至部分 $_SERVER 的內容(例如 HTTP_USER_AGENT)都可能被竄改 |
| 輸入時驗證,輸出時跳脫 | 收到資料先檢查格式、長度、範圍;輸出到 HTML、SQL、檔案路徑時,依那個地方的規則處理 |
| 最小權限 | 程式連資料庫的帳號只給需要的權限(網站用的帳號不需要刪除資料表);檔案權限、伺服器帳號也一樣 |
| 多層防護 | 不依賴單一防線:驗證、跳脫、預備陳述式、Cookie 的安全選項同時使用 |
| 攻擊 | 防護 | 本課 |
|---|---|---|
| XSS(跨網站指令碼) | 輸出時 htmlspecialchars() |
✔ |
| SQL 注入 | 預備陳述式 | 第 17 課 |
| CSRF(跨網站請求偽造) | 表單 token、SameSite Cookie | ✔ |
| 密碼外洩 | password_hash() |
✔ |
| 路徑穿越、惡意上傳 | basename()、白名單、上傳目錄不可執行 |
✔ |
| 錯誤訊息洩漏資訊 | display_errors = Off、記錄到紀錄檔 |
✔ |
| 已知漏洞 | 更新 PHP 與套件 | ✔ |
XSS:輸入被當成 HTML
第 11 課提過:把使用者的輸入原封不動放進 HTML,輸入裡的標籤就會被瀏覽器解讀。
輕微的只是版面被改掉(文字變粗、變紅、出現一張圖),嚴重的是 <script>:它會在每個看到這頁的人的瀏覽器裡執行,
用他們的身分操作網站。這就是 XSS(Cross-Site Scripting,跨網站指令碼)。
防護方法是輸出時跳脫:
echo htmlspecialchars($comment); // 放在標籤之間
echo '<input value="' . htmlspecialchars($name) . '">'; // 放在屬性裡(屬性一定要加引號)
PHP 8.1 起,htmlspecialchars() 預設的旗標是 ENT_QUOTES | ENT_SUBSTITUTE | ENT_HTML401:雙引號和單引號都會轉換,
不合法的 UTF-8 也會被替換。(8.0 以前的預設只轉雙引號,單引號包住的屬性仍然會被突破,舊程式要特別注意。)
- 每個輸出點都要跳脫,最簡單的方法是寫一個短函式
e(),或使用會自動跳脫的模板引擎(例如 Twig、Laravel 的 Blade) htmlspecialchars只負責 HTML 的內容與屬性。放進 JavaScript 字串、網址參數、CSS 時規則不同: JavaScript 用json_encode(),網址參數用urlencode()strip_tags()不是可靠的防護,不要用它代替跳脫
標籤之間與屬性裡的跳脫
模擬有人用 POST 送出姓名 Amy" style="color:red、留言 <i>斜體</i>和<b>粗體</b>。看最後的輸出:沒有跳脫的版本,留言變成真的斜體與粗體,姓名裡的雙引號提前結束了 value="…",多出一個 style 屬性;跳脫之後全部只是文字。
SQL 注入:回顧第 17 課
SQL 注入和 XSS 是同一類問題:資料被當成了程式碼。XSS 是資料被瀏覽器當成 HTML,SQL 注入是資料被資料庫當成 SQL。 防護也一樣是「依目的地的規則處理」:
// 錯:字串串接
$pdo->query("SELECT * FROM members WHERE username = '$user'");
// 對:預備陳述式,資料永遠只是值
$stmt = $pdo->prepare('SELECT * FROM members WHERE username = ?');
$stmt->execute([$user]);
資料表名稱、欄位名稱、排序方向不能用佔位符,要用白名單決定。另外,網站連資料庫的帳號只給需要的權限 (例如只有 SELECT、INSERT、UPDATE、DELETE),萬一有漏洞,損害也比較有限。詳細內容與示範見第 17 課。
CSRF:借用你的登入狀態
你登入了網站 A(瀏覽器有 A 的 Session Cookie),接著打開一個不相關的網站 B。B 的網頁裡藏著一個表單, 會送到 A 的網址,例如「修改 Email」或「轉帳」。瀏覽器送出這個請求時,會自動帶上 A 的 Cookie, A 的伺服器看到合法的 Session,就照做了。這就是 CSRF(Cross-Site Request Forgery,跨網站請求偽造): 攻擊者看不到你的 Cookie,卻能借用你的登入狀態送出請求。
防護方法是 CSRF token:
- 伺服器產生一個隨機值存在 Session:
$_SESSION['csrf'] = bin2hex(random_bytes(32)); - 自己網站的每個表單放一個隱藏欄位:
<input type="hidden" name="csrf" value="…"> - 收到 POST 時比對:
hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? ''),不相符就拒絕(403)
網站 B 讀不到 A 的頁面內容,所以不知道 token,偽造的請求一定過不了。比對用 hash_equals() 而不是 ===:
它花的時間固定,不會讓人從回應時間推測出 token 的前幾個字元。
另外兩層防護:Session Cookie 設定 SameSite=Lax(從別的網站送來的 POST 不會帶這個 Cookie);
會修改資料的動作一律用 POST,不要用 GET(<img src="https://a.example/delete.php?id=3"> 就能觸發 GET)。
產生與比對 token
模擬一個從別的網站送來的 POST:它想把暱稱改掉,但 csrf 欄位只能亂填 abc123。看 check_csrf() 兩次的回傳值。token 是隨機產生的,你重新執行時會不一樣。
想一想:XSS 與 CSRF
在 PHP 8.3,下面的程式會印出什麼?
<?php
echo htmlspecialchars("Tom's \"<b>\" & Co");
為什麼 CSRF token 能擋住從其他網站送來的偽造表單?
密碼:只存雜湊
資料庫可能外洩(SQL 注入、備份檔放錯地方、內部人員)。如果存的是密碼本身,所有使用者的密碼就全部曝光, 而且很多人在不同網站用同一組密碼。所以只存雜湊(hash):一個從密碼算出來、無法反推回去的字串。
$hash = password_hash($password, PASSWORD_DEFAULT); // 註冊:存 $hash(欄位長度建議 255)
if (password_verify($input, $hash)) { … } // 登入:比對
if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { // 登入成功後:演算法或成本改了,就重算一次存回去
$hash = password_hash($input, PASSWORD_DEFAULT);
}
為什麼不用 md5()、sha1()、hash('sha256')? 它們是設計來算得快的。同樣的密碼永遠得到同樣的結果,
攻擊者可以事先算好常見密碼的對照表,或用顯示卡每秒嘗試數十億次。password_hash() 有兩個不同之處:
- 鹽(salt):每次自動產生隨機的鹽和密碼一起計算,同樣的密碼每次得到不同的雜湊,事先算好的對照表就沒用了。鹽存在雜湊字串裡,
password_verify會自己取出來 - 成本(cost):刻意算得慢,而且可以調整。
$2y$10$…的2y是 bcrypt 演算法,10是成本(2 的 10 次方輪)。 PHP 8.3 的 bcrypt 預設成本是 10,PHP 8.4 提高為 12;換新版本後,password_needs_rehash會讓舊的雜湊在使用者登入時逐步更新
PASSWORD_DEFAULT 讓 PHP 自己選目前建議的演算法(現在是 bcrypt),不要自己發明加密方式。
bcrypt 只取密碼的前 72 個位元組,一般密碼不受影響。
password_hash 與 md5 的差別
同一個密碼 uedu2026 算兩次 password_hash,看左邊 $hash1、$hash2 的值:前面都是 $2y$10$,後面完全不同。最後兩行的 md5 則每次都一樣。雜湊含隨機的鹽,你重新執行時會看到不同的值。
檔案與路徑
不要用使用者的輸入組成檔案路徑。 file_get_contents('docs/' . $_GET['file']) 看起來只能讀 docs 資料夾,
但 ../ 代表上一層資料夾,輸入 ../config.php 就能讀到網站的設定檔(裡面常有資料庫密碼),這叫路徑穿越(path traversal)。
- 用
basename()只留下檔名,去掉所有資料夾:basename('../config.php')是config.php - 用白名單檢查副檔名或檔名,而不是黑名單(黑名單永遠列不完)
- 最好的作法是根本不讓使用者指定檔名:資料庫存「編號 → 檔名」,網址只帶編號
上傳檔案($_FILES,模擬環境不能選檔案,這裡只說明原則):
| 原則 | 作法 |
|---|---|
| 檢查大小與類型 | 檢查 $_FILES['f']['error']、大小;副檔名用白名單;用 finfo 檢查檔案內容的實際類型,不要相信瀏覽器送來的 type |
| 不用原本的檔名 | 用 bin2hex(random_bytes(16)) 產生新檔名,再加上檢查過的副檔名 |
| 放在不能執行的位置 | 存到網站根目錄以外的資料夾,由 PHP 讀出來給使用者;或設定伺服器讓上傳目錄不執行 PHP。否則有人上傳 shell.php 再用網址打開,伺服器就會執行它 |
| 移動要用專用函式 | move_uploaded_file() 會確認檔案真的是這次請求上傳的 |
路徑穿越與 basename
網站有 docs/intro.txt(說明文件)和 config.php(裡面有資料庫密碼)。模擬的請求是 ?file=../config.php。看第 8 行把設定檔的內容整個印了出來,以及第 11 到 14 行怎麼擋下來。
錯誤訊息不要給使用者看
開發時,把錯誤顯示在畫面上很方便;正式上線後,錯誤訊息會透露檔案路徑、SQL、資料表名稱,甚至程式片段,
這些都是攻擊者想要的資訊。正式環境的 php.ini 建議這樣設定(前兩項就是官方 php.ini-production 的設定,後兩項要自己加上或修改):
display_errors = Off ; 不在畫面上顯示錯誤
log_errors = On ; 記錄到伺服器的紀錄檔
error_log = /var/log/php/error.log
expose_php = Off ; 回應標頭不要附上 PHP 的版本號
程式裡接住例外時,畫面只給使用者一個通用的訊息,詳細內容寫進紀錄檔:
try {
$stmt->execute([...]);
} catch (PDOException $e) {
error_log($e->getMessage()); // 給開發者看
http_response_code(500);
echo '系統忙碌,請稍後再試'; // 給使用者看
}
也可以用 set_exception_handler() 統一處理所有沒有接住的例外(第 13 課)。
Session 的安全設定
Session 編號就等於登入狀態,被偷走就能冒充使用者。第 16 課做過的登入流程,再加上這些設定:
session.cookie_httponly = 1 ; JavaScript 讀不到 PHPSESSID,XSS 偷不走
session.cookie_secure = 1 ; 只在 HTTPS 傳送(網站要全站 HTTPS)
session.cookie_samesite = "Lax" ; 從別的網站送來的 POST 不帶這個 Cookie,多一層 CSRF 防護
session.use_strict_mode = 1 ; 不接受伺服器沒有發過的 Session 編號
官方 php.ini-production 裡 session.cookie_httponly 預設是空的(關閉)、session.use_strict_mode 是 0,需要自己打開。
在程式裡也可以在 session_start() 之前用 session_set_cookie_params([...]) 設定 Cookie 的選項。
另外記得:登入成功後 session_regenerate_id(true)、登出時銷毀 Session 並刪除 Cookie、敏感操作(改密碼)要求再輸入一次密碼。
保持 PHP 版本更新
程式寫得再小心,PHP 本身也可能有漏洞。2026 年 10 月就有一個實際的例子:
- 2026 年 10 月 21 日,PHP 官方發布 8.5.12、8.4.27、8.3.36、8.2.35 四個安全更新,修補兩個被評為重大(CVSS v4.0 9.2)的漏洞 CVE-2026-109618 與 CVE-2026-109619,可能讓不需要登入的遠端攻擊者在伺服器上執行程式(媒體稱為「PHP2Shell」)
- 影響所有處理網頁請求的 PHP 安裝方式(PHP-FPM、Apache 的 mod_php、php-cgi 等),影響範圍是上述修補版本以前的版本
- 官方公告的說法是:沒有完整的替代防護,更新是唯一安全的選擇。公告也列出暫時無法更新時的設定調整,但明確說明那些設定不能讓系統變得安全
這個事件提醒我們:
- 只使用仍有安全更新的版本。PHP 每個版本大約有兩年的一般支援,之後再兩年只修補安全問題。 以 PHP 8.3 為例,一般支援到 2025 年 12 月 31 日,安全更新到 2027 年 12 月 31 日;PHP 8.1 以前的版本已經過了官方的支援期限
- 訂閱官方消息:php.net 首頁的新聞、各版本的 ChangeLog(php.net/ChangeLog-8.php)、GitHub php/php-src 的 Security Advisories
- 用
php -v確認實際執行的版本,用作業系統的套件管理工具或官方發布的版本更新;網頁伺服器上的 PHP-FPM 要重新啟動才會換成新版本 - 套件也要更新:
composer outdated列出有新版本的套件,composer audit檢查已知的安全公告
檢查版本與套件的安全公告
在自己的電腦或伺服器上執行。下面的版本號只是範例,以你實際看到的為準。
-
1
確認 PHP 版本
php -v顯示命令列使用的 PHP 版本。網頁伺服器(PHP-FPM、Apache)可能使用另一個安裝,可以暫時放一個只有<?php echo PHP_VERSION;的頁面確認,確認完立刻刪除。以 8.3 系列來說,要是 8.3.36 或更新的版本才包含上面那兩個漏洞的修補。 -
2
檢查 Composer 套件的已知漏洞
composer audit依composer.lock比對公開的安全公告資料庫,列出有已知漏洞的套件;沒有問題時會顯示沒有發現安全公告。
想一想:密碼、檔案與設定
下面的程式會印出什麼?
<?php
$h1 = password_hash('abc', PASSWORD_DEFAULT);
$h2 = password_hash('abc', PASSWORD_DEFAULT);
var_dump($h1 === $h2, password_verify('abc', $h2));
下面的程式會印出什麼?
<?php
$f = '../../etc/passwd';
echo basename($f), ' ', pathinfo('photo.JPG', PATHINFO_EXTENSION);
網站正式上線時,php.ini 的錯誤設定應該是哪一組?
動手做:檢查下載的檔名
請完成 safe_name():使用者要下載的檔名先用 basename() 去掉資料夾,接著檢查兩件事,都通過才回傳檔名,否則回傳 null:
- 檔名只包含英文字母、數字、底線
_、連字號-和點.(用preg_match) - 副檔名轉成小寫後在白名單
pdf、png、jpg裡
重點回顧
- 不相信任何輸入;輸入時驗證,輸出時依目的地跳脫;資料庫帳號、檔案權限都給最小權限
- XSS:輸出到 HTML 一律
htmlspecialchars()(PHP 8.1 起預設轉換單、雙引號);屬性要加引號;JavaScript 用json_encode() - SQL 注入:一律用預備陳述式(第 17 課)
- CSRF:Session 存隨機 token、表單放隱藏欄位、用
hash_equals()比對;修改資料只接受 POST;Cookie 設 SameSite - 密碼:
password_hash()存、password_verify()比對、password_needs_rehash()更新;不要用 md5、sha1。PHP 8.3 的 bcrypt 預設成本 10,8.4 改為 12 - 檔案:
basename()、白名單、不讓使用者決定路徑;上傳檔換新檔名,放在不能執行 PHP 的位置 - 正式環境
display_errors = Off、log_errors = On;畫面只給通用訊息 - Session Cookie:HttpOnly、Secure、SameSite、strict mode;登入後
session_regenerate_id(true) - 只用仍有安全更新的 PHP 版本(8.3 到 2027 年底),關注 php.net 的安全公告並及時更新;套件用
composer audit檢查