在前面幾天完成了圖書管理系統的核心功能,從會員登入、書籍 CRUD 到併發借書與還書邏輯。
今天要實測 SQL 注入攻擊,看看不安全的程式碼是如何讓攻擊者繞過密碼登入、甚至偷走整個資料庫的資料,並學習如何用標準的 PDO Prepared Statements 徹底封鎖這個漏洞。
當應用程式把「使用者輸入的字串」沒有經過處理就直接拼接到 SQL 指令中時,攻擊者輸入的特殊字元(例如 ' 或 --)會改變原本 SQL 語法結構,讓資料庫執行非預期的指令。
先暫時把正常的 PDO 預編譯語句換掉,寫一段用「字串拼接」處理 SQL 的漏洞程式碼 unsafe_login.php:
請幫我撰寫一個 PHP 檔案 `unsafe_login.php`,用於教學與測試 SQL Injection 攻擊:
1. 載入 config/database.php 取得 PDO 連線。
2. 從 $_POST 接收 username 與 password。
3. 【重點】請故意使用「字串拼接(String Concatenation)」的方式將使用者輸入直接放入 SQL 查詢中(SELECT * FROM members WHERE username = '\(username' AND password = '\)password'),不要使用 prepare() 或綁定參數,以製造 SQL Injection 漏洞。
4. 在網頁上方印出當前實際拼出來的 SQL 指令字串(使用 htmlspecialchars 避免 HTML 渲染問題),方便觀察攻擊效果。
5. 執行 PDO query(),如果查到使用者資料就顯示「登入成功」與該使用者的 username 與 role;如果查不到則顯示「登入失敗」。
6. 使用 try-catch 包覆,若 SQL 語法報錯(例如括號或單引號未閉合),直接印出 PDOException 的錯誤訊息。
<?php
/**
* unsafe_login.php - SQL Injection 教學示範
* 【警告】此檔案故意包含安全漏洞,僅供教學與安全測試使用
*/
// ========================================
// 區塊 1:載入資料庫連線
// ========================================
require_once 'config/database.php';
?>
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>SQL Injection 教學示範 - 不安全登入</title>
<style>
body { font-family: Arial, sans-serif; margin: 20px; }
.danger { background-color: #ffcccc; border: 2px solid #cc0000; padding: 15px; margin: 10px 0; }
.info { background-color: #ccffcc; border: 2px solid #00cc00; padding: 15px; margin: 10px 0; }
.error { background-color: #ffeecc; border: 2px solid #ff6600; padding: 15px; margin: 10px 0; }
code { background-color: #f0f0f0; padding: 2px 5px; }
pre { background-color: #f5f5f5; padding: 10px; overflow-x: auto; }
</style>
</head>
<body>
<h1>⚠️ SQL Injection 教學示範</h1>
<p><strong>此頁面故意包含 SQL Injection 漏洞,用於教學與安全測試</strong></p>
<?php
// ========================================
// 區塊 2:接收使用者輸入 & 建構 SQL 查詢
// ========================================
$username = '';
$password = '';
$sql_query = '';
$result = null;
$error_message = '';
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
// 從 POST 接收使用者輸入(【重點】未做任何淨化)
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
// 【漏洞所在】直接使用字串拼接,沒有使用 prepare() 或綁定參數
$sql_query = "SELECT * FROM members WHERE username = '" . $username . "' AND password = '" . $password . "'";
}
?>
<!-- ========================================
區塊 3:顯示當前的 SQL 查詢字串
======================================== -->
<?php if (!empty($sql_query)): ?>
<div class="danger">
<h2>📋 目前拼出來的 SQL 查詢:</h2>
<pre><code><?php echo htmlspecialchars($sql_query, ENT_QUOTES, 'UTF-8'); ?></code></pre>
<p><small>上方就是拼接後的 SQL 指令。使用者輸入直接被放入其中,沒有任何轉義或參數化。</small></p>
</div>
<?php endif; ?>
<!-- ========================================
區塊 4:執行查詢 & 顯示結果
======================================== -->
<?php
if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($sql_query)) {
try {
// 使用 PDO 的 query() 方法執行拼接後的 SQL(【重點】不安全做法)
$stmt = $pdo->query($sql_query);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user) {
// 登入成功
echo '<div class="info">';
echo '<h2>✅ 登入成功</h2>';
echo '<p><strong>帳號:</strong>' . htmlspecialchars($user['username'], ENT_QUOTES, 'UTF-8') . '</p>';
echo '<p><strong>角色:</strong>' . htmlspecialchars($user['role'], ENT_QUOTES, 'UTF-8') . '</p>';
if (isset($user['email'])) {
echo '<p><strong>電郵:</strong>' . htmlspecialchars($user['email'], ENT_QUOTES, 'UTF-8') . '</p>';
}
echo '</div>';
} else {
// 登入失敗
echo '<div class="error">';
echo '<h2>❌ 登入失敗</h2>';
echo '<p>找不到相符的帳號或密碼。</p>';
echo '</div>';
}
} catch (PDOException $e) {
// 捕獲 SQL 語法錯誤(例如括號未閉合、單引號未配對等)
echo '<div class="error">';
echo '<h2>⚠️ SQL 錯誤</h2>';
echo '<p><strong>錯誤訊息:</strong></p>';
echo '<pre>' . htmlspecialchars($e->getMessage(), ENT_QUOTES, 'UTF-8') . '</pre>';
echo '<p><small>攻擊者常利用錯誤訊息來偵測漏洞。</small></p>';
echo '</div>';
}
}
?>
<!-- ========================================
區塊 5:登入表單
======================================== -->
<div class="info">
<h2>🔐 測試登入表單</h2>
<form method="POST">
<div>
<label for="username">帳號:</label>
<input type="text" id="username" name="username" value="<?php echo htmlspecialchars($username, ENT_QUOTES, 'UTF-8'); ?>" style="width: 300px;">
</div>
<div style="margin-top: 10px;">
<label for="password">密碼:</label>
<input type="password" id="password" name="password" value="<?php echo htmlspecialchars($password, ENT_QUOTES, 'UTF-8'); ?>" style="width: 300px;">
</div>
<div style="margin-top: 15px;">
<button type="submit">登入</button>
</div>
</form>
</div>
<!-- ========================================
區塊 6:SQL Injection 攻擊示範
======================================== -->
<div class="danger">
<h2>🎯 SQL Injection 攻擊示範</h2>
<p>在帳號欄位輸入以下內容,觀察 SQL 查詢如何被破壞:</p>
<ul>
<li>
<strong>繞過認證(萬用帳號):</strong><br>
帳號:<code>admin' --</code><br>
密碼:<code>任意</code><br>
<small>結果:<code>WHERE username = 'admin' --' AND password = '...'</code>,<code>--</code> 之後的條件被註解掉,只需要帳號存在就能登入</small>
</li>
<li>
<strong>使用 OR 邏輯跳過認證:</strong><br>
帳號:<code>' OR '1'='1</code><br>
密碼:<code>' OR '1'='1</code><br>
<small>結果:條件變成 <code>WHERE username = '' OR '1'='1' AND password = '' OR '1'='1'</code>,永遠為真,可以列出所有使用者</small>
</li>
<li>
<strong>聯合查詢攻擊(UNION):</strong><br>
帳號:<code>' UNION SELECT 1,2,3,4 --</code><br>
<small>將原本的查詢替換為自定義結果</small>
</li>
<li>
<strong>布林盲注:</strong><br>
帳號:<code>' AND (SELECT COUNT(*) FROM members) > 0 --</code><br>
<small>透過條件真假來推測資料庫內容</small>
</li>
</ul>
</div>
<!-- ========================================
區塊 7:漏洞說明 & 修復建議
======================================== -->
<div class="info">
<h2>🔒 如何修復此漏洞</h2>
<h3>錯誤做法(此檔案使用):</h3>
<pre><code>$sql = "SELECT * FROM members WHERE username = '" . $username . "'";
$stmt = $pdo->query($sql); // 直接執行拼接的字串</code></pre>
<h3>正確做法(使用參數化查詢):</h3>
<pre><code>$sql = "SELECT * FROM members WHERE username = ? AND password = ?";
$stmt = $pdo->prepare($sql);
$stmt->execute([$username, $password]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);</code></pre>
<h3>或使用具名參數:</h3>
<pre><code>$sql = "SELECT * FROM members WHERE username = :username AND password = :password";
$stmt = $pdo->prepare($sql);
$stmt->execute([':username' => $username, ':password' => $password]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);</code></pre>
<p><strong>關鍵點:</strong>永遠使用 <code>prepare()</code> 與 <code>execute()</code>,讓資料庫引擎區分 SQL 程式碼與資料。</p>
</div>
</body>
</html>
萬能密碼(繞過驗證,任意帳號登入):
SELECT * FROM members WHERE username = 'admin' AND password = '' OR '1'='1'
發生了什麼事?
因為 '1'='1' 永遠為真(TRUE),WHERE 的判斷式被改寫成「帳號是 admin 且 (密碼為空 OR 成立)」,整條條件直接被判定為成立。
結果: 成功以 admin 身份登入系統。
攻擊二:註解攻擊(閉合單引號,註解掉密碼檢查):
SELECT * FROM members WHERE username = 'admin' -- ' AND password = 'xxx'
發生了什麼事?
後面的所有 SQL 文字全部被 MySQL 當成註解忽略掉了,這條指令在 MySQL 眼裡變成了:
SELECT * FROM members WHERE username = 'admin'
攻擊三:UNION 攻擊(竊取其他資料表的敏感資料):
' UNION SELECT 1, username, password_hash, role, 5, 6, 7 FROM members --
SELECT * FROM members WHERE username = '' UNION SELECT 1, username, password_hash, role, 5, 6, 7 FROM members -- ' AND password = ''
發生了什麼事?
前面前半段查詢不到任何資料,但後半段 UNION SELECT 會強制把整張 members 資料表的帳號、密碼 Hash 值與權限撈出來,並直接顯示在網頁畫面上。
為什麼參數化查詢能防禦?
,? 或 :username 只是佔位符。'、OR '1'='1、--),MySQL 都只會把它當成純粹的字串資料來比對,絕對不會重新解析成 SQL 指令。修復後的安全程式碼 safe_login.php:
<?php
// safe_login.php (安全的完整實作)
require_once 'config/database.php';
$username = $_POST['username'] ?? '';
$password = $_POST['password'] ?? '';
// 1. 使用具名佔位符 (:username, :password),不直接拼接變數
$sql = "SELECT * FROM members WHERE username = :username AND password = :password";
// 2. 預編譯 SQL 範本(進行參數化)
$stmt = $pdo->prepare($sql);
// 3. 綁定參數並執行(資料與語法完全分離)
$stmt->execute([
':username' => $username,
':password' => $password
]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if ($user) {
echo "🎉 登入成功!";
} else {
echo "❌ 登入失敗:帳號或密碼錯誤";
}
再次測試攻擊:
當攻擊者再次在 $username 輸入 admin' -- 時:
今天寫出 SQL 注入漏洞並實測攻擊,體會到了資安漏洞的危險性。
在開發 PHP 應用程式時,需記住以下規則: