虽然今年国赛没打,但是题还是看了的。听说 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 配置中的一个错误:

image.png

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 攻击

image.png

正好在请求 ask 的时候还会返回完整的渲染后 prompt,也就实现了 ssti 的回显。

实际的攻击展示:

注册账户并登录:

image.png

获取 cookie 后添加到请求头中,携带特殊请求头进行添加模型端口,将 redis 的端口添加到端口列表

image.png

这时候就能通过 ssrf 控制 redis 了,先看一下 info:

image.png

redis6.0.16,这个版本是修复过的 导致 luaCVE 打不了,因此就直接写恶意模板吧

image.png

写入模板后去新建一个简单的 ask 请求

image.png

这样就成功泄露了 flag 变量。

如果要说这个题目还有改进空间的话,可能就是添加模型端点引发的 SSRF 攻击不够贴近真实场景。如果是我出题,会设计成一个网页访问或知识库检索的 tool call 功能,在访问过程中触发 SSRF - 这是一种更常见的漏洞出现方式。

不过这道题目的难度已经相当高了。在线下断网环境做这道题压力很大 - 光是代码审计到打通整个流程我就花了将近两小时。总的来说,这确实是一道设计优秀的题目。