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.
Vì sao bạn quan tâm
Đ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ửiHost: evil.example, và nạn nhân nhận email với link trỏ tớievil.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(hayX-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).
Và 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.
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.
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ác | Header | Bản vá thuộc |
|---|---|---|
| Password-reset poisoning | Host, X-Forwarded-Host | Dùng domain đã cấu hình để dựng link |
| Cache poisoning | Host, X-Forwarded-Host | Khô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/backend | Allowlist Host ở proxy |
Bỏ qua kiểm bằng X-Forwarded-Host | client tự gửi header proxy | Proxy đặ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.
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.
# 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.# 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"}# 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.[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();}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.
Cách phòng chống
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
PublicBaseUrltrong 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.
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();Allowlist Host, và quyết định rõ tin proxy nào
bắt buộcHai phần: kiểm Host đến, và xử lý đúng header của proxy.
- Allowlist
Hostở tầng proxy/framework. Một request vớiHostkhông thuộc danh sách domain của bạn bị từ chối 400. Trong ASP.NET Core:app.UseHostFiltering()vớiAllowedHosts— nó có sẵn nhưng nhiều người đểAllowedHosts: "*"(mặc định của template). X-Forwarded-Hostchỉ tin từ proxy đáng tin, và chỉ khi proxy ĐẶT LẠI nó. Cấu hìnhForwardedHeadersOptionsvớiKnownProxies/KnownNetworks— nếu không, một client gửiX-Forwarded-Host: eviltrực tiếp và framework tin nó, vòng qua phép kiểmHost. Đâ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-Hostmà 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.
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
Hostvào cache key để bản của mỗi Host tách riêng (topic web-cache-poisoning), hoặc đặt responseCache-Control: private/no-storecho trang phụ thuộc Host.
Và 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.
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ó
Hostngoài allowlist, kèm path. Alert khi tốc độ tăng — và đặc biệt chú ý request tới/password-resetvớ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".
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:
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:
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 *:
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à 4005. Kiểm X-Forwarded-Host từ client bị bỏ qua:
# 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 0public 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 }}Sai lầm thường gặp
| "Bản vá" | Vì sao không đúng |
|---|---|
Dựng link đặt lại từ Request.Host | Host 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-Host | Client 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 cache | Cache poisoning cho mọi người sau. Dùng URL tương đối / domain cấu hình |
Vary: Host để tách cache | Mộ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.
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.
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…