Bỏ qua để đến nội dung
Knowledge Hub

SSRF: khi API fetch URL do client cung cấp Trung cấp

Thời gian đọc ước tính: 10 phút

Ứ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.

URL không tin cậy biến API thành client gọi vào mạng nội bộ
URL không tin cậy biến API thành client gọi vào mạng nội bộBước 1/4: Client gửi URL để API lấy ảnh; URL là dữ liệu không tin cậy.ClientFetch APIInternalURL
Client → Fetch APIURL
Bước 1/4

Client gửi URL để API lấy ảnh; URL là dữ liệu không tin cậy.

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.

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():

Terminal window
npx --prefix examples ts-node examples/src/ssrf/local-ssrf-demo.ts

fetch 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.

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 BLOCKED
http://2130706433/ -> 127.0.0.1 BLOCKED
http://0x7f.1/ -> 127.0.0.1 BLOCKED
http://0177.0.0.1/ -> 127.0.0.1 BLOCKED
http://[::ffff:127.0.0.1]/ -> ::ffff:7f00:1 BLOCKED
http://[::1]/ -> ::1 BLOCKED
http://169.254.169.254/ -> 169.254.169.254 BLOCKED
http://8.8.8.8/ -> 8.8.8.8 allowed

Bà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 Ý 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.

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.
DNS rebinding: kiểm tra một IP, kết nối tới IP khác
DNS rebinding: kiểm tra một IP, kết nối tới IP khácBước 1/6: Attacker gửi một URL có hostname do họ kiểm soát, cả DNS lẫn TTL.thời giangửi URLevil.exampleAttackerđiều khiển DNSAPI fetch URLvalidator + clientDNS evil.exampleTTL = 0Dịch vụ nội bộ127.0.0.1
Bước 1/6

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-secret
redirect: manual -> 302 http://127.0.0.1:64768/admin

Tiế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.1
pinned (resolve 1 lần): kết nối tới 203.0.113.9 (IP đã validate) -> timeout
số 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.)

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.

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 NetworkPolicy chỉ 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ỏ.
  1. Liệt kê mọi chỗ outbound request dựng từ input: avatar importer, link preview, PDF/screenshot renderer, webhook sender, image proxy.
  2. Có thể dùng allowlist thì dùng allowlist chính xác; tự dựng scheme, port, path.
  3. Chỉ cho http/https và các port cần thiết; từ chối URL có userinfo.
  4. Resolve một lần, kiểm tra mọi A/AAAA (kể cả IPv4-mapped), connect tới đúng IP đã validate.
  5. Redirect: tắt tự động; mỗi hop validate lại, tối đa vài hop.
  6. Đặt timeout, giới hạn kích thước body, giới hạn đồng thời cho worker fetch.
  7. Egress policy ở tầng mạng; IMDSv2 bắt buộc; IAM role tối thiểu.
  8. Log hostname, IP kết nối, port, số hop; cảnh báo khi có request tới dải private/link-local.
  9. Test trong môi trường cô lập với server giả: loopback, private, IPv6, dạng IP số, redirect, rebinding.

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?