SecLab

HTTP Host header attacks

A05CWE-644CWE-20
01

Là gì

Tấn công Host header là khi ứng dụng tin giá trị header Host (hoặc X-Forwarded-Host) — một giá trị do client gửi và kẻ tấn công đổi tự do — và dùng nó để dựng URL, chọn cấu hình, hay điều hướng. Hậu quả điển hình là password-reset poisoning (link đặt lại trỏ tới domain kẻ tấn công) và cache poisoning.

02

Vì sao bạn quan tâm

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

Điều phản trực giác phải nắm: Host là input người dùng, dù nó trông như một phần của hạ tầng. Developer coi Host là "domain của tôi" vì trong trình duyệt bình thường nó đúng thế — nhưng curl -H "Host: evil" đổi nó trong một giây, và server vẫn nhận request.

Hai hậu quả lớn nhất, và cả hai đến từ việc dùng Host để dựng thứ gì đó:

  • Password-reset poisoning. Nếu code dựng link đặt lại bằng $"https://{Request.Host}/reset?token=...", kẻ tấn công gửi Host: evil.example, và nạn nhân nhận email với link trỏ tới evil.example — bấm vào là token đặt lại đi tới kẻ tấn công. Account takeover, và nó nằm ở đúng luồng reset mà topic authentication cảnh báo là ít ai nhìn.
  • Cache poisoning. Nếu Host (hay X-Forwarded-Host) phản chiếu vào response và response được cache, một request độc hại làm mọi người dùng sau nhận response đã bị đầu độc (xem topic web-cache-poisoning).

X-Forwarded-Host là bẫy riêng: đội thêm nó để hỗ trợ reverse proxy, rồi tin nó — nhưng nếu proxy không đặt lại nó, client gửi được X-Forwarded-Host: evil và nhiều framework ưu tiên header đó hơn Host.

03

Cơ chế hoạt động

Cơ chế: request tới với một Host do client kiểm soát, và ứng dụng dùng nó ở một chỗ nhạy cảm thay vì dùng một giá trị đã cấu hình.

Nguồn sơ đồ
flowchart TD    A["Kẻ tấn công gửi:<br/>POST /password-reset<br/>Host: evil.example<br/>email=victim@acme.com"] --> S{App dùng Host thế nào?}    S -->|"Dựng link từ Request.Host"| P["Link: https://evil.example/reset?token=..."]    P --> M["Email tới NẠN NHÂN với link đó"]    M --> V["Nạn nhân bấm → token đi tới evil.example"]    V --> X["🔓 Account takeover"]    S -->|"Dùng domain ĐÃ CẤU HÌNH"| G["Link: https://app.example/reset?token=...<br/>Host độc hại bị bỏ qua"]

Điểm cốt lõi: giá trị độc hại tới NẠN NHÂN, không ở lại với kẻ tấn công. Kẻ tấn công gửi request với email của nạn nhân, nhưng link poisoned đi vào hộp thư nạn nhân — nên đây là một tấn công vào người khác, giống CSRF và request smuggling.

Bảng cách khai thác và header liên quan:

Khai thácHeaderBản vá thuộc
Password-reset poisoningHost, X-Forwarded-HostDùng domain đã cấu hình để dựng link
Cache poisoningHost, X-Forwarded-HostKhông phản chiếu Host, hoặc đưa Host vào cache key (topic web-cache-poisoning)
Định tuyến sai / SSRF nội bộHost chọn vhost/backendAllowlist Host ở proxy
Bỏ qua kiểm bằng X-Forwarded-Hostclient tự gửi header proxyProxy đặt lại, app tin proxy đáng tin

Hàng cuối là bẫy phổ biến: một app kiểm Host đúng, nhưng framework của nó ưu tiên X-Forwarded-Host (để hỗ trợ proxy), và client gửi được header đó trực tiếp — nên phép kiểm Host bị vòng qua. Đây là lý do bản vá phải quyết định rõ tin proxy nào và những header nào từ proxy đó.

Mô tả sơ đồ: Sơ đồ nhánh cho một request POST /password-reset với header Host là evil.example và email của nạn nhân. Nhánh sai dựng link đặt lại từ Request.Host, nên link trỏ tới https://evil.example/reset kèm token, và email với link đó đi tới hộp thư nạn nhân. Nạn nhân bấm vào và token đặt lại đi tới evil.example, thành account takeover. Nhánh đúng dùng domain đã cấu hình để dựng link, nên link trỏ tới app.example và Host độc hại bị bỏ qua.

04

Ví dụ cụ thể

Luồng đặt lại mật khẩu dựng link từ Host — và Host là input người dùng.

HTTP
# Kẻ tấn công gửi request đặt lại với email NẠN NHÂN nhưng Host của mình.POST /api/password-reset HTTP/1.1Host: evil.exampleContent-Type: application/json {"email":"victim@acme.com"}
# Email tới hộp thư NẠN NHÂN, với link dựng từ Host độc hại:Nhấn để đặt lại mật khẩu: https://evil.example/reset?token=a1b2c3...# Nạn nhân bấm (link trông hợp lý vì đến từ email thật của app), và token# đặt lại đi tới server kẻ tấn công. Account takeover không cần mật khẩu.
HTTP
# Biến thể X-Forwarded-Host: app kiểm Host đúng, nhưng framework ưu tiên header này.POST /api/password-reset HTTP/1.1Host: app.example                    ← đúng, qua được phép kiểmX-Forwarded-Host: evil.example       ← nhưng framework dùng cái này để dựng link {"email":"victim@acme.com"}
HTTP
# Cache poisoning: Host phản chiếu vào response và response được cache.GET / HTTP/1.1Host: evil.example HTTP/1.1 200 OK<link rel="canonical" href="https://evil.example/">   ← phản chiếu, và bị cache# Mọi người dùng sau nhận trang có link tới evil.example (xem topic web-cache-poisoning).
# Sau khi vá: link dựng từ domain ĐÃ CẤU HÌNH, không từ Host. Host độc hại bị bỏ qua.
C#Một dòng: link đặt lại dựng từ Request.Host, và Host là input người dùng.
[HttpPost("/api/password-reset")]public async Task<IActionResult> RequestReset([FromBody] ResetRequest req, CancellationToken ct){    var user = await _users.FindByEmailAsync(req.Email, ct);    if (user is null) return Ok();   // giống hệt cho email tồn tại/không (topic authentication)     var token = await _resets.IssueAsync(user, ct);     // ❌ Request.Host là INPUT NGƯỜI DÙNG — curl -H "Host: evil.example" đổi nó trong    //    một giây. Kẻ tấn công gửi reset với email NẠN NHÂN nhưng Host của mình, và    //    link poisoned đi vào hộp thư nạn nhân. Bấm vào = token tới evil.example =    //    account takeover không cần mật khẩu.    var link = $"https://{Request.Host}/reset?token={token}";     await _mail.SendAsync(user.Email, $"Reset your password: {link}", ct);    return Ok();}
05

Chuyện đã xảy ra

Password-reset poisoning (nhiều báo cáo bug bounty, ~2016–nay). Mẫu lặp lại và gần như luôn giống nhau: luồng đặt lại dựng link bằng Host (hoặc X-Forwarded-Host), nên một request với Host của kẻ tấn công gửi token đặt lại tới domain của họ. Đáng nhớ vì nó là account takeover đầy đủ với một request duy nhất, và nó nằm ở đúng luồng reset mà topic authentication cảnh báo là ít ai review.

Nghiên cứu của James Kettle — "Practical HTTP Host header attacks" (2013) và "Cracking the lens" (2017). Định nghĩa lớp lỗ hổng này và cho thấy Host/X-Forwarded-Host đến được những chỗ bất ngờ: password reset, cache key, thậm chí SQL và template khi Host được log hoặc lưu. Đây là nguồn của bảng khai thác ở khối 3.

Các CVE routing qua Host trong reverse proxy và framework. Nhiều trường hợp một app dùng Host để chọn tenant hoặc backend, nên một Host giả định tuyến request tới một tài nguyên nội bộ — một dạng SSRF (topic ssrf) qua header. Minh hoạ luận điểm khối 2: Host là input, và dùng input để định tuyến là nguy hiểm.

06

Cách phòng chống

Lớp 1

Dựng URL từ domain ĐÃ CẤU HÌNH, không từ `Host`

bắt buộc

Đây là bản vá cho password-reset poisoning và phần lớn lớp lỗ hổng: không dùng Request.Host để dựng bất cứ URL nào rời khỏi server (link trong email, canonical URL, redirect tuyệt đối).

  • Lấy domain từ cấu hình: một hằng PublicBaseUrl trong appsettings, không từ request. Link đặt lại là $"{_config.PublicBaseUrl}/reset?token=...". Host độc hại không có đường vào.
  • Đặc biệt với email: đây là chỗ giá trị đi tới nạn nhân, nên nó là chỗ nguy hiểm nhất. Xem topic authentication lớp 1b — luồng đặt lại đã là bề mặt ít review; đừng để nó dựng link từ Host.

Cách tìm: grep Request.Host, X-Forwarded-Host, HttpContext...Host trong code (khối 7). Chúng gần như không bao giờ nên xuất hiện ngoài tầng hạ tầng — nếu một handler dùng chúng để dựng URL, đó là một lỗ hổng.

C# · Layer 1Link từ domain đã cấu hình, host filtering allowlist, và forwarded headers từ proxy tin cậy.
public sealed class PasswordResetService(IOptions<AppOptions> opts, IMailer mail, IResetStore resets){    // Domain lấy từ CẤU HÌNH, không từ request. Đây là bản vá cốt lõi: một giá trị    // hằng trong appsettings mà Host độc hại không có đường chạm tới.    private readonly Uri _publicBaseUrl = new(opts.Value.PublicBaseUrl);   // "https://app.example"     public async Task RequestResetAsync(string email, CancellationToken ct)    {        var user = await _users.FindByEmailAsync(email, ct);        if (user is null) return;         var token = await resets.IssueAsync(user, ct);         // Link dựng từ _publicBaseUrl, KHÔNG từ Request.Host. Kẻ tấn công gửi        // Host: evil.example bao nhiêu lần cũng không đổi được domain trong link.        var link = new Uri(_publicBaseUrl, $"/reset?token={token}");         await mail.SendAsync(user.Email, $"Reset your password: {link}", ct);    }} // ── Program.cs · host filtering + forwarded headers ─────────────────────────// AllowedHosts là danh sách domain THẬT, không phải "*" (mặc định template là *).// Một request với Host ngoài danh sách bị từ chối 400 ở tầng framework.builder.Services.Configure<HostFilteringOptions>(o =>{    o.AllowedHosts = ["app.example", "www.app.example"];});app.UseHostFiltering(); // X-Forwarded-Host chỉ tin từ proxy ĐÃ BIẾT, và chỉ khi proxy đặt lại nó. Không cấu// hình KnownProxies thì một client gửi X-Forwarded-Host: evil trực tiếp và framework// tin nó — vòng qua host filtering ở trên (bẫy ở hàng cuối bảng khối 3).builder.Services.Configure<ForwardedHeadersOptions>(o =>{    o.ForwardedHeaders = ForwardedHeaders.XForwardedHost | ForwardedHeaders.XForwardedProto;    o.KnownProxies.Add(IPAddress.Parse("10.0.0.1"));   // chỉ reverse proxy của ta    o.ForwardLimit = 1;                                 // đúng một hop});app.UseForwardedHeaders();
Lớp 1b

Allowlist Host, và quyết định rõ tin proxy nào

bắt buộc

Hai phần: kiểm Host đến, và xử lý đúng header của proxy.

  • Allowlist Host ở tầng proxy/framework. Một request với Host không thuộc danh sách domain của bạn bị từ chối 400. Trong ASP.NET Core: app.UseHostFiltering() với AllowedHosts — nó có sẵn nhưng nhiều người để AllowedHosts: "*" (mặc định của template).
  • X-Forwarded-Host chỉ tin từ proxy đáng tin, và chỉ khi proxy ĐẶT LẠI nó. Cấu hình ForwardedHeadersOptions với KnownProxies/KnownNetworks — nếu không, một client gửi X-Forwarded-Host: evil trực tiếp và framework tin nó, vòng qua phép kiểm Host. Đây là bẫy ở hàng cuối bảng khối 3.
  • Proxy phải ĐẶT LẠI (không nối thêm) các header X-Forwarded-* từ client. Một client gửi X-Forwarded-Host mà proxy chuyển tiếp nguyên là proxy đang tin input người dùng.

Điểm quan trọng: đây là quyết định cấu hình hạ tầng, và nó phải rõ ràng — "tin proxy X, header Y từ nó" — không phải một mặc định vô tình.

Lớp 2

Không phản chiếu Host vào response được cache

Đây là giao với topic web-cache-poisoning. Nếu Host (hay X-Forwarded-Host) phản chiếu vào response — canonical link, <base href>, absolute URL trong HTML — và response đó được cache, thì một request độc hại đầu độc bản cache cho mọi người sau.

  • Không phản chiếu Host. Dùng URL tương đối hoặc domain đã cấu hình (lớp 1) cho mọi URL trong HTML.
  • Nếu buộc phản chiếu: đưa Host vào cache key để bản của mỗi Host tách riêng (topic web-cache-poisoning), hoặc đặt response Cache-Control: private/no-store cho trang phụ thuộc Host.

Vary không đủ ở đây: một số CDN không tôn trọng Vary: Host (vì Host đáng lẽ không đổi), nên phải cấu hình cache key ở tầng CDN, không chỉ tin header.

Lớp 3

Phát hiện: một `Host` ngoài allowlist là tín hiệu độ nhiễu thấp

Request hợp lệ tới app của bạn mang đúng một tập Host — các domain bạn phục vụ. Nên một Host khác, hoặc một X-Forwarded-Host từ client, là một lần thử.

  • Log mọi request có Host ngoài allowlist, kèm path. Alert khi tốc độ tăng — và đặc biệt chú ý request tới /password-reset với Host lạ, vì đó là poisoning đang xảy ra.
  • Log X-Forwarded-Host đến từ ngoài dải proxy tin cậy — client không nên gửi nó, nên sự xuất hiện của nó là một lần dò.

Đây là lớp 3 vì allowlist ở lớp 1 đã chặn; log biến "đã chặn" thành "biết ai đang thử poison reset của ai".

07

Kiểm chứng đã vá

1. Test đặt lại mật khẩu với Host giả — phép kiểm trực tiếp cho poisoning:

Shell
B=https://staging.example.com# Gửi reset với Host của kẻ tấn công, rồi kiểm link trong email.curl -s -X POST "$B/api/password-reset" -H 'Host: evil.example' \  -H 'Content-Type: application/json' -d '{"email":"test@acme.com"}'# Link trong email PHẢI trỏ tới app.example, KHÔNG tới evil.example.

2. Test biến thể X-Forwarded-Host — bẫy phổ biến. Gửi Host: app.example đúng nhưng X-Forwarded-Host: evil.example và khẳng định link vẫn trỏ tới app.example. Xem tab csharp / test.

3. Grep dùng Host để dựng URL:

Shell
grep -rnE 'Request\.Host|X-Forwarded-Host|HttpContext.*\.Host' --include='*.cs' src/ \  | grep -viE 'HostFiltering|ForwardedHeaders' \  && echo "CẢNH BÁO: có thể đang dùng Host để dựng URL — xem lại từng chỗ"

4. Kiểm host filtering bật, không phải *:

Shell
grep -rn 'AllowedHosts' src/ appsettings*.json \  | grep -q '"\*"' && { echo "AllowedHosts là * — mọi Host được nhận"; exit 1; }exit 0# Và test trực tiếp: một Host lạ phải 400.curl -s -o /dev/null -w '%{http_code}\n' "$B/" -H 'Host: evil.example'   # phải là 400

5. Kiểm X-Forwarded-Host từ client bị bỏ qua:

Shell
# Gửi trực tiếp (không qua proxy tin cậy) → framework phải KHÔNG dùng nó.curl -si "$B/" -H 'Host: app.example' -H 'X-Forwarded-Host: evil.example' \  | grep -i 'evil.example' && { echo "X-Forwarded-Host từ client được tin"; exit 1; }exit 0
C#Host giả và X-Forwarded-Host giả — cả hai phải bị bỏ qua khi dựng link.
public class HostHeaderTests : IClassFixture<ApiFixture>{    private readonly ApiFixture _fx;     public HostHeaderTests(ApiFixture fx) => _fx = fx;     /// <summary>    /// Password-reset poisoning — account takeover đầy đủ với một request. Gửi reset    /// với Host của kẻ tấn công và khẳng định link trong email trỏ tới domain THẬT.    /// </summary>    [Fact]    public async Task Reset_link_ignores_a_forged_host()    {        await _fx.SeedUserAsync("victim@acme.com");         var req = new HttpRequestMessage(HttpMethod.Post, "/api/password-reset")        {            Content = JsonContent.Create(new { email = "victim@acme.com" }),        };        req.Headers.Host = "evil.example";   // Host giả         await _fx.Client.SendAsync(req);         var link = _fx.ExtractLinkFromLastEmail();        Assert.StartsWith("https://app.example/", link);        Assert.DoesNotContain("evil.example", link);    }     /// <summary>    /// Bẫy X-Forwarded-Host: Host đúng qua được phép kiểm, nhưng nhiều framework ưu    /// tiên X-Forwarded-Host để dựng URL. Client gửi trực tiếp (không qua proxy tin    /// cậy) → phải bị bỏ qua.    /// </summary>    [Fact]    public async Task Reset_link_ignores_x_forwarded_host_from_the_client()    {        await _fx.SeedUserAsync("victim@acme.com");         var req = new HttpRequestMessage(HttpMethod.Post, "/api/password-reset")        {            Content = JsonContent.Create(new { email = "victim@acme.com" }),        };        req.Headers.Host = "app.example";                       // đúng        req.Headers.Add("X-Forwarded-Host", "evil.example");    // nhưng client gửi cái này         await _fx.Client.SendAsync(req);         Assert.DoesNotContain("evil.example", _fx.ExtractLinkFromLastEmail());    }     /// <summary>Host ngoài allowlist bị từ chối 400 ở tầng framework.</summary>    [Fact]    public async Task Unknown_host_is_rejected()    {        var req = new HttpRequestMessage(HttpMethod.Get, "/");        req.Headers.Host = "evil.example";         Assert.Equal(HttpStatusCode.BadRequest, (await _fx.Client.SendAsync(req)).StatusCode);    }     /// <summary>Host phản chiếu vào response được cache là cache poisoning — không được có.</summary>    [Fact]    public async Task Cacheable_responses_do_not_reflect_the_host()    {        var req = new HttpRequestMessage(HttpMethod.Get, "/");        req.Headers.Host = "app.example";   // hợp lệ         var res = await _fx.Client.SendAsync(req);        var body = await res.Content.ReadAsStringAsync();         // Nếu response cacheable, nó KHÔNG được chứa Host trong canonical/base/absolute URL —        // dùng URL tương đối hoặc domain cấu hình. Ở đây kiểm nó không phản chiếu Host thô.        if (res.Headers.CacheControl?.Public == true)            Assert.DoesNotContain("app.example", body);   // chỉ URL tương đối trong HTML cacheable    }}
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Dựng link đặt lại từ Request.HostHost là input người dùng. Password-reset poisoning → account takeover. Dùng domain đã cấu hình
Kiểm Host nhưng framework ưu tiên X-Forwarded-HostClient gửi được X-Forwarded-Host trực tiếp, vòng qua phép kiểm. Cấu hình KnownProxies
AllowedHosts: "*" (mặc định template)Mọi Host được nhận. Đặt danh sách domain thật
Tin X-Forwarded-Host "vì có proxy"Chỉ tin khi proxy ĐẶT LẠI nó và request đến từ proxy tin cậy
Phản chiếu Host vào trang được cacheCache poisoning cho mọi người sau. Dùng URL tương đối / domain cấu hình
Vary: Host để tách cacheMột số CDN không tôn trọng nó (Host đáng lẽ không đổi). Cấu hình cache key ở CDN
Coi Host là "domain của tôi"curl -H "Host: evil" đổi nó trong một giây. Nó là input, không phải hạ tầng

Sai lầm về nhận thức, và nó là sai lầm chính: coi Host là một phần đáng tin của hạ tầng. Nó là một header do client gửi, đổi tự do bằng một dòng curl. Mọi chỗ dùng Host như thể nó là domain của bạn là một lỗ hổng chờ sẵn.

Sai lầm về phạm vi: vá luồng đặt lại rồi dừng. Cùng Host không tin cũng đi vào canonical URL, redirect tuyệt đối, email khác, và cache key. Cách tìm là grep mọi chỗ dùng Host, không chỉ luồng reset.

09

Nguồn tham khảo

Bậc 1Host filtering and forwarded headers in ASP.NET Core · Microsoft · ASP.NET Core docs · .NET 8
Bậc 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Bậc 2HTTP Host header attacks · PortSwigger · Web Security Academy
Bậc 2Practical HTTP Host header attacks · James Kettle, Skeleton Scribe
Bậc 3Password reset poisoning · PortSwigger
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…