SecLab
Server-sideĐầy đủ

Web cache deception

A01CWE-525CWE-668
01

Là gì

Cache deception là ngược lại của cache poisoning: thay vì đầu độc một trang công khai, kẻ tấn công lừa cache lưu một trang RIÊNG TƯ của nạn nhân (trang tài khoản, dữ liệu cá nhân) rồi tự đọc nó. Cơ chế là cache và server bất đồng về "URL này là tài nguyên tĩnh hay động".

02

Vì sao bạn quan tâm

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

Điều phải nắm: cache deception khai thác một sự bất đồng về hậu tố URL. Cache thường quyết định "cache hay không" theo phần mở rộng — .css, .js, .jpg là tĩnh, cache được. Nhưng nhiều server bỏ qua phần thừa của URL và vẫn phục vụ trang động. Ghép hai điều đó lại:

  • Kẻ tấn công lừa nạn nhân mở https://app.example/account/profile.css.
  • Server bỏ qua /profile.css, phục vụ trang /account/profile của nạn nhân (kèm dữ liệu cá nhân).
  • Cache thấy đuôi .css, nghĩ đây là tĩnh, và LƯU response — kèm dữ liệu riêng của nạn nhân.
  • Kẻ tấn công mở cùng URL .../profile.css, cache phục vụ bản đã lưu → đọc được dữ liệu nạn nhân.

Điểm khác biệt với cache poisoning: poisoning ghi một bản độc vào cache công khai; deception đọc một bản riêng tư ra khỏi cache. Cả hai là cùng gốc — cache và server bất đồng — nhưng hướng ngược nhau.

Và giống hai topic cache/smuggling khác: bản vá ở cấu hình cache, không ở code. Nó là một lỗi của việc cache quyết định "cache gì" theo một quy tắc (phần mở rộng) khác với quy tắc server dùng để quyết định "phục vụ gì".

03

Cơ chế hoạt động

Cơ chế là bất đồng về hậu tố URL giữa cache (quyết định theo phần mở rộng) và server (bỏ qua phần thừa).

Nguồn sơ đồ
sequenceDiagram    autonumber    actor A as Kẻ tấn công    actor V as Nạn nhân (đã đăng nhập)    participant C as Cache (cache theo .css)    participant S as Server (bỏ qua /profile.css)    A->>V: Lừa mở link /account/profile.css    V->>C: GET /account/profile.css<br/>Cookie: session=<của nạn nhân>    C->>S: chuyển tiếp (chưa có trong cache)    S-->>C: 200, trang profile của NẠN NHÂN<br/>(server bỏ qua .css)    Note over C: Cache thấy đuôi .css → nghĩ TĨNH → LƯU response<br/>(kèm dữ liệu riêng của nạn nhân).    A->>C: GET /account/profile.css (KHÔNG cookie)    C-->>A: bản đã lưu — dữ liệu của NẠN NHÂN

Điểm cốt lõi: cache lưu một trang cần đăng nhập vì nó tưởng đó là tĩnh. Kẻ tấn công không cần phiên nạn nhân để đọc — cache phục vụ bản đã lưu cho bất kỳ ai hỏi cùng URL, kể cả không cookie.

Bảng điều kiện — cả hai phải đúng thì mới khai thác được:

Điều kiệnChi tiết
Server bỏ qua phần thừa của URL/account/profile.css phục vụ như /account/profile
Cache quyết định theo phần mở rộng.css/.js/.jpg → cache, bỏ qua Cache-Control của trang
Trang có dữ liệu riêng tưTrang tài khoản, dữ liệu cá nhân — thứ đáng đọc trộm

Các biến thể ở phần "server bỏ qua phần thừa": không chỉ .css. /account/profile/xyz (path thừa), /account/profile;.css (path parameter), /account/profile%00.css (null byte), /account/profile.css?x=1 — mỗi cách là một sự bất đồng khác giữa cách server parse URL và cách cache quyết định. Đây là lý do blocklist phần mở rộng không đủ (giống các topic injection).

Mô tả sơ đồ: Sơ đồ tuần tự một cuộc cache deception. Kẻ tấn công lừa nạn nhân đang đăng nhập mở link /account/profile.css. Trình duyệt nạn nhân gửi request đó kèm cookie phiên của nạn nhân tới cache; cache chưa có nên chuyển tiếp tới server. Server bỏ qua đuôi .css và phục vụ trang profile của nạn nhân kèm dữ liệu cá nhân. Cache thấy đuôi .css nên nghĩ đây là tài nguyên tĩnh và lưu response đó, kèm dữ liệu riêng của nạn nhân. Sau đó kẻ tấn công mở cùng URL profile.css mà không cần cookie, và cache trả lại bản đã lưu — dữ liệu của nạn nhân.

04

Ví dụ cụ thể

Một trang tài khoản GET /account/profile cần đăng nhập, sau một CDN cache theo phần mở rộng.

HTTP
# Kẻ tấn công lừa nạn nhân mở URL này (qua email, link chat). Nạn nhân đã đăng nhập.GET /account/profile.css HTTP/1.1Host: app.exampleCookie: session=<phiên của nạn nhân>
HTTP
# Server BỎ QUA ".css" và phục vụ trang profile động của nạn nhân:HTTP/1.1 200 OKContent-Type: text/htmlCache-Control: private, no-store       ← trang NÓI đừng cache <h1>Alice Nguyen</h1><p>Email: alice@acme.com · Thẻ: **** 4242</p>
# Nhưng CACHE quyết định theo ĐUÔI .css và BỎ QUA Cache-Control: private của trang.# Nó lưu response — kèm dữ liệu của Alice — dưới key /account/profile.css.
HTTP
# Kẻ tấn công mở cùng URL, KHÔNG cần cookie của Alice:GET /account/profile.css HTTP/1.1Host: app.example(không cookie) HTTP/1.1 200 OK  (từ cache)<h1>Alice Nguyen</h1><p>Email: alice@acme.com · Thẻ: **** 4242</p>   ← dữ liệu của Alice
Shell
# Phát hiện: mở một trang riêng tư với đuôi tĩnh giả, rồi mở lại KHÔNG cookie.curl -s https://app.example/account/profile.css -b "session=$MY_SESSION" > /dev/nullcurl -s https://app.example/account/profile.css | grep -q "alice@acme.com" \  && echo "CACHE DECEPTION: dữ liệu riêng đọc được không cần cookie"
# Sau khi vá: cache tôn trọng Cache-Control: private, VÀ server từ chối /profile.css# (không bỏ qua phần thừa), VÀ cache chỉ lưu theo Content-Type thật, không theo đuôi.
YAMLCache theo phần mở rộng, ghi đè Cache-Control của app — hai dòng, và là toàn bộ lỗ hổng.
# Cấu hình CDN. Cache deception sống Ở ĐÂY, không trong code app.cache:  rules:    # ❌ Cache theo PHẦN MỞ RỘNG URL. Một trang tài khoản mở với đuôi .css được cache    #    NHƯ MỘT tài nguyên tĩnh — kèm dữ liệu riêng của người dùng trong response.    #    Đây là điều kiện 2 của khối 3.    - match: "\\.(css|js|jpg|png|woff2|svg)$"      action: cache      ttl: 86400   # ❌ BỎ QUA Cache-Control của origin. App đặt "private, no-store" cho trang tài  #    khoản, nhưng CDN ghi đè nó và cache theo đuôi ở trên. Nên một app ĐÚNG vẫn bị  #    deception vì cấu hình cache sai.  respect_origin_cache_control: false # Và ở server: router khớp "bắt đầu bằng", nên /account/profile.css khớp route# /account/profile và server phục vụ trang động — điều kiện 1 của khối 3.# routes:#   - path_prefix: /account/profile   # ❌ prefix, nên .css đi kèm cũng khớp
05

Chuyện đã xảy ra

Omer Gil — "Web Cache Deception Attack" (Black Hat 2017). Bài trình bày định nghĩa lớp lỗ hổng này và cho thấy nó hoạt động trên các dịch vụ lớn (bao gồm một demo nổi tiếng trên PayPal thời điểm đó): mở một URL trang tài khoản với đuôi .css, và cache lưu trang có dữ liệu cá nhân. Đây là nguồn của bảng điều kiện ở khối 3.

Các sự cố CDN theo mặc định cache-by-extension (nhiều báo cáo). Nhiều CDN mặc định cache mọi thứ có đuôi tĩnh, và mặc định đó ghi đè Cache-Control: private của app. Nên một app đúng (đặt no-store cho trang tài khoản) vẫn bị deception nếu CDN cấu hình cache-by-extension. Minh hoạ luận điểm khối 2: bản vá ở cấu hình cache, không ở code app.

Nghiên cứu mở rộng của Kettle/Gil về các biến thể URL (2020). Cho thấy .css chỉ là một cách; path thừa, path parameter (;), null byte, và các cách parse URL khác giữa cache và server đều mở ra deception. Đây là lý do khối 6 nói bản vá phải là "cache và server ĐỒNG Ý về URL này là gì", không phải "chặn đuôi .css".

06

Cách phòng chống

Lớp 1

Cache theo `Content-Type` và `Cache-Control` của app, KHÔNG theo phần mở rộng URL

bắt buộc

Nguyên nhân gốc là cache quyết định "cache gì" theo phần mở rộng, ghi đè điều app nói. Bản vá là làm cache TÔN TRỌNG điều app nói.

  • Cache tôn trọng Cache-Control của response. Nếu app đặt private, no-store, cache KHÔNG được lưu — bất kể đuôi URL. Đây là dòng quan trọng nhất, và nó là một cấu hình CDN: tắt "cache theo phần mở rộng" và bật "tôn trọng origin Cache-Control".
  • Chỉ cache theo Content-Type thật. Một response Content-Type: text/html KHÔNG được cache như tĩnh dù URL kết thúc .css. Cache dựa vào những gì server TRẢ VỀ, không vào những gì URL GỢI Ý.
  • App đặt Cache-Control: no-store cho mọi trang phụ thuộc phiên/người dùng — đây là nửa ở phía app, và nó phải có kể cả khi tin cache tôn trọng nó. Phòng thủ chiều sâu.

Điểm quan trọng: đây là quyết định cấu hình cache, và nó phải rõ ràng. Nhiều CDN mặc định cache-by-extension vì nó nhanh; bạn phải tắt nó tường minh cho các path có nội dung động.

YAML · Layer 1Allowlist prefix tĩnh, tôn trọng Cache-Control, và server khớp path chính xác.
cache:  # Mặc-định-ĐÓNG: chỉ cache các PREFIX tĩnh đã biết, mọi thứ khác không cache. Đây là  # biện pháp mạnh nhất (lớp 2) — một trang động mới thêm KHÔNG bị cache do sơ ý, giống  # nguyên tắc [Authorize] toàn cục ở topic access-control.  default: no-cache  rules:    - match_prefix: ["/_next/static/", "/assets/"]   # tài nguyên tĩnh có hash tên file      action: cache      ttl: 31536000                                   # tĩnh bất biến → cache lâu là an toàn   # TÔN TRỌNG Cache-Control của origin. Nếu app nói private/no-store, cache KHÔNG lưu —  # bất kể đuôi URL. Đây là dòng đóng deception ở phía cache.  respect_origin_cache_control: true   # Cache theo CONTENT-TYPE thật, không theo đuôi URL. Một response text/html KHÔNG  # được cache như tĩnh dù URL kết thúc .css — cache dựa vào những gì server TRẢ VỀ.  cache_by: content_type  cacheable_content_types: ["text/css", "application/javascript", "image/*", "font/*"] server_routing: |  # Nửa phía server (lớp 1b): router khớp path CHÍNH XÁC, không "bắt đầu bằng".  # /account/profile.css KHÔNG khớp route /account/profile → trả 404, không phục vụ  # trang động. Cache và server ĐỒNG Ý về "URL này là gì".  app.MapGet("/account/profile", ...);   // khớp đúng "/account/profile"  // /account/profile.css, /account/profile/x, /account/profile;.css → 404 app_headers: |  # Và app đặt no-store cho MỌI trang phụ thuộc phiên — nửa phía app. Phòng thủ  # chiều sâu: kể cả khi tin cache tôn trọng nó, app vẫn nói ra.  [ResponseCache(NoStore = true)]   // trên controller của trang tài khoản  // → Cache-Control: no-store, và cache (đã cấu hình respect) sẽ nghe.
Lớp 1b

Cache và server phải ĐỒNG Ý về "URL này là gì"

bắt buộc

Deception là một sự bất đồng, nên bản vá thứ hai là loại bỏ khả năng bất đồng — giống topic request-smuggling.

  • Server không bỏ qua phần thừa của URL. /account/profile.css phải là 404, không phải phục vụ /account/profile. Một router khớp CHÍNH XÁC đường dẫn (không "bắt đầu bằng") thì /profile.css không khớp route /profile. Đây là nửa ở phía server.
  • Chuẩn hoá URL nhất quán giữa cache và server. Nếu server coi /profile;.css, /profile%00.css, /profile/x/profile nhưng cache coi chúng là tĩnh, có chỗ cho deception. Cả hai phải parse URL giống nhau — đây là lý do blocklist đuôi .css không đủ (bảng biến thể ở khối 3).

Nguyên tắc chung: cache và server là hai bộ parse URL khác nhau, và mọi chỗ chúng bất đồng là một lỗ hổng — cùng bài học với request-smuggling (cache/backend bất đồng về ranh giới request) và web-cache-poisoning (bất đồng về cache key).

Lớp 2

Allowlist đường dẫn được cache, không blocklist

Biện pháp mạnh nhất về cấu hình là đảo mặc định: cache KHÔNG cache gì trừ khi được liệt kê rõ.

  • Chỉ cache các đường dẫn/prefix tĩnh đã biết (/static/, /assets/, /_next/static/), và mọi thứ khác mặc định không cache. Đây là mặc-định-đóng, giống nguyên tắc [Authorize] toàn cục ở topic access-control: một trang động mới thêm vào KHÔNG tự nhiên bị cache.
  • Ngược lại — blocklist các đường dẫn động — là mặc-định-MỞ, và một trang tài khoản mới quên thêm vào blocklist sẽ bị cache. Cùng lý do blocklist thua ở các topic injection.

Với kiến trúc hiện đại (Next.js, static assets có hash tên file), allowlist rất tự nhiên: tài nguyên tĩnh nằm dưới một prefix biết trước, nên "chỉ cache prefix đó" vừa an toàn vừa đơn giản. SecLab dùng đúng cách này — chỉ _next/static/assets được cache.

Lớp 3

Phát hiện: dữ liệu riêng tư trong response được cache

Deception khó phát hiện vì nạn nhân không thấy gì bất thường — kẻ tấn công đọc dữ liệu ở một request riêng. Hai biện pháp:

  • Synthetic canary: một job đăng nhập bằng một tài khoản test, mở các trang riêng tư với các đuôi giả (.css, .js, path thừa), rồi mở lại KHÔNG cookie và kiểm response có chứa dữ liệu của tài khoản test không. Nếu có → cache đang lưu trang riêng tư (kỹ thuật ở khối 4, tự động).
  • Log request tới đường dẫn động có đuôi tĩnh (/account/*.css, /api/*.js) — client hợp lệ không tạo ra chúng, nên chúng là chữ ký của một lần dò deception.
  • Kiểm response được cache có header Set-Cookie hay Cache-Control: private — một response cá nhân hoá lọt vào cache là một cảnh báo đỏ; scanner ở tầng CDN có thể bắt được.

Đây là lớp 3 vì cấu hình ở lớp 1 đã chặn; giám sát cho biết nếu một cấu hình sai lọt qua.

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 (giống cache poisoning nhưng đọc, không ghi):

Shell
B=https://staging.example.com# Bước 1: đăng nhập bằng tài khoản test, mở trang riêng tư với đuôi tĩnh giả.curl -s "$B/account/profile.css" -b "session=$TEST_SESSION" | grep -q "$TEST_EMAIL" \  && echo "server bỏ qua .css và phục vụ trang riêng"# Bước 2: mở CÙNG URL không cookie — dữ liệu test KHÔNG được xuất hiện.sleep 1curl -s "$B/account/profile.css" | grep -q "$TEST_EMAIL" \  && { echo "CACHE DECEPTION: dữ liệu riêng đọc được không cần đăng nhập"; exit 1; }exit 0

2. Thử nhiều biến thể URL — bảng ở khối 3: không chỉ .css:

Shell
for u in /account/profile.css /account/profile.js /account/profile/x \         "/account/profile;.css" "/account/profile%00.css"; do  curl -s "$B$u" -b "session=$TEST_SESSION" > /dev/null  curl -s "$B$u" | grep -q "$TEST_EMAIL" && echo "DECEPTION qua $u"done

3. Kiểm server TỪ CHỐI đường dẫn có đuôi thừa (nửa phía server ở lớp 1b):

Shell
# /account/profile.css phải là 404, không phục vụ /account/profile.curl -s -o /dev/null -w '%{http_code}\n' "$B/account/profile.css" -b "session=$TEST_SESSION"# Mong đợi 404. Nếu 200 → server bỏ qua phần thừa, điều kiện 1 của khối 3 đúng.

4. Kiểm cache tôn trọng Cache-Control: private — đây là kiểm cấu hình CDN:

Shell
# Response của trang riêng phải có Cache-Control private/no-store...curl -sI "$B/account/profile" -b "session=$TEST_SESSION" | grep -i cache-control# ...VÀ CDN phải tôn trọng nó (không cache-by-extension). Kiểm cấu hình CDN trực tiếp.

5. Kiểm allowlist cache (lớp 2) — xác nhận cấu hình CDN chỉ cache các prefix tĩnh đã biết (/_next/static, /assets), mọi thứ khác mặc định không cache. Đây là kiểm cấu hình, không phải test code.

HTTPCanary hai bước: mở trang riêng với đuôi giả bằng cookie, rồi đọc lại KHÔNG cookie.
### Web cache deception — kỹ thuật canary (httpyac/REST Client hoặc script)###### Ngược với cache poisoning: ở đây ta kiểm cache có LƯU một trang RIÊNG TƯ và phục### vụ nó cho người KHÔNG có cookie hay không. Hai bước, và bước 2 mới chứng minh lỗ hổng. ### Bước 1 · đăng nhập (tài khoản TEST) và mở trang riêng với đuôi tĩnh giảGET https://staging.example.com/account/profile.css HTTP/1.1Cookie: session={{TEST_SESSION}} ### Kỳ vọng SAU KHI VÁ: 404 (server không phục vụ /profile.css như /profile).### Nếu 200 và chứa email của tài khoản test → server bỏ qua .css (điều kiện 1, khối 3). ### Bước 2 · mở CÙNG URL, KHÔNG cookie — đây là request của "kẻ tấn công"GET https://staging.example.com/account/profile.css HTTP/1.1 ### Kỳ vọng SAU KHI VÁ: KHÔNG chứa email của tài khoản test (cache không lưu trang riêng).### Nếu CHỨA → cache đã lưu trang riêng của tài khoản test và phục vụ nó cho một request### không đăng nhập. Đây là cache deception đã xảy ra — dữ liệu riêng đọc được không cần cookie. ### Bước 3 · thử các biến thể URL (bảng khối 3: không chỉ .css)GET https://staging.example.com/account/profile/x HTTP/1.1Cookie: session={{TEST_SESSION}} GET https://staging.example.com/account/profile;.css HTTP/1.1Cookie: session={{TEST_SESSION}} ### Bước 4 · kiểm response trang riêng có Cache-Control đúngGET https://staging.example.com/account/profile HTTP/1.1Cookie: session={{TEST_SESSION}} ### Kỳ vọng: header Cache-Control chứa "no-store" hoặc "private". Nhưng lưu ý: header### đúng ở app KHÔNG đủ nếu CDN cache-by-extension ghi đè nó — bản vá thật ở cấu hình### cache (xem tab yaml), và các request này là cách CHỨNG MINH nó đúng.
08

Sai lầm thường gặp

"Bản vá"Vì sao không đúng
Đặt Cache-Control: private ở app rồi coi là xongCache-by-extension GHI ĐÈ nó ở nhiều CDN. Cache phải được cấu hình tôn trọng Cache-Control
Blocklist đuôi .css/.js trên trang độngCòn path thừa, ;, null byte, và các cách parse URL khác (bảng khối 3). Allowlist prefix tĩnh
Cache theo phần mở rộng URLĐây là nguyên nhân. Cache theo Content-Type thật và Cache-Control của app
Server phục vụ /profile.css như /profileĐiều kiện 1 của khối 3. Router phải khớp path CHÍNH XÁC → /profile.css là 404
Chỉ vá ở appBản vá chính ở CẤU HÌNH CACHE. App đặt no-store là nửa phía app, cache tôn trọng là nửa phía cache
Coi nó là cache poisoningNgược chiều: poisoning GHI bản độc vào cache công khai; deception ĐỌC bản riêng ra khỏi cache

Sai lầm về mô hình, và nó là sai lầm chính: nghĩ Cache-Control: private ở app là đủ. Nhiều CDN cache theo phần mở rộng và ghi đè header đó — nên bản vá phải ở cấu hình cache: tôn trọng Cache-Control, cache theo Content-Type, và allowlist prefix tĩnh.

Sai lầm về vị trí: tìm bản vá trong code. Như request-smuggling và web-cache-poisoning, đây là một lỗi của sự BẤT ĐỒNG giữa hai thành phần hạ tầng (cache và server) về "URL này là gì", nên bản vá sống ở nơi giải quyết bất đồng đó — cấu hình cache và định tuyến server, không phải một handler.

09

Nguồn tham khảo

Bậc 1A01:2021 – Broken Access Control · OWASP · Top 10 · 2021
Bậc 2Web cache deception · PortSwigger · Web Security Academy
Bậc 2HTTP Caching · MDN · HTTP guide · 2025
Bậc 3Web Cache Deception Attack · Omer Gil (Black Hat USA 2017)
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…