Lidando com um dos piores problemas de cybersegurança: executar códigos de terceiros
Anteriormente, fiz um pitch aqui sobre uma plataforma educacional que estou construindo. Uma parte central do sistema consiste em receber códigos escritos por alunos, executá-los e devolver o resultado ao professor.
No começo, isso parecia uma funcionalidade relativamente simples:
- receber o código;
- salvá-lo em um arquivo temporário;
- executar o interpretador ou compilador;
- devolver
stdout,stderre o código de saída.
Um exec.Command e problema resolvido, certo?
Não exatamente.
O que deveria ser apenas mais uma funcionalidade do projeto rapidamente se tornou um problema de segurança e infraestrutura.
O código não precisa ser sofisticado para derrubar o servidor
Quando você executa código de terceiros, precisa partir do princípio de que esse código é hostil.
Mesmo sem explorar qualquer vulnerabilidade avançada, um programa pode:
- entrar em um loop infinito;
- consumir toda a memória disponível;
- criar milhares de processos;
- gerar gigabytes de saída;
- tentar ler variáveis de ambiente;
- acessar arquivos do sistema;
- fazer requisições para serviços internos;
- consultar endpoints de metadata da nuvem;
- tentar executar outros programas instalados na máquina.
O problema deixa de ser apenas “como executar Python ou C?” e passa a ser:
Como permitir que alguém execute instruções arbitrárias sem permitir que essas instruções controlem o restante da infraestrutura?
A resposta honesta é que não existe execução de código completamente segura.
O que existe é uma combinação de isolamento, redução de privilégios, limites de recursos, monitoramento e diminuição do impacto caso alguma camada falhe.
Como esse problema costuma ser resolvido
Existem projetos consolidados, como Judge0 e Piston, que oferecem APIs para compilação e execução de código em diversas linguagens.
São soluções válidas e muito mais maduras do que qualquer implementação criada durante um side project.
Eu mesmo comecei integrando uma dessas soluções.
O problema não era exatamente a qualidade delas. O problema era o encaixe com o que eu precisava construir.
Eu queria um componente pequeno, isolado do restante da aplicação e com uma fronteira de segurança que eu conseguisse compreender e auditar. Também precisava controlar detalhes como:
- quais linguagens estariam disponíveis;
- quais arquivos poderiam ser enviados;
- quais limites seriam aplicados;
- como compilação e execução seriam separadas;
- quais syscalls seriam permitidas;
- como erros de infraestrutura seriam diferenciados de erros do código;
- como o sistema se comportaria quando estivesse sobrecarregado.
Além disso, descobri uma restrição operacional importante: mecanismos fortes de sandboxing no Linux dependem de recursos que muitas plataformas gerenciadas bloqueiam.
Namespaces de usuário, criação de novos namespaces e delegação de cgroups frequentemente não estão disponíveis em serviços PaaS convencionais. O backend poderia continuar hospedado em uma plataforma gerenciada, mas o executor precisaria rodar em uma VPS na qual eu tivesse controle sobre o sistema operacional.
Nesse momento, percebi que o runner não seria apenas uma biblioteca dentro do backend. Ele precisava ser um serviço separado.
O que é sandboxing?
Sandboxing consiste em executar um programa dentro de um ambiente restrito, no qual ele consegue acessar apenas os recursos necessários para funcionar.
Isso não significa apenas colocar o programa dentro de um container Docker.
Containers melhoram o isolamento operacional, mas ainda compartilham o kernel do host. Para código realmente não confiável, é necessário adicionar outras camadas de contenção.
No runner que construí, cada execução acontece dentro de uma jail criada pelo nsjail, uma ferramenta de isolamento para processos Linux.
Cada programa recebe seu próprio conjunto de namespaces:
- mount namespace, para controlar o sistema de arquivos visível;
- PID namespace, para isolar os processos;
- network namespace, para remover o acesso à rede;
- user namespace, para executar com identidade e privilégios isolados;
- IPC namespace, para impedir comunicação indevida entre processos.
O filesystem é montado como somente leitura. A área de trabalho da execução é descartável, e as escritas ficam limitadas a um pequeno tmpfs.
O processo também roda como um usuário sem privilégios e com no_new_privs, impedindo que adquira novos privilégios durante a execução.
Seccomp: reduzindo o que o programa pode pedir ao kernel
Mesmo isolado, o programa continua fazendo chamadas ao kernel por meio de syscalls.
Por isso, o runner também utiliza seccomp.
Syscalls relacionadas a comportamentos perigosos, como manipulação de namespaces, montagem de filesystems, tracing de processos, carregamento de módulos do kernel e acesso a determinadas interfaces de baixo nível, podem ser bloqueadas.
Nos programas compilados estaticamente, é possível ir ainda mais longe.
A compilação ocorre em uma jail própria. Depois disso, o artefato é transferido para uma segunda jail, mais restrita, que não contém compiladores, interpretadores, bibliotecas ou outros executáveis.
Nesse ambiente, uma allowlist pode permitir somente o conjunto mínimo de syscalls necessário para o programa funcionar. Qualquer syscall fora dessa lista encerra o processo.
Essa separação também é importante porque o próprio compilador está processando uma entrada hostil. Um código C++ com templates recursivos ou includes abusivos pode atacar a etapa de compilação antes mesmo de o programa ser executado.
Por isso, compilação e execução possuem limites e sandboxes independentes.
cgroup v2: limitando memória e processos
Namespaces controlam o que o processo enxerga. Seccomp controla o que ele pode pedir ao kernel. Mas ainda precisamos limitar quanto ele pode consumir.
Para isso, cada execução recebe seu próprio cgroup v2.
O cgroup define limites como:
- quantidade máxima de memória;
- quantidade máxima de processos;
- contabilização de consumo;
- identificação de encerramentos provocados pelo OOM killer.
Isso é especialmente importante para runtimes como Node.js, Go e Java.
Algumas máquinas virtuais reservam grandes espaços de memória virtual durante a inicialização. Aplicar apenas um limite tradicional de espaço de endereçamento, como RLIMIT_AS, pode impedir que o runtime inicie ou produzir resultados pouco confiáveis.
Com memory.max, o kernel aplica um limite real de memória residente ao grupo de processos daquela execução.
Se um programa tentar alocar memória indefinidamente, apenas aquela execução é encerrada. O runner consegue consultar os eventos do cgroup e classificar o resultado como memory_exceeded, em vez de tentar descobrir o que aconteceu analisando mensagens de erro.
Outros limites necessários
Memória não é o único recurso que pode ser abusado.
Cada execução também possui:
- limite de tempo total;
- limite de CPU;
- limite de processos;
- limite para arquivos;
- limite de saída em
stdoutestderr; - limite para tamanho do código e da entrada;
- limite de execuções simultâneas.
Se um programa começar a imprimir dados infinitamente, ele é encerrado ao atingir o limite de saída.
Se entrar em loop infinito, o grupo inteiro de processos é morto ao atingir o timeout.
Se o serviço já estiver executando a quantidade máxima de programas, novas requisições recebem imediatamente uma resposta de sobrecarga. Isso evita que uma fila ilimitada consuma toda a memória do servidor.
Sem acesso à rede
Cada execução recebe um network namespace vazio.
O programa não consegue acessar a internet, outros containers, o backend da plataforma ou serviços internos da nuvem.
Isso reduz ataques como:
- varredura da rede interna;
- comunicação com servidores externos;
- download de payloads;
- acesso a bancos de dados;
- consultas a endpoints de metadata;
- exfiltração de informações.
Bloquear apenas a internet não seria suficiente. O isolamento precisa impedir qualquer tráfego, inclusive para endereços privados e para a rede do próprio deploy.
Falhar de maneira segura
Uma das decisões mais importantes foi fazer o serviço falhar fechado.
Em produção, se o nsjail não conseguir criar a sandbox, o runner não inicia.
Ele não deve pensar:
“Não consegui ativar o isolamento, então vou executar normalmente.”
A resposta correta é:
“Não consigo garantir o isolamento, portanto não vou executar código algum.”
O endpoint de readiness também verifica se a contenção esperada está realmente ativa. Dessa forma, um container que iniciou sem a postura de segurança necessária é retirado de circulação.
O que o runner faz atualmente
O serviço recebe código por meio de uma API HTTP e devolve um resultado estruturado com:
- status da execução;
- saída padrão;
- saída de erro;
- código de saída;
- sinal recebido;
- duração;
- consumo de memória;
- informações do runtime;
- resultado da compilação, quando aplicável.
Atualmente, ele suporta Python, JavaScript, TypeScript, Lua, SQL, C, C++, Go, Rust, Java e C#.
Também há suporte para projetos com múltiplos arquivos, dentro de regras restritas.
Os caminhos enviados são validados, arquivos como package.json, go.mod e scripts de instalação são bloqueados, e nenhum gerenciador de pacotes é executado.
Isso é proposital.
Permitir que uma submissão rode npm install, pip install, cargo ou scripts arbitrários de build ampliaria consideravelmente a superfície de ataque e tornaria as execuções menos determinísticas.
Nos testes em lote, o programa pode ser compilado uma única vez e executado contra diferentes entradas. Cada entrada recebe uma jail nova, evitando que uma execução contamine a seguinte.
Como testar um sistema desses?
Testes tradicionais não são suficientes.
Além dos testes unitários, o projeto possui um conjunto de submissões adversariais que tenta:
- iniciar uma fork bomb;
- ler variáveis de ambiente;
- acessar arquivos do host;
- executar syscalls bloqueadas;
- consumir toda a memória;
- entrar em loop infinito;
- gerar saída sem parar;
- acessar a internet;
- consultar metadata da nuvem;
- escrever fora do diretório permitido.
O objetivo não é apenas verificar se o ataque foi interrompido. O runner também precisa continuar saudável depois dele.
Isso torna a execução completamente segura?
Não.
O runner ainda compartilha o kernel Linux com o host.
Uma vulnerabilidade desconhecida no kernel, no nsjail, no runtime, no compilador ou em alguma outra parte da cadeia pode permitir um escape.
Uma arquitetura baseada em microVMs, como Firecracker, adicionaria uma fronteira de isolamento mais forte ao colocar cada execução dentro de uma máquina virtual mínima, com seu próprio kernel.
Essa ainda não é a arquitetura do projeto.
Também existem diferenças entre as garantias oferecidas para programas estáticos e para runtimes dinâmicos. Uma allowlist mínima de syscalls é mais forte do que uma denylist compartilhada, mas nem sempre é viável para máquinas virtuais complexas.
Portanto, o projeto não deve ser apresentado como “impossível de invadir”.
A proposta é outra: aplicar defesa em profundidade, reduzir privilégios, limitar recursos e diminuir o impacto caso uma das camadas seja contornada.
Por que tornar isso público?
Durante o desenvolvimento, percebi que esse problema aparece em diversas plataformas:
- sistemas educacionais;
- juízes online;
- processos seletivos;
- ambientes de programação;
- ferramentas de automação;
- plataformas que executam plugins ou scripts de usuários.
Tornei o repositório público tanto para documentar o que aprendi quanto para permitir que outras pessoas analisem as decisões, encontrem falhas e proponham melhorias.
Segurança por obscuridade não tornaria esse componente mais seguro.
O código, a documentação de deploy, as decisões de arquitetura e os testes adversariais estão disponíveis aqui:
dalivim-runner
P.S: Se você se perguntar por que os últimos testes falharam é porquê o serviço de CI do Github é pago e esses testes não são baratos porquê precisam de uma VM linux, foi me cobrado aproximadamente $9 dólares por todas requisições de teste até hoje, mas confirmo que está seguro.