Web 缓存投毒:被忽略的 CDN 攻击面

系统讲解 Web 缓存投毒(Cache Poisoning)与缓存欺骗(Cache Deception):CDN 与反向代理的缓存原理、缓存键设计缺陷、请求头污染、URL 规范化差异、请求走私与缓存投毒的组合利用,并给出缓存配置加固、缓存键规范与检测方案。

导语:缓存命中了恶意内容,所有用户一起遭殃

企业把静态资源交给 CDN,把动态响应交给反向代理缓存,性能与成本都得到优化。但缓存是共享的:同一份缓存条目会被成千上万个用户复用。如果攻击者能让"一份恶意响应"被缓存,就等于让 CDN 替他把恶意内容分发给每一个访问者。

一句话总结: Web 缓存投毒 = 让一个本不该被缓存的、携带攻击内容的响应进入共享缓存,一次投毒,全网扩散;它把"对单个请求的攻击"放大成"对所有缓存消费者的攻击"。


1. 缓存命中与缓存键组成

1.1 缓存的角色

层代表缓存什么特点
浏览器缓存浏览器本地静态资源单用户隔离,无扩散风险
反向代理Nginx/Varnish应用响应服务器本地,单节点
CDN 边缘Cloudflare/阿里云 CDN边缘节点响应分布式,扩散范围最大

1.2 缓存键 Cache Key 怎么生成

缓存键 = 决定"哪些请求共用一个缓存条目"的参数集合

默认缓存键通常包含:
  · 请求方法(GET/POST)
  · 完整 URL(路径 + 查询字符串)
  · Host 头
  · 部分请求头(如 Accept-Encoding、Accept-Language)

Vary 头的作用:告诉缓存"除了 URL,还要按哪个响应头区分条目"
  Vary: Accept-Encoding   → 压缩与未压缩的响应分开缓存
  Vary: Cookie            → 不同用户的响应分开缓存(若错误使用会击穿缓存)

1.3 一个正常缓存流程

① 用户 A 请求 /static/app.js
② 缓存未命中 → 回源到应用 → 得到 200 与 Cache-Control: public, max-age=3600
③ 响应被缓存,键 = GET /static/app.js
④ 用户 B 请求相同键 → 缓存命中,直接返回用户 A 的响应副本

关键点:任何"没有进入缓存键、却影响响应内容"的输入,
        都是投毒的候选入口(Unkeyed Input)。

一句话总结: 缓存把"相同键"的请求合并成一个条目复用;凡是影响响应内容、又不参与键计算的输入,都可能在某个用户身上产生一个可被全体复用的恶意条目。


2. 缓存键设计缺陷与不可信输入

2.1 典型缺陷一 查询字符串直接拼进响应

缺陷代码(示意):

  GET /search?q=foo
  → 响应头 Cache-Control: public, max-age=300
  → 响应体 <h1>搜索:foo</h1>

攻击:
  GET /search?q=<script>alert(document.cookie)</script>
  → 同样的 Cache-Control 允许缓存
  → 恶意脚本作为缓存条目被存储
  → 后续所有访问 /search?q=任意值 的用户拿到 XSS 载荷
缺陷类型示例后果
动态内容可缓存搜索/分页参数拼 HTML存储型 XSS 级扩散
缓存键未含身份信息用户资料页被缓存信息泄露、越权读取
未规范化 URL大小写/编码差异被忽略绕过防护或注入载荷

2.2 关键原则 缓存键必须与响应内容一一对应

安全原则:
  ① 只缓存"内容完全由缓存键决定"的响应
  ② 动态片段(用户信息、CSRF Token、个性化内容)要么不进响应,
     要么用 Cache-Control: no-store / private 明确排除
  ③ 校验响应头:只允许白名单 Cache-Control,杜绝应用层随手 public

一句话总结: 缓存投毒的根因是键与内容失配——应用把用户可控数据放进响应,又允许该响应进共享缓存;先确认"这个响应值不值得缓存",再谈缓存多久。


3. 请求头污染与未进键的输入

3.1 可被利用的请求头

请求头典型用法被忽略的风险
X-Forwarded-Host反向代理传递原始 Host拼接进页面链接/资源 URL
X-Original-URL重写 URL影响路径、可绕过路径校验
X-Rewrite-URL同上同上
X-Forwarded-Proto传递 http/https影响 scheme 拼接
X-Forwarded-For记录客户端 IP拼接日志/页脚,少数会进响应

3.2 一个经典的 Host 头投毒场景

攻击者发送:

  GET /index.html
  Host: example.com
  X-Forwarded-Host: evil.com

后端信任 X-Forwarded-Host 并把它拼进页面资源 URL:

  <script src="https://evil.com/static/main.js"></script>

若 /index.html 允许缓存,恶意响应进入共享缓存:

  → 所有用户访问 /index.html,浏览器都会从 evil.com 拉取脚本
  → CDN 边缘节点把"植入了远程脚本的首页"分发给全站访客

3.3 为什么这类头常被信任

反向代理架构中,X-Forwarded-Host 等头默认由代理设置;
应用误以为"既然经过了代理,头一定可信"。

实际上:
  · CDN/代理若允许客户端透传该头,客户端即可任意指定
  · 应当只在代理与源站之间"重新生成"这些头,而非原样转发
  · 白名单化:只接受代理签名后的头,或剥离全部客户端传入值

一句话总结: 请求头污染利用了"代理头被无脑信任"的假设——凡是客户端能伪造、又能影响响应内容的头,都必须由可信代理重新赋值,绝不能透传。


4. Web Cache Deception 缓存欺骗

4.1 与投毒的区别

攻击对象思路
缓存投毒攻击者可控的恶意响应把恶意内容注入缓存
缓存欺骗受害者自己的敏感响应诱导缓存存储"本不该存"的私密页

4.2 用静态后缀伪装动态页

目标站:/myaccount/profile   返回用户个人资料(含敏感信息)

攻击者构造 URL:
  /myaccount/profile/foo.css

服务器按路径映射,仍返回 profile 内容;
而缓存规则按"扩展名"判断——.css 是静态资源,允许 public 缓存:

  /myaccount/profile/foo.css → 200 + Cache-Control: public

攻击者把该链接发给受害者,受害者登录后访问;
边缘缓存存储了"受害者的个人资料页";
攻击者再访问同一 URL → 缓存命中 → 读到受害者数据。

4.3 为什么难以察觉

受害者看到的是正常的个人资料页,URL 尾部多了一个 .css 后缀;
浏览器按 text/html 渲染,一切正常;
攻击者在缓存键上做文章,无需受害者做任何可疑操作。

变体:
  · 路径分隔符混淆:/profile/;/foo.css
  · 编码混淆:/profile%2ffoo.css
  · 空路径段:/profile//foo.css
  · 文件名伪装:/profile/..;/foo.css 等

一句话总结: Cache Deception 不需要投毒——攻击者只是"借"缓存把受害者自己的私密响应存下来再取走;根因是缓存判定"该不该缓存"的依据(扩展名)与应用路由的依据(路径)不一致。


5. 请求走私与缓存投毒的组合利用

5.1 CL.TE 前端与后端解析不一致

请求走私利用"代理与后端对消息边界的理解不同"。

CL.TE 示意(代理按 Content-Length,后端按 Transfer-Encoding):

POST / HTTP/1.1
Host: example.com
Content-Length: 4
Transfer-Encoding: chunked

60

GPOST /search?q=alert(1) HTTP/1.1
Host: example.com
Content-Length: 0

0

代理:只看到第一个请求(Content-Length: 4 → 只消费 "60\r\n")
后端:按 chunked 解析,把后续字节当作"走私进来的第二个请求"

5.2 走私乘缓存等于存储型投毒

攻击步骤:
  ① 走私一个"只对后端可见"的请求:POST /search?q=<script>...
  ② 该请求绕过了代理的 WAF/校验(代理根本没看到它)
  ③ 后端处理并返回可缓存的响应
  ④ 代理把这条响应存入缓存(键 = 走私请求的 URL)
  ⑤ 后续正常用户请求命中该条目 → 拿到恶意载荷

相比普通投毒的优势:
  · 恶意请求对代理完全隐形,WAF 拦不到
  · 不必依赖"未进键的头/参数",直接利用协议层差异
走私变体条件组合危害
CL.TE前端只认 Content-Length绕过 WAF + 投毒
TE.CL后端只认 Content-Length同上
TE.TE混淆多个 Transfer-Encoding同上

一句话总结: 请求走私让投毒"升维"——恶意请求从未在代理层出现,却把恶意响应留在代理的缓存里;检测缓存投毒时,必须同时排查前后端消息边界不一致。


6. 检测与利用场景

6.1 判断当前响应是否命中缓存

辅助标志:
  · CDN 响应头:CF-Cache-Status、X-Cache(HIT/MISS)、Age
  · 自定义头:X-Proxy-Cache: HIT / MISS
  · 两次请求对比:第二次是否仍返回同一版本内容

探测是否可缓存:
  GET /assets/app.js
  → 记录响应头 Cache-Control、Age
  → 换一个 User-Agent 再请求 → 观察 X-Cache 是否仍 HIT

6.2 探测 Unkeyed Input 的通用思路

① 找一个可缓存的页面(静态资源或带 public 的动态页)
② 修改某个请求头(如 X-Forwarded-Host: test123.example)
③ 观察响应中是否回显该值(搜 test123 字符串)
④ 若回显:尝试替换为恶意值,确认缓存键未包含该头
⑤ 让代理缓存后,用"干净请求"复现,验证扩散性

工具化思路(伪代码):
  遍历候选头/参数列表 → 注入唯一标记 → 比对响应回显 → 标记可投毒点

一句话总结: 检测投毒点的核心是找"没进键却影响内容"的输入:注入唯一标记、看回显、再验证缓存扩散——三步即可筛出大部分 Unkeyed Input。


7. 防御与加固实践

7.1 缓存键与缓存策略清单

措施落地
规范化缓存键对 URL 做大小写、编码、路径段规范化后再入键
只缓存白名单路径静态资源/CDN 专属路径才允许 public
动态页禁用缓存响应带 Cache-Control: no-store 或 private
头白名单仅缓存键声明过的头参与条目区分
剥离透传头源站忽略或覆盖 X-Forwarded-Host 等客户端头
统一消息边界前端与后端使用相同的 Content-Length/TE 处理策略
版本参数入键若用 ?v= 做资源版本,确保 v 进缓存键

7.2 反向代理层缓存加固配置

# Nginx 反向代理缓存示例(示意)

# 只有特定路径允许缓存
location ~ ^/(assets|static|images)/ {
    proxy_cache cache_zone;
    proxy_cache_valid 200 1h;
    proxy_set_header X-Forwarded-Host "";          # 剥离不可信头
    proxy_set_header X-Original-URL "";            # 剥离
    proxy_cache_key $scheme$request_method$host$uri$is_args$args;
}

# 动态页面一律不缓存
location / {
    proxy_cache off;
    add_header Cache-Control "no-store";
}

7.3 源站侧兜底

· 输出安全:动态内容即使误缓存,也按 XSS 标准做输出编码(纵深防御)
· Host 校验:后端只接受白名单域名,非白名单 Host 直接 400
· 统一响应头:由框架统一输出 Cache-Control,应用层禁止私自 public
· 定期扫描:把"可缓存的动态页 + 未入键输入回显"加入自动化扫描

一句话总结: 缓存安全三原则——白名单化(只缓存明确允许的路径)、键规范化(键与内容严格对应)、头清理(透传头一律剥离);后端输出编码作为兜底,误缓存也不至于直接 RCE 级扩散。


8. 总结

环节关键动作
原理键与内容失配才产生投毒
入口未入键的请求头、查询参数、路径伪装
升级请求走私绕代理 + 缓存存储型扩散
防御白名单缓存、键规范化、剥离透传头
检测唯一标记回显 + 缓存命中对比

一句话记住:缓存把性能放大,也把危害放大——凡是"会被共享、又由不可信输入决定"的响应,都不该进缓存。把缓存当作"又一个信任边界"来加固,才能避免一次投毒、全网遭殃。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「安全」更多文章

  1. 暴力破解防御与 MFA 加固
  2. 邮件安全与钓鱼防护实战
  3. 勒索软件防御与应急恢复