CVE-2026-22738: Spring AI SpEL Injection in SimpleVectorStore - REFLEX Analysis
CVE Overview
- CVE ID: CVE-2026-22738
- Component/Software: Spring AI
SimpleVectorStore(artifactorg.springframework.ai:spring-ai-vector-store) - Vulnerable Versions: 1.0.0 through 1.0.4 and 1.1.0 through 1.1.3
- Severity: 9.8 CRITICAL (CVSS 3.1, AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H)
- CWE: CWE-917 (Expression Language Injection)
- Impact: Remote code execution via SpEL injection in filter expression keys
- Date: Spring advisory published 26 March 2026 (NVD published 27 March 2026)
- Credit: Hyunwoo Kim (@V4bel) and Cantina's AppSec agent, Apex
- Safe Version: Upgrade to Spring AI 1.0.5 or later (1.0.x line) or 1.1.4 or later (1.1.x line)
Legacy System Support
For organisations unable to upgrade immediately, use the temporary mitigations in the Fortify section while you plan your upgrade path.
A classic injection bug inside a modern AI stack. While the industry worried about prompt injection, this CVE showed that expression-language injection is alive and well. It sits inside the vector store behind your RAG pipeline.
An attacker called "Kael" reads the Spring advisory on the day it is published. He searches GitHub for Spring AI projects that use SimpleVectorStore and finds a healthcare startup's patient knowledge base. Its search API lets the frontend pass a filter string, so users can narrow results by department. Kael sends a filter whose key closes the SpEL string and calls Runtime.exec(). The JVM runs his command. The pod's only egress rule allows HTTPS to anywhere, so a reverse shell over port 443 connects first time, and the cloud credentials in the environment go with it.
REFLEX Analysis
Reconnaissance
From an attacker's perspective:
Spring AI is young, and most applications using it were built in the last two years. Their developers share a lot. Conference talks, blog posts and sample repositories about "RAG with Spring AI" give attackers a map.
The first signal is the dependency. Public pom.xml and build.gradle files that pull in spring-ai-vector-store, or a spring-ai-starter-* module, flag a candidate. Searching code for SimpleVectorStore narrows it down. Many teams use it for prototypes before moving to PGvector or Milvus, and prototype code has a habit of reaching production.
Attackers then look for places where a filter can be influenced:
- Search endpoints that take a
filterparameter alongside the query. - Chat endpoints that pass a per-request filter to
QuestionAnswerAdvisor(qa_filter_expression) orVectorStoreDocumentRetriever(vector_store_filter_expression). - Pipelines where an LLM writes the filter as part of query augmentation. A prompt injection can then shape the filter string.
Error messages from failed filter parsing can confirm the stack and hint at the version.
Developer insight: The features you configure once and forget are the ones attackers study most carefully.
Evaluate
Vulnerability assessment:
CVE-2026-22738 is exploitable when three conditions align:
- The application uses
SimpleVectorStore, not an external vector database. - User-influenced input reaches a filter expression key, either through the Java API or a text filter string.
- The application runs 1.0.0 to 1.0.4 or 1.1.0 to 1.1.3.
Technical root cause: SimpleVectorStore turned filter expressions into SpEL and evaluated them against each document's metadata. Values were escaped. Keys were not. The converter's doKey() method stripped any outer quotes and spliced the key straight into a SpEL string:
// Spring AI 1.0.4, SimpleVectorStoreFilterExpressionConverter
protected void doKey(Filter.Key key, StringBuilder context) {
var identifier = hasOuterQuotes(key.key()) ? removeOuterQuotes(key.key()) : key.key();
context.append("#metadata['").append(identifier).append("']");
}
So the attacker must close the quote and bracket, inject an expression, then reopen them. A key of this shape does it:
x'] + T(java.lang.Runtime).getRuntime().exec('id') + #metadata['y
The generated SpEL becomes #metadata['x'] + T(java.lang.Runtime).getRuntime().exec('id') + #metadata['y'] == ..., and the command runs during evaluation.
There are two main ways user input reaches a key. The first is the programmatic API:
// VULNERABLE: the client chooses the filter field
@GetMapping("/search")
public List<Document> search(@RequestParam String query,
@RequestParam String field,
@RequestParam String value) {
var filter = new Filter.Expression(Filter.ExpressionType.EQ,
new Filter.Key(field), // injection point
new Filter.Value(value));
return vectorStore.similaritySearch(SearchRequest.builder()
.query(query)
.filterExpression(filter)
.build());
}
The second, and more common, is the text filter. Any filter string the client supplies or helps build is parsed into keys and values, so the attacker controls the key:
// VULNERABLE: a filter string from the request
SearchRequest.builder()
.query(query)
.filterExpression(request.filter()) // e.g. "department == 'cardiology'"
.build();
// VULNERABLE: the same string passed to a RAG advisor
chatClient.prompt()
.user(question)
.advisors(a -> a.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, request.filter()))
.call()
.content();
Scanners can flag the library version. Confirming exploitability means tracing how each application builds its filters. That is a code review job.
Developer insight: If user input ends up inside any expression language, you have an injection risk, however modern the framework around it.
Fortify
Prevention and hardening:
Upgrade first. Move to Spring AI 1.0.5 (1.0.x) or 1.1.4 (1.1.x). The fix removes SpEL from SimpleVectorStore filtering entirely. A new SimpleVectorStoreFilterExpressionEvaluator walks the filter tree and resolves keys with plain map lookups, so there is no expression language left to inject into.
If you cannot upgrade immediately:
- Never accept filter strings from clients. Build the filter on the server with
FilterExpressionBuilder, and take only values from the request. - Allowlist filter keys. If users choose a field, map their choice to a fixed set.
- Check what the LLM produces. If a model generates filters, parse the result and reject any key outside your allowlist before it reaches the vector store.
- Add a WAF rule as a stopgap. Flag
T(,getRuntime,ProcessBuilderand#metadata[in request parameters and bodies. Treat this as a tripwire, not a fix.
private static final Set<String> ALLOWED_KEYS = Set.of("department", "author", "status");
@GetMapping("/search")
public List<Document> search(@RequestParam String query,
@RequestParam String field,
@RequestParam String value) {
if (!ALLOWED_KEYS.contains(field)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported filter field");
}
var filter = new FilterExpressionBuilder().eq(field, value).build();
return vectorStore.similaritySearch(SearchRequest.builder()
.query(query)
.filterExpression(filter)
.build());
}
Long-term fortification: manage Spring AI versions through the spring-ai-bom. Let Dependabot or Renovate raise upgrade PRs. Fail CI on critical CVEs. Treat SimpleVectorStore as a development tool and use a dedicated vector database in production.
Developer insight: An allowlist of field names is a few lines of code. A breach response is months of work.
Limit
Damage containment:
Assume exploitation could happen before you upgrade. The question is how far an attacker can get once they run code in your JVM.
Network egress is the most useful control, but be honest about what it does. Most AI services need HTTPS to a model provider. A rule allowing port 443 to 0.0.0.0/0 still allows HTTPS exfiltration and reverse shells. Tighten it to the provider's address ranges where they publish them, or route outbound calls through an egress proxy that allowlists hostnames.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: ai-search-egress
spec:
podSelector:
matchLabels:
app: ai-search-service
policyTypes:
- Egress
egress:
# Egress proxy that allowlists the model provider's hostnames
- to:
- podSelector:
matchLabels:
app: egress-proxy
ports:
- protocol: TCP
port: 3128
# Cluster DNS: without this, name resolution fails
- to:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: kube-system
podSelector:
matchLabels:
k8s-app: kube-dns
ports:
- protocol: UDP
port: 53
- protocol: TCP
port: 53
Least privilege: the service account behind the AI service should read only the data it serves. Keep model API keys and database credentials in a secrets manager with narrow scopes, not in broad environment variables.
Container hardening: run as non-root with a read-only root filesystem, drop all capabilities, and apply the RuntimeDefault seccomp profile or an AppArmor profile. The Java Security Manager is not an option: it is permanently disabled from JDK 24 (JEP 486).
securityContext:
runAsNonRoot: true
readOnlyRootFilesystem: true
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]
seccompProfile:
type: RuntimeDefault
Service boundaries: run vector search as its own service. A compromise there should not reach the user database or payment systems.
Developer insight: Code execution is the start of an attack. Every boundary between the compromised service and your important data buys time.
Expose
Detection and visibility:
Legitimate filter keys are short identifiers like department. Attack payloads contain Java class references and SpEL syntax. That makes them easy to spot if you log them.
Log filter keys at the application layer. Filters often arrive in JSON bodies that access logs never record. Log the requested field and the built filter before the search runs:
log.info("vector search user={} field={} filter={}", user.getId(), field, filter);
Then search for injection markers:
grep -E "T\(java|getRuntime|ProcessBuilder|#metadata\[" /var/log/app/application.log
Watch the JVM's behaviour. Falco or Tetragon can flag a Java process spawning a shell or network tool:
- rule: Shell spawned by Java
desc: Detect a shell or network tool started by a Java application
condition: >
spawned_process and
proc.pname = "java" and
proc.name in (bash, sh, curl, wget, nc, python3)
output: >
Shell spawned from Java process
(user=%user.name command=%proc.cmdline container=%container.name
image=%container.image.repository)
priority: CRITICAL
Post-exploitation signals: connections blocked by your egress policy, DNS queries for domains outside your provider list, and new outbound connections from the vector search pods.
Developer insight: You can't detect what you don't log. Log the inputs to your AI pipeline with the same care as your login endpoint.
Exercise
Practice and preparedness:
Inventory drill: can you list every Spring AI application and its version in ten minutes?
# Find Spring AI usage across checked-out repositories
grep -rl --include=pom.xml --include='build.gradle*' "spring-ai" /path/to/repos
# Resolved versions for one Maven project
mvn dependency:tree -Dincludes=org.springframework.ai
Code review drill: find every place a filter is built and trace where its inputs come from.
grep -rnE "Filter\.Key|filterExpression\(|FILTER_EXPRESSION|FilterExpressionTextParser" \
--include="*.java" /path/to/project
Lab test: in a local sandbox on a vulnerable version, send a key that runs a harmless command such as touch /tmp/spel-canary. Confirm your logging, WAF rule and Falco rule all fire. Then upgrade and confirm the same request is rejected.
Team preparedness checklist:
- Can you produce a dependency tree for every Spring AI service in five minutes?
- Does CI fail the build on critical CVEs?
- Is every user-selectable filter key checked against an allowlist?
- Are LLM-generated filters validated before they reach the vector store?
- Do AI services have egress limited to named providers, not all of port 443?
- Are filter keys logged with user context?
What-if scenarios:
- Prompt-to-filter: an attacker uses prompt injection to make your query-augmentation step emit a malicious filter. Which control catches it?
- Credential reach: the vector search pod is compromised. Which secrets can it read, and how fast can you rotate them?
Developer insight: AI stacks inherit old bug classes. Teams that rehearse those will cope better with the new ones too.
Key Takeaways for Developers
Most important lesson: AI frameworks inherit every vulnerability class traditional frameworks have. SpEL injection in a vector store is expression-language injection, the same class of bug as SQL injection.
CRITICAL: Upgrade to Spring AI 1.0.5+ or 1.1.4+. This is the only complete fix for CVE-2026-22738.
Immediate actions:
- Upgrade: move every application to Spring AI 1.0.5 (1.0.x) or 1.1.4 (1.1.x).
- Audit filter construction: search for
Filter.Key,filterExpression(and the advisorFILTER_EXPRESSIONparameters, and trace their inputs. - Stop accepting filter strings from clients: build filters on the server with
FilterExpressionBuilderand allowlisted keys. - Validate LLM-generated filters before they reach the vector store.
- Tighten egress: route model API calls through an allowlisting proxy and allow cluster DNS explicitly.
- Scan dependencies in CI with Dependabot, OSV-Scanner or OWASP Dependency-Check.
Long-term practices:
- Review your RAG pipeline like your authentication code. It handles untrusted input.
- Keep
SimpleVectorStoreout of production. Use a dedicated vector database. - Isolate AI services with minimal privileges and narrow network access.
- Log AI pipeline inputs with user context.
Related patterns: Prompt Injection in AI Systems covers how model output can carry attacker input into tools like a filter builder. LLM Tooling Misuse covers agents and plugins acting on untrusted instructions. Kubernetes Misconfigurations covers the isolation and egress controls in the Limit section.