iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0

在前面幾天完成了圖書管理系統的核心功能,從會員登入、書籍 CRUD 到併發借書與還書邏輯。

今天要實測 SQL 注入攻擊,看看不安全的程式碼是如何讓攻擊者繞過密碼登入、甚至偷走整個資料庫的資料,並學習如何用標準的 PDO Prepared Statements 徹底封鎖這個漏洞。

一、什麼是 SQL Injection?

當應用程式把「使用者輸入的字串」沒有經過處理就直接拼接到 SQL 指令中時,攻擊者輸入的特殊字元(例如 ' 或 --)會改變原本 SQL 語法結構,讓資料庫執行非預期的指令。

二、 建造漏洞:寫一個不安全的登入 API

先暫時把正常的 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>

三、 實戰攻擊:三種攻擊情境示範

  1. 萬能密碼(繞過驗證,任意帳號登入):

    • 輸入帳號:admin
    • 輸入密碼:' OR '1'='1
    • SQL 指令
      SELECT * FROM members WHERE username = 'admin' AND password = '' OR '1'='1'
      

    發生了什麼事?
    因為 '1'='1' 永遠為真(TRUE),WHERE 的判斷式被改寫成「帳號是 admin 且 (密碼為空 OR 成立)」,整條條件直接被判定為成立。
    結果: 成功以 admin 身份登入系統。

  2. 攻擊二:註解攻擊(閉合單引號,註解掉密碼檢查):

    • 輸入帳號:admin' --
    • 輸入密碼:(隨便亂填)
    • SQL 指令:
      SELECT * FROM members WHERE username = 'admin' -- ' AND password = 'xxx'
      

    發生了什麼事?
    後面的所有 SQL 文字全部被 MySQL 當成註解忽略掉了,這條指令在 MySQL 眼裡變成了:

    SELECT * FROM members WHERE username = 'admin'
    
  3. 攻擊三:UNION 攻擊(竊取其他資料表的敏感資料):

    • 輸入帳號:' UNION SELECT 1, username, password_hash, role, 5, 6, 7 FROM members --
    • 輸入密碼:(空白)
    • SQL 指令:
      SELECT * FROM members WHERE username = '' UNION SELECT 1, username, password_hash, role, 5, 6, 7 FROM members -- ' AND password = ''
      

    發生了什麼事?
    前面前半段查詢不到任何資料,但後半段 UNION SELECT 會強制把整張 members 資料表的帳號、密碼 Hash 值與權限撈出來,並直接顯示在網頁畫面上。

四、 防禦之道:參數化查詢

為什麼參數化查詢能防禦?

  1. Step 1 預編譯(Prepare):伺服器先把 SQL 語法模板送到 MySQL 進行解析與編譯。此時 SQL 的結構已經固定,? 或 :username 只是佔位符。
  2. Step 2 綁定與執行(Execute):最後才把使用者輸入的字串傳給 MySQL。不管使用者輸入了什麼(包含 '、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' -- 時:

  • MySQL 搜尋的帳號名稱是:"admin' --"(包含單引號與減號的完整字串)。
  • 由於資料庫裡沒有人的帳號叫 "admin' --",查詢結果為空,攻擊完全失效。

今天寫出 SQL 注入漏洞並實測攻擊,體會到了資安漏洞的危險性。

在開發 PHP 應用程式時,需記住以下規則:

  1. 絕對不要用字串拼接 SQL:文字串接是 SQL Injection 的唯一根源。
  2. 全面使用 PDO 參數化查詢:無論是 SELECT、INSERT、UPDATE 還是 DELETE,一律使用 prepare() + execute()。

上一篇
Day 21 時區害我算錯一天
下一篇
Day 23 XSS 與輸入驗證
系列文
從零打造圖書管理系統:WSL2 × MySQL × Claude Code 的整合實作 共 24 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言