Hãy tưởng tượng một người gác cửa được dặn: "ai cầm dao thì chặn lại". Anh ta khám dao rất kỹ, và trong nhiều tháng không con dao nào lọt qua — nên ai cũng khen anh giỏi. Vấn đề là công việc thật của anh không phải "chặn dao", mà là "chặn kẻ có ý đồ xấu". Ngày một kẻ mang thứ vũ khí không phải dao đi vào, anh mở cửa mời chào, vì nó không nằm trong danh sách anh được dạy. Ba trong bốn cách chặn SQL injection dưới đây là người gác cửa đó — chúng khám đúng một loại vũ khí, và bài này là lúc tôi cho một kẻ không cầm dao đi qua.
SQL injection là lỗ hổng bị nói tới nhiều nhất và bị hiểu sai nhiều nhất. Ai cũng biết "đừng nối chuỗi vào câu SQL", nhưng khi hỏi cách chặn đúng là gì, câu trả lời thường trộn lẫn bốn thứ rất khác nhau về độ an toàn. Tôi dựng một ứng dụng có lỗ thật — Flask cộng PostgreSQL trong container, tự dựng tự dọn — rồi bắn payload thật qua bốn cách chặn để đo cái nào thật sự đứng vững.
Toàn bộ chạy trên ứng dụng do tôi dựng, không nhắm vào hệ thống của ai.
Bốn cách chặn
Cùng một truy vấn đăng nhập, viết bốn kiểu:
- v0 — nối chuỗi:
"... WHERE ten = '%s'" % ten. Lỗ kinh điển. - v1 — tham số hoá:
execute("... WHERE ten = %s", (ten,)). Cách sách giáo khoa khuyên. - v2 — lọc ký tự: chặn request nếu đầu vào chứa
',--,;,/*. - v3 — thoát ký tự bằng tay: đổi mỗi
'thành''rồi vẫn nối chuỗi.
Số đo, ngữ cảnh chuỗi
Bắn năm payload — bỏ qua mật khẩu bằng admin' --, OR 1=1, UNION SELECT lấy cột mật khẩu — vào trường tên đăng nhập. Ba lần, kết quả giống hệt:
| Payload | v0 nối chuỗi | v1 tham số hoá | v2 lọc | v3 thoát tay |
|---|---|---|---|---|
| Đăng nhập thật | 1 dòng | 1 dòng | 1 dòng | 1 dòng |
admin' -- |
lọt vào | từ chối | chặn | từ chối |
OR 1=1 |
3 dòng lộ | từ chối | chặn | từ chối |
UNION SELECT mật khẩu |
3 dòng lộ | từ chối | chặn | từ chối |
Riêng dòng cuối đáng dừng lại: payload UNION SELECT ten, mat_khau FROM nguoi_dung ở v0 không chỉ vượt qua đăng nhập — nó trả về nội dung cột mat_khau của mọi tài khoản, kể cả bi-mat-quan-tri-99 của admin. Một câu truy vấn tưởng chỉ để kiểm tra đăng nhập trở thành công cụ đọc trộm cả bảng người dùng. Đó là lý do SQL injection đứng đầu mọi bảng xếp hạng lỗ hổng: nó không dừng ở "đăng nhập lậu" mà mở ra toàn bộ cơ sở dữ liệu.
Nhìn bảng này, kết luận tự nhiên là: v0 hỏng, còn ba cách kia đều an toàn như nhau. Đó chính xác là kết luận tôi suýt viết ra — và nó sai.
Lần đo sai của tôi
Thí nghiệm đầu tiên của tôi chỉ có bảng trên. Cả năm payload đều dựa vào dấu nháy đơn để thoát khỏi chuỗi, mà v2 lọc dấu nháy và v3 nhân đôi dấu nháy — nên cả hai chặn sạch. Bảng cho thấy v2 và v3 an toàn ngang v1, và tôi đã gần như tin điều đó.
Nhưng đó là vì tôi chỉ thử đúng một ngữ cảnh: đầu vào nằm giữa hai dấu nháy. Câu hỏi tôi quên đặt: nếu đầu vào không nằm trong dấu nháy thì sao?
Rất nhiều truy vấn tra cứu theo id số: WHERE id = 5 — không có dấu nháy nào bao quanh. Ở đó, payload để tấn công cũng không cần dấu nháy nào. Tôi thêm ba route tra cứu theo id số và bắn đúng một payload: 0 OR 1=1.
Số đo, ngữ cảnh số
Truy vấn là WHERE id = <đầu vào>, không dấu nháy. Payload 0 OR 1=1, không một ký tự nào nằm trong danh sách đen. Bảng mon có 2 dòng; một id hợp lệ trả 1 dòng:
| Cách chặn | Kết quả với 0 OR 1=1 |
|---|---|
| v1 tham số hoá | 500 — không injection được |
| v2 lọc ký tự | THỦNG: 2 dòng, cả bảng lộ |
| v3 thoát ký tự bằng tay | THỦNG: 2 dòng, cả bảng lộ |
Hai cách trông an toàn ở bảng trước thủng hoàn toàn ở đây. Payload không chứa dấu nháy, --, hay dấu chấm phẩy — nên bộ lọc không thấy gì để chặn, và phép thoát dấu nháy không có dấu nháy nào để thoát. 0 OR 1=1 đi thẳng vào câu SQL và biến WHERE id = 0 OR 1=1 thành "luôn đúng". Người gác cửa khám dao đã mở cửa cho kẻ không cầm dao.
Vì sao chỉ tham số hoá đứng vững
Đây là khác biệt cốt lõi, và nó không phải về ký tự:
-
v2 và v3 bảo vệ chống một ký tự. Chúng giả định mọi cuộc tấn công phải đi qua dấu nháy đơn. Giả định đó chỉ đúng khi đầu vào nằm trong chuỗi. Đổi sang trường số, giả định sụp, và cả hai thành vô dụng — không phải vì viết sai, mà vì chúng bảo vệ nhầm thứ.
-
v1 bảo vệ chống cấu trúc. Khi tham số hoá, câu lệnh SQL và dữ liệu đi tới cơ sở dữ liệu bằng hai đường tách rời.
0 OR 1=1được trao cho PostgreSQL như một giá trị duy nhất, và vì cột là số nguyên, PostgreSQL cố ép chuỗi"0 OR 1=1"thành số, thất bại, trả lỗi. Payload không bao giờ được đọc như mã — nó luôn chỉ là dữ liệu. Đó là lý do injection bất khả thi, chứ không phải vì một ký tự nào bị chặn.
Con số 500 ở đây thực ra là một khuyết điểm nhỏ về chất lượng — ứng dụng thật nên bắt lỗi ép kiểu và trả 400. Nhưng về mặt bảo mật, nó là bằng chứng đẹp nhất: payload tấn công đã bị hạ cấp xuống thành một chuỗi vô nghĩa mà cơ sở dữ liệu không thèm hiểu.
Nghĩa là gì trong công việc hằng ngày
- Tham số hoá là cách chặn duy nhất trong bốn cách này thật sự đủ. Không phải vì ba cách kia viết cẩu thả, mà vì chúng giải sai bài toán.
- Danh sách đen luôn thua. Nó phải đoán trước mọi hình dạng tấn công; kẻ tấn công chỉ cần tìm một hình dạng bạn quên. Ở đây hình dạng bị quên là "không có dấu nháy".
- Thoát ký tự bằng tay là bẫy nguy hiểm nhất, vì nó trông giống làm đúng và chặn được mọi ví dụ trong hầu hết bài hướng dẫn — cho tới trường số đầu tiên.
- Đừng tin một phép thử một ngữ cảnh. Nếu bài kiểm của bạn chỉ bắn payload vào trường chuỗi, nó sẽ cho v2 và v3 điểm tuyệt đối. Phải thử cả trường số, và lý tưởng là cả tên bảng, tên cột,
LIMIT— những chỗ tham số hoá không đặt được và cần cách chặn khác hẳn.
Cùng một khác biệt cốt lõi, đổi ngăn xếp
Chuyện "tham số hoá tách cấu trúc khỏi dữ liệu, còn lọc/thoát chỉ vá một ký tự" không riêng gì psycopg2 — nó đúng ở mọi ngôn ngữ, và mỗi hệ có cái bẫy riêng.
Trong Java, PreparedStatement là hiện thân của v1: câu SQL biên dịch trước, tham số điền sau, đúng cơ chế hai kênh tách rời. Nhưng Java có một cái bẫy tinh vi mà tôi thấy đáng cảnh báo: các ORM như MyBatis phân biệt #{param} (tham số hoá, an toàn) với ${param} (nối chuỗi thô, injection); và JPA cho phép createQuery với chuỗi nối tay dễ như createQuery với tham số. Có PreparedStatement trong tay không cứu bạn nếu bạn vẫn nối chuỗi vào nó — công cụ đúng dùng sai vẫn thủng.
Trong Go, database/sql với placeholder $1/? là tham số hoá thật, và điều đáng khen là go vet cùng các linter cảnh báo khi bạn fmt.Sprintf một câu SQL. Nhưng cái bẫy y hệt vẫn còn: một db.Query("... WHERE id = " + id) biên dịch trơn tru và chỉ nổ khi bị khai thác. Ngôn ngữ cho công cụ đúng, nhưng không ép bạn dùng.
Còn ở chính cơ sở dữ liệu, đây là nơi cơ chế thật sự sống: tham số hoá hoạt động vì DB nhận prepared statement — câu lệnh đã phân tích cú pháp thành cây, rồi tham số chỉ điền vào các ô lá, không bao giờ đổi được hình dạng cây. Đó là lý do 0 OR 1=1 không thể biến thành mệnh đề: cây đã cố định trước khi giá trị tới. Hiểu điều này giải thích luôn vì sao tham số hoá không đặt được vào tên bảng hay ORDER BY — những chỗ đó quyết định hình dạng cây, phải cố định trước, nên cần danh sách trắng thay vì placeholder.
Mẫu số chung: an toàn thật đến từ việc tách bạch cấu trúc lệnh khỏi dữ liệu người dùng ở tầng kiến trúc, chứ không từ việc rình bắt các ký tự nguy hiểm ở tầng chuỗi. Lọc và thoát ký tự luôn thua vì chúng đoán trước hình dạng tấn công — và tập hình dạng là vô hạn, đổi theo ngữ cảnh (chuỗi, số, định danh). Tham số hoá thắng vì nó không đoán gì cả: nó chỉ đảm bảo dữ liệu không bao giờ được đọc như mã. Nên câu hỏi đúng không phải "tôi đã lọc ký tự xấu chưa" mà là "dữ liệu người dùng có đi tới DB qua một kênh tách khỏi câu lệnh không".
Muốn tự soát, tìm trong mã của bạn một truy vấn tra cứu theo id số. Xem id đó tới câu SQL bằng cách nào: nếu nó được nối chuỗi hoặc chỉ được "làm sạch" bằng lọc/thoát ký tự, hãy thử gửi 0 OR 1=1 vào đúng tham số đó trên môi trường thử. Nếu nó trả về nhiều hơn một dòng, bạn vừa tìm thấy một lỗ mà mọi bộ lọc dấu nháy trên đời không bao giờ bắt được.