导语:缓存命中了恶意内容,所有用户一起遭殃
企业把静态资源交给 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. 总结
| 环节 | 关键动作 |
|---|---|
| 原理 | 键与内容失配才产生投毒 |
| 入口 | 未入键的请求头、查询参数、路径伪装 |
| 升级 | 请求走私绕代理 + 缓存存储型扩散 |
| 防御 | 白名单缓存、键规范化、剥离透传头 |
| 检测 | 唯一标记回显 + 缓存命中对比 |
一句话记住:缓存把性能放大,也把危害放大——凡是"会被共享、又由不可信输入决定"的响应,都不该进缓存。把缓存当作"又一个信任边界"来加固,才能避免一次投毒、全网遭殃。
延伸阅读
- DDoS 防护与 WAF 实战 — CDN/WAF 边缘防护层的缓存与流量治理
- XSS 与 CSRF 防御实战 — 缓存投毒载荷多落地为存储型 XSS
- SSRF 与开放重定向 — 请求头污染与 URL 信任边界问题同源
- API 安全设计 — 缓存键与请求规范化在 API 层的实践
- 威胁建模与评审 — 把缓存作为资产纳入威胁建模
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。