Hãy hình dung tham số hoá như một cái phong bì có cửa sổ: bạn nhét tờ giấy ghi giá trị vào trong, và dù kẻ gian có viết gì lên tờ giấy đó — kể cả "hãy mở két sắt" — thì bưu điện cũng chỉ đọc nó như một dòng địa chỉ, không bao giờ như một mệnh lệnh. Cái phong bì ấy an toàn tuyệt đối. Vấn đề là có những chỗ trên bì thư mà cửa sổ không với tới được — và bài này đo đúng cái chỗ đó, rồi cho thấy nó thủng đến mức nào.
Bài trước kết luận: tham số hoá là cách chặn SQL injection duy nhất thật sự đủ. Câu đó đúng, nhưng chưa đủ, vì nó ngầm giả định bạn có thể tham số hoá. Phần này đo đúng ranh giới ấy: tham số hoá đặt được vào đâu, không đặt được vào đâu, và chuyện gì xảy ra ở chỗ nó không đặt được.
Toàn bộ chạy trên ứng dụng Flask + PostgreSQL tôi tự dựng trong container, tự dọn. Tôi chỉ tấn công tiến trình của chính mình, không nhắm vào hệ thống của ai.
Placeholder đặt được ở đâu
Tham số hoá dùng một placeholder — %s trong psycopg2, ? ở nhiều thư viện khác — để đánh dấu vị trí một giá trị. Cơ sở dữ liệu nhận câu lệnh và giá trị qua hai đường tách rời, nên giá trị không bao giờ được đọc như mã.
| Vị trí | Đặt placeholder được? |
|---|---|
Giá trị so sánh (WHERE ten = ?) |
Được |
Giá trị trong IN (?, ?, ?) |
Được (mỗi phần tử một placeholder) |
LIMIT ? / OFFSET ? |
Được (chúng là giá trị) |
Tên cột (ORDER BY ?) |
Không |
| Tên bảng | Không |
Chiều sắp xếp ASC/DESC |
Không |
Đo lại điểm đầu cho chắc: gửi ' OR 1=1 -- vào một WHERE ten = %s cho về 0 dòng. Payload trở thành một chuỗi cần tìm đúng như nó viết ra, không có tài khoản nào tên vậy. Placeholder ở vị trí giá trị làm đúng việc của nó.
Chỗ placeholder không đặt được
Ranh giới nằm ở một quy tắc của chính SQL: placeholder chỉ thay cho giá trị, không thay cho định danh (tên bảng, tên cột). Đây không phải giới hạn của thư viện — nó là cách SQL được phân tích cú pháp. Tên cột quyết định hình dạng câu truy vấn, và hình dạng phải cố định trước khi giá trị được điền vào.
Chuyện gì xảy ra nếu cứ cố đặt placeholder vào tên cột? Tôi thử ORDER BY %s với %s = "ten":
ORDER BY 'ten' -> loi: non-integer constant in ORDER BY
Đây là cái bẫy tinh vi, và tôi muốn bạn nhìn kỹ nó. Placeholder không báo lỗi "không cho phép" — nó âm thầm biến tên cột ten thành chuỗi hằng 'ten'. Câu lệnh vẫn hợp lệ về cú pháp, nhưng nó không sắp xếp theo cột ten nữa; nó cố sắp theo một hằng số, và PostgreSQL từ chối. Trong trường hợp khác — một chuỗi hợp lệ ở đúng chỗ — nó sẽ chạy mà âm thầm không sắp xếp gì cả. Lập trình viên tưởng đã tham số hoá, thực ra đã viết một câu lệnh khác hẳn ý mình. Vì placeholder không dùng được, mã thật buộc phải nối chuỗi tên cột vào: "... ORDER BY " + cot. Và đó là một lỗ SQL injection đầy đủ.
Trích khoá bí mật chỉ qua thứ tự sắp xếp
Rất nhiều người đinh ninh ORDER BY là chỗ "an toàn" để nối chuỗi — nó chỉ quyết định thứ tự, đâu trả về dữ liệu mới. Tôi đo để bác bỏ thẳng niềm tin đó.
Bảng có một khoá bí mật KHOA-API-TUYET-MAT nằm trong bảng khác, không route nào hiển thị nó. Route dễ tổn thương chỉ nối tên cột vào ORDER BY rồi trả về dòng đầu tiên. Payload không đặt vào giá trị nào — nó đặt vào cách sắp xếp:
ORDER BY (CASE WHEN <điều kiện về khoá bí mật>
THEN ten ELSE email END)
Nếu điều kiện đúng, kết quả sắp theo cột ten, dòng đầu là alpha. Nếu sai, sắp theo email, dòng đầu là zebra. Mỗi request rò đúng một bit — đúng/sai của một câu hỏi về khoá bí mật, đọc được qua việc dòng đầu là alpha hay zebra.
Dùng tìm kiếm nhị phân trên mã ASCII từng ký tự, tôi đọc:
| Kết quả | |
|---|---|
| Độ dài khoá | 18 (đọc qua blind) |
| Khoá đọc được | KHOA-API-TUYET-MAT |
| Số request để trích | ~144 |
Khoá không bao giờ xuất hiện trong bất kỳ phản hồi nào. Nó rò ra hoàn toàn qua thứ tự của kết quả — một bit mỗi lần, 144 request cho cả chuỗi. Ba lần chạy cho kết quả giống hệt. Đây gọi là blind injection, và ORDER BY là mảnh đất kinh điển của nó. Cái "chỉ quyết định thứ tự" hoá ra là một kênh rò rỉ đủ rộng để lôi ra nguyên một khoá API.
Cách chặn đúng ở chỗ không tham số hoá được
Vì không đặt placeholder được, bạn không thể để cơ sở dữ liệu phân tách mã và dữ liệu hộ. Cách chặn phải chuyển sang danh sách trắng: liệt kê trước những tên cột hợp lệ, và chỉ chấp nhận đúng chúng.
CHO_PHEP = {"id", "ten", "email"}
if cot not in CHO_PHEP:
return loi(400)
Đo lại: với danh sách trắng, cot=email cho 3 dòng như thường, còn payload subquery bị chặn ở 400 trước khi chạm tới câu SQL. Khác biệt cốt lõi so với danh sách đen ở bài trước: danh sách trắng liệt kê cái được phép (hữu hạn, biết trước), còn danh sách đen cố liệt kê cái bị cấm (vô hạn, không bao giờ đủ).
Hai lần đo sai của tôi
Bài này tôi vấp hai lần, và cả hai đều là cùng một loại lỗi: thí nghiệm dựng ra không đủ khắt khe để tín hiệu lộ ra.
Lần một (từ bài trước, nhắc lại vì liên quan): tôi chỉ thử payload ở trường chuỗi, nên mọi cách chặn dựa vào dấu nháy trông đều an toàn. Phải thêm trường số mới thấy chúng thủng.
Lần hai (bài này): phép blind đầu tiên của tôi không đọc được gì — cả ORDER BY ten lẫn ORDER BY email đều cho dòng đầu là admin, nên tín hiệu một bit câm hoàn toàn, mọi ký tự đọc ra thành ~~~~~~~~. Lý do: dữ liệu mẫu của tôi có thứ tự theo ten trùng thứ tự theo email (admin, an, binh song song với admin@, an@, binh@) — nên đổi cột sắp xếp không đổi dòng đầu. Tôi phải dựng lại dữ liệu để hai thứ tự đảo ngược nhau (zebra/aaa và alpha/zzz) thì tín hiệu mới xuất hiện, và khoá 18 ký tự hiện ra ngay.
Bài học lặp lại lần thứ n trong hai sê-ri: một phép đo không cho tín hiệu nào cũng đáng ngờ y như một phép đo cho kết quả hoàn hảo. Cả hai thường có nghĩa là thí nghiệm bị dựng sai, chứ không phải đối tượng đo an toàn.
Cùng một ranh giới, đổi hệ sinh thái
Cái ranh giới "placeholder thay được giá trị nhưng không thay được định danh" không riêng gì psycopg2 — nó là quy tắc của SQL, nên mọi ngôn ngữ đều vấp đúng chỗ đó, chỉ khác cách vá.
Trong Java, PreparedStatement chặn injection ở vị trí giá trị hệt như vậy, và cũng bó tay y hệt với tên cột — SELECT * FROM t ORDER BY ? không hoạt động. Điều đáng nói là ngay cả các tầng cao hơn cũng không cứu bạn miễn phí: JPA/Hibernate với Sort của Spring Data có một lớp chống, nhưng chỉ khi bạn để nó ánh xạ tên thuộc tính entity; hễ bạn rơi xuống native query nối chuỗi tên cột là lỗ mở lại. Cách chữa vẫn đúng một thứ: danh sách trắng tên cột, thường gói trong một enum hoặc Set các cột được phép sắp.
Trong Go, database/sql với $1/? cũng chỉ tham số hoá giá trị. Vì Go bắt bạn viết SQL khá trần trụi, cái bẫy fmt.Sprintf("ORDER BY %s", col) hiện ra rất rõ trong code review — nhưng chính vì trần trụi mà nó cũng dễ bị viết ra. Chuẩn cộng đồng là một map[string]string ánh xạ tên do người dùng gửi sang tên cột thật đã kiểm, không bao giờ nối thẳng đầu vào.
Còn ở cơ sở dữ liệu, cái lõi của cả câu chuyện: ORDER BY là dynamic SQL, và mọi DBA đều biết dynamic SQL — dù trong một stored procedure hay ở tầng ứng dụng — là chỗ tham số hoá không với tới. PostgreSQL còn cho hàm quote_ident() để escape định danh đúng cách khi buộc phải dựng câu lệnh động, nhưng ngay cả nó cũng chỉ nên dùng sau khi tên đã qua một danh sách trắng.
Mẫu số chung: tham số hoá bảo vệ được giá trị vì giá trị và cấu trúc đi qua hai kênh tách rời; nó bất lực với bất cứ thứ gì quyết định cấu trúc câu lệnh — tên bảng, tên cột, chiều sắp xếp — vì những thứ đó phải cố định trước khi giá trị được điền. Ở mọi ngôn ngữ, ranh giới nằm đúng một chỗ, và cách chặn duy nhất bên kia ranh giới là danh sách trắng: liệt kê cái hữu hạn được phép, từ chối tất cả phần còn lại. Nên câu hỏi đúng không phải "tôi đã tham số hoá chưa" mà là "trong câu lệnh này, có mẩu nào là cấu trúc đến từ người dùng không" — vì đó chính là chỗ cái phong bì có cửa sổ không che tới.
Muốn tự soát, hãy tìm trong mã của bạn một chỗ nối chuỗi vào ORDER BY, GROUP BY, hay tên bảng — thường ẩn trong các tính năng "sắp xếp theo cột người dùng chọn". Xem giá trị đó có đi qua một danh sách trắng không. Nếu nó chỉ được kiểm bằng cách lọc vài ký tự, hoặc không kiểm gì, bạn có một lỗ mà tham số hoá không vá được — vì đây đúng là chỗ tham số hoá chưa từng với tới.