SQL injection vẫn còn đó: cách attacker khai thác và cách fix đúng trong ASP.NET Core

SQL injection vẫn nằm trong OWASP Top 10 2025 (vị trí #5, 38 CWEs). Lý do nó chưa biến mất không phải vì developer không biết — mà vì string concatenation trong SQL vẫn được viết hàng ngày, đôi khi từ code AI generate ra. Bài này đi thẳng vào attack demo, sau đó fix từng layer: EF Core, Dapper, ADO.NET. ---

Vấn đề

Nếu tôi là attacker và tìm thấy API endpoint search sản phẩm như thế này:

GET /api/products/search?name=laptop

Tôi sẽ thử ngay:

GET /api/products/search?name=' OR '1'='1
GET /api/products/search?name='; DROP TABLE Products; --
GET /api/products/search?name=' UNION SELECT username, password FROM Users --

Nếu server trả về toàn bộ danh sách sản phẩm cho query đầu tiên, hoặc trả về lỗi database chi tiết, tôi biết chắc endpoint này dính SQL injection. Từ đó, tôi có thể leo thang — dump toàn bộ bảng Users, đọc password hash, thậm chí đọc file hệ thống tùy database config.

Phía backend code trông như thế nào?


Code vulnerable trông ra sao

Pattern 1: string concatenation trực tiếp

// ❌ NGUY HIỂM — thường thấy trong legacy code và AI-generated code
[HttpGet("search")]
public async Task<IActionResult> Search(string name)
{
    var query = $"SELECT * FROM Products WHERE Name LIKE '%{name}%'";
    
    using var connection = new SqlConnection(_connectionString);
    var products = await connection.QueryAsync<Product>(query);
    
    return Ok(products);
}

Input ' OR '1'='1 biến câu query thành:

SELECT * FROM Products WHERE Name LIKE '%' OR '1'='1%'

Kết quả: trả về toàn bộ Products table.

Input '; DROP TABLE Products; -- biến thành:

SELECT * FROM Products WHERE Name LIKE '%'; DROP TABLE Products; --%'

Trong SQL Server, multi-statement execution phụ thuộc cấu hình, nhưng nguy cơ là có thật.

Pattern 2: FromSqlRaw trong EF Core

EF Core an toàn theo mặc định khi dùng LINQ — nhưng khi developer dùng FromSqlRaw với string interpolation, bảo vệ đó biến mất:

// ❌ NGUY HIỂM — FromSqlRaw + string interpolation = SQL injection
var results = _db.Products
    .FromSqlRaw($"SELECT * FROM Products WHERE Name LIKE '%{name}%'")
    .ToList();

Đây là lỗi phổ biến vì developer nghĩ "EF Core lo hết rồi" — nhưng FromSqlRaw không parameterize string interpolation.


Giải thích: tại sao parameterized query an toàn

Parameterized query tách SQL statement và data thành hai phần riêng biệt. Database engine nhận được:

  1. Template SQL đã compile: SELECT * FROM Products WHERE Name LIKE @p0
  2. Giá trị riêng: @p0 = "%laptop%"

Không có cách nào để data ảnh hưởng vào cấu trúc câu SQL. Kể cả input là ' OR '1'='1, nó sẽ được xử lý là một chuỗi ký tự, không phải SQL syntax.


Fix đúng cách theo từng data access layer

EF Core — LINQ là đủ, dùng đúng Raw SQL khi cần

// ✅ ĐÚNG — LINQ auto-parameterize, mặc định an toàn
var products = await _db.Products
    .Where(p => p.Name.Contains(name))
    .ToListAsync();

// Generated SQL:
// SELECT * FROM [Products] WHERE [Name] LIKE @p0
// @p0 = "%laptop%"

Khi buộc phải dùng raw SQL trong EF Core, chọn FromSqlInterpolated thay vì FromSqlRaw:

// ✅ ĐÚNG — FromSqlInterpolated xử lý parameterization
var results = _db.Products
    .FromSqlInterpolated($"SELECT * FROM Products WHERE Name LIKE {$"%{name}%"}")
    .ToList();

// ❌ SAI — FromSqlRaw với string interpolation không được bảo vệ
var results = _db.Products
    .FromSqlRaw($"SELECT * FROM Products WHERE Name LIKE '%{name}%'")
    .ToList();

Sự khác biệt: FromSqlInterpolated nhận FormattableString và tự convert holes thành parameters. FromSqlRaw nhận string thô.

Dapper — named parameters

// ✅ ĐÚNG — named parameter tách data khỏi SQL
[HttpGet("search")]
public async Task<IActionResult> Search(string name)
{
    using var connection = new SqlConnection(_connectionString);
    
    var query = @"
        SELECT [Id], [Name], [Price], [Stock]
        FROM [Products]
        WHERE [Name] LIKE @SearchTerm
        ORDER BY [Name]";
    
    var products = await connection.QueryAsync<Product>(
        query,
        new { SearchTerm = $"%{name}%" }  // Data tách riêng
    );
    
    return Ok(products);
}

Nguyên tắc với Dapper: không bao giờ ghép biến trực tiếp vào string SQL. Luôn dùng @ParameterName trong query và truyền object anonymous.

ADO.NET — SqlParameter explicit

// ✅ ĐÚNG — SqlParameter với explicit type
public async Task<DataTable> SearchProducts(string name)
{
    using var connection = new SqlConnection(_connectionString);
    using var command = new SqlCommand(
        "SELECT [Id], [Name], [Price] FROM [Products] WHERE [Name] LIKE @SearchTerm",
        connection
    );
    
    // Khai báo type rõ ràng — không dùng AddWithValue nếu có thể
    command.Parameters.Add(new SqlParameter
    {
        ParameterName = "@SearchTerm",
        SqlDbType = SqlDbType.NVarChar,
        Value = $"%{name}%",
        Size = 100
    });
    
    await connection.OpenAsync();
    var adapter = new SqlDataAdapter(command);
    var result = new DataTable();
    adapter.Fill(result);
    return result;
}

Lưu ý về AddWithValue: Nhiều bài viết dùng command.Parameters.AddWithValue("@SearchTerm", name). Cách này vẫn an toàn về SQL injection, nhưng có vấn đề về performance vì ADO.NET phải tự suy luận type. Trong production, khai báo type rõ ràng với SqlDbType.


Stored Procedures: an toàn nhưng không hoàn toàn miễn dịch

Stored Procedure thường được coi là silver bullet cho SQL injection. Đúng — nếu được viết đúng.

-- ✅ ĐÚNG: SP với parameterized input
CREATE PROCEDURE SearchProducts
    @SearchTerm NVARCHAR(100)
AS
BEGIN
    SELECT [Id], [Name], [Price]
    FROM [Products]
    WHERE [Name] LIKE '%' + @SearchTerm + '%'
END
-- ❌ NGUY HIỂM: SP với dynamic SQL không parameterized
CREATE PROCEDURE SearchProductsDangerous
    @SearchTerm NVARCHAR(100)
AS
BEGIN
    DECLARE @sql NVARCHAR(MAX)
    SET @sql = 'SELECT * FROM Products WHERE Name LIKE ''%' + @SearchTerm + '%'''
    EXEC (@sql)  -- Injection ở đây!
END

Dynamic SQL bên trong Stored Procedure vẫn có thể bị inject. Nếu buộc phải dùng dynamic SQL trong SP, dùng sp_executesql với parameters:

EXEC sp_executesql 
    N'SELECT * FROM Products WHERE Name LIKE @Term',
    N'@Term NVARCHAR(100)',
    @Term = @SearchTerm;

Defense in depth: những lớp bảo vệ bổ sung

Parameterized query là lớp bảo vệ chính, nhưng không phải duy nhất:

1. Input validation — lớp ngoài cùng:

// Validate trước khi pass vào bất kỳ đâu
if (string.IsNullOrWhiteSpace(name) || name.Length > 100)
    return BadRequest("Invalid search term");

// Reject suspicious characters nếu business logic cho phép
var allowedPattern = new Regex(@"^[\w\s\-\.]+$");
if (!allowedPattern.IsMatch(name))
    return BadRequest("Search term contains invalid characters");

2. Least privilege — database user:

-- Database user cho application chỉ có SELECT, INSERT, UPDATE, DELETE
-- KHÔNG có DROP, CREATE, ALTER, EXECUTE xSP nguy hiểm
GRANT SELECT, INSERT, UPDATE, DELETE ON dbo.Products TO app_user;
-- DENY DROP TABLE ON dbo.Products TO app_user;

3. Error handling — không leak schema:

catch (SqlException ex)
{
    // Log chi tiết internally
    _logger.LogError(ex, "Database error during product search. Query: {Query}", sanitizedQuery);
    
    // Return generic message — không expose SQL error details
    return StatusCode(500, "Lỗi tìm kiếm. Vui lòng thử lại sau.");
}

Kiểm tra code hiện tại: regex audit nhanh

Để tìm potential SQL injection trong codebase hiện có:

# Tìm FromSqlRaw với string interpolation (EF Core)
grep -rn "FromSqlRaw\s*(\s*\$" --include="*.cs"

# Tìm string concatenation trong SQL-like strings
grep -rn '"SELECT\|"INSERT\|"UPDATE\|"DELETE' --include="*.cs" | grep -v "@\|{"

# Tìm ExecuteQuery với string building
grep -rn "ExecuteQuery\|ExecuteNonQuery\|ExecuteScalar" --include="*.cs"

Đây không phải full scan — chỉ là starting point. Kết quả cần human review.


Kết

SQL injection không phải lỗi mới, nhưng nó vẫn ở top 5 OWASP 2025 vì lý do đơn giản: code mới vẫn viết sai. Với .NET stack, quy tắc chỉ có một: không bao giờ ghép biến vào SQL string. LINQ là đủ cho 90% trường hợp. Khi cần raw SQL, dùng FromSqlInterpolated hoặc named parameters trong Dapper/ADO.NET.

Bài tiếp theo trong series bảo mật này: JWT vulnerabilities — cụ thể là algorithm confusion attack, cách attacker biến RS256 thành HS256, và cách ASP.NET Core middleware phòng ngừa nếu cấu hình đúng.


Thiết Kiếm — BKGlobal Tech Team

#BKGlobal #appsec #security #owasp #securecoding #dotnet

Bảo mật & AppSec

Xem tất cả