Share
## https://sploitus.com/exploit?id=0CD6EB69-616A-5F14-BC54-BAF18F35CE8E
# CVE-2026-26030 โ€” Semantic Kernel filter `eval()` RCE (lab)

A self-contained, network-isolated Docker lab reproducing **CVE-2026-26030**:
prompt-injectable remote code execution via the in-memory vector store search
filter in Microsoft Semantic Kernel (Python, ` Ethical lab only. Payload is harmless (`touch` a marker file), runs as a
> non-root user, with `--network none` so nothing can leave the container.

## Run

```bash
./run.sh          # builds 1.39.3 (vulnerable) and 1.39.4 (patched), runs both
```

Vulnerable output ends with `RCE CONFIRMED`; patched output rejects the same
payload with `'__subclasses__' ... is not allowed`.

## The vulnerability

An agent exposes a search tool backed by `InMemoryCollection`. The LLM emits a
**filter expression string** from the conversation (`lambda x: x.city == 'Paris'`).
That string is attacker-influenceable โ€” via the user prompt, or via injected
text in retrieved/tool content โ€” and lands in `_parse_and_validate_filter`,
which `compile()`s and `eval()`s it (`connectors/in_memory.py:383`):

```python
code = compile(tree, filename="", mode="eval")
func = eval(code, {"__builtins__": {}}, {})  # nosec
```

`__builtins__` is emptied, and an AST allowlist is applied first โ€” so this is a
**sandbox bypass**, not a missing guard. Two gaps make it bypassable:

1. **`ast.Attribute` access is unrestricted** โ€” no dunder blocklist, so
   `().__class__.__base__.__subclasses__` dunder-walking is allowed.
2. **The `ast.Call` name-check only inspects `func` when it is a `Name` or
   `Attribute`.** When `func` is a `Subscript` (which *is* allowlisted),
   `func_name` stays `None` and the allowed-function check is skipped entirely.

So wrapping any callable as `[obj.method][0](args)` calls anything. Chained:

```
object.__subclasses__()[i]  ->  BuiltinImporter.load_module('os')  ->  os.system(cmd)
```

## The payload

```python
lambda x: [[[().__class__.__base__.__subclasses__][0]()[107].load_module][0]('os').system][0]('touch /tmp/pwned_by_filter')
```

Every call's `func` is a `Subscript`; every traversal step is plain attribute
access. The index (`107`) is the position of `BuiltinImporter` in
`object.__subclasses__()` for this image โ€” `exploit.py` computes it at runtime.

## The fix (1.39.4)

The patch adds a **dangerous-attribute blocklist** on top of the allowlist, so
the dunder walk is rejected before `eval`:

```
Access to attribute '__subclasses__' is not allowed in filter expressions.
This attribute could be used to escape the filter sandbox.
```

## Files

| file | purpose |
|------|---------|
| `Dockerfile` | pins `semantic-kernel` (`--build-arg SK_VERSION=`), non-root user |
| `exploit.py` | end-to-end: real collection + search filter -> RCE |
| `find_sink.py` | locates the `eval`/`compile` sink in the installed package |
| `probe.py` | minimal validator-only confirmation of the bypass |
| `run.sh` | builds + runs vulnerable and patched side by side |

## Blog angle

The cleanest possible statement of the agent-security thesis: there is no
boundary between "data" and "instructions." A filter the model writes from a
user's hotel search becomes `os.system`. The sandbox existed โ€” an allowlist and
emptied `__builtins__` โ€” and still fell to an attribute-traversal + subscript-call
bypass. Mitigation hierarchy worth making in the post: don't `eval` model
output at all; if you must, restrict on a *closed* grammar, not a node allowlist
with open attribute access; and isolate the worker (seccomp / no network /
unprivileged) so code execution isn't game over.