虽然今年国赛没打,但是题还是看了的。听说 web 很难?学弟打的 AWDP,打一天 结果一个 break/fix 都没出。
rng-assistant 这道题很有意思。它用一个模拟的 Ollama 服务器,配合 Nginx 反向代理和 Redis 缓存,搭建了一个简单的 LLM 应用 API 服务环境。题目中包含了大模型应用中可能出现的各类真实漏洞,可以看出出题人一定有挖掘实战大模型应用漏洞的经验。
先看源码:
入口点启动了 Redis、Nginx 和 Python 的 Flask 后端,同时把 flag 加载注入到了主程序的变量中。也就是说,题目的目标是通过某些方式实现程序变量的泄漏。
#!/bin/bash | |
echo "FLAG='$FLAG'" > /app/flag.py | |
service redis-server start | |
redis-cli config set save "" | |
python3 /app/mini-ollama/default.py & | |
python3 /app/mini-ollama/math-v1.py & | |
gunicorn --workers 1 --user=www-data --bind 127.0.0.1:8000 app:app & | |
nginx | |
while true; do | |
sleep 1 | |
done |
nginx 的配置文件:
map $remote_addr $user_role { | |
default "guest"; | |
"127.0.0.1" "admin"; | |
} | |
server { | |
listen 80; | |
proxy_set_header X-User-Role $user_role; | |
location /static/public/ { | |
root /app; | |
} | |
location /admin/ { | |
proxy_pass http://localhost:8000; | |
proxy_set_header X-Secret "210317a2ee916063014c57d879b9d3bc"; | |
} | |
location / { | |
proxy_pass http://localhost:8000; | |
} | |
} |
我们可以看到,Nginx 会根据请求来源(本地或外部)添加不同的请求头。
在实际的 SLB(服务器负载均衡)服务中,这是一种常见做法,用于标识用户的真实 IP 和身份。然而,对于一道 CTF 题目来说,单纯为了区分请求来源而添加 Nginx 层似乎有些多余。这暗示着出题人在此处设置了某个漏洞点,很可能会成为关键的突破口。
接下来让我们分析主程序中的潜在威胁点:
def get_template(template_id): | |
prompt_key = f"prompt:{template_id}" | |
prompt = redis_conn.get(prompt_key) | |
if not prompt: | |
template_path = join(PromptTemplate.PROMPT_DIR, | |
f"{template_id}.txt") | |
with open(template_path, "rb") as file: | |
prompt = file.read() | |
redis_conn.set(prompt_key, prompt) | |
prompt = prompt.decode(errors="ignore") | |
return prompt |
这段代码的逻辑是先在 Redis 中查找 prompt 模板的缓存,如果没有找到则从本地 txt 文件读取。虽然使用 join 拼接路径可能存在路径穿越风险,但由于有.txt 后缀的限制,且经过代码追踪发现 template_id 参数实际不可控,所以这里并不存在安全隐患。
@app.route("/admin/model_ports", methods=["POST", "PUT", "DELETE"]) | |
def manage_model_ports(): | |
if ( | |
"user" not in session | |
or request.headers.get("X-User-Role") != "admin" | |
or request.headers.get("X-Secret") != "210317a2ee916063014c57d879b9d3bc" | |
): | |
return jsonify({"error": "Access denied"}), 403 | |
data = request.json | |
model_id = data.get("model_id") | |
port = data.get("port") | |
if request.method in ["POST", "PUT"]: | |
if not model_id or not port: | |
return jsonify({"error": "Missing parameters"}), 400 | |
model_ports[model_id] = port | |
return jsonify({"message": "Update successful", "user": whoami(session['user'])}) | |
elif request.method == "DELETE": | |
if not model_id: | |
return jsonify({"error": "Missing model_id"}), 400 | |
if model_id in model_ports: | |
del model_ports[model_id] | |
return jsonify({"message": "Delete successful", "user": whoami(session['user'])}) |
这是一个用于编辑模型端口的管理员接口。它的权限控制机制包括:
- 需要有登录态
- 请求头中需要包含两个特定值
只有同时满足这些条件才能访问该 API。这两个请求头是由 nginx 自动添加的。那么,获取这个接口的访问权限后,我们能用新添加的端口做什么呢?
@app.route("/admin/raw_ask", methods=["POST", "PUT", "DELETE"]) | |
def manage_ask(): | |
if ( | |
"user" not in session | |
or request.headers.get("X-User-Role") != "admin" | |
or request.headers.get("X-Secret") != "210317a2ee916063014c57d879b9d3bc" | |
): | |
return jsonify({"error": "Access denied"}), 403 | |
data = request.json | |
model_id = data.get("model_id", "default") | |
custom_prompt = data.get("prompt") | |
final_prompt = custom_prompt | |
response = query_model(final_prompt, model_id) | |
return jsonify({"answer": response, "user": whoami(session['user'])}) |
可以看到如果使用 admin 权限,可以向指定模型端口发送 raw_prompt
def query_model(prompt, model_id="default"): | |
cache_key = f"{md5(prompt.encode()).hexdigest()}:{model_id}" | |
cached = redis_conn.get(cache_key) | |
if cached: | |
return cached.decode() | |
try: | |
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: | |
s.connect(("127.0.0.1", get_model_port(model_id))) | |
s.sendall(prompt.encode("utf-8")) | |
response = s.recv(4096).decode("utf-8") | |
redis_conn.setex(cache_key, 3600, response) # Cache for 1 hour | |
return response | |
except Exception as e: | |
return f"Model service error: {str(e)}" |
这意味着我们可以直接打开一个 TCP socket,实现 SSRF 请求任意数据,并且能看到响应内容。
发现有 SSRF 和 Redis,这个组合很熟悉啊!我们可以尝试利用 Redis 进行攻击,甚至可能实现 RCE。这是一条很有希望的攻击链路。
让我们继续查找 Redis 在题目中的其他应用点。回顾代码可以发现,系统在获取 prompt template 时也会先在 Redis 中查找缓存。
def get_prompt(self, template_id): | |
return PromptTemplate.get_template(template_id).format(t=self) |
一旦找到缓存,系统会用 format 方法填充 prompt 模板,然后继续处理请求。
虽然 format 方法是固定的,但由于 prompt 模板可控,我们可以利用 SSTI(服务器端模板注入)攻击。通过 self 对象的构造链,可以获取到 globals 对象。由于 FLAG 变量在程序启动时就被导入,它必然存在于 globals 对象中。
至此,完整的攻击链已经形成。首先利用 nginx 配置中的一个错误:
https://groups.google.com/g/openresty/c/Cpf2Kk81tDc?pli=1
location 块中的 proxy_set_header 配置会覆盖全局配置,导致全局的 proxy_set_header 设置失效。
利用这一特性可以伪造本地身份,然后通过端口添加 API 将 Redis 服务加入系统,再利用 raw_ask 进行 SSRF 攻击,最终向 Redis 中注入恶意的 prompt 模板。
set prompt:math-v1 "{t.__init__.__globals__} " |
实现了对渲染模板的控制,从而在渲染的时候完成 SSTI 攻击
正好在请求 ask 的时候还会返回完整的渲染后 prompt,也就实现了 ssti 的回显。
实际的攻击展示:
注册账户并登录:
获取 cookie 后添加到请求头中,携带特殊请求头进行添加模型端口,将 redis 的端口添加到端口列表
这时候就能通过 ssrf 控制 redis 了,先看一下 info:
redis6.0.16,这个版本是修复过的 导致 luaCVE 打不了,因此就直接写恶意模板吧
写入模板后去新建一个简单的 ask 请求
这样就成功泄露了 flag 变量。
如果要说这个题目还有改进空间的话,可能就是添加模型端点引发的 SSRF 攻击不够贴近真实场景。如果是我出题,会设计成一个网页访问或知识库检索的 tool call 功能,在访问过程中触发 SSRF - 这是一种更常见的漏洞出现方式。
不过这道题目的难度已经相当高了。在线下断网环境做这道题压力很大 - 光是代码审计到打通整个流程我就花了将近两小时。总的来说,这确实是一道设计优秀的题目。