SecLab

Web cache poisoning

A05CWE-524CWE-444
01

Là gì

Cache poisoning là khi kẻ tấn công làm một cache (CDN, reverse proxy) lưu một response độc hại, rồi cache đó phục vụ response đó cho mọi người dùng sau. Cơ chế gốc là cache key không bao gồm một input mà response THỰC SỰ phụ thuộc vào — nên hai request "giống nhau theo cache" lại cho hai response khác nhau, và bản độc hại được lưu.

02

Vì sao bạn quan tâm

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

Điều làm cache poisoning nguy hiểm: nó biến một lỗi phản chiếu chỉ hại chính kẻ tấn công thành một lỗi hại MỌI NGƯỜI. Một header phản chiếu (như X-Forwarded-Host ở topic host-header) tự nó chỉ ảnh hưởng request của kẻ tấn công; nhưng nếu response đó được cache, nó phục vụ cho mọi người dùng sau — nên một XSS "self-only" trở thành một XSS lưu trữ ở quy mô CDN.

Hai khái niệm phải nắm, và chúng là toàn bộ topic:

  • Cache key là tập input mà cache dùng để quyết định "hai request này có giống nhau không". Mặc định thường chỉ là method + đường dẫn + vài header. Mọi input NGOÀI cache key mà response phụ thuộc vào là một lỗ hổng: kẻ tấn công đổi nó, response đổi theo, nhưng cache coi hai request là một.
  • Unkeyed input: header như X-Forwarded-Host, X-Forwarded-Scheme, User-Agent, hay một tham số query mà cache bỏ qua nhưng ứng dụng lại dùng. Đây là bề mặt tấn công.

Và một điều về vị trí: bản vá gần như luôn ở cấu hình cache, không ở code ứng dụng — giống topic request-smuggling. Nó là một lỗi của sự bất đồng giữa cache và app về "request này là gì".

03

Cơ chế hoạt động

Cơ chế là sự chênh lệch giữa cache key (thứ cache dùng để so request) và thứ response thực sự phụ thuộc vào.

Nguồn sơ đồ
sequenceDiagram    autonumber    actor A as Kẻ tấn công    participant C as Cache (key = method + path)    participant S as Server    A->>C: GET / <br/>X-Forwarded-Host: evil.example    Note over C: Cache key KHÔNG gồm X-Forwarded-Host → coi như GET / bình thường.    C->>S: chuyển tiếp    S-->>C: 200, HTML có <script src=//evil.example/x.js><br/>(server dùng X-Forwarded-Host)    Note over C: Cache LƯU response độc hại dưới key "GET /".    actor V as Nạn nhân    V->>C: GET / (bình thường)    C-->>V: response ĐÃ ĐẦU ĐỘC từ cache    Note over V: Nạn nhân nhận script của kẻ tấn công. XSS quy mô CDN.

Điểm cốt lõi: response độc hại được LƯU dưới một cache key bình thường, nên nạn nhân gửi một request hoàn toàn bình thường lại nhận response đã bị đầu độc. Đây là lý do nó hại mọi người, không chỉ kẻ tấn công.

Bảng các dạng, theo nguyên nhân:

DạngUnkeyed inputKết quả
Header phản chiếuX-Forwarded-Host, X-Forwarded-SchemeXSS/redirect quy mô CDN
Cache key normalizationcache và app chuẩn hoá đường dẫn khác nhauPhục vụ sai nội dung
Cache key injectiontham số query mà cache bỏ nhưng app dùngĐầu độc theo tham số
Fat GETcache key bỏ body của GETĐầu độc qua body
DoS qua cacheheader dị dạng làm server trả lỗi, lỗi bị cacheTrang lỗi phục vụ cho mọi người

Hàng cuối đáng biết: cache poisoning không chỉ là XSS — đầu độc một trang lỗi vào cache là DoS. Một request làm server trả 400, 400 đó bị cache, và mọi người sau nhận 400 cho một trang bình thường.

Mô tả sơ đồ: Sơ đồ tuần tự một cuộc cache poisoning. Kẻ tấn công gửi GET / kèm header X-Forwarded-Host là evil.example. Cache có cache key chỉ gồm method và path nên không tính header đó, coi như một GET / bình thường và chuyển tiếp tới server. Server dùng X-Forwarded-Host để dựng HTML nên trả về trang có thẻ script trỏ tới evil.example, và cache lưu response độc hại đó dưới key GET /. Khi một nạn nhân gửi GET / bình thường, cache trả lại response đã bị đầu độc, nên nạn nhân nhận script của kẻ tấn công — một XSS ở quy mô CDN.

04

Ví dụ cụ thể

Một trang chủ được CDN cache, và server dùng X-Forwarded-Host để dựng canonical URL.

HTTP
# Request đầu độc. X-Forwarded-Host KHÔNG nằm trong cache key, nhưng server dùng nó.GET / HTTP/1.1Host: app.exampleX-Forwarded-Host: evil.example
HTTP
HTTP/1.1 200 OKCache-Control: public, max-age=300 <script src="//evil.example/analytics.js"></script>   ← server phản chiếu X-Forwarded-Host

Cache lưu response này dưới key GET / (nó bỏ qua X-Forwarded-Host). Trong 300 giây tiếp theo, mọi người dùng gửi GET / bình thường nhận trang có script của kẻ tấn công.

Shell
# Phát hiện: gửi một header unkeyed với một marker và xem nó có được cache không.curl -s https://app.example/ -H 'X-Forwarded-Host: canary123.evil' | grep -q canary123 \  && echo "X-Forwarded-Host phản chiếu"# Rồi gửi request BÌNH THƯỜNG và xem marker còn không → nếu còn, nó đã bị cache.curl -s https://app.example/ | grep -q canary123 && echo "ĐÃ ĐẦU ĐỘC CACHE"
HTTP
# Biến thể DoS: header dị dạng làm server trả 400, và 400 bị cache.GET / HTTP/1.1X-Forwarded-Host: evil\r\n(header dị dạng làm server lỗi)# 400 bị cache dưới key GET / → mọi người sau nhận 400 cho trang chủ.
# Sau khi vá: X-Forwarded-Host không phản chiếu (dùng domain cấu hình, topic host-header),# hoặc đưa nó vào cache key, và không cache trang lỗi.
YAMLCache key chỉ method + path, và cache lưu cả response lỗi.
# Cấu hình CDN/reverse proxy. Đây là nơi cache poisoning sống — không phải code app.cache:  # ❌ Cache key chỉ gồm method + path. Nếu app dùng X-Forwarded-Host để dựng response  #    (topic host-header), thì hai request khác X-Forwarded-Host cho hai response khác  #    nhau — nhưng cache coi chúng là MỘT, nên bản độc hại phục vụ cho mọi người.  key:    - method    - path    # thiếu mọi header mà app thực sự dùng để dựng response   # ❌ Cache cả 4xx/5xx. Một request với header dị dạng làm server trả 400, và 400 đó  #    bị cache dưới key GET / → mọi người sau nhận 400 cho trang chủ (CPDoS).  cacheable_status: [200, 301, 400, 404, 500]   # ❌ TTL dài. Một bản đã đầu độc sống 24 giờ.  default_ttl: 86400   # ❌ Ghi đè Cache-Control của app: cache trang kể cả khi app nói no-store.  ignore_origin_cache_control: true
05

Chuyện đã xảy ra

Nghiên cứu của James Kettle — "Practical Web Cache Poisoning" (2018) và "Web Cache Entanglement" (2020). Định nghĩa lớp lỗ hổng này và cho thấy nó tồn tại trên nhiều site lớn qua các header unkeyed như X-Forwarded-Host, X-Forwarded-Scheme. Đây là nguồn của bảng dạng ở khối 3, và của kỹ thuật phát hiện bằng canary marker ở khối 4.

Các sự cố CDN thực tế (nhiều báo cáo). Nhiều báo cáo bug bounty mô tả cùng mẫu: một header phản chiếu cộng một CDN cache theo mặc định, cho phép một request đầu độc trang chủ hoặc một tài nguyên JS dùng chung cho mọi người dùng. Đáng nhớ vì hậu quả nhân theo quy mô của cache: một request, hàng triệu nạn nhân.

Cache poisoning DoS (CPDoS, nghiên cứu 2019). Một họ tấn công dùng header dị dạng để làm server trả lỗi rồi làm cache lưu lỗi đó — biến cache thành một công cụ từ chối dịch vụ. Minh hoạ hàng cuối bảng khối 3: cache poisoning không chỉ là XSS, nó là bất cứ response xấu nào bạn ép được vào cache.

06

Cách phòng chống

Lớp 1

Đừng phản chiếu input không tin cậy vào response được cache

bắt buộc

Đây là bản vá cốt lõi, và nó là giao với topic host-header và xss: nếu response không phản chiếu input nào của kẻ tấn công thì không có gì để đầu độc.

  • Không dùng Host/X-Forwarded-Host/X-Forwarded-Scheme để dựng URL trong response — dùng domain đã cấu hình (topic host-header lớp 1). Đây là nguồn phổ biến nhất của cache poisoning.
  • Không phản chiếu header/tham số vào HTML được cache. Một <link rel=canonical> hay <script src> dựng từ input là XSS quy mô CDN khi bị cache.
  • Nếu phải phản chiếu: escape theo ngữ cảnh (topic xss) VÀ đảm bảo input đó nằm trong cache key (lớp 1b) — nếu không, bản độc hại phục vụ cho mọi người.

Nguyên tắc: response được cache là response dùng chung, nên nó không được chứa gì phụ thuộc vào một request cụ thể. Nếu nội dung phụ thuộc vào input, response đó không nên cache được (lớp 1b).

YAML · Layer 1Cache key gồm mọi input response phụ thuộc, chỉ cache 200, TTL ngắn, tôn trọng app.
cache:  # Cache key gồm MỌI input mà app dùng để dựng response. Nguyên tắc: cache key phải  # KHỚP với thứ response phụ thuộc vào — nếu không, hai request "giống theo cache"  # cho hai response khác nhau, và đó là chỗ đầu độc.  #  # Tốt hơn nữa (lớp 1): app KHÔNG dùng các header này để dựng response, và lúc đó  # cache key chỉ cần method + path là an toàn. Cấu hình dưới là cho trường hợp buộc  # phải phụ thuộc một header (ví dụ đa ngôn ngữ theo Accept-Language).  key:    - method    - path    - normalized_query          # chuẩn hoá GIỐNG app (chống cache-key injection)    - header:Accept-Language    # response phụ thuộc nó → phải trong key   # Chỉ cache 200 (và 301/404 tĩnh có chủ ý). KHÔNG cache 4xx/5xx động — một 400 do  # header dị dạng bị cache là CPDoS: một request, DoS cho mọi người.  cacheable_status: [200, 301]   # TTL ngắn cho nội dung động: nếu một bản đầu độc lọt qua, nó không sống lâu.  default_ttl: 60   # TÔN TRỌNG Cache-Control của app. Nếu app đặt no-store cho một trang phụ thuộc  # phiên, cache phải nghe — ghi đè điều đó là một lỗ hổng cấu hình.  ignore_origin_cache_control: false   # Chuẩn hoá path GIỐNG app: nếu cache coi /PROFILE và /profile là hai key nhưng app  # coi là một, có chỗ cho cache-key injection.  normalize_path:    lowercase: true    strip_trailing_slash: true # Và ở app (lớp 1, giao với topic host-header): KHÔNG phản chiếu X-Forwarded-Host vào# response được cache. Dùng domain đã cấu hình cho canonical URL, base href, absolute URL.# Trang phụ thuộc người dùng: Cache-Control: private, no-store.
Lớp 1b

Cache key phải gồm MỌI input mà response phụ thuộc vào

bắt buộc

Cache poisoning là một sự bất đồng: cache nghĩ hai request giống nhau, app trả hai response khác nhau. Bản vá là làm cache key khớp với thứ response thực sự phụ thuộc vào.

  • Đưa vào cache key mọi header/tham số mà app dùng để dựng response. Nếu response phụ thuộc X-Forwarded-Host, thì X-Forwarded-Host phải trong cache key — hoặc app đừng dùng nó (lớp 1).
  • Vary là công cụ chuẩn cho việc này, nhưng phải cấu hình ở cả CDN: Vary: X-Forwarded-Host nói cho cache biết response khác nhau theo header đó. Lưu ý: một số CDN bỏ qua Vary cho header không chuẩn, nên phải đặt cache key tường minh ở tầng CDN.
  • Chuẩn hoá đường dẫn NHẤT QUÁN giữa cache và app. Nếu cache coi /PROFILE/profile là hai key nhưng app coi là một (hoặc ngược lại), có chỗ cho cache key injection. Cấu hình cả hai chuẩn hoá giống nhau.

giới hạn những gì được cache: chỉ cache tài nguyên tĩnh thật sự dùng chung (JS, CSS, ảnh). Trang phụ thuộc phiên/người dùng đặt Cache-Control: private, no-store.

Lớp 2

Không cache response lỗi, và giới hạn TTL

Đây là bản vá cho biến thể CPDoS ở khối 3: một trang lỗi bị cache là DoS.

  • Không cache 4xx/5xx (trừ một số 404 tĩnh có chủ ý). Một request làm server trả 400 vì header dị dạng, rồi 400 đó phục vụ cho mọi người, là một DoS mà một request gây ra. Cấu hình cache chỉ lưu 200 (và có thể 301/404 tĩnh).
  • TTL ngắn cho nội dung động, để một bản đầu độc (nếu lọt qua) không sống lâu. Một trang cache 24 giờ bị đầu độc là 24 giờ mọi người nhận bản độc; cache 60 giây thì cửa sổ nhỏ hơn nhiều.
  • Kiểm header response của app trước khi cache: nếu app đặt Cache-Control: no-store cho một trang, CDN phải tôn trọng — một cấu hình CDN ghi đè điều đó là một lỗ hổng cấu hình.

Đây là lớp 2 vì nó giảm thiệt hại chứ không đóng nguyên nhân; nguyên nhân là phản chiếu (lớp 1) và cache key (lớp 1b).

Lớp 3

Phát hiện: canary marker và giám sát nội dung cache

Cache poisoning khó phát hiện vì nạn nhân nhận response trông bình thường từ cache. Hai biện pháp:

  • Synthetic canary định kỳ: một job gửi request bình thường tới các trang được cache và kiểm response không chứa domain lạ, script lạ, hay dấu hiệu bị sửa. Nếu bản cache bị đầu độc, canary bắt được nó — đây là kỹ thuật ở khối 4, chạy tự động.
  • Log request có header unkeyed bất thường (X-Forwarded-Host khác domain của bạn, header X-Forwarded-* từ ngoài dải proxy). Đây là chữ ký của một lần dò cache poisoning, cùng phát hiện với topic host-header.
  • Alert khi tỉ lệ lỗi từ cache tăng — có thể là CPDoS: một trang lỗi bị cache đang phục vụ cho mọi người.

Đây là lớp 3 vì nó không chặn, nhưng với một lỗ hổng phục vụ response độc hại từ cache một cách im lặng, giám sát nội dung cache là cách duy nhất biết nó đã xảy ra.

07

Kiểm chứng đã vá

1. Kỹ thuật canary — phép kiểm cốt lõi, và nó là hai bước:

Shell
B=https://staging.example.comM="canary$RANDOM"# Bước 1: gửi header unkeyed với marker, xem nó có phản chiếu vào response không.curl -s "$B/" -H "X-Forwarded-Host: $M.evil" | grep -q "$M" \  && echo "phản chiếu — nguy hiểm nếu bị cache" || { echo "không phản chiếu, ok"; exit 0; }# Bước 2: gửi request BÌNH THƯỜNG (không header) và xem marker còn không.sleep 1curl -s "$B/" | grep -q "$M" && { echo "CACHE ĐÃ BỊ ĐẦU ĐỘC"; exit 1; }exit 0

2. Thử qua nhiều header unkeyed — bảng ở khối 3 là danh sách bắt đầu:

Shell
for h in X-Forwarded-Host X-Forwarded-Scheme X-Host X-Forwarded-Server X-Original-URL; do  M="c$RANDOM"  curl -s "$B/" -H "$h: $M" > /dev/null  curl -s "$B/" | grep -q "$M" && echo "ĐẦU ĐỘC được qua $h"done

3. Grep app dùng header phản chiếu vào response — cùng phép kiểm topic host-header:

Shell
grep -rnE 'X-Forwarded-Host|X-Forwarded-Scheme|Request\.Host' --include='*.cs' src/ \  | grep -viE 'ForwardedHeaders|HostFiltering' \  && echo "CẢNH BÁO: có thể phản chiếu header vào response"

4. Kiểm response lỗi KHÔNG được cache (CPDoS):

Shell
# Ép một 400 rồi kiểm nó không có Cache-Control public.curl -sI "$B/" -H 'X-Forwarded-Host: a\r\nb' | grep -iE 'cache-control|^HTTP'# 4xx/5xx phải là no-store/private, không public.

5. Kiểm cấu hình cache key ở CDN — đây là nơi bản vá thật sống. Xác nhận header nào app dùng để dựng response đều nằm trong cache key của CDN, hoặc app không dùng chúng. Đây là kiểm cấu hình, không phải test code.

HTTPKỹ thuật canary hai bước: phản chiếu, rồi kiểm bản bình thường có bị đầu độc không.
### Web cache poisoning — kỹ thuật canary (chạy bằng httpyac/REST Client hoặc script)###### Nguyên tắc: cache poisoning khó phát hiện vì nạn nhân nhận response BÌNH THƯỜNG từ### cache. Canary là hai bước — bước 1 chứng minh header phản chiếu, bước 2 chứng minh### nó bị CACHE (một request bình thường vẫn thấy marker). ### Bước 1 · gửi header unkeyed với marker, xác nhận nó phản chiếu vào responseGET https://staging.example.com/ HTTP/1.1X-Forwarded-Host: canary-poison-marker.evil ### Kỳ vọng: response CHỨA "canary-poison-marker" (app phản chiếu header).### Nếu KHÔNG chứa → app không dùng header này, an toàn với dạng này. ### Bước 2 · gửi request BÌNH THƯỜNG (không header) — marker KHÔNG được cònGET https://staging.example.com/ HTTP/1.1 ### Kỳ vọng SAU KHI VÁ: response KHÔNG chứa "canary-poison-marker".### Nếu CÒN chứa → cache đã lưu bản độc hại từ bước 1, và nó đang phục vụ cho mọi### người dùng. Đây là cache poisoning đã xảy ra. ### Bước 3 · thử nhiều header unkeyed (bảng ở khối 3 là danh sách bắt đầu)GET https://staging.example.com/ HTTP/1.1X-Forwarded-Scheme: nothttps GET https://staging.example.com/ HTTP/1.1X-Original-URL: /admin ### Bước 4 · CPDoS — ép một lỗi và xác nhận nó KHÔNG bị cacheGET https://staging.example.com/ HTTP/1.1X-Forwarded-Host: %00malformed ### Kỳ vọng: nếu request này gây 400, response 400 phải có Cache-Control: no-store —### KHÔNG public. Một 400 bị cache dưới key "GET /" là DoS cho mọi người sau. ### Ghi chú: bản vá thật ở CẤU HÌNH CACHE (xem tab yaml), không ở đây. Các request này### là cách CHỨNG MINH cấu hình đúng, không phải cách sửa.
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Escape output nhưng vẫn phản chiếu vào trang cacheEscape đóng XSS phản chiếu, nhưng bản đã cache vẫn phục vụ input của kẻ tấn công cho mọi người
Vary: X-Forwarded-Host mà không cấu hình CDNNhiều CDN bỏ qua Vary cho header không chuẩn. Phải đặt cache key tường minh ở CDN
Coi cache poisoning là XSS thườngNó nhân theo quy mô cache: một request, mọi người dùng. Và nó có cả biến thể DoS (CPDoS)
Cache 4xx/5xxMột 400 do header dị dạng bị cache là DoS cho mọi người. Chỉ cache 200
Cache key chỉ method + pathMọi input NGOÀI key mà response phụ thuộc là bề mặt. Đưa chúng vào key hoặc đừng dùng chúng
Dùng X-Forwarded-Host để dựng URLCùng lỗi topic host-header, và cache biến nó thành hại-mọi-người. Dùng domain cấu hình
Cache và app chuẩn hoá path khác nhauCache key injection. Cấu hình cả hai chuẩn hoá giống nhau

Sai lầm về mô hình, và nó là sai lầm chính: nghĩ cache key mặc định (method + path) là đủ. Cache poisoning là một sự BẤT ĐỒNG giữa "cache nghĩ request này là gì" và "app dựng response từ gì". Mọi input mà app dùng nhưng cache bỏ qua là một lỗ hổng.

Sai lầm về vị trí: tìm bản vá trong code ứng dụng. Như topic request-smuggling, bản vá gần như luôn ở cấu hình cache/CDN — cache key gồm gì, cache gì, TTL bao lâu. Code chỉ liên quan ở chỗ nó có phản chiếu input hay không (lớp 1).

09

Nguồn tham khảo

Bậc 1RFC 9111 — HTTP Caching · IETF · RFC · RFC 9111
Bậc 1A05:2021 – Security Misconfiguration · OWASP · Top 10 · 2021
Bậc 2Web cache poisoning · PortSwigger · Web Security Academy
Bậc 2Practical Web Cache Poisoning · James Kettle, PortSwigger
Bậc 3CPDoS: Cache Poisoned Denial of Service · Nguyen, Schneider et al.
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…