Là gì
SQL injection xảy ra khi dữ liệu người dùng gửi lên được ghép vào câu truy vấn dưới dạng CÚ PHÁP thay vì dữ liệu. Kết quả là kẻ tấn công không chỉ đổi được giá trị tìm kiếm, mà đổi được chính câu lệnh: thêm điều kiện, gộp bảng khác, hay chạy câu lệnh thứ hai. NoSQL injection là cùng một lỗi ở tầng khác — thay vì chuỗi SQL thì kẻ tấn công gửi lên một object toán tử mà driver diễn giải như truy vấn.
Vì sao bạn quan tâm
SQL injection có tuổi lớn hơn phần lớn người đang đọc dòng này, và vẫn nằm trong A05:2025. Lý do nó không chết là vì cách viết sai luôn tiện hơn cách viết đúng trong đúng một tình huống: khi câu truy vấn phải thay đổi hình dạng theo input (ORDER BY động, filter tuỳ chọn, IN (...) độ dài thay đổi). Nếu bạn thấy mình đang nối chuỗi ở một trong ba chỗ đó, đó là chỗ lỗi sẽ ở.
Về mức thiệt hại, SQL injection khác các lỗi khác ở chỗ nó không cần leo thang: nó đã ở ngay tầng dữ liệu. Một câu UNION SELECT là đủ để đọc toàn bộ bảng người dùng, và với quyền DB rộng thì xp_cmdshell, COPY … FROM PROGRAM hay LOAD_FILE biến nó thành RCE.
Với ORM: ORM không bảo vệ bạn. Nó bảo vệ bạn ở 95% câu truy vấn bạn viết bằng LINQ/QueryBuilder, rồi để bạn tự lo ở đúng cái FromSqlRaw/$queryRaw/.raw() mà bạn buộc phải dùng cho câu thứ 96.
Cơ chế hoạt động
Cơ chế là một sự nhầm lẫn về ranh giới: parser của DB nhận một chuỗi và phải tự quyết đâu là lệnh, đâu là dữ liệu. Nối chuỗi làm mất ranh giới đó trước khi parser nhìn thấy nó.
flowchart TD I["Input: email = a@b.c<br/>' OR 1=1 --"] --> C{App dựng câu lệnh} C -->|Nối chuỗi| S1["SELECT * FROM users<br/>WHERE email = 'a@b.c' OR 1=1 --'"] C -->|Tham số hoá| S2["SELECT * FROM users<br/>WHERE email = $1<br/>$1 = #quot;a@b.c#quot; OR 1=1 --#quot;"] S1 --> P1["Parser thấy 2 điều kiện<br/>→ trả VỀ MỌI HÀNG"] S2 --> P2["Parser thấy 1 điều kiện<br/>+ 1 giá trị chuỗi<br/>→ trả 0 hàng"] P1 --> B["🔓 Rò toàn bộ bảng"] P2 --> G["✅ Đúng như thiết kế"]Điểm cốt lõi: ở nhánh tham số hoá, chuỗi ' OR 1=1 -- vẫn tới được DB nguyên vẹn. Nó không bị xoá, không bị escape — nó chỉ được đặt ở phía DỮ LIỆU của ranh giới, nên parser không đọc nó như cú pháp. Đây là lý do tham số hoá thắng mà escape thì không: escape cố sửa dữ liệu, tham số hoá dựng lại ranh giới.
Bốn dạng, khác nhau ở cách kẻ tấn công ĐỌC được kết quả:
| Dạng | Cách đọc kết quả | Dấu hiệu |
|---|---|---|
| In-band / UNION | Kết quả hiện ngay trong response | Số cột phải khớp; ORDER BY n để dò |
| Error-based | Thông báo lỗi DB chứa dữ liệu | CAST((SELECT …) AS int) |
| Blind boolean | Response khác nhau giữa true/false | AND 1=1 vs AND 1=2 |
| Blind time | Thời gian phản hồi | pg_sleep(5), WAITFOR DELAY |
NoSQL: cùng ranh giới, khác cú pháp. {"email": {"$ne": null}} — kẻ tấn công gửi một object vào chỗ code mong đợi một string, và driver diễn giải $ne như toán tử. Trong Express, ?email[$ne]=null tạo ra đúng object đó mà không cần JSON.
Mô tả sơ đồ: Sơ đồ nhánh so sánh hai cách dựng câu lệnh với cùng một input chứa dấu nháy. Nhánh nối chuỗi cho ra câu lệnh có hai điều kiện nên parser trả về mọi hàng, dẫn tới rò toàn bộ bảng. Nhánh tham số hoá giữ nguyên input nhưng đặt nó ở phía dữ liệu, nên parser chỉ thấy một điều kiện và một giá trị chuỗi, trả về 0 hàng.
Ví dụ cụ thể
Endpoint tìm kiếm sản phẩm với filter tuỳ chọn và sắp xếp theo cột do client chọn — đúng hình dạng câu truy vấn khiến người ta nối chuỗi.
Khai thác 1 — rò bảng khác qua UNION:
GET /api/products?category=Gifts' UNION SELECT email,password_hash,NULL FROM users-- HTTP/1.1Host: shop.example.comHTTP/1.1 200 OK [{"name":"admin@shop.example.com","description":"$2b$12$Kq3…","price":null}, {"name":"alice@shop.example.com","description":"$2b$12$8Zt…","price":null}]Khai thác 2 — qua tham số sort, chỗ mà tham số hoá KHÔNG dùng được:
GET /api/products?sort=(CASE WHEN (SELECT current_setting('is_superuser'))='on' THEN name ELSE price END) HTTP/1.1Thứ tự kết quả đổi hay không chính là một bit thông tin. Đây là lý do khối 6 phải nói riêng về ORDER BY.
public async Task<List<Product>> Search(string? category, string sort, bool desc){ // Câu truy vấn phải đổi HÌNH DẠNG theo input — đây chính là tình huống khiến // người ta nối chuỗi, và là chỗ gần như mọi SQL injection thật sự nằm. var sql = "SELECT id, name, description, price FROM products WHERE 1=1"; if (!string.IsNullOrEmpty(category)) // LỖI 1 — giá trị nối vào cú pháp. ' UNION SELECT … -- đọc được bảng khác. sql += $" AND category = '{category}'"; // LỖI 2 — định danh nối vào cú pháp. Tham số hoá KHÔNG vá được dòng này; // nó cần một cách vá khác hẳn (map allowlist), và đó là lý do nó sống lâu. sql += $" ORDER BY {sort} {(desc ? "DESC" : "ASC")}"; return await _db.Products.FromSqlRaw(sql).ToListAsync();}app.post("/api/login", async (req, res) => { const { email, password } = req.body; // Không có SQL, không có dấu nháy — và vẫn là injection. // Với body {"email": {"$ne": null}, "password": {"$ne": null}} câu này trả về // user ĐẦU TIÊN trong collection, thường là tài khoản admin tạo lúc seed. const user = await db.collection("users").findOne({ email, password }); if (!user) return res.status(401).json({ error: "invalid" }); return res.json({ token: sign(user) });});Chuyện đã xảy ra
TalkTalk, tháng 10/2015 — 156.959 khách hàng, phạt £400.000. Báo cáo của ICO nêu rõ: SQL injection trên ba trang web thừa hưởng từ một vụ mua lại, chạy trên một phiên bản Drupal có lỗ đã được vá ba năm rưỡi trước đó. ICO gọi đó là lỗi "cơ bản" và nhấn mạnh TalkTalk không biết ba trang đó còn tồn tại. Bài học không phải "hãy tham số hoá" — mà là code bạn không biết mình đang chạy là code không ai vá.
Heartland Payment Systems, 2008 — ~130 triệu số thẻ. Điểm vào là SQL injection trên một web form; từ đó kẻ tấn công đi ngang vào mạng xử lý thanh toán. Đây là ví dụ cho luận điểm ở khối 6 lớp 3: quyền của tài khoản DB quyết định SQL injection là "rò một bảng" hay "mất cả hệ thống".
Cách phòng chống
Tham số hoá, không có ngoại lệ nào cho giá trị
bắt buộcMọi giá trị đi vào câu truy vấn phải qua tham số. Không escape, không string.Format, không nội suy chuỗi. Với ORM: dùng LINQ/query builder; khi buộc phải viết SQL thô thì dùng dạng có tham số của nó (FromSql với FormattableString trong EF Core 8 — KHÔNG phải FromSqlRaw nối chuỗi).
/// <summary>/// Hai kỹ thuật, không phải một, vì hai lỗi ở ví dụ trên khác bản chất:////// • GIÁ TRỊ → tham số. DB nhận nó ở phía dữ liệu của ranh giới cú pháp./// • ĐỊNH DANH → map allowlist. Tham số hoá không áp dụng được cho tên cột,/// nên chỗ này KHÔNG có cách nào ngoài việc client chỉ được chọn trong một/// tập hữu hạn mà server định nghĩa./// </summary>public sealed class ProductSearch{ // Client gửi KHOÁ, server tra ra tên cột. Không có đường nào để một chuỗi do // client kiểm soát đi vào câu lệnh — chỉ có giá trị bên phải của map này đi vào, // và mọi giá trị đó do chúng ta gõ ra. private static readonly Dictionary<string, string> SortColumns = new(StringComparer.OrdinalIgnoreCase) { ["name"] = "name", ["price"] = "price", ["newest"] = "created_at", }; private readonly SecLabDbContext _db; public ProductSearch(SecLabDbContext db) => _db = db; public async Task<List<Product>> Search(string? category, string sort, bool desc, CancellationToken ct) { // Khoá lạ → 400, KHÔNG rơi về mặc định. Rơi về mặc định trông như an toàn // nhưng làm mất tín hiệu: sẽ không ai biết là có người đang dò tên cột. if (!SortColumns.TryGetValue(sort, out var column)) throw new ApplicationGeneralException(CatalogErrorsList.INVALID_SORT_KEY, $"Unknown sort key '{sort}'"); // Đường đi ưa dùng: LINQ. Provider tự tham số hoá, và không có chuỗi SQL nào // để ai đó nối thêm vào trong lần sửa sau. var query = _db.Products.AsNoTracking(); if (!string.IsNullOrEmpty(category)) query = query.Where(p => p.Category == category); // → tham số // Sắp xếp theo cột đã map. Chỉ có ba chuỗi có thể tới đây, cả ba do ta viết. query = (column, desc) switch { ("name", false) => query.OrderBy(p => p.Name), ("name", true) => query.OrderByDescending(p => p.Name), ("price", false) => query.OrderBy(p => p.Price), ("price", true) => query.OrderByDescending(p => p.Price), (_, false) => query.OrderBy(p => p.CreatedAt), (_, true) => query.OrderByDescending(p => p.CreatedAt), }; return await query.Take(100).ToListAsync(ct); } /// <summary> /// Khi buộc phải viết SQL thô (CTE, window function, hint mà LINQ không sinh được): /// dùng FromSql với FormattableString, KHÔNG phải FromSqlRaw. /// /// Khác biệt tinh vi và đáng nhớ: cả hai đều viết được dưới dạng $"…{x}…", nhưng /// FromSql nhận FormattableString nên nó thấy được x là một hole và biến x thành /// tham số; FromSqlRaw nhận string nên nội suy đã xảy ra TRƯỚC khi nó được gọi và /// nó chỉ còn thấy một chuỗi đã ghép xong. /// </summary> public Task<List<Product>> TopInCategory(string category, CancellationToken ct) => _db.Products.FromSql( $""" SELECT * FROM products WHERE category = {category} ORDER BY price DESC LIMIT 10 """) .AsNoTracking() .ToListAsync(ct);}import { z } from "zod"; // Ranh giới ở đây là ranh giới KIỂU, không phải ranh giới cú pháp: một toán tử// Mongo chỉ tồn tại được nếu giá trị là object. Ép về string là đóng được cả họ lỗi// này, và nó đóng luôn những toán tử chưa ai nghĩ ra.const LoginBody = z.object({ email: z.string().email().max(320), password: z.string().min(1).max(200),}); app.post("/api/login", async (req, res) => { const parsed = LoginBody.safeParse(req.body); if (!parsed.success) return res.status(400).json({ error: "invalid_body" }); const { email, password } = parsed.data; // cả hai chắc chắn là string // Và mật khẩu không bao giờ được so ở tầng truy vấn: tra user rồi verify hash, // để việc so sánh đi qua một hàm hằng-thời-gian thay vì qua index của DB. const user = await db.collection("users").findOne({ email }); if (!user || !(await argon2.verify(user.passwordHash, password))) return res.status(401).json({ error: "invalid" }); return res.json({ token: sign(user) });});Định danh (tên cột, tên bảng, hướng sắp xếp) thì tham số hoá KHÔNG làm được
Đây là chỗ duy nhất còn lại và là chỗ lỗi thật sự sống. Cách đúng là map qua allowlist: client gửi một khoá, server tra khoá đó ra tên cột thật. Không kiểm regex tên cột — map nó. Nếu khoá không có trong map thì trả 400, đừng "rơi về mặc định" một cách im lặng.
Quyền tối thiểu cho tài khoản DB
Tài khoản của app không được có DROP, CREATE, không phải superuser, không đọc được pg_shadow. Với Postgres: GRANT SELECT, INSERT, UPDATE, DELETE theo bảng, và cân nhắc RLS như lớp thứ hai cho phân quyền theo hàng. Lớp này quyết định quy mô thiệt hại khi lớp 1 có lỗ.
Không để thông báo lỗi DB ra ngoài
Error-based injection cần thông báo lỗi. Trả về một mã lỗi có correlation id, ghi chi tiết vào log. Đây là lớp chỉ làm chậm kẻ tấn công, không chặn — nên nó ở lớp 3, không ở lớp 1.
NoSQL: ép kiểu trước khi truy vấn. Nếu code mong đợi một string thì if (typeof email !== 'string') return 400. Với Mongoose, dùng schema có type: String và bật sanitizeFilter. Trong Express, ?email[$ne]=null biến query param thành object — nên kiểm kiểu là bắt buộc, không phải phòng xa.
Kiểm chứng đã vá
1. Test đơn vị chứng minh payload TỚI ĐƯỢC DB mà không đổi cú pháp. Đây là điểm mà phần lớn test SQL injection viết sai: chúng khẳng định "không có lỗi", trong khi điều cần khẳng định là dữ liệu độc hại được lưu và đọc lại nguyên vẹn như một chuỗi. Xem tab csharp / test.
2. Test allowlist của ORDER BY — khoá lạ phải trả 400, không rơi về mặc định. Rơi về mặc định là hành vi trông như an toàn nhưng làm mất tín hiệu: không ai biết có người đang dò.
3. Grep chặn merge trong CI. Đây là phép kiểm có tỉ lệ hiệu quả cao nhất:
# Bất kỳ SQL thô nào có nội suy chuỗi:grep -rnE '(FromSqlRaw|ExecuteSqlRaw|queryRaw|\$queryRawUnsafe)' --include='*.cs' --include='*.ts' src/ \ | grep -E '\$"|\+ *[a-z]|\$\{' && { echo "SQL thô có nối chuỗi — chặn"; exit 1; }exit 04. Kiểm quyền DB thật sự đã bị thu hẹp:
-- Phải trả về 0 hàng:SELECT rolname FROM pg_roles WHERE rolname = 'app_api' AND (rolsuper OR rolcreatedb OR rolcreaterole);-- Phải KHÔNG có quyền nào ngoài CRUD:SELECT DISTINCT privilege_type FROM information_schema.table_privileges WHERE grantee = 'app_api';5. Quét động (sqlmap) trong pipeline nightly, không trong PR gate — nó chậm và có false positive, nhưng nó là thứ duy nhất tìm được chỗ mà grep không thấy.
public class ProductSearchTests{ /// <summary> /// Phần lớn test SQL injection khẳng định sai thứ: chúng kiểm "không throw", mà /// một bản vá kiểu blocklist cũng không throw. Test này khẳng định đúng thứ cần: /// chuỗi độc hại đi tới DB NGUYÊN VẸN như một giá trị, khớp đúng 0 hàng, và /// bảng users vẫn còn nguyên. /// </summary> [Theory] [InlineData("Gifts' UNION SELECT email,password_hash,NULL FROM users--")] [InlineData("Gifts' OR 1=1--")] [InlineData("Gifts'; DROP TABLE products;--")] [InlineData("Gifts' AND (SELECT pg_sleep(5))--")] public async Task Malicious_category_is_data_not_syntax(string payload) { var usersBefore = await _db.Users.CountAsync(); var result = await _search.Search(payload, "name", false, default); // 0 hàng: không có category nào TÊN như vậy — đúng như một so sánh chuỗi. Assert.Empty(result); // Bảng users còn nguyên: câu lệnh thứ hai không chạy. Assert.Equal(usersBefore, await _db.Users.CountAsync()); } /// <summary> /// Khoá sort lạ phải 400. Nếu test này pass khi ta đổi throw thành "rơi về /// created_at" thì test đang cho phép đúng cái hành vi làm mất tín hiệu dò. /// </summary> [Theory] [InlineData("(CASE WHEN (SELECT current_setting('is_superuser'))='on' THEN name ELSE price END)")] [InlineData("name; DROP TABLE products")] [InlineData("created_at")] // tên cột THẬT nhưng không có trong map public async Task Unknown_sort_key_is_rejected(string sort) { var ex = await Assert.ThrowsAsync<ApplicationGeneralException>( () => _search.Search(null, sort, false, default)); Assert.Contains("Unknown sort key", ex.Message); }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Escape dấu nháy đơn (' → '') | Không cứu được ngữ cảnh số: id=1 OR 1=1 không có nháy nào. Và encoding nhiều byte đưa dấu nháy trở lại |
Blocklist từ khoá UNION, SELECT, -- | UNiOn, /**/, %55NION, comment lồng — và bạn vừa làm hỏng ô tìm kiếm của người dùng tên "Select" |
| Dùng ORM nên "an toàn rồi" | Đúng cho câu bạn viết bằng LINQ. FromSqlRaw($"…{input}") vẫn nối chuỗi, và $"…" khiến nó trông như đã tham số hoá |
| Stored procedure nên "an toàn rồi" | Một sp_ có EXEC(@sql) bên trong là SQL injection nằm sâu hơn một tầng, khó thấy hơn |
Kiểm regex tên cột cho ORDER BY | name khớp regex, (CASE WHEN …) cũng có thể khớp một regex đủ lỏng. Map allowlist thì không có chỗ cho "đủ lỏng" |
| Chặn ở WAF | Vòng qua được, và nó không sửa lỗi. Đúng là lớp phòng thủ tạm khi chưa vá được — không phải bản vá |
Sai lầm riêng của NoSQL: nghĩ rằng "không có SQL thì không có injection". db.users.find({email: req.query.email}) với ?email[$ne]=null trả về mọi user, và không có một dấu nháy nào trong đó.
Sai lầm về tầng: vá SQL injection ở tầng validation input thay vì tầng dựng truy vấn. Validation là tốt và nên có, nhưng nó phụ thuộc vào việc bạn dự đoán đúng mọi payload; tham số hoá thì không cần dự đoán gì.
Bình luận
Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.
Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.
Đang tải bình luận…