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. Phần này 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.
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".
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 thực tế
- 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.
Chỗ tôi không kết luận được
Tôi đo trên PostgreSQL với standard_conforming_strings bật mặc định. Trên MySQL, hoặc với cấu hình chuỗi khác, dấu gạch chéo ngược \ có thể thoát dấu nháy — và khi đó phép thoát tay '' của v3 còn có thêm một đường thủng nữa mà bài này không chạm tới.
Và tôi chỉ thử ngữ cảnh chuỗi và số. Còn những chỗ không tham số hoá được — tên bảng, tên cột, chiều sắp xếp ASC/DESC — cần một lớp phòng thủ khác (danh sách trắng các giá trị cho phép), và đó là một bài riêng.
Thử ba mươi giây
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ử của bạn.
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.