SQL Injection và Prepared Statement trong Java
1. Tổng quan
SQL injection là lỗ hổng bảo mật top đầu trong danh sách OWASP, xuất phát từ việc nối chuỗi SQL thủ công với input người dùng. Bài này giải thích cơ chế tấn công, chỉ ra vì sao PreparedStatement chặn được nó, và hướng dẫn bạn áp dụng vào JDBC, JPA/Hibernate, cũng như MyBatis. Đọc xong, bạn sẽ viết code truy vấn an toàn và biết cách viết test để phát hiện sớm lỗ hổng.
2. SQL injection hoạt động thế nào
Lỗi xảy ra khi bạn ghép trực tiếp giá trị input vào câu SQL dạng chuỗi, thay vì tham số hóa.
// KHÔNG NÊN LÀM THEO CÁCH NÀY
public Customer findByUsername(String username) throws SQLException {
String sql = "SELECT * FROM customers WHERE username = '" + username + "'";
try (Statement stmt = connection.createStatement();
ResultSet rs = stmt.executeQuery(sql)) {
return rs.next() ? mapRow(rs) : null;
}
}
Nếu username là giá trị hợp lệ như "minh", câu SQL sinh ra là:
SELECT * FROM customers WHERE username = 'minh'
Nhưng nếu attacker truyền username = "' OR '1'='1", câu SQL trở thành:
SELECT * FROM customers WHERE username = '' OR '1'='1'
Điều kiện '1'='1' luôn đúng, nên query trả về toàn bộ bảng customers — bỏ qua hoàn toàn logic xác thực username. Tệ hơn, attacker có thể nối thêm ; DROP TABLE customers; -- để xóa dữ liệu, hoặc dùng UNION SELECT để đọc bảng khác (ví dụ bảng users chứa password hash).
Lưu ý: Input không chỉ đến từ form HTML. Header HTTP, cookie, query param, hay dữ liệu từ file upload đều là nguồn tấn công tiềm năng.
3. Prepared Statement giải quyết vấn đề thế nào
3.1. Cơ chế tham số hóa
PreparedStatement tách biệt cấu trúc SQL (gửi trước, được DB parse và compile thành execution plan) với giá trị tham số (gửi sau, chỉ được xem là dữ liệu thuần). Nhờ vậy, input của attacker không bao giờ được DB diễn giải thành cú pháp SQL.
public Customer findByUsername(String username) throws SQLException {
String sql = "SELECT * FROM customers WHERE username = ?";
try (PreparedStatement stmt = connection.prepareStatement(sql)) {
stmt.setString(1, username);
try (ResultSet rs = stmt.executeQuery()) {
return rs.next() ? mapRow(rs) : null;
}
}
}
- Dấu
?là placeholder, DB compile sẵn câu lệnh với placeholder này. stmt.setString(1, username)gán giá trị vào vị trí tham số thứ 1 (đánh số từ 1, không phải 0).- Dù
usernamechứa' OR '1'='1, DB vẫn hiểu đây là một chuỗi ký tự duy nhất cần so khớp, không phải cú pháp SQL. Query trả về rỗng vì không có user nào có tên kỳ lạ đó.
3.2. Named parameter với Spring JdbcTemplate
Nếu bạn dùng Spring, NamedParameterJdbcTemplate giúp code dễ đọc hơn so với ? đánh số thứ tự.
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-jdbc</artifactId>
</dependency>
@Repository
public class CustomerRepository {
private final NamedParameterJdbcTemplate jdbc;
public CustomerRepository(NamedParameterJdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public Optional<Customer> findByUsername(String username) {
String sql = "SELECT id, username, email FROM customers WHERE username = :username";
Map<String, Object> params = Map.of("username", username);
List<Customer> result = jdbc.query(sql, params, this::mapRow);
return result.stream().findFirst();
}
private Customer mapRow(ResultSet rs, int rowNum) throws SQLException {
return new Customer(rs.getLong("id"), rs.getString("username"), rs.getString("email"));
}
}
:username là named placeholder, Spring tự chuyển thành ? nội bộ và bind giá trị an toàn. Cách này tránh nhầm lẫn thứ tự tham số khi câu SQL có nhiều điều kiện.
4. Áp dụng với JPA/Hibernate
JPA mặc định sinh SQL dùng prepared statement, nhưng bạn vẫn có thể tự bắn vào chân mình nếu dùng sai API.
// AN TOÀN — JPQL với named parameter
public List<Order> findOrdersByStatus(String status) {
return entityManager.createQuery(
"SELECT o FROM Order o WHERE o.status = :status", Order.class)
.setParameter("status", status)
.getResultList();
}
// NGUY HIỂM — nối chuỗi vào JPQL
public List<Order> findOrdersByStatusUnsafe(String status) {
String jpql = "SELECT o FROM Order o WHERE o.status = '" + status + "'";
return entityManager.createQuery(jpql, Order.class).getResultList();
}
setParameter("status", status)ở ví dụ an toàn khiến Hibernate dịch JPQL thành SQL có?, sau đó bind giá trị quaPreparedStatement— attacker không can thiệp được cấu trúc câu lệnh.- Ở ví dụ nguy hiểm, Hibernate vẫn parse JPQL, nhưng chuỗi JPQL đã bị “bẩn” trước khi parse, nên attacker có thể tiêm JPQL injection (tương tự SQL injection, chỉ khác tầng cú pháp).
Với Spring Data JPA, @Query cũng cần dùng tham số thay vì nối chuỗi:
public interface OrderRepository extends JpaRepository<Order, Long> {
@Query("SELECT o FROM Order o WHERE o.customerEmail = :email AND o.status = :status")
List<Order> findByEmailAndStatus(@Param("email") String email, @Param("status") String status);
}
5. Trường hợp không parameterize được: tên bảng, tên cột
PreparedStatement chỉ parameterize được giá trị, không parameterize được identifier (tên bảng, tên cột, mệnh đề ORDER BY). Đây là tình huống nhiều người bỏ qua.
// Sắp xếp động theo cột do người dùng chọn
public List<Order> findAllSorted(String sortColumn) {
Set<String> allowedColumns = Set.of("id", "createdAt", "totalAmount");
if (!allowedColumns.contains(sortColumn)) {
throw new IllegalArgumentException("Cột sắp xếp không hợp lệ: " + sortColumn);
}
String sql = "SELECT * FROM orders ORDER BY " + sortColumn; // an toàn vì đã whitelist
// ... thực thi bằng PreparedStatement như bình thường
return jdbc.query(sql, this::mapRow);
}
- Vì không thể viết
ORDER BY ?, bạn phải whitelist giá trị hợp lệ trước khi nối chuỗi. - Không bao giờ dùng blacklist (chặn từ khóa như
DROP,;) vì attacker luôn có cách né qua encoding hoặc biến thể cú pháp.
6. Viết test để phát hiện sớm lỗ hổng
Một bài test đơn giản giúp bạn khẳng định endpoint chống injection đúng cách.
@SpringBootTest
@AutoConfigureMockMvc
class CustomerSearchSecurityTest {
@Autowired
private MockMvc mockMvc;
@Test
void searchByUsername_withInjectionPayload_returnsEmptyNotAllRows() throws Exception {
String payload = "' OR '1'='1";
mockMvc.perform(get("/api/customers").param("username", payload))
.andExpect(status().isOk())
.andExpect(jsonPath("$").isEmpty());
}
@Test
void sortBy_withInvalidColumn_rejectsRequest() throws Exception {
mockMvc.perform(get("/api/orders").param("sortBy", "id; DROP TABLE orders"))
.andExpect(status().isBadRequest());
}
}
class CustomerRepositoryTest {
@Test
void findByUsername_withSqlInjectionPayload_returnsEmptyOptional() {
CustomerRepository repo = new CustomerRepository(jdbcTemplate);
Optional<Customer> result = repo.findByUsername("nonexistent' OR '1'='1");
assertThat(result).isEmpty();
}
}
- Test đầu tiên gửi payload injection kinh điển, kỳ vọng kết quả rỗng (vì không có username nào khớp chuỗi literal đó) chứ không phải toàn bộ dữ liệu.
- Test thứ hai kiểm tra endpoint reject input không nằm trong whitelist cột sắp xếp — trả về
400 Bad Requestthay vì thực thi SQL nguy hiểm. assertThat(result).isEmpty()dùng AssertJ xác nhậnOptionalrỗng, chứng minh query không bị bypass.
7. Lỗi hay gặp
- Dùng
Statement.executeQuery(String)cho input động: nhanh gọn khi viết demo nhưng là backdoor thật sự trong production. String.formatđể “làm gọn” câu SQL: vẫn là nối chuỗi, không khác gì cộng+.- Tin tưởng ORM 100%: JPQL/HQL/Criteria vẫn bị injection nếu bạn tự nối chuỗi trước khi gọi
createQuery. - Escape thủ công thay vì parameterize: tự viết hàm escape dấu nháy đơn (
replace("'", "\\'")) dễ sót case (ví dụ encoding Unicode, multi-byte) và không đáng tin bằng cơ chế native của driver. - Dynamic
INclause sai cách: khi buildWHERE id IN (?, ?, ?)với số lượng tham số thay đổi, phải sinh đúng số dấu?tương ứng với kích thước list, không nối chuỗi giá trị trực tiếp.
Lưu ý: Prepared statement chống được SQL injection nhưng không chống được injection ở tầng khác như command injection, LDAP injection hay NoSQL injection. Mỗi tầng dữ liệu cần cơ chế parameterize riêng.
8. Khi nào không cần PreparedStatement
Nếu câu SQL hoàn toàn tĩnh, không chứa bất kỳ giá trị động nào từ input người dùng (ví dụ SELECT COUNT(*) FROM customers), dùng Statement thông thường vẫn an toàn và đôi khi nhẹ hơn một chút vì bỏ qua bước parse/bind. Tuy nhiên trong thực tế, phần lớn query đều có điều kiện động, nên mặc định hãy dùng PreparedStatement hoặc tương đương (JPQL với setParameter, MyBatis với #{} thay vì ${}) như một thói quen, tránh phải nhớ “trường hợp nào thì an toàn”.
9. Kết luận
SQL injection xuất phát từ việc trộn lẫn code và data trong một chuỗi SQL. PreparedStatement giải quyết tận gốc bằng cách tách biệt hai thứ đó: cấu trúc câu lệnh được compile trước, giá trị tham số chỉ được bind sau như dữ liệu thuần. Hãy áp dụng nhất quán ở JDBC, JPQL, MyBatis, luôn whitelist cho các identifier động như tên cột/bảng, và viết test với payload injection kinh điển để đảm bảo endpoint của bạn không bao giờ thực thi SQL do attacker kiểm soát.