Hai bài trước nói về SQL injection, và dễ để lại ấn tượng rằng injection là chuyện riêng của cơ sở dữ liệu. Không phải. Injection là chuyện của trộn dữ liệu người dùng vào một chuỗi rồi đưa cho một trình thông dịch — bất kể trình đó là SQL, shell của hệ điều hành, hay bộ lọc LDAP. Phần này dựng lại đúng lỗ hổng ấy ở hai nơi ngoài SQL, và cho thấy cùng một cách chặn hoạt động ở cả ba.

Toàn bộ chạy trên ứng dụng tự dựng trong container, tự dọn. Không nhắm vào hệ thống của ai.

Command injection va LDAP injection: cung mot lo, cung mot cach chan

Command injection: một chức năng ping

Ứng dụng có một chức năng quen thuộc: nhập tên máy, nó chạy ping rồi trả kết quả. Bốn cách viết, tôi tạo một tệp bi_mat_he_thong.txt trên máy chủ để đo xem tấn công có đọc trộm được nó không:

  • v0 — shell nối chuỗi: subprocess.run("ping -c 1 " + host, shell=True).
  • v1 — mảng đối số, không shell: subprocess.run(["ping", "-c", "1", host]).
  • v2 — lọc ký tự phổ biến: chặn nếu chứa ;, |, &.
  • v3 — danh sách trắng: chỉ nhận chuỗi khớp mẫu hostname hợp lệ.

Ba lần chạy, kết quả giống hệt:

| Payload | v0 shell | v1 mảng | v2 lọc ;|& | v3 trắng | |---|---|---|---|---| | 127.0.0.1 | ping OK | ping OK | ping OK | ping OK | | 127.0.0.1; cat bí_mật | đọc trộm được | thành 1 hostname | chặn | chặn | | 127.0.0.1 + xuống dòng + cat | đọc trộm được | thành 1 hostname | đọc trộm được | chặn |

Điều đáng nhớ về command injection

v1 chặn mọi thứ mà không cần lọc gì. Khi truyền mảng đối số thay vì một chuỗi cho shell, "127.0.0.1; cat bí_mật" được đưa cho ping như một tên máy duy nhất. Không có shell để diễn giải dấu ;, nên không có lệnh thứ hai. Ping báo "không tìm thấy host" và dừng. Đây là bản sao chính xác của tham số hoá SQL: dữ liệu và mã đi hai đường tách rời, dữ liệu không bao giờ được đọc như mã.

v2 thủng ở đúng chỗ danh sách đen luôn thủng: cái nó quên. Bộ lọc chặn ;, |, & — ba ký tự "ai cũng nghĩ tới". Nhưng nó quên ký tự xuống dòng. Trong shell, xuống dòng phân tách hai lệnh y như dấu chấm phẩy. Payload 127.0.0.1\ncat bí_mật không chứa ký tự nào trong danh sách đen, đi thẳng vào shell, và đọc trộm được tệp bí mật. Đây không phải lỗi giả định — nó là kết quả đo được, lặp lại cả ba lần.

Có một payload chạy được mà không lộ ra ở bảng: $(cat bí_mật). Ở v0 nó không hiện chuỗi bí mật trong kết quả, nên trông như "không thủng". Thực ra lệnh cat đã chạy — kết quả của nó bị nhét làm tên máy cho ping, rồi biến mất. Đây là blind command injection: lệnh thực thi thành công nhưng đầu ra đi qua kênh khác. Vắng bằng chứng trong phản hồi không phải là bằng chứng vắng lỗ hổng.

Một chi tiết đáng dừng lại ở v3: danh sách trắng chặn cả $(cat ...) — payload duy nhất mà v1 và v2 để lọt qua dưới dạng "một hostname vô hại" nhưng vẫn thực thi lệnh ngầm. Vì mẫu hostname hợp lệ ([A-Za-z0-9.-]) không chứa $, (, hay ), danh sách trắng loại nó ngay. Đây là ưu điểm cấu trúc của danh sách trắng: nó không cần biết $() nguy hiểm, chỉ cần biết nó không phải một hostname.

LDAP injection: đăng nhập admin không cần mật khẩu

LDAP dùng một cú pháp bộ lọc riêng. Đăng nhập thường tra cứu bằng (&(uid=<tên>)(userPassword=<mật khẩu>)) — khớp khi cả hai điều kiện đúng. Tôi dựng một OpenLDAP thật với hai tài khoản, anadmin, rồi nối chuỗi tên và mật khẩu vào bộ lọc đó.

Payload v0 nối chuỗi v1 thoát ký tự
admin + đúng mật khẩu đăng nhập được đăng nhập được
Tìm * (wildcard) lộ cả anadmin 0 kết quả
admin + mật khẩu * đăng nhập thành admin từ chối

Dòng cuối là đòn nặng nhất. Với mật khẩu là *, bộ lọc trở thành (&(uid=admin)(userPassword=*)). Trong LDAP, * khớp bất kỳ giá trị nào tồn tại — nên điều kiện mật khẩu luôn đúng, và kẻ tấn công đăng nhập vào tài khoản admin mà không hề biết mật khẩu. Đây chính là OR 1=1 của SQL, viết bằng cú pháp LDAP.

Wildcard cũng biến "tìm một người" thành "liệt kê mọi người": payload * ở route tra cứu lộ ra cả hai tài khoản trong danh bạ.

v1 chặn tất cả bằng cách thoát ký tự đặc biệt của LDAP*, (, ), \ được đổi thành dạng mã hoá, nên * trở thành một dấu sao theo nghĩa đen cần tìm, không còn là ký tự đại diện. Không tài khoản nào có mật khẩu đúng bằng ký tự *, nên đăng nhập bị từ chối.

Vì sao cùng một cách chặn đúng ở cả ba

Ba trình thông dịch khác nhau — SQL, shell, LDAP — nhưng lỗ hổng giống hệt và cách chặn cũng vậy:

  • Tách dữ liệu khỏi mã ngay từ đầu là cách đúng ở mọi nơi. Tham số hoá cho SQL, mảng đối số cho shell, thoát ký tự chuẩn cho LDAP. Cả ba đều đảm bảo đầu vào không bao giờ được phân tích cú pháp như một phần của lệnh.
  • Danh sách đen thua ở mọi nơi. Nó phải đoán trước mọi ký tự nguy hiểm; kẻ tấn công chỉ cần một ký tự bị quên. Với shell đó là xuống dòng; với LDAP đó là * — mỗi trình thông dịch có tập ký tự đặc biệt riêng, và không ai nhớ hết.
  • Danh sách trắng là lưới cuối cùng khi không tách được. Khi đầu vào buộc phải là một phần cấu trúc (như bài trước về tên cột), chỉ còn cách liệt kê trước những giá trị hợp lệ.

Điều đáng nhớ nhất: nếu bạn đã hiểu SQL injection, bạn đã hiểu cả ba. Đây không phải ba lỗ hổng cần học riêng — là một lỗ hổng mặc ba bộ quần áo.

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

Tôi đo trên ping và một bộ lọc LDAP đơn giản. Ứng dụng thật có nhiều trình thông dịch hơn — bộ dựng câu lệnh cho NoSQL, template engine, trình phân tích XML với thực thể ngoài (XXE), trình giải mã tuần tự hoá. Mỗi cái là một biến thể nữa của cùng câu chuyện, và tôi chỉ chạm hai trong số đó.

Payload $(...) blind của tôi chứng minh lệnh chạy nhưng không trích được dữ liệu qua kênh phản hồi. Trích qua kênh khác — DNS, thời gian, kết nối ra ngoài — là có thật và nguy hiểm hơn, nhưng cần một môi trường đo phức tạp hơn container đơn lẻ, nên tôi để lại.

Thử ba mươi giây

Tìm trong mã của bạn mọi chỗ gọi subprocess, os.system, exec, hay dựng bộ lọc LDAP. Với mỗi chỗ, hỏi: đầu vào người dùng có được nối vào một chuỗi rồi trao cho trình thông dịch không?

Nếu có, chuyển sang truyền dữ liệu tách khỏi lệnh — mảng đối số cho shell, thoát ký tự chuẩn cho LDAP. Đừng thêm bộ lọc ký tự và tin rằng thế là xong: nó sẽ quên một ký tự, và ký tự đó là lỗ hổng.