Cuộc gọi lúc 2 giờ sáng: Câu chuyện về hai lỗ hổng
PagerDuty không quan tâm đến lịch ngủ của bạn. Vào lúc 2:14 sáng, điện thoại của tôi rung mạnh đến mức suýt rơi khỏi tủ đầu giường, báo hiệu lượng lỗi 500 trên API production tăng vọt 400%.
Đến lúc tôi đăng nhập vào hệ thống, SIEM đã cảnh báo liên tục về lưu lượng truy cập outbound đáng ngờ. Một web server đang cố gắng kết nối với một IP lạ tại một trung tâm dữ liệu cách nửa vòng trái đất. Ai đó không chỉ đang dò dẫm; họ đang trích xuất dữ liệu trái phép bằng hai trong số những lỗ hổng nguy hiểm nhất trong các stack hiện đại: XML External Entity (XXE) và Server-Side Template Injection (SSTI).
Sàng lọc nhật ký truy cập (access logs) cho thấy một cuộc tấn công có phối hợp. Một kẻ tấn công đang dồn dập gửi các payload XML bị lỗi cấu trúc tới một SOAP endpoint cũ. Cùng lúc đó, một kẻ khác đang tấn công công cụ báo cáo động của chúng tôi bằng cú pháp dấu ngoặc nhọn lạ thường. Đây không phải là bot ngẫu nhiên. Đây là một nỗ lực có chủ đích nhằm vượt qua WAF bằng cách biến chính các trình phân tích (parser) mà chúng tôi tin dùng thành vũ khí.
Nguyên nhân gốc rễ 1: Khi trình phân tích XML trò chuyện với người lạ (XXE)
Đám cháy đầu tiên tôi phải dập tắt là lỗ hổng XXE. Mặc dù XML có vẻ như là một tàn tích của quá khứ, nó vẫn là xương sống của các SAML token và các API cũ. Nguy hiểm không nằm ở bản thân định dạng XML, mà nằm ở tính năng Document Type Definition (DTD) mà many thư viện để ở chế độ kích hoạt mặc định.
Cơ chế khai thác
Kẻ tấn công gửi một tài liệu XML định nghĩa một “thực thể bên ngoài” (external entity). Nếu trình phân tích bị cấu hình sai, nó sẽ lấy các tệp từ đĩa cục bộ hoặc thực hiện các yêu cầu mạng thay mặt cho kẻ tấn công. Trong sự cố này, tôi đã tìm thấy payload sau đang tấn công endpoint /api/v1/shipping của chúng tôi:
<?xml version="1.0" encoding="ISO-8859-1"?>
<!DOCTYPE foo [
<!ELEMENT foo ANY >
<!ENTITY xxe SYSTEM "file:///etc/passwd" >]>
<foo>&xxe;</foo>
Trình phân tích nhìn thấy &xxe; và ngoan ngoãn thay thế nó bằng nội dung của tệp /etc/passwd. Nếu kết quả đó được trả về trong phản hồi API, kẻ tấn công có thể đọc bất kỳ tệp nào mà dịch vụ web có quyền truy cập. Trong trường hợp của chúng tôi, họ còn sử dụng các scheme http:// để quét instance Redis nội bộ trên cổng 6379, biến máy chủ của chúng tôi thành một proxy để họ thực hiện trinh sát.
Tại sao nó xảy ra
Backend của chúng tôi đang chạy một phiên bản lxml 3.7.1 chưa được gia cố. Chúng tôi đã lầm tưởng rằng WAF sẽ chặn đứng các nỗ lực này, nhưng kẻ tấn công đã sử dụng mã hóa UTF-16 để lọt qua các bộ lọc regex. Đó là một sai lầm kinh điển: dựa dẫm vào phòng thủ biên giới thay vì bảo mật chính mã nguồn.
Nguyên nhân gốc rễ 2: Thực thi logic qua SSTI
Trong khi tôi đang vá trình phân tích XML, một cảnh báo thứ hai được kích hoạt. Công cụ dashboard tùy chỉnh của chúng tôi, cho phép người dùng cá nhân hóa các mẫu email, đang bị lạm dụng. Chúng tôi sử dụng engine Jinja2 và nghĩ rằng mình an toàn vì không sử dụng hàm eval(). Chúng tôi đã nhầm.
Cơ chế khai thác
Server-Side Template Injection (SSTI) xảy ra khi đầu vào của người dùng được nối trực tiếp vào một chuỗi template. Kẻ tấn công nhận ra rằng việc nhập {{ 7*7 }} vào trường “Tên công ty” khiến bản xem trước hiển thị kết quả là 49. Đó chính là bằng chứng đanh thép cho việc thực thi mã.
Họ không dừng lại ở các phép toán. Họ sử dụng Method Resolution Order (MRO) của Python để leo lên cây đối tượng và chiếm lấy module os. Đây chính xác là payload đã giúp họ chiếm được shell:
{{ self.__init__.__globals__['__builtins__']['__import__']('os').popen('id').read() }}
Điều này dẫn đến Remote Code Execution (RCE). Họ không chỉ đọc tệp; họ đang chạy các lệnh hệ thống với đặc quyền của web worker của chúng tôi.
So sánh các bản vá: Miếng dán tạm thời vs. Bảo mật thực thụ
Khi hệ thống production đang gặp nguy cấp, bạn rất dễ nảy ra ý định viết một regex nhanh để chặn ENTITY hoặc {{. Đừng làm vậy. Kẻ tấn công có thể vượt qua các bộ lọc này bằng cách sử dụng mã hóa hex, biến thể khoảng trắng hoặc các tag thay thế như {%. Các bộ lọc chỉ là những gờ giảm tốc tạm thời.
Hướng tiếp cận thất bại: Blacklisting
Tôi đã thấy nhiều đội ngũ cố gắng chặn các từ khóa như SYSTEM hoặc __globals__. Đây là một trò chơi nắm phần thua. Trong XML, bạn có thể thay đổi mã hóa ký tự để vượt qua đối khớp chuỗi. Trong SSTI, bạn có thể sử dụng truy cập dictionary như request['__class__'] để tránh các bộ lọc dùng dấu chấm. Bạn không thể thắng một kẻ tấn công quyết tâm chỉ bằng regex.
Cách khắc phục triệt để: Hardening thư viện
Cách thực sự duy nhất để chặn đứng XXE là yêu cầu trình phân tích bỏ qua hoàn toàn các DTD. Đối với SSTI, bạn phải tách biệt nghiêm ngặt mã nguồn khỏi dữ liệu. Không có ngoại lệ.
Triển khai các biện pháp bảo vệ thực tế
Sau khi vô hiệu hóa mối đe dọa tức thời, tôi đã cấu trúc lại các thư viện tiện ích của chúng tôi. Dưới đây là cách bạn nên xử lý các vấn đề này trong môi trường production.
1. Bảo mật trình phân tích XML (Ví dụ với Python)
Nếu bạn đang sử dụng lxml, bạn phải vô hiệu hóa rõ ràng các thực thể bên ngoài và việc tải DTD. Tôi đã cập nhật cấu hình chung của chúng tôi để sử dụng thiết lập an toàn này:
from lxml import etree
# Cấu hình trình phân tích bảo mật
parser = etree.XMLParser(
resolve_entities=False,
no_network=True,
dtd_validation=False,
load_dtd=False
)
def safe_parse_xml(xml_string):
# Hàm này sẽ báo lỗi nếu phát hiện DTD hoặc thực thể bên ngoài
return etree.fromstring(xml_string, parser=parser)
Đối với môi trường Java sử dụng Jackson hoặc DocumentBuilderFactory, bạn phải đặt thuộc tính disallow-doctype-decl thành true. Luôn kiểm tra các giá trị mặc định của thư viện bạn dùng so với bảng tra cứu phòng chống của OWASP.
2. Đánh bại SSTI bằng cách tách biệt logic
Khắc phục SSTI đòi hỏi sự kỷ luật. Tuyệt đối không sử dụng định dạng chuỗi (string formatting) để xây dựng template.
Lỗ hổng:
template = "Xin chào " + user_input
render_template_string(template)
Bảo mật:
render_template_string("Xin chào {{ name }}", name=user_input)
Bằng cách truyền đầu vào dưới dạng một biến, engine sẽ xử lý nó như một chuỗi thuần túy (literal string). Nó sẽ không thực thi bất cứ thứ gì bên trong đó. Nếu bạn buộc phải cho phép người dùng cung cấp template riêng, hãy sử dụng môi trường sandbox với một whitelist nghiêm ngặt các hàm được phép.
Cách tiếp cận tốt nhất: Phòng thủ chiều sâu
Bảo mật không phải là một dòng code đơn lẻ; đó là một hệ thống nhiều lớp. Sau khi vá các lỗ hổng, tôi đã thực hiện thêm ba bước để gia cố hạ tầng.
- Quyền tối thiểu (Least Privilege): Chúng tôi đã chuyển web worker sang một profile người dùng bị hạn chế. Ngay cả khi có RCE, kẻ tấn công cũng không thể đọc thư mục
/root/hoặc cài đặt các công cụ nhưnmap. - Lọc lưu lượng đầu ra (Egress Filtering): Chúng tôi đã cấu hình tường lửa để chặn tất cả các kết nối ra ngoài từ tầng web, trừ khi chúng đến một dịch vụ nội bộ nằm trong whitelist. Điều này triệt tiêu SSRF dựa trên XXE và ngăn các reverse shell kết nối ngược lại máy của kẻ tấn công.
- Tạo bí mật an toàn: Trong quá trình dọn dẹp, tôi phải thay đổi (rotate) nhiều mật khẩu quản trị. Tôi đã sử dụng công cụ tạo mật khẩu tại toolcraft.app/vi/tools/security/password-generator. Vì nó chạy hoàn toàn ở phía client, tôi không phải lo lắng về việc các bí mật mới bị đánh chặn qua mạng.
Danh sách kiểm tra tóm tắt
- Đối với XXE: Vô hiệu hóa DTD và phân giải Thực thể bên ngoài (External Entity) trong mọi trình phân tích. Chuyển sang dùng JSON cho các endpoint mới.
- Đối với SSTI: Sử dụng các template tĩnh. Không bao giờ nối chuỗi cho dữ liệu do người dùng cung cấp.
-
Giám sát: Thiết lập cảnh báo cho các chuỗi
{{,<!ENTITY, và/etc/passwdtrong logs ứng dụng.
Đến khi mặt trời mọc, các bản vá đã được triển khai và logs đã sạch bóng mã độc. Những lỗ hổng này tuy cũ nhưng vẫn hiệu quả vì chúng ta thường tin rằng các thư viện mặc định đã an toàn. Thực tế không phải vậy. Với tư cách là kỹ sư, nhiệm vụ của chúng ta là kiểm chứng các thiết lập mặc định đó trước khi chuông báo động lúc 2 giờ sáng vang lên.

