SecLab
Server-sideĐầy đủ

SSRF — Server-side request forgery

A01V13API7C10CWE-918
01

Là gì

SSRF là lỗi cho phép kẻ tấn công quyết định server của bạn sẽ gửi request HTTP đi đâu. Ứng dụng nhận một URL từ người dùng — để lấy ảnh preview, gọi webhook, import dữ liệu — rồi tự đi gọi URL đó. Vì request phát ra từ TRONG mạng tin cậy, nó tới được những thứ mà kẻ tấn công không tự gọi được từ Internet.

02

Vì sao bạn quan tâm

Mức liên quan: Bắt buộcKỳ vọng: L2

Nếu code của bạn có bất kỳ chỗ nào nhận URL từ request rồi gọi HttpClient.GetAsync(url), fetch(url), requests.get(url) — bạn đang có một SSRF cho tới khi chứng minh được là không.

Điều làm SSRF khác các lỗi khác là phần thưởng của nó không tỉ lệ với vẻ vô hại của tính năng. Một endpoint "lấy tiêu đề trang web cho ô nhập link" nhìn như tính năng nhỏ nhất trong sprint, nhưng trên EC2 nó đọc được 169.254.169.254 và trả về credential IAM của chính instance đó; trong Kubernetes nó gọi được kube-apiserver và mọi service không có mTLS.

SSRF cũng là lỗi mà firewall không cứu được: request đi ra từ chính service của bạn, qua đúng cái egress mà bạn đã mở cho nó.

03

Cơ chế hoạt động

Chuỗi sự kiện luôn có đúng bốn bước, và bước gây lỗi là bước ②.

Nguồn sơ đồ
sequenceDiagram    autonumber    actor A as Kẻ tấn công    participant App as App của bạn<br/>(trong VPC)    participant Meta as 169.254.169.254<br/>(metadata service)    A->>App: POST /preview<br/>url=http://169.254.169.254/latest/meta-data/iam/security-credentials/    Note over App: App TIN url và tự gọi đi.<br/>Không kiểm url trỏ tới đâu.    App->>Meta: GET /latest/meta-data/iam/...    Meta-->>App: 200 — AccessKeyId, SecretAccessKey, Token    App-->>A: 200 — nội dung "trang" vừa lấy được

Vì sao được: bước ② không kiểm URL trỏ đi đâu. App nằm trong mạng tin cậy, nên nó gọi được thứ kẻ tấn công không gọi được từ ngoài — đó là toàn bộ giá trị của lỗi này.

Ba biến thể mà bản vá ngây thơ bỏ sót:

Biến thểPayloadVì sao lọt
RedirectURL ngoài trả 302 Location: http://169.254.169.254/Bạn kiểm URL ban đầu, HTTP client đi theo redirect
DNS rebindingattacker.com trả TTL=0, lần 1 → IP công cộng, lần 2 → 169.254.169.254Bạn kiểm rồi mới gọi — hai lần resolve khác nhau (TOCTOU)
Blind SSRFApp không trả body vềVẫn đo được qua thời gian phản hồi, mã lỗi, hoặc callback DNS

0177.0.0.1, [::1], 127.1, 2130706433 đều là 127.0.0.1. Bất kỳ bản vá nào so sánh chuỗi đều thua ở đây.

Mô tả sơ đồ: Sơ đồ tuần tự bốn bước: kẻ tấn công gửi POST /preview với url trỏ tới 169.254.169.254; app không kiểm url mà gọi thẳng tới metadata service; metadata service trả về AccessKeyId, SecretAccessKey và Token; app trả nội dung đó về cho kẻ tấn công. Bước gây lỗi là bước app tin url.

04

Ví dụ cụ thể

Tính năng: người dùng dán một link, app lấy tiêu đề trang để hiện preview.

Request khai thác và response thật trên một EC2 dùng IMDSv1:

HTTP
POST /api/preview HTTP/1.1Host: app.example.comContent-Type: application/json {"url":"http://169.254.169.254/latest/meta-data/iam/security-credentials/app-role"}
HTTP
HTTP/1.1 200 OKContent-Type: application/json {"title":null,"body":"{\n  \"Code\": \"Success\",\n  \"AccessKeyId\": \"ASIA…\",\n  \"SecretAccessKey\": \"wJal…\",\n  \"Token\": \"IQoJb3…\",\n  \"Expiration\": \"2026-08-23T10:14:22Z\"\n}"}

Không có exploit nào phức tạp ở đây: chỉ là một URL khác trong đúng ô nhập mà tính năng mời bạn nhập URL. Xem tab ngôn ngữ bên dưới cho bản lỗi và bản vá.

C#Toàn bộ lỗi nằm ở dòng 8: url tới từ người dùng và được gọi thẳng.
[HttpPost("/api/preview")]public async Task<IActionResult> Preview([FromBody] PreviewRequest req){    // Không kiểm gì. Không phải vì tác giả bất cẩn — mà vì "lấy tiêu đề trang"    // nghe như một tính năng không có mặt bảo mật nào.    using var http = new HttpClient();     var body = await http.GetStringAsync(req.Url);     return Ok(new { title = ExtractTitle(body), body });}
PythonCùng một lỗi trong Python — và `requests` đi theo redirect theo mặc định.
@app.post("/api/preview")def preview():    url = request.json["url"]     # requests.get đi theo redirect theo MẶC ĐỊNH, nên kể cả khi có kiểm url ở    # trên thì một 302 vẫn đưa request tới bất cứ đâu.    resp = requests.get(url, timeout=3)     return {"title": extract_title(resp.text), "body": resp.text}
05

Chuyện đã xảy ra

Capital One, tháng 7/2019 — ~100 triệu hồ sơ. Một WAF cấu hình sai cho phép SSRF; request đi tới metadata service của EC2, lấy credential của role *****-WAF-Role, và role đó có quyền ListBuckets/GetObject trên các bucket S3 chứa dữ liệu đơn xin thẻ tín dụng. Bản khởi tố của DOJ mô tả đúng chuỗi này. Đây là ví dụ rõ nhất cho luận điểm ở khối 2: lỗi nằm ở một component ngoại vi, thiệt hại nằm ở tầng dữ liệu.

CVE-2021-44228 thì không, nhưng CVE-2026-31847 (Django URLValidator) thì có: validator chấp nhận URL mà urlopen sau đó resolve khác đi — cùng mô hình TOCTOU ở bảng biến thể của khối 3. Bài phân tích nằm trong feed của SecLab.

06

Cách phòng chống

Lớp 1

Allowlist ở tầng ứng dụng (bắt buộc)

bắt buộc

Không blocklist. Blocklist là cuộc đua bạn thua vì 0177.0.0.1, 127.1, [::ffff:169.254.169.254] và DNS rebinding đều là cách viết mới cho cùng một đích. Allowlist theo host chính xác, và nếu tính năng thật sự cần gọi URL tuỳ ý thì việc đó phải làm ở một service riêng không có credential và không nằm trong VPC.

Ba việc phải làm cùng lúc, thiếu một cái là bản vá không kín:

  1. Resolve DNS một lần, kiểm IP đó, rồi kết nối tới chính IP đó — không kết nối lại bằng hostname. Đây là cách duy nhất đóng DNS rebinding.
  2. Tắt tự động đi theo redirect (AllowAutoRedirect = false), hoặc kiểm lại từ đầu ở mỗi hop.
  3. Chỉ cho http/https. file:, gopher:, dict:, ftp: không có lý do tồn tại ở đây.
C# · Layer 1Lớp 1: allowlist host, resolve một lần, kết nối tới chính IP đã kiểm, không đi theo redirect.
/// <summary>/// Ba việc phải cùng đúng, và thứ tự của chúng là phần dễ làm sai nhất:/// kiểm host → resolve MỘT lần → kết nối tới chính IP vừa kiểm.////// Nếu bước cuối kết nối lại bằng hostname thì cả hàm này vô nghĩa: giữa lúc kiểm/// và lúc kết nối, DNS của kẻ tấn công trả về một IP khác (DNS rebinding)./// </summary>public sealed class SafeFetcher{    private static readonly HashSet<string> AllowedHosts =        new(StringComparer.OrdinalIgnoreCase) { "images.partner.example", "cdn.partner.example" };     private readonly HttpClient _http;     public SafeFetcher(HttpClient http) => _http = http;     public async Task<string> FetchAsync(string rawUrl, CancellationToken ct)    {        if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var uri))            throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Not a URL");         // file:, gopher:, dict: không có lý do tồn tại ở một endpoint lấy preview.        if (uri.Scheme != Uri.UriSchemeHttps && uri.Scheme != Uri.UriSchemeHttp)            throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Only http(s)");         if (!AllowedHosts.Contains(uri.Host))            throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Host not allowed");         // Resolve MỘT lần. Đây là lần resolve duy nhất trong toàn bộ luồng.        var addresses = await Dns.GetHostAddressesAsync(uri.Host, ct);        if (addresses.Length == 0)            throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Does not resolve");        foreach (var ip in addresses)            if (IsInternal(ip))                throw new ApplicationGeneralException(ContentErrorsList.INVALID_SOURCE, "Resolves internally");         // Kết nối tới chính IP vừa kiểm; Host header giữ tên miền để TLS/SNI và vhost còn đúng.        var pinned = new UriBuilder(uri) { Host = addresses[0].ToString() }.Uri;        using var request = new HttpRequestMessage(HttpMethod.Get, pinned);        request.Headers.Host = uri.Host;         using var response = await _http.SendAsync(request, ct);        response.EnsureSuccessStatusCode();        return await response.Content.ReadAsStringAsync(ct);    }     private static bool IsInternal(IPAddress ip)    {        if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();        if (IPAddress.IsLoopback(ip)) return true;         var b = ip.GetAddressBytes();        if (ip.AddressFamily == AddressFamily.InterNetwork)            return b[0] == 10                                  // 10/8                || (b[0] == 172 && b[1] >= 16 && b[1] < 32)     // 172.16/12                || (b[0] == 192 && b[1] == 168)                 // 192.168/16                || (b[0] == 169 && b[1] == 254)                 // link-local + metadata                || b[0] == 0 || b[0] == 127;         return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal            || (b[0] & 0xfe) == 0xfc;                           // fc00::/7 unique-local    }} // Đăng ký: AllowAutoRedirect = false là phần KHÔNG ĐƯỢC bỏ. Không có nó, một host// trong allowlist chỉ cần trả 302 là mọi kiểm ở trên bị vòng qua.services.AddHttpClient<SafeFetcher>()    .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler    {        AllowAutoRedirect = false,        ConnectTimeout = TimeSpan.FromSeconds(3),    });
Python · Layer 1Cùng ba nguyên tắc: allowlist, resolve một lần, kết nối tới IP đã kiểm, allow_redirects=False.
import ipaddressimport socketfrom urllib.parse import urlsplit, urlunsplit import requests ALLOWED_HOSTS = {"images.partner.example", "cdn.partner.example"}  class UnsafeUrl(Exception):    pass  def fetch(raw_url: str, timeout: float = 3.0) -> str:    parts = urlsplit(raw_url)     if parts.scheme not in ("http", "https"):        raise UnsafeUrl("only http(s)")    if parts.hostname not in ALLOWED_HOSTS:        raise UnsafeUrl("host not allowed")     # Resolve MỘT lần, kiểm mọi bản ghi trả về — không chỉ bản ghi đầu.    infos = socket.getaddrinfo(parts.hostname, parts.port or 443, proto=socket.IPPROTO_TCP)    addrs = [ipaddress.ip_address(i[4][0]) for i in infos]    if not addrs:        raise UnsafeUrl("does not resolve")    for ip in addrs:        if ip.is_private or ip.is_loopback or ip.is_link_local or ip.is_reserved:            raise UnsafeUrl(f"resolves internally: {ip}")     # Kết nối tới chính IP vừa kiểm; Host header giữ tên miền.    pinned = urlunsplit(parts._replace(netloc=str(addrs[0])))     resp = requests.get(        pinned,        headers={"Host": parts.hostname},        allow_redirects=False,   # KHÔNG bỏ dòng này        timeout=timeout,    )    resp.raise_for_status()    return resp.text
Lớp 2

Egress ở tầng mạng

Service đi gọi bên ngoài chạy trong subnet riêng, NetworkPolicy chỉ mở đúng đích cần thiết, và chặn 169.254.0.0/16, 10/8, 172.16/12, 192.168/16, ::1/128, fd00::/8. Lớp này là lớp cứu bạn khi lớp 1 có bug — và lớp 1 sẽ có bug.

YAML · Layer 2Lớp 2: NetworkPolicy chặn link-local và RFC1918, chỉ mở đúng egress cần thiết.
apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata:  name: preview-service-egress  namespace: appspec:  podSelector:    matchLabels: { app: preview-service }  policyTypes: [Egress]  egress:    # DNS phải mở, nếu không service không resolve được gì.    - to:        - namespaceSelector:            matchLabels: { kubernetes.io/metadata.name: kube-system }          podSelector:            matchLabels: { k8s-app: kube-dns }      ports:        - { protocol: UDP, port: 53 }        - { protocol: TCP, port: 53 }     # Internet, TRỪ mọi dải nội bộ. except là phần làm nên giá trị của policy này:    # thiếu nó thì "mở ra Internet" cũng mở luôn đường vào metadata service.    - to:        - ipBlock:            cidr: 0.0.0.0/0            except:              - 169.254.0.0/16   # link-local — metadata service của cloud              - 10.0.0.0/8              - 172.16.0.0/12              - 192.168.0.0/16              - 127.0.0.0/8      ports:        - { protocol: TCP, port: 443 }
Lớp 3

Bỏ thứ đáng lấy khỏi tầm với

Bắt buộc IMDSv2 (HttpTokens: required) — nó cần một PUT để lấy token, và SSRF qua GET không làm được PUT. Trên AWS: aws ec2 modify-instance-metadata-options --http-tokens required --http-put-response-hop-limit 1. Đây là biện pháp có tỉ lệ hiệu quả/công sức cao nhất trong cả trang này.

07

Kiểm chứng đã vá

Bản vá chưa có test là bản vá chưa tồn tại — lần refactor sau sẽ gỡ nó ra mà không ai biết. Bốn phép kiểm, mỗi phép ứng với một biến thể ở khối 3:

1. Test đơn vị — cả bốn cách viết của localhost phải bị từ chối. Xem tab csharp / test. Điểm quan trọng là test dùng danh sách cách-viết-lạ làm dữ liệu vào, nên khi có người thêm cách viết mới thì test là nơi ghi nhận nó.

2. Test redirect. Dựng một server test trả 302 tới 169.254.169.254, gọi qua fetcher, và khẳng định fetcher từ chối. Nếu test này pass mà không cần sửa gì thì bạn chưa tắt auto-redirect.

3. Kiểm từ ngoài — chứng minh egress đóng. Từ trong pod/instance:

Shell
# Phải TIMEOUT, không phải trả 200:curl -s -m 3 http://169.254.169.254/latest/meta-data/ ; echo "exit=$?"# exit=28 (timeout) là đạt. exit=0 nghĩa là lớp 2 chưa đóng.

4. Kiểm IMDSv2 đã bắt buộc.

Shell
aws ec2 describe-instances --instance-ids i-… \  --query 'Reservations[].Instances[].MetadataOptions.HttpTokens' --output text# phải in ra: required

Cả bốn phép kiểm nên nằm trong CI, không nằm trong một file runbook mà không ai chạy.

C#Danh sách cách-viết-lạ là DỮ LIỆU của test, nên phát hiện mới ghi vào đây được.
public class SafeFetcherTests{    // Mỗi dòng ở đây là một cách viết khác của cùng một đích. Đây là chỗ để thêm khi    // có người tìm ra cách viết mới — không phải một câu if mới trong code sản phẩm.    [Theory]    [InlineData("http://127.0.0.1/")]    [InlineData("http://127.1/")]    [InlineData("http://0177.0.0.1/")]    [InlineData("http://2130706433/")]    [InlineData("http://[::1]/")]    [InlineData("http://[::ffff:169.254.169.254]/")]    [InlineData("http://169.254.169.254/latest/meta-data/")]    [InlineData("file:///etc/passwd")]    [InlineData("gopher://127.0.0.1:6379/_INFO")]    public async Task Rejects_every_spelling_of_internal(string url)    {        var fetcher = new SafeFetcher(new HttpClient());         await Assert.ThrowsAsync<ApplicationGeneralException>(            () => fetcher.FetchAsync(url, CancellationToken.None));    }     /// <summary>    /// Biến thể redirect. Test này là lý do AllowAutoRedirect = false tồn tại — bỏ dòng    /// cấu hình đó ra thì đúng test này đỏ, không phải một test nào khác.    /// </summary>    [Fact]    public async Task Does_not_follow_redirect_to_metadata()    {        await using var evil = TestServer.Returning(            302, location: "http://169.254.169.254/latest/meta-data/");        var fetcher = new SafeFetcher(HttpClientWith(allowAutoRedirect: false));         var ex = await Record.ExceptionAsync(            () => fetcher.FetchAsync(evil.Url, CancellationToken.None));         Assert.NotNull(ex);        Assert.DoesNotContain("meta-data", ex!.Message);    }}
08

Sai lầm thường gặp

Năm "bản vá" trông đúng mà không đúng. Bốn cái đầu đều từng là bản vá được merge trong một CVE thật.

"Bản vá"Vì sao không đúng
Blocklist 127.0.0.1, localhost, 169.254.169.254127.1, 0177.0.0.1, 2130706433, [::ffff:127.0.0.1], localtest.me đều đi qua
Regex kiểm hostname trước khi gọiDNS rebinding: lần resolve lúc kiểm và lúc kết nối là hai lần khác nhau
Allowlist domain, giữ auto-redirectDomain trong allowlist trả 302 tới đâu cũng được
Kiểm Uri.Host nhưng dùng lại Uri gốc để gọiĐúng URL, nhưng kết nối lại resolve lần hai
Chỉ chặn ở NetworkPolicy, không sửa codeĐúng và cần, nhưng SSRF vẫn quét được nội bộ trong phạm vi egress đang mở, và lỗi vẫn còn khi service được deploy ở nơi khác

Một sai lầm nữa, ở tầng khác: coi blind SSRF là rủi ro thấp. Không có body trả về vẫn đủ để map mạng nội bộ bằng thời gian phản hồi, và đủ để gọi một endpoint POST gây tác dụng phụ.

09

Nguồn tham khảo

Bậc 1API7:2023 Server Side Request Forgery · OWASP · API Security Top 10 · 2023
Bậc 1C10: Stop Server Side Request Forgery · OWASP · Proactive Controls · 2024
Bậc 1Use IMDSv2 — Retrieve instance metadata · Amazon Web Services · EC2 User Guide · 2025-06
Bậc 2Server-side request forgery (SSRF) · PortSwigger · Web Security Academy
Bậc 2Server Side Request Forgery Prevention Cheat Sheet · OWASP · Cheat Sheet Series
Nằm trong lộ trình
Secure Backend DeveloperXem lộ trình

Bình luận

Tham gia thảo luận
Đăng ký để bình luận

Bình luận cần tài khoản đã hoàn thành ít nhất một bài học. Điều kiện đó là thứ giữ cho luồng thảo luận này còn đáng đọc: mỗi ý kiến gắn với một người có thể bị hỏi lại, và reputation tích luỹ theo thời gian.

Đăng kýĐăng nhập

Bạn vẫn đọc được toàn bộ bình luận dưới đây mà không cần tài khoản. Đăng nhập xong bạn sẽ quay lại đúng chỗ này, không phải đầu trang.

Đang tải bình luận…