
Laboratório Docker ético e isolado em rede reproduzindo CVE-2026-26030 — Semantic Kernel in-memory vector store filter eval() RCE (corrigido na versão 1.39.4)
eval() do Semantic Kernel com RCE (laboratório)Um laboratório autocontido reproduzindo CVE-2026-26030: execução remota de código injetável por prompt através do filtro de busca da loja vetorial em memória no Microsoft Semantic Kernel (Python, < 1.39.4).
Apenas laboratório ético. Isolado em virtualenvs dedicados; payloads são inofensivos (
touchum arquivo marcador no PoC headless;open -a Calculatorna demo de UI) e executados como seu próprio usuário não privilegiado.
./setup.sh # builds two isolated venvs (1.39.3 vulnerable, 1.39.4 patched)
Requer python3.13 (wheels para as dependências numpy/scipy do SK). Substitua com PYTHON=.
./run.sh # runs the same payload against both venvs
A saída vulnerável termina com RCE CONFIRMED; a saída corrigida rejeita o mesmo payload com '__subclasses__' ... não é permitido.
OpsBot, um agente interno de base de conhecimento de engenharia (Llama hospedado na Groq via Semantic Kernel), expõe uma ferramenta search_runbooks(team). Um invasor injeta por prompt um valor team malicioso; o agente chama a ferramenta, o filtro vulnerável executa, e uma "nota de resgate" abre no TextEdit — enquanto o agente relata resultados de runbooks, alheio. O payload (open -e <note>) é não bloqueante e inofensivo; a nota é pré-colocada em /tmp/PWNED_by_CVE-2026-26030.txt.
A ferramenta executa o eval genuíno do filtro vulnerável em um subprocesso limpo (run_filter.py). Isso é uma necessidade do macOS, não uma trapaça: o próprio fork() do servidor é envenenado pelas threads assíncronas LLM/httpx + numpy/scipy, então um lançamento de GUI a partir desse processo silenciosamente não faz nada. O subprocesso é exatamente o caminho de código do CVE (_parse_and_validate_filter → executa o lambda em um registro).
Nota: a separação em subprocesso limpo é uma peculiaridade deste harness de demonstração macOS, não da vulnerabilidade. Em um agente implantado no Linux, o
os.systemno processo dispara diretamente — nenhum subprocesso é necessário.
echo 'GROQ_API_KEY=gsk_...' > .env # free key from console.groq.com/keys
./demo-ui/run.sh # http://127.0.0.1:8000
A mensagem do invasor está pré-carregada na caixa de entrada; pressione Enviar.
Um agente expõe uma ferramenta de busca baseada em InMemoryCollection. O LLM emite uma string de expressão de filtro da conversa (lambda x: x.team == 'platform'). Essa string é influenciável pelo invasor — através do prompt do usuário, ou através de texto injetado em conteúdo recuperado/ferramenta — e chega em _parse_and_validate_filter, que a compile() e eval() (connectors/in_memory.py:383):
code = compile(tree, filename="<filter>", mode="eval")
func = eval(code, {"__builtins__": {}}, {}) # nosec
__builtins__ é esvaziado, e uma lista de permissões AST é aplicada primeiro — então isso é uma burla de sandbox, não uma guarda ausente. Duas lacunas a tornam contornável:
ast.Attribute é irrestrito — sem lista de bloqueio de dunder, então o caminhamento de dunder ().__class__.__base__.__subclasses__ é permitido.ast.Call só inspeciona func quando é um Name ou Attribute. Quando func é um Subscript (que está na lista de permissões), func_name permanece None e a verificação de função permitida é completamente ignorada.Então, embrulhar qualquer chamável como [obj.method][0](https://github.com/inertfluid/sk-cve-2026-26030-lab/blob/HEAD/args) chama qualquer coisa. Encadeado:
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')
O func de cada chamada é um Subscript; cada passo de travessia é acesso de atributo simples. O índice (107) é a posição de BuiltinImporter em object.__subclasses__() — varia conforme a build do Python, então exploit.py e a demo o calculam em tempo de execução.
O patch adiciona uma lista de bloqueio de atributos perigosos sobre a lista de permissões, então o caminhamento de dunder é rejeitado antes do eval:
Access to attribute '__subclasses__' is not allowed in filter expressions.
This attribute could be used to escape the filter sandbox.
A afirmação mais clara possível da tese de segurança de agentes: não há fronteira entre "dados" e "instruções". Um filtro que o modelo escreve a partir de uma consulta de runbook do usuário se torna os.system. A sandbox existia — uma lista de permissões e __builtins__ esvaziado — e ainda assim caiu para uma burla de travessia de atributo + chamada subscript. Hierarquia de mitigação que vale a pena fazer no post: não use eval na saída do modelo; se precisar, restrinja a uma gramática fechada, não a uma lista de permissões de nós com acesso aberto a atributos; e isole o worker (seccomp / sem rede / não privilegiado) para que execução de código não seja fim de jogo.
| file | purpose |
|---|
setup.sh | constrói os dois venvs isolados (vulnerável + corrigido) |
run.sh | PoC headless em ambos os venvs |
exploit.py | fim a fim: coleção real + filtro de busca -> RCE |
find_sink.py | localiza o sink eval/compile no pacote instalado |
probe.py | confirmação mínima apenas do validador da burla |
demo-ui/app.py | FastAPI + SK + agente Groq; a ferramenta vulnerável search_runbooks |
demo-ui/run_filter.py | executa o eval genuíno do filtro em um subprocesso limpo (o fork() do servidor está envenenado) |
demo-ui/index.html | UI de chat; abre uma nota de resgate no TextEdit ao RCE |