SecLab
Server-sideĐầy đủ

SQL & NoSQL injection

V1A05CWE-89
01

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.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

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.

03

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ó.

Nguồn sơ đồ
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ạngCách đọc kết quảDấu hiệu
In-band / UNIONKết quả hiện ngay trong responseSố cột phải khớp; ORDER BY n để dò
Error-basedThông báo lỗi DB chứa dữ liệuCAST((SELECT …) AS int)
Blind booleanResponse khác nhau giữa true/falseAND 1=1 vs AND 1=2
Blind timeThời gian phản hồipg_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.

04

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:

HTTP
GET /api/products?category=Gifts' UNION SELECT email,password_hash,NULL FROM users-- HTTP/1.1Host: shop.example.com
HTTP
HTTP/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:

HTTP
GET /api/products?sort=(CASE WHEN (SELECT current_setting('is_superuser'))='on'                       THEN name ELSE price END) HTTP/1.1

Thứ 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.

C#Hai lỗi khác nhau: dòng 12 nối giá trị, dòng 16 nối định danh. Bản vá của chúng KHÔNG giống nhau.
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();}
TypeScriptNoSQL injection: không có dấu nháy nào, và đó là lý do nó bị bỏ qua.
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) });});
05

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".

06

Cách phòng chống

Lớp 1

Tham số hoá, không có ngoại lệ nào cho giá trị

bắt buộc

Mọ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).

C# · Layer 1Giá trị → tham số. Định danh → map allowlist. Hai lỗi, hai bản vá khác nhau.
/// <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);}
TypeScript · Layer 1Ép kiểu ở biên. Toán tử `$ne` chỉ tồn tại được nếu giá trị là object — nên chặn object là đủ.
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) });});
Lớp 1b

Đị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.

Lớp 2

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ỗ.

Lớp 3

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.

07

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:

Shell
# 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 0

4. Kiểm quyền DB thật sự đã bị thu hẹp:

SQL
-- 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.

C#Test khẳng định payload TỚI ĐƯỢC DB nguyên vẹn — không phải khẳng định "không có lỗi".
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);    }}
08

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_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 BYname 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 ở WAFVò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ì.

09

Nguồn tham khảo

Bậc 1A03:2021 – Injection · OWASP · Top 10 · 2021
Bậc 1Querying data with raw SQL — FromSql and parameterisation · Microsoft · EF Core docs · EF Core 8
Bậc 1ICO monetary penalty notice — TalkTalk Telecom Group PLC · UK Information Commissioner's Office · 2016-10-05
Bậc 2SQL injection · PortSwigger · Web Security Academy
Bậc 2NoSQL injection · PortSwigger · Web Security Academy
Bậc 2SQL Injection Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để 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.

Đăng kýĐăng nhập

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…