SSRF: khi API fetch URL do client cung cấp Trung cấp
Thời gian đọc ước tính: 10 phút
Triệu chứng
Phần tiêu đề “Triệu chứng”Ứng dụng có endpoint “nhập URL ảnh đại diện”, “xem trước liên kết” hoặc “đăng ký webhook”. Client gửi URL, server fetch(url) rồi xử lý nội dung. Trong log xuất hiện request tới 127.0.0.1, địa chỉ private trong VPC, 169.254.169.254, hoặc một hostname “bình thường” resolve về IP nội bộ.
Đó là SSRF (Server-Side Request Forgery, mục API7:2023 của OWASP API Security Top 10): attacker điều khiển đích của request do server phát ra. Request đi từ vị trí mạng của server, nơi thấy được admin UI, metadata service và API nội bộ. Nếu server trả body về client, dữ liệu nội bộ có đường đi ra ngoài.
URLClient gửi URL để API lấy ảnh; URL là dữ liệu không tin cậy.
Mục tiêu hay bị nhắm: metadata endpoint
Phần tiêu đề “Mục tiêu hay bị nhắm: metadata endpoint”Trên cloud, mỗi máy ảo gọi được dịch vụ metadata ở địa chỉ link-local 169.254.169.254 (EC2 còn có fd00:ec2::254 cho IPv6 trên Nitro). Trong đó có credentials tạm của IAM role gắn với instance. SSRF tới đây là đường ngắn nhất từ “fetch URL” tới “có quyền trên tài khoản cloud”.
AWS có hai phiên bản:
| IMDSv1 | IMDSv2 | |
|---|---|---|
| Cách gọi | GET đơn giản |
PUT /latest/api/token (kèm X-aws-ec2-metadata-token-ttl-seconds, tối đa 6 giờ) rồi GET kèm header X-aws-ec2-metadata-token |
| Vì sao khó khai thác qua SSRF | Một GET là đủ |
Cần PUT và header tùy ý, mà SSRF thông thường chỉ điều khiển được URL |
| Hop limit | Không áp dụng | Thường mặc định 1 cho response của PUT (là 2 nếu AMI đặt ImdsSupport: v2.0); token khó đi qua proxy hoặc container thêm một hop |
| Mặc định | Cả hai được bật, trừ khi AMI (ImdsSupport: v2.0) hoặc account default đã chọn IMDSv2 |
Có thể cấu hình chỉ nhận IMDSv2 (HttpTokens=required) |
Quy tắc: bắt buộc IMDSv2 và tắt IMDSv1 trên mọi instance chạy code nhận URL từ người dùng. Container cần gọi metadata có thể phải tăng hop limit; chỉ làm khi thật sự cần. IMDSv2 là lớp giảm thiểu, không thay cho validate. Các cloud khác có endpoint tương tự (ví dụ metadata.google.internal); đọc tài liệu từng nhà cung cấp.
Tái hiện
Phần tiêu đề “Tái hiện”Demo gốc mở một HTTP server tạm trên 127.0.0.1 với response giả internal-secret, rồi gọi fetch không kiểm tra và một hàm validateKnownHost():
npx --prefix examples ts-node examples/src/ssrf/local-ssrf-demo.tsfetch không kiểm tra đọc được chuỗi từ server local; validateKnownHost() từ chối 127.0.0.1 và chấp nhận https://images.example.com/.... Hàm này chỉ kiểm tra chuỗi hostname, nên chưa chống được redirect hay DNS rebinding. Demo thứ hai (examples/src/ssrf/safe-fetch-demo.ts) bù phần đó, output ở các mục dưới.
Vì sao danh sách cấm localhost không đủ
Phần tiêu đề “Vì sao danh sách cấm localhost không đủ”Một IP, nhiều cách viết
Phần tiêu đề “Một IP, nhiều cách viết”URL parser chuẩn hóa nhiều dạng về cùng một địa chỉ. Output thật từ demo (new URL(raw).hostname, sau đó kiểm tra range):
http://127.0.0.1/ -> 127.0.0.1 BLOCKEDhttp://2130706433/ -> 127.0.0.1 BLOCKEDhttp://0x7f.1/ -> 127.0.0.1 BLOCKEDhttp://0177.0.0.1/ -> 127.0.0.1 BLOCKEDhttp://[::ffff:127.0.0.1]/ -> ::ffff:7f00:1 BLOCKEDhttp://[::1]/ -> ::1 BLOCKEDhttp://169.254.169.254/ -> 169.254.169.254 BLOCKEDhttp://8.8.8.8/ -> 8.8.8.8 allowedBài học: kiểm tra địa chỉ đã parse và đã resolve, không kiểm tra chuỗi. Dạng thập phân, hex, bát phân và IPv4-mapped IPv6 (::ffff:127.0.0.1, parser chuẩn hóa thành ::ffff:7f00:1) đều trỏ về loopback; chuỗi so sánh '127.0.0.1' bỏ sót chúng. Một thư viện khác có thể parse khác Node, nên OWASP khuyên từ chối URL mà các parser trong chuỗi xử lý hiểu khác nhau.
Dải địa chỉ cần chặn
Phần tiêu đề “Dải địa chỉ cần chặn”| Dải | Ý nghĩa |
|---|---|
127.0.0.0/8, ::1/128 |
Loopback |
0.0.0.0/8, ::/128 |
“This host” / unspecified, nhiều hệ thống coi như localhost |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
Private RFC1918 |
169.254.0.0/16, fe80::/10 |
Link-local, gồm metadata 169.254.169.254 |
fc00::/7 |
IPv6 unique local |
100.64.0.0/10 |
Carrier-grade NAT, hay dùng trong mạng nội bộ cloud |
::ffff:0:0/96 |
IPv4-mapped IPv6: bỏ lớp mapped rồi kiểm tra theo bảng IPv4 |
224.0.0.0/4, ff00::/8 |
Multicast |
Các dải 100.64.0.0/10, ::/128 và ::ffff:0:0/96 là bổ sung của bài này; OWASP liệt kê loopback, private, link-local, unique local và multicast. Danh sách cấm vẫn là phương án phụ: OWASP nêu rõ deny-list dễ bị bypass, nên ưu tiên allowlist khi đích đã biết.
Redirect và DNS rebinding
Phần tiêu đề “Redirect và DNS rebinding”Hai đường vòng phổ biến nhất khi chỉ validate URL ban đầu:
- Redirect: host được phép trả
302 Location: http://127.0.0.1/admin. Client tự đi theo thì request kế tiếp tới đích bị cấm. - DNS rebinding: hostname resolve ra IP public lúc kiểm tra, rồi ra IP nội bộ lúc connect, vì attacker kiểm soát DNS và đặt TTL rất thấp.
Attacker gửi một URL có hostname do họ kiểm soát, cả DNS lẫn TTL.
Output thật từ safe-fetch-demo.ts (cổng ngẫu nhiên mỗi lần chạy). Đầu tiên là redirect: client mặc định đi theo, redirect: 'manual' trả nguyên response 302:
redirect: follow -> internal-secretredirect: manual -> 302 http://127.0.0.1:64768/adminTiếp theo là resolver giả lập rebinding (lần 1 IP public, lần 2 loopback). Resolve hai lần cho hai đáp án; cách pin chỉ resolve một lần và kết nối tới IP đã validate:
check-then-fetch (resolve 2 lần): 203.0.113.9 rồi 127.0.0.1pinned (resolve 1 lần): kết nối tới 203.0.113.9 (IP đã validate) -> timeoutsố lần resolve: 1(203.0.113.9 thuộc dải TEST-NET-3 dùng cho tài liệu nên kết nối timeout; điểm cần xem là client không bao giờ tới 127.0.0.1.)
Safe-fetch: bộ khung
Phần tiêu đề “Safe-fetch: bộ khung”Quy trình cho trường hợp phải fetch URL tùy ý:
Đang tải diagram…
Bộ khung bằng Node (https, rút gọn; phần phân loại IP nằm trong safe-fetch-demo.ts):
import { request } from 'node:https';import { lookup } from 'node:dns/promises';
async function safeGet(raw: string, hops = 0): Promise<Buffer> { if (hops > 3) throw new Error('too many redirects'); const url = new URL(raw); if (url.protocol !== 'https:' || url.username || url.password) throw new Error('not allowed'); if (url.port && url.port !== '443') throw new Error('port not allowed');
const addrs = await lookup(url.hostname, { all: true }); // resolve một lần if (addrs.length === 0 || addrs.some((a) => isBlockedIp(a.address))) throw new Error('blocked'); const pinned = addrs[0];
const res = await new Promise<import('node:http').IncomingMessage>((ok, fail) => { const req = request(url, { timeout: 5000, // ép socket dùng đúng IP đã validate; Host/SNI vẫn là hostname gốc lookup: (_h, opts, cb) => (opts.all ? cb(null, [pinned] as never) : cb(null, pinned.address, pinned.family)), }, ok); req.on('timeout', () => req.destroy(new Error('timeout'))); req.on('error', fail).end(); });
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { res.resume(); return safeGet(new URL(res.headers.location, url).toString(), hops + 1); // validate lại từ đầu } const chunks: Buffer[] = []; let size = 0; for await (const c of res) { size += c.length; if (size > 1_000_000) throw new Error('too large'); chunks.push(c); } return Buffer.concat(chunks);}Điểm quyết định: dùng lookup tùy chỉnh để socket kết nối đúng IP đã kiểm tra, và tự xử lý redirect để mỗi hop đi lại từ đầu. Trường hợp ngoại lệ: nếu chỉ gọi một tập host biết trước, bỏ toàn bộ phần này và dùng allowlist chính xác, tự dựng URL với scheme, port, path cố định.
Egress policy: lớp cuối cùng
Phần tiêu đề “Egress policy: lớp cuối cùng”Validate trong code có thể sót; network là lớp không phụ thuộc code:
- Chạy worker fetch URL tùy ý trong subnet riêng, security group hoặc
NetworkPolicychỉ cho ra Internet public, chặn dải private và link-local. - Dùng outbound proxy có policy (và log) cho mọi request ra ngoài từ workload này.
- Bắt buộc IMDSv2, hoặc chặn hẳn metadata cho workload không cần.
- Cấp IAM role tối thiểu: lộ credentials thì phạm vi thiệt hại vẫn nhỏ.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Checklist production
Phần tiêu đề “Checklist production”- Liệt kê mọi chỗ outbound request dựng từ input: avatar importer, link preview, PDF/screenshot renderer, webhook sender, image proxy.
- Có thể dùng allowlist thì dùng allowlist chính xác; tự dựng scheme, port, path.
- Chỉ cho
http/httpsvà các port cần thiết; từ chối URL có userinfo. - Resolve một lần, kiểm tra mọi A/AAAA (kể cả IPv4-mapped), connect tới đúng IP đã validate.
- Redirect: tắt tự động; mỗi hop validate lại, tối đa vài hop.
- Đặt timeout, giới hạn kích thước body, giới hạn đồng thời cho worker fetch.
- Egress policy ở tầng mạng; IMDSv2 bắt buộc; IAM role tối thiểu.
- Log hostname, IP kết nối, port, số hop; cảnh báo khi có request tới dải private/link-local.
- Test trong môi trường cô lập với server giả: loopback, private, IPv6, dạng IP số, redirect, rebinding.
Khắc phục ngay
Phần tiêu đề “Khắc phục ngay”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) Vì sao startsWith('https://trusted.example.com') không an toàn (gợi ý: https://trusted.example.com@127.0.0.1/)? (2) http://2130706433/ trỏ về đâu? (3) Vì sao validate DNS rồi để HTTP client tự resolve lại vẫn có rủi ro?