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 do tôi dựng trong container, tự dọn. Không nhắm vào hệ thống của ai.

Tham so hoa dat duoc o dau, va cho no khong dat duoc thi chuyen gi xay ra

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). Điều nà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ứ thử đặ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. 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 chạy về mặt cú pháp, nhưng nó không sắp xếp theo cột ten nữa; nó cố sắp xếp theo một hằng số, và PostgreSQL từ chối. Trong trường hợp khác — ví dụ 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

Nhiều người nghĩ 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ỏ điều đó.

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á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/aaaalpha/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 được dựng sai, không phải là đối tượng đo an toàn.

Chỗ tôi không kết luận được

Tôi đo blind qua ORDER BY kiểu boolean — mỗi request một bit. Còn kiểu blind theo thời gian (pg_sleep khi điều kiện đúng) thì hoạt động cả khi không có gì trong phản hồi thay đổi, kể cả dòng đầu; tôi không đo nó ở đây, nhưng nó tồn tại và nguy hiểm ngang.

Và tôi chỉ đo ORDER BY. Tên bảng động, GROUP BY động, mệnh đề HAVING — mỗi chỗ là một biến thể của cùng một bài toán "không tham số hoá được", và danh sách trắng là câu trả lời chung cho tất cả.

Thử ba mươi giâ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 là 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.