National Chengchi University
Uedu Main Site
Explore Uedu
Student Console
Register as Member/Login
Research Informed Consent Center
Survey Center
Educational Big Data Challenge
Claim or create a course
Try a Scenario Task
Support & Messages
Uptime Data

UeduGPTs

--

Jupyters

1

Local AI

--

Uedu Code

--

政治大學 AQI 58 25°C PM2.5 12
AI Reply Desktop Notifications

Show a desktop notification when the AI TA finishes replying

Chat Message Notifications

Notify me when classmates post messages in the forum

Sound notification

Play an alert sound whenever there is a new notification

Unit 18

安全性

XSS、SQL 注入、CSRF、密碼雜湊、檔案上傳與更新 PHP 版本
Interaction實作導覽 30 minute(s)

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 屬性;跳脫之後全部只是文字。

動手操作:修好留言板

這個留言板輸出留言時沒有跳脫(第 19 行的 <?= $p ?>)。為了在模擬環境裡保存留言,留言存在 Session;真正的網站會存在資料庫。照任務操作,再打開「回應的 HTML 原始碼」比較前後的差別。

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:

  1. 伺服器產生一個隨機值存在 Session:$_SESSION['csrf'] = bin2hex(random_bytes(32));
  2. 自己網站的每個表單放一個隱藏欄位:<input type="hidden" name="csrf" value="…">
  3. 收到 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 是隨機產生的,你重新執行時會不一樣。

動手操作:有 token 與沒有 token

index.php 是你登入中的帳號頁,修改暱稱的表單送到 safe.php(會檢查 token)。unsafe.php 做同樣的事,但不檢查 token。evil.php 模擬攻擊者的網站:模擬的瀏覽器裡所有檔案都在同一個網址底下,請把它想像成另一個網域。

想一想:XSS 與 CSRF

1.

在 PHP 8.3,下面的程式會印出什麼?

<?php
echo htmlspecialchars("Tom's \"<b>\" & Co");
2.

為什麼 CSRF token 能擋住從其他網站送來的偽造表單?

第 1 次作答

密碼:只存雜湊

資料庫可能外洩(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 等),影響範圍是上述修補版本以前的版本
  • 官方公告的說法是:沒有完整的替代防護,更新是唯一安全的選擇。公告也列出暫時無法更新時的設定調整,但明確說明那些設定不能讓系統變得安全

這個事件提醒我們:

  1. 只使用仍有安全更新的版本。PHP 每個版本大約有兩年的一般支援,之後再兩年只修補安全問題。 以 PHP 8.3 為例,一般支援到 2025 年 12 月 31 日,安全更新到 2027 年 12 月 31 日;PHP 8.1 以前的版本已經過了官方的支援期限
  2. 訂閱官方消息:php.net 首頁的新聞、各版本的 ChangeLog(php.net/ChangeLog-8.php)、GitHub php/php-src 的 Security Advisories
  3. 用 php -v 確認實際執行的版本,用作業系統的套件管理工具或官方發布的版本更新;網頁伺服器上的 PHP-FPM 要重新啟動才會換成新版本
  4. 套件也要更新:composer outdated 列出有新版本的套件,composer audit 檢查已知的安全公告

檢查版本與套件的安全公告

在自己的電腦或伺服器上執行。下面的版本號只是範例,以你實際看到的為準。

  1. 1

    確認 PHP 版本

    php -v 顯示命令列使用的 PHP 版本。網頁伺服器(PHP-FPM、Apache)可能使用另一個安裝,可以暫時放一個只有 <?php echo PHP_VERSION; 的頁面確認,確認完立刻刪除。以 8.3 系列來說,要是 8.3.36 或更新的版本才包含上面那兩個漏洞的修補。

  2. 2

    檢查 Composer 套件的已知漏洞

    composer audit 依 composer.lock 比對公開的安全公告資料庫,列出有已知漏洞的套件;沒有問題時會顯示沒有發現安全公告。

這段導覽完成了

想一想:密碼、檔案與設定

1.

下面的程式會印出什麼?

<?php
$h1 = password_hash('abc', PASSWORD_DEFAULT);
$h2 = password_hash('abc', PASSWORD_DEFAULT);
var_dump($h1 === $h2, password_verify('abc', $h2));
2.

下面的程式會印出什麼?

<?php
$f = '../../etc/passwd';
echo basename($f), ' ', pathinfo('photo.JPG', PATHINFO_EXTENSION);
3.

網站正式上線時,php.ini 的錯誤設定應該是哪一組?

第 1 次作答

動手做:檢查下載的檔名

請完成 safe_name():使用者要下載的檔名先用 basename() 去掉資料夾,接著檢查兩件事,都通過才回傳檔名,否則回傳 null:

  1. 檔名只包含英文字母、數字、底線 _、連字號 - 和點 .(用 preg_match)
  2. 副檔名轉成小寫後在白名單 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 檢查
這一課完成了!
看我的學習進度
你目前沒有登入,學習進度只存在這個瀏覽器。 Login 後,進度會跟著帳號,換電腦也看得到。