Web 缓存欺骗防御实战:Nginx 缓存规则加固

适用场景

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.confhttp 块或站点 server 块);
  • 准备两个不同账号的登录态 Cookie,用于复现与验证;
  • 可选:curl 7.0 以上、Python 3.8 以上,用于批量探测脚本。

原理说明

缓存欺骗成立的三个必要条件:

  1. 后端容错:应用服务器、框架路由或 pathinfo 解析忽略 URL 中的多余后缀,仍将请求交给原有动态处理器;
  2. 缓存判定脱节:缓存层仅依据 URL 模式(后缀、目录前缀)判断可缓存性,而不检查响应的 Cache-ControlSet-CookieVary
  3. 无合理缓存键隔离:缓存键未包含 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 纳入日志与告警,因为这类问题不会在攻击时产生明显异常流量,只有缓存状态字段能把真相记录下来。