适用场景
Web 缓存欺骗防御(Web Cache Deception)适用于所有在 Nginx、Varnish 或 CDN 之上开启了页面缓存的站点,尤其是同时具备「动态用户页面」与「静态资源缓存规则」的电商、SaaS、后台管理系统与会员中心。当缓存系统按 URL 后缀或路径规则决定是否缓存,而后端框架按路由前缀返回内容时,攻击者只需在他人可访问的私有页面地址后拼接静态资源后缀,即可让缓存系统把包含用户隐私数据的响应体写入公共缓存区。
典型受影响的 URL 组合如下:
- 后端路由
/user/profile返回个人信息,缓存规则却按.css / .js / .png判定为可缓存对象; - 攻击者诱导受害者访问
/user/profile/avatar.png,后端忽略多余路径段仍返回个人资料页; - 缓存节点把该响应以公共对象存储,攻击者随后直接请求同一 URL,无需登录即读取受害者的邮箱、手机号、收货地址乃至订单列表。
与缓存投毒不同,Web 缓存欺骗不需要污染源站,只依赖「缓存键与后端路由不一致」这一配置缺陷,因此更隐蔽、更难通过日志告警发现。
前置条件
- Nginx 1.19.3 以上版本(需要
proxy_cookie_flags等较新指令),CDN 侧需具备自定义缓存规则与「遵循源站 Cache-Control」开关; - 站点已开启反向代理或 CDN,且缓存配置中使用了基于扩展名或路径的缓存规则;
- 具备源站 Nginx 配置修改权限(nginx.conf 的
http块或站点server块); - 准备两个不同账号的登录态 Cookie,用于复现与验证;
- 可选:
curl7.0 以上、Python 3.8 以上,用于批量探测脚本。
原理说明
缓存欺骗成立的三个必要条件:
- 后端容错:应用服务器、框架路由或
pathinfo解析忽略 URL 中的多余后缀,仍将请求交给原有动态处理器; - 缓存判定脱节:缓存层仅依据 URL 模式(后缀、目录前缀)判断可缓存性,而不检查响应的
Cache-Control、Set-Cookie与Vary; - 无合理缓存键隔离:缓存键未包含 Cookie 或用户标识,导致 A 用户的响应被 B 用户命中。
防御的核心思路是打破其中任意一条。工程上最有效且代价最低的是第二条:由源站显式声明「带会话身份的响应不可被公共缓存」,并让缓存层强制尊重该声明。
操作步骤
第一步:复现并确认是否可被欺骗
使用已登录的 Cookie 请求带静态后缀的动态地址,观察响应头中的缓存状态与缓存指令:
# 1. 用登录态请求动态页面(正常路径)
curl -sI -H "Cookie: PHPSESSID=REPLACE_ME" https://www.example.com/user/profile | grep -Ei "cache-control|set-cookie|x-cache"
# 2. 追加静态后缀再请求一次,对比响应体与缓存状态
curl -s -H "Cookie: PHPSESSID=REPLACE_ME" https://www.example.com/user/profile/avatar.png -o step2.html
grep -Eo "邮箱|手机号|订单|logout" step2.html | sort -u
# 3. 去掉 Cookie 直接访问,若仍能取回上一步内容,则确认存在缓存欺骗
curl -s -H "Cookie: " https://www.example.com/user/profile/avatar.png -o step3.html
diff <(md5sum < step2.html) <(md5sum < step3.html) && echo "!!! 命中公共缓存,存在缓存欺骗"
若第 3 步内容与第 2 步一致,说明该响应已被公共缓存,需立即按后续步骤加固。
第二步:Nginx 侧禁止缓存带身份信息的响应
在 http 块中定义缓存跳过判定,把「请求携带任何 Cookie」「响应写入 Set-Cookie」「请求 URI 命中敏感前缀」三类情形统一纳入禁用缓存的名单:
http {
# 依据请求与响应特征决定是否跳过缓存
map $http_cookie $skip_cache_cookie {
default 1;
"" 0; # 无 Cookie 的匿名请求才允许缓存
}
map $upstream_http_set_cookie $skip_cache_setcookie {
default 1; # 响应下发 Set-Cookie,一律不缓存
"" 0;
}
map $request_uri $skip_cache_path {
default 0;
~*^/(user|account|member|api|cart|order|admin) 1;
}
map "$skip_cache_cookie$skip_cache_setcookie$skip_cache_path" $skip_cache {
default 1;
"000" 0; # 三者均为 0 时才允许缓存
}
server {
listen 443 ssl;
server_name www.example.com;
proxy_cache_path /var/cache/nginx/site levels=1:2 keys_zone=SITE:100m
max_size=10g inactive=60m use_temp_path=off;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_cache SITE;
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache_valid 200 302 10m;
proxy_cache_valid 404 1m;
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache;
# 关键:把命中状态暴露出来,便于验证与监控
add_header X-Cache-Status $upstream_cache_status always;
proxy_hide_header Cache-Control;
add_header Cache-Control "no-store, private" always;
}
# 静态资源白名单:仅这些前缀允许按扩展名缓存
location ~* ^/(static|assets|uploads)/.*\.(png|jpg|jpeg|gif|webp|css|js|woff2)$ {
proxy_pass http://127.0.0.1:8080;
proxy_cache SITE;
proxy_cache_valid 200 7d;
proxy_cache_bypass $skip_cache_cookie;
proxy_no_cache $skip_cache_cookie;
add_header X-Cache-Status $upstream_cache_status always;
}
}
}
其中三个要点:$skip_cache 为 "000" 才命中缓存;proxy_no_cache 与 proxy_cache_bypass 必须成对配置,前者阻止写入、后者阻止读取;X-Cache-Status 是后续验证与告警的唯一依据。
第三步:修正路由层的后缀容错
若框架存在「忽略多余路径段」的行为,应在入口处显式拒绝,避免动态路由匹配带静态后缀的 URL:
# Nginx 入口拦截:动态前缀后不允许出现静态扩展名
location ~* ^/(user|account|member|order)/.*\.(png|jpg|gif|css|js|ico|txt|json)$ {
return 404;
}
# 若应用依赖 PATH_INFO,务必关闭对该类前缀的 PATH_INFO 传递
location ^~ /user/ {
proxy_pass http://127.0.0.1:8080;
proxy_set_header X-Original-URI $request_uri;
fastcgi_split_path_info ^(.+\.php)(/.*)$; # 仅在确需 PATH_INFO 的 PHP 入口使用
}
验证配置语法并平滑重载:
nginx -t && nginx -s reload
第四步:CDN 侧收紧缓存规则
- 缓存规则只匹配静态目录前缀,例如
/static/*、/assets/*,禁止使用「所有 .css/.js/.png」这类全局扩展名规则; - 开启 遵循源站 Cache-Control,让第二步下发的
no-store, private生效; - 关闭或谨慎使用「忽略查询字符串」选项,避免
?_=1之类的变体互相命中; - 对
/user、/account、/api等路径显式设置 不缓存,优先级高于扩展名规则; - 若 CDN 支持,开启响应头校验:一旦源站返回
Set-Cookie,不得写入边缘缓存。
配置验证
# 1. 匿名静态资源:应命中缓存
curl -sI https://www.example.com/static/logo.png | grep -i x-cache-status
# 期望:X-Cache-Status: HIT(第二次请求)
# 2. 携带 Cookie 的动态页:必须绕过缓存
curl -sI -H "Cookie: PHPSESSID=REPLACE_ME" https://www.example.com/user/profile | grep -iE "x-cache-status|cache-control"
# 期望:X-Cache-Status: BYPASS,Cache-Control: no-store, private
# 3. 带静态后缀的动态页:必须 404 或 BYPASS,且绝不写入缓存
curl -sI -H "Cookie: PHPSESSID=REPLACE_ME" https://www.example.com/user/profile/avatar.png | grep -iE "HTTP/|x-cache-status"
# 4. 去掉 Cookie 复查是否残留缓存对象
curl -sI https://www.example.com/user/profile/avatar.png | grep -i x-cache-status
# 期望:MISS 或 BYPASS,绝不能是 HIT
批量回归可用如下脚本,将站点敏感路径与静态后缀做笛卡尔积探测,任何出现 HIT 的组合都需要立即整改:
import itertools, subprocess
base = "https://www.example.com"
paths = ["/user/profile", "/account/settings", "/order/list", "/api/me"]
suffix = [".css", ".js", ".png", ".ico", ".txt"]
for p, s in itertools.product(paths, suffix):
url = base + p + s
out = subprocess.run(["curl", "-sI", "-H", "Cookie: ", url],
capture_output=True, text=True).stdout
for line in out.splitlines():
if line.lower().startswith("x-cache-status"):
print(url, line.strip())
线上还应将 X-Cache-Status: HIT 与「响应体包含身份字段」的组合纳入日志告警:可在 Nginx 日志格式中追加 $upstream_cache_status,再在日志平台对敏感路径的 HIT 计数设阈值。
常见问题(FAQ)
Q1:加上 Vary: Cookie 是不是就安全了?
不充分。Vary: Cookie 只是让缓存键包含 Cookie,理论上可隔离用户,但会带来两个问题:一是几乎所有 CDN 对 Cookie 维度的缓存键支持有限,部分厂商直接忽略 Vary;二是会话 Cookie 每次登录都会变化,缓存命中率会跌至接近零,等于放弃缓存收益。正确做法仍是以 no-store + 路径白名单为主,Vary 仅作为静态资源按语言、编码区分的补充手段。
Q2:为什么明明把 Cache-Control: private 下发了,CDN 还在缓存?
多数 CDN 的默认策略是「按扩展名判定优先级高于源站头」,只有当缓存规则显式勾选「遵循源站缓存策略」时,源站头才会生效。整改顺序应为:先在 CDN 控制台确认缓存规则与优先级,再验证源站头是否被 proxy_hide_header 等指令隐藏,最后用 curl -I 核对经 CDN 返回的响应头是否与源站一致。
Q3:静态资源本身也被缓存欺骗影响吗?
纯静态文件不含隐私数据,风险有限,但仍需注意两类衍生问题:一是静态目录内混放用户上传文件(如 /uploads/{user_id}/),此时应按用户维度隔离并设置 Cache-Control: private;二是攻击者可利用后缀混淆把动态 404 页面缓存为公共对象,形成内容替换与钓鱼面。建议上传目录单独关闭缓存或加签名校验。
总结
Web 缓存欺骗的本质是缓存判定与后端路由的语义偏差,防御不依赖复杂规则,而依赖三条硬约束:带身份的响应永不写入公共缓存、缓存规则只匹配明确白名单的静态前缀、动态路由拒绝携带静态后缀的请求。落地后务必把 X-Cache-Status 纳入日志与告警,因为这类问题不会在攻击时产生明显异常流量,只有缓存状态字段能把真相记录下来。