Hãy tưởng tượng bạn nhờ một nhân viên lễ tân đáng tin gửi giúp một lá thư tới địa chỉ bạn ghi trên phong bì. Bình thường vô hại. Nhưng lễ tân này có chìa khoá vào cả những căn phòng trong toà nhà mà khách như bạn không được vào — kho, phòng máy chủ, két sắt. Nếu bạn ghi lên phong bì "phòng két sắt tầng hầm" thay vì một địa chỉ ngoài phố, và anh ta ngoan ngoãn đi lấy nội dung trong đó mang ra cho bạn, thì bạn vừa mượn được đúng cái quyền truy cập mà mình không có. SSRF chính là trò đó: máy chủ của bạn là anh lễ tân có chìa khoá vào mạng nội bộ, và kẻ tấn công chỉ việc ghi đúng địa chỉ lên phong bì.
Server-Side Request Forgery (SSRF) là khi kẻ tấn công khiến máy chủ của bạn gửi một yêu cầu mạng thay cho nó — tới một địa chỉ mà kẻ tấn công tự chọn. Điều làm nó nguy hiểm: máy chủ của bạn thường ở trong một mạng nội bộ mà internet không chạm tới được — cơ sở dữ liệu, dịch vụ nội bộ, và trên cloud là endpoint metadata chứa khoá truy cập. Tôi dựng lại lỗ này và đo cách chặn.
Toàn bộ chạy trong container tôi tự dựng, tự dọn. Tôi chỉ tấn công dịch vụ do chính mình tạo, không nhắm vào hệ thống của ai.
Cách đo
Tôi dựng hai dịch vụ trong một mạng riêng. Một là dịch vụ nội bộ giả lập endpoint metadata của cloud — nó phục vụ một "khoá bí mật" (AKIA-...) mà không được lộ ra internet. Hai là một ứng dụng có tính năng quen thuộc: nhập một URL ảnh, máy chủ tải ảnh đó về. Câu hỏi: kẻ tấn công có khiến ứng dụng tải URL nội bộ thay vì URL ảnh không?
Số đo
Ba lần chạy, kết quả giống hệt:
SSRF trên v0 (không kiểm URL) — rò khoá bí mật:
| Kẻ tấn công yêu cầu máy chủ tải | Kết quả |
|---|---|
| URL metadata nội bộ | AKIA-KHOA-BI-MAT-CUA-MAY-CHU-CLOUD |
http://dịch-vụ-nội-bộ/ |
nội dung nội bộ trả về |
Máy chủ tải URL nội bộ hộ kẻ tấn công, và trả nội dung về cho nó — gồm cả khoá bí mật cloud. Từ khoá đó, kẻ tấn công có thể chiếm toàn bộ tài khoản cloud. Đây chính là cơ chế đằng sau vụ rò rỉ Capital One 2019 (hơn 100 triệu hồ sơ) — không phải một lỗ hổng lý thuyết, mà là một trong những vụ vỡ dữ liệu lớn nhất lịch sử.
v1 (danh sách cho phép + chặn IP nội bộ) — chặn mọi đường:
| URL | Kết quả |
|---|---|
| URL metadata nội bộ | 403 — tên miền không trong danh sách |
http://localhost/ |
403 — không trong danh sách |
file:///etc/passwd |
400 — chỉ cho http/https |
Ba điều đáng gằn giọng
SSRF nguy hiểm vì máy chủ có quyền mạng mà kẻ tấn công không có. Từ internet, không ai gọi được endpoint metadata 169.254.169.254 của một máy chủ cloud — nó chỉ truy cập được từ bên trong máy chủ đó. SSRF cho kẻ tấn công mượn đúng vị trí đó: nó không tự gọi được, nhưng nó bảo máy chủ của bạn gọi hộ. Mọi dịch vụ nội bộ mà máy chủ tin tưởng — không đặt mật khẩu vì "chỉ nội bộ mới gọi được" — bỗng phơi ra trần trụi.
Danh sách chặn thua, danh sách cho phép thắng — và lý do sâu hơn câu "liệt kê cái xấu là vô tận". Cách chặn ngây thơ là danh sách chặn: cấm localhost, cấm 127.0.0.1, cấm 169.254.169.254. Nó thua vì có vô số cách viết cùng một địa chỉ nội bộ: 127.0.0.1, 127.1, 0177.0.0.1 (octal), 2130706433 (dạng số nguyên), localhost, [::1], một tên miền do kẻ tấn công trỏ về IP nội bộ. Không danh sách chặn nào liệt kê hết. Danh sách cho phép lật ngược vấn đề: chỉ cho phép đúng những tên miền bạn thật sự cần gọi (cdn-anh.com), từ chối tất cả còn lại. Cách này an toàn theo mặc định — một biến thể mới của địa chỉ nội bộ không phá được nó, vì nó không nằm trong danh sách cho phép.
Phải chặn IP nội bộ sau khi phân giải DNS — và fail-closed. Kể cả với danh sách cho phép, còn một cái bẫy tên là DNS rebinding: một tên miền hợp lệ có thể phân giải ra một IP nội bộ. Nên sau khi kiểm tên miền, phải phân giải nó ra IP và kiểm IP đó có phải nội bộ không (is_private, is_loopback, is_link_local). Trong phép đo, hàm la_ip_noi_bo của tôi làm đúng một điều quan trọng: nếu không phân giải được tên miền, nó trả về "nội bộ" và từ chối — fail-closed. Thà chặn nhầm một tên miền hợp lệ còn hơn cho lọt một tên miền độc. (Đây cũng là lý do trong môi trường test không có DNS internet, ngay cả cdn-anh.com cũng bị chặn — hành vi an toàn đúng đắn, dù bất tiện.)
Vì sao danh sách chặn là cuộc chiến không thắng được
Đáng nhìn kỹ vì sao "cấm localhost và 127.0.0.1" thất bại, vì nó minh hoạ một nguyên tắc rộng hơn. Địa chỉ loopback 127.0.0.1 viết được bằng vô số cách mà máy tính đều hiểu như nhau:
127.0.0.1 dạng chuẩn
127.1 bỏ bớt số 0 ở giữa
0x7f.0.0.1 hệ mười sáu
0177.0.0.1 hệ tám (octal)
2130706433 số nguyên 32 bit của 127.0.0.1
[::ffff:127.0.0.1] ánh xạ IPv6
Mỗi dòng trên là một cách vượt qua bộ lọc chỉ tìm chuỗi 127.0.0.1. Thêm vào đó là các tên miền do kẻ tấn công kiểm soát, trỏ về IP nội bộ — bộ lọc theo tên không thể bắt. Danh sách chặn phải đoán trước mọi cách viết; kẻ tấn công chỉ cần tìm một cách bạn quên. Đây đúng là cấu trúc đã gặp ở SQL injection, XSS, command injection: liệt kê cái xấu luôn thua, vì tập cái xấu là vô hạn và biến đổi. Danh sách cho phép thắng vì tập cái tốt thì hữu hạn và bạn biết trước.
Vì sao một URL là ba thứ đáng nghi
SSRF là một biến thể của cùng bài học xuyên suốt sê-ri: đừng tin đầu vào người dùng — kể cả khi đầu vào đó là một URL trông vô hại. Một URL có ba phần kẻ tấn công điều khiển, và cả ba đều phải bị nghi ngờ:
- Giao thức: chỉ cho
http/https.file:///etc/passwdđọc tệp cục bộ;gopher://gửi được byte tuỳ ý tới dịch vụ nội bộ. - Tên miền: danh sách cho phép, cộng chặn IP nội bộ sau phân giải.
- Kết quả: cân nhắc không trả nội dung về cho người dùng — SSRF "mù" (không thấy phản hồi) yếu hơn nhiều SSRF trả nội dung như
v0.
Nghĩa là gì trong công việc hằng ngày
- Nếu tính năng chỉ cần vài nguồn cố định, dùng danh sách cho phép tên miền. Đây là cách chặn mạnh nhất và đơn giản nhất khi áp dụng được.
- Luôn chặn IP nội bộ sau khi phân giải DNS, và fail-closed khi không phân giải được. Lý tưởng: phân giải một lần, kiểm IP, rồi kết nối tới chính IP đó (tránh rebinding giữa lúc kiểm và lúc tải).
- Giới hạn giao thức về http/https. Đừng để thư viện tải tự nhận
file://,ftp://,gopher://. - Ở tầng hạ tầng: chặn máy chủ ứng dụng gọi tới endpoint metadata. Trên AWS, dùng IMDSv2 (đòi token, chống SSRF cơ bản); ở mạng, chặn
169.254.169.254từ tầng ứng dụng. Đây là lớp phòng thủ độc lập với mã.
Cùng một cầu nối nguy hiểm, đổi ngăn xếp
Chuyện "một thành phần tin cậy bị lừa gửi yêu cầu tới nơi kẻ tấn công chọn" không riêng gì web Python — mỗi hệ sinh thái có góc SSRF của nó, và điều đáng chú ý là các thư viện càng tiện càng dễ dính.
Trong Java, cái bẫy kinh điển là new URL(userInput).openStream() hay một RestTemplate/WebClient gọi thẳng URL người dùng — SSRF hiện ra y hệt. Java còn một điểm nhức nhối riêng: URLConnection mặc định theo redirect, nên một tên miền trong danh sách cho phép có thể 302 chuyển hướng máy chủ tới 169.254.169.254. Nên với Java, kiểm URL đầu vào là chưa đủ — phải kiểm từng chặng redirect, hoặc tắt tự-theo-redirect.
Trong Go, http.Get(userURL) cũng dính SSRF như mọi ngôn ngữ, nhưng Go cho một công cụ đẹp để chặn ở đúng tầng: http.Transport.DialContext — bạn chèn một hàm kiểm IP ngay tại lúc quay số, sau khi DNS đã phân giải, trước khi kết nối. Đây chính là hiện thực sạch nhất của "phân giải một lần, kiểm IP, kết nối tới đúng IP đó" mà bài này khuyên, và nó đóng luôn cửa DNS rebinding.
Còn ở cơ sở dữ liệu, họ hàng gần nhất là các tính năng để CSDL tự đi ra ngoài mạng: COPY FROM PROGRAM/foreign data wrapper của PostgreSQL, hay LOAD DATA LOCAL INFILE của MySQL (một MySQL server độc có thể lừa client đọc tệp cục bộ). Cùng cấu trúc: một thành phần đặc quyền bị điều khiển để với ra một nơi mà kẻ tấn công không tự tới được.
Mẫu số chung: bất cứ khi nào một thành phần đặc quyền của bạn nhận một đích đến từ đầu vào không tin cậy, nó trở thành proxy mượn quyền của chính nó cho kẻ tấn công — và phòng thủ luôn là danh sách cho phép cái đích, cộng kiểm ở tầng thật sự kết nối (IP sau DNS), chứ không phải lọc chuỗi cái đích. Lọc theo tên hay theo chuỗi luôn thua vì có vô hạn cách viết cùng một đích; chỉ có "tôi biết trước đúng những nơi được phép đi tới" mới an toàn theo mặc định. Nên câu hỏi đúng cho mỗi tính năng gọi-mạng-theo-đầu-vào không phải "URL này có trông độc không" mà là "tôi có một danh sách hữu hạn những nơi thành phần này được phép với tới không, và tôi ép nó ở tầng kết nối chưa".
Muốn tự soát, tìm trong ứng dụng của bạn mọi chỗ máy chủ gửi một yêu cầu tới URL do người dùng cung cấp — tải ảnh từ URL, webhook, "kiểm tra liên kết", nhập dữ liệu từ URL, preview link. Với mỗi chỗ, hỏi: nó có kiểm URL trước khi gọi không, hay tin bất kỳ URL nào? Thử đưa vào http://169.254.169.254/ (trên môi trường thử). Nếu máy chủ trả về nội dung thay vì từ chối, bạn có một SSRF — và trên cloud, đó là một đường thẳng tới khoá truy cập của cả tài khoản.