Bài 10. SQL Injection và bảo mật truy vấn

SQL Injection là lỗ hổng phổ biến và nguy hiểm nhất của ứng dụng web: kẻ tấn công chèn mã SQL vào ô nhập để đọc, sửa, xóa dữ liệu. Bài này chỉ ra cách tấn công hoạt động và cách phòng chống bằng prepared statement cùng các lớp phòng thủ bổ sung.

45 phútTrung cấp
Bạn sẽ học được
  • Hiểu cơ chế SQL Injection qua ví dụ
  • Nhận diện mã dễ bị tấn công
  • Phòng chống bằng prepared statement (MySQLi, PDO)
  • Phòng thủ nhiều lớp: whitelist, quyền tối thiểu, ẩn lỗi
Thuật ngữ cần biết
Injection
Chèn mã vào nơi lẽ ra chỉ có dữ liệu.
Prepared statement
Tách mẫu SQL và dữ liệu, gửi riêng tới máy chủ.
Placeholder
Dấu ? hoặc :ten giữ chỗ cho dữ liệu.
Whitelist
Danh sách giá trị được phép.

Khái niệm và lý thuyết

Nguyên nhân gốc: ghép chuỗi dữ liệu người dùng vào câu SQL, máy chủ không phân biệt được đâu là lệnh, đâu là dữ liệu. Cách chữa tận gốc: prepared statement: máy chủ biên dịch mẫu SQL trước, dữ liệu gửi sau và luôn được coi là giá trị thuần.

Tấn công hoạt động thế nào?

<?php
// MÃ DỄ BỊ TẤN CÔNG
$sql = "SELECT * FROM nguoidung WHERE email = '$email' AND mat_khau = '$mk'";

Nhập email là ' OR '1'='1' -- , câu lệnh thành:

SELECT * FROM nguoidung WHERE email = '' OR '1'='1' -- ' AND mat_khau = 'x'
-- điều kiện luôn đúng, phần sau bị chú thích -> đăng nhập không cần mật khẩu

-- Ô tìm kiếm: ?id=1 UNION SELECT email, mat_khau_hash FROM nguoidung
-- -> đọc trộm bảng khác

Phòng chống: prepared statement

<?php
// MySQLi
$st = $conn->prepare("SELECT id, mat_khau_hash FROM nguoidung WHERE email = ?");
$st->bind_param("s", $email);
$st->execute();
$nd = $st->get_result()->fetch_assoc();
if ($nd && password_verify($mk, $nd["mat_khau_hash"])) { /* đăng nhập */ }

// PDO
$pdo = new PDO("mysql:host=localhost;dbname=qlsv;charset=utf8mb4", "qlsv_app", $pass,
  [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false]);
$st = $pdo->prepare("SELECT * FROM sinhvien WHERE ho_ten LIKE :q AND lop_id = :lop");
$st->execute([":q" => "%$q%", ":lop" => $lop_id]);
$ds = $st->fetchAll(PDO::FETCH_ASSOC);

Không đặt dấu ? trong nháy. Với LIKE, đặt % trong giá trị, không trong mẫu SQL.

Khi không dùng được placeholder

<?php
// Tên cột, hướng sắp xếp: dùng whitelist
$cot = in_array($_GET["sort"] ?? "", ["ho_ten", "ngay_sinh", "ma_sv"], true) ? $_GET["sort"] : "ho_ten";
$huong = ($_GET["dir"] ?? "") === "desc" ? "DESC" : "ASC";
$st = $conn->prepare("SELECT * FROM sinhvien ORDER BY $cot $huong LIMIT ? OFFSET ?");
$st->bind_param("ii", $limit, $offset);
// IN (...) động
$dau = implode(",", array_fill(0, count($ids), "?"));
$st = $conn->prepare("SELECT * FROM sinhvien WHERE id IN ($dau)");
$st->bind_param(str_repeat("i", count($ids)), ...$ids);

Phòng thủ nhiều lớp

  • Prepared statement cho mọi truy vấn có dữ liệu bên ngoài.
  • Validate kiểu, độ dài, định dạng trước khi truy vấn.
  • Tài khoản CSDL quyền tối thiểu (bài 9).
  • Không hiển thị lỗi SQL cho người dùng; ghi log.
  • Dùng ORM/Query Builder (Eloquent, PDO) đã bind sẵn.
Cách cũVấn đề
mysqli_real_escape_stringDễ sai, chỉ là lớp phụ
Lọc từ khóa SELECT, DROPDễ lách, hỏng dữ liệu hợp lệ
Giấu lỗi, đổi tên bảngKhông ngăn được tấn công
Mẹo hay

Luyện tập an toàn trên DVWA hoặc lab miễn phí tại portswigger.net/web-security: tự khai thác rồi tự vá.

Lưu ý

Chỉ thử tấn công trên hệ thống của chính bạn hoặc được phép. Tấn công hệ thống người khác là vi phạm pháp luật.

Thực hành
  1. Viết trang đăng nhập dễ bị tấn công trên localhost, thử tấn công, rồi sửa bằng prepared statement.
  2. Viết tìm kiếm có sắp xếp theo cột người dùng chọn, dùng whitelist.
  3. Viết hàm lấy sinh viên theo danh sách id với IN (...) động.
  4. Rà soát dự án của bạn: 100% truy vấn dùng prepared statement.
Tóm tắt bài học
  • Ghép chuỗi SQL = lỗ hổng; prepared statement chữa tận gốc.
  • Placeholder cho giá trị; whitelist cho tên cột, hướng sắp xếp.
  • Validate, quyền tối thiểu, ẩn lỗi là lớp bổ sung.