Loạt bài này là kiến thức phòng thủ cho lập trình viên: ta tái hiện lỗ hổng trong môi trường lab cô lập chỉ để hiểu cơ chế, rồi tập trung vào cách viết code an toàn. Không áp dụng lên hệ thống bạn không có quyền.
SQL injection đã bị cảnh báo suốt hơn hai mươi năm, có đủ công cụ và thư viện để chặn, vậy mà nó vẫn nằm trong OWASP Top 10. Vì sao? Vì gốc rễ của nó không phải một lỗi phức tạp — nó là một thói quen code tự nhiên mà nguy hiểm: ghép chuỗi để dựng câu SQL. Khi bạn viết "... WHERE username='" + u + "'", bạn đang trộn lẫn hai thứ phải tách biệt tuyệt đối: code (câu lệnh SQL) và data (input của người dùng). Và một khi data có thể len vào thành code, kẻ tấn công viết lại được câu lệnh của bạn.
Bài này (phần 1 loạt Bảo mật web) tái hiện thật lỗ hổng kinh điển nhất — bypass đăng nhập — trong một lab PostgreSQL cô lập, rồi cho thấy prepared statement chặn nó triệt để ra sao. Hiểu đúng cơ chế này là nền tảng của secure coding.
Cơ chế: trộn code+data vs tách biệt code+data

Hình 1: Nối chuỗi — input ' OR '1'='1 chèn vào làm điều kiện luôn đúng, bypass đăng nhập. Prepared statement (db.Query với placeholder $1, $2) — input được coi là tham số dữ liệu, chỉ so khớp như một chuỗi mật khẩu bình thường. Cốt lõi: nối chuỗi trộn code+data; prepared tách biệt — DB nhận cấu trúc câu lệnh trước, rồi mới điền tham số sau.
Tái hiện thật trong pg-lab (lab cô lập)
Mình tạo trong pg-lab (PostgreSQL 16) một bảng appusers chỉ để thí nghiệm, với một người dùng thường (alice) và một admin. Rồi mô phỏng logic đăng nhập theo hai cách.

Hình 2: Kết quả thật — nối chuỗi + mật khẩu sai: 0 dòng (từ chối đúng); nối chuỗi + input độc ' OR '1'='1: 2 dòng (BYPASS — trả cả alice lẫn admin); prepared + cùng input độc: 0 dòng (chặn, coi là chuỗi mật khẩu); prepared + mật khẩu đúng: 1 dòng (đăng nhập đúng).
Con số kể rõ câu chuyện:
- Nối chuỗi hoạt động "đúng"... cho tới khi gặp input độc. Với mật khẩu sai bình thường, query trả 0 dòng — login bị từ chối, đúng như mong đợi. Lập trình viên test với input hợp lệ và thấy mọi thứ ổn.
- Một chuỗi
' OR '1'='1phá tung. Khi password là' OR '1'='1, câu lệnh nối chuỗi trở thành... password='' OR '1'='1'— mệnh đềOR '1'='1'luôn đúng, nên điều kiện WHERE khớp mọi dòng. Kết quả: 2 dòng, trả về cả alice và admin. Kẻ tấn công đăng nhập được mà không cần biết mật khẩu, và còn lộ luôn tài khoản quản trị. (Biến thểUNION SELECTcòn moi được dữ liệu bảng khác.) - Prepared statement chặn cùng input đó. Qua
$1, $2, PostgreSQL nhận cấu trúc câu lệnh trước (biết "đây là một so sánh password"), rồi mới điền tham số. Chuỗi' OR '1'='1được coi là giá trị mật khẩu — nó so khớp xem có user nào mật khẩu đúng bằng chuỗi đó không. Không có, nên 0 dòng. Mật khẩu đúng vẫn cho 1 dòng. Data không bao giờ len vào thành code.
Đánh đổi cần cân nhắc
Luôn dùng prepared statement/parameterized query — nó có ở mọi driver và ORM. Đây không phải "nên", mà là "luôn luôn". Mọi driver DB (database/sql của Go, psycopg của Python, JDBC...) và mọi ORM đều hỗ trợ tham số hoá — thường còn là mặc định. Quy tắc tuyệt đối: không bao giờ ghép input của người dùng vào chuỗi SQL. Nếu bạn thấy dấu + nối một biến vào câu SQL, đó là một lỗ hổng tiềm tàng. Chi phí dùng prepared statement gần như bằng không (và thường còn nhanh hơn nhờ plan cache), nên không có lý do gì để nối chuỗi.
Escape thủ công là cái bẫy — đừng tự làm. Có người nghĩ "tôi sẽ tự escape dấu nháy đơn". Đừng. Escape thủ công cực kỳ dễ sai: có nhiều kiểu nháy, nhiều encoding, nhiều ngữ cảnh (chuỗi, số, định danh), và chỉ cần sót một trường hợp là thủng. Hàm thư viện (và prepared statement) đã xử lý đúng tất cả các trường hợp đó, được kiểm thử kỹ qua nhiều năm. Tin vào thư viện, không tin vào regex escape tự viết.
Tham số hoá không áp cho tên bảng/cột — dùng allowlist. Một giới hạn thật: prepared statement chỉ tham số hoá được giá trị (WHERE x = $1), không tham số hoá được định danh (tên bảng, tên cột, hướng ORDER BY). Nếu bạn cần cho người dùng chọn cột sắp xếp động (ORDER BY <cột người dùng chọn>), không nối chuỗi trực tiếp — hãy dùng allowlist: kiểm input có nằm trong một danh sách cột hợp lệ cố định không, rồi mới dùng. Và phòng thủ theo lớp: cấp cho DB user quyền tối thiểu (least privilege — tài khoản app không cần quyền DROP TABLE), để nếu có lỗ hổng thì thiệt hại bị giới hạn. WAF (tường lửa ứng dụng) chỉ là lớp phụ, không thay được code đúng.
Ba ý mang về
- SQL injection = trộn code và data: tái hiện thật, query nối chuỗi bị input
' OR '1'='1biến điều kiện thành luôn-đúng → trả 2 dòng (bypass đăng nhập, lộ cả tài khoản admin); gốc rễ là input của người dùng len vào thành một phần câu lệnh SQL. - Prepared statement tách biệt code và data, chặn triệt để: cùng input độc qua
$1, $2→ 0 dòng (coi là chuỗi mật khẩu, không khớp), mật khẩu đúng vẫn cho 1 dòng; DB nhận cấu trúc câu lệnh trước rồi mới điền tham số, nên data không bao giờ thành code. - Luôn tham số hoá, phòng thủ theo lớp: dùng prepared statement/ORM ở mọi nơi (đừng nối chuỗi, đừng tự escape); với định danh (tên bảng/cột) dùng allowlist; cấp DB user quyền tối thiểu để giới hạn thiệt hại; WAF chỉ là lớp phụ.
Nguồn
- OWASP — SQL Injection Prevention Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
- OWASP Top 10 — A03:2021 Injection: https://owasp.org/Top10/A03_2021-Injection/
- Go — database/sql: Avoiding SQL injection: https://go.dev/doc/database/sql-injection
Phần sau ta bàn về lưu mật khẩu an toàn: vì sao SHA-256 nhanh là sai cho mật khẩu, bcrypt/argon2 chậm là cố ý, và vai trò của salt — đo thật thời gian băm theo cost factor.