Bản Chất Của Các Cuộc Tấn Công SSRF Hiện Đại

Server-Side Request Forgery (SSRF - Giả mạo yêu cầu từ phía máy chủ) là một trong những lỗ hổng nguy hiểm nhất trong kiến trúc microservices và môi trường điện toán đám mây. Khi một dịch vụ chấp nhận URL do người dùng cung cấp—cho dù để kích hoạt webhook, tạo bản xem trước liên kết (link preview), thu thập dữ liệu web, hay tải xuống tài nguyên lưu trữ—kẻ tấn công có thể ép máy chủ backend gửi các truy vấn trái phép tới các hệ thống nội bộ.

Trong môi trường đám mây như AWS, GCP hay Azure, mục tiêu phổ biến nhất là Dịch vụ Siêu dữ liệu Phiên bản (Instance Metadata Service - IMDS) tại địa chỉ 169.254.169.254. Trong hạ tầng container, các dịch vụ nội bộ nằm sau API gateway (như HashiCorp Consul, etcd, Redis, hoặc kubelet API) thường bỏ qua xác thực nội bộ do giả định rằng vành đai mạng riêng đã an toàn tuyệt đối.

Tại Sao Phân Tích URL Ở Tầng Ứng Dụng Luôn Thất Bại?

Cách tiếp cận ngây thơ phổ biến nhất là kiểm tra URL trước khi truyền nó vào thư viện HTTP client chuẩn. Một đoạn mã kiểm tra denylist điển hình bằng chuỗi hoặc regex thường trông như thế này:

// CẢNH BÁO: Đoạn mã có lỗ hổng TOCTOU và DNS Rebinding
func isSafeURL(rawURL string) bool {
    u, err := url.Parse(rawURL)
    if err != nil {
        return false
    }
    ips, _ := net.LookupIP(u.Hostname())
    for _, ip := range ips {
        if ip.IsPrivate() || ip.IsLoopback() {
            return false // Phát hiện IP nội bộ
        }
    }
    return true
}

// Client thực thi request sau đó vài mili-giây
resp, err := http.Get(targetURL)

Cơ chế phòng thủ này bộc lộ ba lỗ hổng trí mạng:

  • Time-of-Check to Time-of-Use (TOCTOU) & DNS Rebinding: Kẻ tấn công thiết lập một máy chủ DNS có quyền (authoritative DNS server) với giá trị TTL bằng 0 giây. Ở lần phân giải đầu tiên tại net.LookupIP(), máy chủ DNS trả về một IP công khai vô hại (ví dụ: 93.184.216.34). Nhưng khi http.Get() bắt đầu bắt tay TCP ngay sau đó, bộ phân giải của hệ điều hành truy vấn lại máy chủ DNS và lần này nhận về 169.254.169.254 hoặc 127.0.0.1. Lớp kiểm tra ban đầu hoàn toàn bị vô hiệu hóa.
  • Làm Rối Định Dạng IP (IP Obfuscation): Kẻ tấn công có thể lách qua bộ lọc chuỗi bằng các biểu diễn số khác nhau: định dạng số thập phân (http://2130706433 tương đương 127.0.0.1), hệ bát phân (octal - http://0177.0.0.1), hoặc địa chỉ IPv4 ánh xạ trong IPv6 ([::ffff:127.0.0.1]).
  • Chuyển Hướng HTTP Không Được Kiểm Soát: Một máy chủ công khai hợp lệ có thể trả về mã 302 Found chuyển hướng tới http://169.254.169.254/latest/meta-data/. Nếu HTTP client tự động chạy theo redirect mà không kiểm tra lại, phòng tuyến hoàn toàn sụp đổ.

Kỹ Thuật Phòng Thủ: Xác Thực Ở Tầng Socket

Cách duy nhất để phòng chống triệt để SSRF là can thiệp sâu vào tầng transport tại thời điểm thiết lập kết nối. Bằng cách can thiệp vào bộ dialer của socket, bạn có thể kiểm tra chính xác địa chỉ IP mà hệ điều hành lựa chọn sau khi quá trình phân giải DNS đã hoàn tất, ngay trước khi gói tin TCP SYN đầu tiên được gửi đi.

Xây Dựng SSRF-Safe Transport Bằng Go

Trong ngôn ngữ Go, chúng ta hiện thực điều này bằng cách tùy biến http.Transport và sử dụng hook Control của net.Dialer, kết hợp với chính sách chuyển hướng (redirect policy) nghiêm ngặt.

package ssrfsafe

import (
	"context"
	"errors"
	"fmt"
	"net"
	"net/http"
	"net/netip"
	"syscall"
	"time"
)

var reservedCIDRs = []netip.Prefix{
	netip.MustParsePrefix("0.0.0.0/8"),       // Mạng hiện tại
	netip.MustParsePrefix("10.0.0.0/8"),      // Mạng riêng RFC 1918
	netip.MustParsePrefix("100.64.0.0/10"),   // Không gian địa chỉ Carrier-grade NAT
	netip.MustParsePrefix("127.0.0.0/8"),     // Loopback
	netip.MustParsePrefix("169.254.0.0/16"),  // Link-Local (AWS/GCP IMDS)
	netip.MustParsePrefix("172.16.0.0/12"),   // Mạng riêng RFC 1918
	netip.MustParsePrefix("192.168.0.0/16"),  // Mạng riêng RFC 1918
	netip.MustParsePrefix("::1/128"),         // IPv6 Loopback
	netip.MustParsePrefix("fc00::/7"),        // IPv6 Unique Local
	netip.MustParsePrefix("fe80::/10"),       // IPv6 Link-Local
}

var ErrForbiddenIP = errors.New("truy cập vào IP riêng/bảo lưu đã bị chặn")

func isBlocked(addr netip.Addr) bool {
	// Bỏ ánh xạ IPv4-in-IPv6 (ví dụ: ::ffff:127.0.0.1 -> 127.0.0.1)
	unmapped := addr.Unmap()
	for _, prefix := range reservedCIDRs {
		if prefix.Contains(unmapped) {
			return true
		}
	}
	return false
}

// NewSafeHTTPClient khởi tạo http.Client miễn nhiễm với SSRF DNS rebinding
func NewSafeHTTPClient(timeout time.Duration) *http.Client {
	dialer := &net.Dialer{
		Timeout:   timeout,
		KeepAlive: 30 * time.Second,
		Control: func(network, address string, c syscall.RawConn) error {
			host, _, err := net.SplitHostPort(address)
			if err != nil {
				return fmt.Errorf("địa chỉ không hợp lệ: %w", err)
			}

			ip, err := netip.ParseAddr(host)
			if err != nil {
				return fmt.Errorf("phân tích IP phân giải thất bại: %w", err)
			}

			if isBlocked(ip) {
				return fmt.Errorf("%w: %s", ErrForbiddenIP, ip.String())
			}

			return nil
		},
	}

	transport := &http.Transport{
		DialContext:           dialer.DialContext,
		ForceAttemptHTTP2:     true,
		MaxIdleConns:          100,
		IdleConnTimeout:       90 * time.Second,
		TLSHandshakeTimeout:   10 * time.Second,
		ExpectContinueTimeout: 1 * time.Second,
	}

	return &http.Client{
		Transport: transport,
		Timeout:   timeout,
		CheckRedirect: func(req *http.Request, via []*http.Request) error {
			if len(via) >= 5 {
				return errors.New("dừng lại sau 5 lần chuyển hướng")
			}
			// Yêu cầu chỉ chuyển hướng qua HTTPS
			if req.URL.Scheme != "https" {
				return errors.New("từ chối chuyển hướng tới giao thức không an toàn")
			}
			return nil
		},
	}
}

Giải Pháp Này Giải Quyết Vấn Đề Như Thế Nào?

  1. Hóa Giải DNS Rebinding Hoàn Toàn: Hàm Control nhận IP cuối cùng do hệ điều hành lựa chọn ngay trước khi syscall connect được thực thi. Nếu máy chủ DNS cố tình đổi IP đích sang 127.0.0.1 hoặc 169.254.169.254, hàm isBlocked(ip) sẽ phát hiện ngay lập tức và trả về lỗi, hủy bỏ tiến trình mở socket.
  2. Chuẩn Hóa IPv6 Với netip.Addr.Unmap(): Package netip của Go tự động chuyển đổi các địa chỉ dual-stack. Khi kẻ tấn công truyền ::ffff:169.254.169.254, hàm Unmap() sẽ chuẩn hóa về IPv4 169.254.169.254, từ đó bị chặn bởi danh sách CIDR link-local.
  3. Kiểm Soát Chuyển Hướng An Toàn: Khi nhận header Location: ..., mỗi bước chuyển hướng đều đi qua cùng một transport bảo mật, kích hoạt việc kiểm tra IP trên từng chặng một cách nghiêm ngặt.

Lưu Ý Thực Tế Khi Triển Khai Trên Production

Kỹ thuật phòng thủ không chỉ ngăn chặn tấn công mà còn phải lường trước các điểm nghẽn vận hành hệ thống.

1. Connection Pooling & Persistent Keep-Alives

Hook Control chỉ được gọi khi mở một kết nối TCP mới. Khi một kết nối đã nằm trong idle pool của http.Transport, các request kế tiếp sẽ tái sử dụng socket có sẵn mà không gọi lại dialer. Đối với các tên miền không tin cậy, hãy cấu hình IdleConnTimeout ngắn (tầm 30–60 giây) hoặc tắt keep-alive nếu bạn xử lý các webhook từ nhiều nguồn lạ khác nhau.

2. Chống Tràn Bộ Nhớ Với Chunked Encoding

Máy chủ của kẻ tấn công có thể trả về một luồng dữ liệu vô hạn hoặc nhả từng byte cực chậm (tấn công Slowloris). Luôn bọc request bằng context.WithTimeout và giới hạn kích thước dữ liệu nhận về qua io.LimitReader:

ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()

req, _ := http.NewRequestWithContext(ctx, http.MethodGet, untrustedURL, nil)
resp, err := client.Do(req)
if err != nil {
    return nil, err
}
defer resp.Body.Close()

// Giới hạn đọc tối đa 2MB để tránh cạn kiệt bộ nhớ RAM
body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024))

Điểm Cốt Lõi

  • Kiểm tra URL bằng chuỗi hoặc regex ở tầng ứng dụng không thể ngăn chặn SSRF do ảnh hưởng của kỹ thuật DNS rebinding, biểu diễn IP dị biệt và chuyển hướng HTTP.
  • Hãy can thiệp trực tiếp ở tầng transport thông qua socket hook (như hàm net.Dialer.Control trong Go) để kiểm tra IP đã phân giải ngay trước khi lệnh kết nối diễn ra.
  • Chuẩn hóa các địa chỉ IPv4-in-IPv6 bằng netip.Addr.Unmap() để ngăn chặn việc vượt mặt bộ lọc CIDR.
  • Kết hợp transport bảo mật với giới hạn số lần redirect, ép buộc HTTPS và giới hạn kích thước phản hồi bằng io.LimitReader để bảo vệ tài nguyên hạ tầng.