Skip to content

Judge sandbox: cap resident memory with cgroups #86

Closed

Description

@matbrgz

Follow-up da #49, registrado no merge da #77.

Duas pontas ficaram em aberto quando o sandbox bwrap entrou:

1. Memória residente não tem teto real

O sandbox aplica ulimit -t (CPU), -f (tamanho de arquivo) e -u (processos), mas não ulimit -v: JVM, Go e Rust reservam faixas grandes de endereçamento virtual no startup e morreriam sob um teto de address space independentemente da memória que realmente tocam.

O resultado é que memória residente hoje depende só do flag {memory} por linguagem (-Xmx e similares) — o que não existe para C, C++, Rust, Go, Pascal. Um malloc em loop numa submissão em C não tem limite nenhum além da RAM da máquina.

O caminho certo é cgroup v2 por run (memory.max), não rlimit.

2. O safeexec está quebrado, com sandbox e sem

Isolado na imagem do juiz durante a #77:

safeexec DENTRO do bwrap  -> 137  "ERROR Operation not permitted"
bwrap SEM safeexec        ->   0  "8"
safeexec SEM bwrap        ->  16  "unable to exec python3"

A primeira linha tem explicação: o safeexec é setuid-root (chmod 4555 no Dockerfile.judge) e o bubblewrap monta tudo nosuid com no_new_privs por design, então a escalada falha com EPERM. Por isso a #77 tornou os dois mutuamente exclusivos.

A terceira linha é o problema: o safeexec falha em achar o interpretador mesmo sem sandbox nenhum. Isso é anterior à #77 e significa que, em qualquer implantação Docker que aponte AUTOJUDGE_SAFEEXEC_PATH para o binário real (o .env.docker aponta), o julgamento estava quebrado antes do sandbox existir.

Como o sandbox agora entrega isolamento e limites, o safeexec não tem mais função. Proposta: remover tools/safeexec.c, a compilação no Dockerfile.judge, wrapWithSafeExec() e a config safeexec_path — em vez de consertar um binário que nada mais usa.

Escopo sugerido

  • cgroup v2 por run com memory.max, e mapear o OOM kill para MLE em vez de RE genérico
  • remover o safeexec inteiro
  • estender tests/Integration/JudgeSandboxConfinementTest.php com um caso de estouro de memória, já que ele hoje afere tempo/disco/processos mas não memória

Activity

  1. matbrgz commented on Sep 13, 2026

    @matbrgz
    MemberAuthor

    A #96 remove o safeexec e o resto do modelo pré-sandbox. A metade de cgroups fica aberta, e aqui está o que eu medi para quem for pegar, em vez de deixar a pessoa começar do zero.

    O que dá e o que não dá hoje

    Na imagem do juiz, com --privileged:

    $ stat -fc %T /sys/fs/cgroup          # cgroup2fs
    $ cat /sys/fs/cgroup/cgroup.controllers
    cpuset cpu io memory hugetlb pids rdma
    

    O controlador memory está disponível. Mas:

    $ id
    uid=1000(judge) gid=1000(judge)
    $ ls -ld /sys/fs/cgroup
    drwxr-xr-x 2 root root
    $ mkdir /sys/fs/cgroup/probe
    mkdir: Permission denied
    

    O container roda como judge (o Dockerfile.judge tem USER judge) e a raiz do cgroup é do root. Tentando delegar como root no entrypoint:

    $ echo "+memory" > /sys/fs/cgroup/cgroup.subtree_control
    subtree_control failed
    

    Isso falha pela regra "no internal processes" do cgroup v2: não dá para habilitar controlador num cgroup que já tem processos membros. O caminho documentado é mover os processos existentes para um cgroup folha (/sys/fs/cgroup/init) antes de habilitar o controlador na raiz — e só então criar e delegar /sys/fs/cgroup/judge para o uid do juiz.

    Forma que isso teria

    Uma vez delegado, o limite por run não precisa de acesso a cgroup dentro do sandbox — basta o processo entrar no cgroup antes do exec:

    sh -c 'echo $$ > /sys/fs/cgroup/judge/run_N/cgroup.procs; exec bwrap ...'

    O sh externo se move, e o bwrap e todos os filhos herdam.

    Por que não fiz agora

    É mudança de plumbing de container, não de código PHP: mexe no entrypoint, na delegação, na dança de "no internal processes", e precisa de um caminho de fallback quando o cgroup não está disponível. Não consigo validar aqui que se comporta igual num host Linux de verdade, na VM do Docker Desktop e no runner do GitHub — e "funciona na minha máquina" é exatamente o que não serve para um limite de memória.

    O que fica valendo até lá

    Depois da #77 o sandbox aplica ulimit -t (CPU), -f (arquivo) e -u (processos). Memória residente depende só do flag {memory} por linguagem — que existe para Java e Kotlin (-Xmx) e não existe para C, C++, Rust, Go e Pascal. Um malloc em laço numa submissão em C não tem teto nenhum além da RAM da máquina.

    Sem ulimit -v de propósito: JVM, Go e Rust reservam faixas grandes de endereçamento virtual no startup e morreriam independentemente da memória que realmente tocam. Um teto de address space não é um teto de memória.

    Escopo que sobra

    • Delegação de cgroup v2 no entrypoint do Dockerfile.judge, com a dança de "no internal processes"
    • memory.max por run, e fallback explícito (falhar fechado? avisar?) quando o cgroup não estiver disponível
    • Mapear o OOM kill para MLE, não para RE genérico — hoje não há verdict de memória em lugar nenhum do caminho do juiz
    • Estender o JudgeSandboxConfinementTest com um caso de estouro de memória: ele hoje afere tempo, disco e processos, e não memória
  2. changed the title [-]Judge sandbox: cap resident memory with cgroups, and remove safeexec[/-] [+]Judge sandbox: cap resident memory with cgroups[/+] on Sep 13, 2026
  3. matbrgz commented on Sep 13, 2026

    @matbrgz
    MemberAuthor

    A #98 fechou a parte que dava para fechar sem mexer em como o container é implantado.

    O buraco era real e está medido: uma submissão em C alocando em laço passava de 4 GB e continuava. Isso valia para toda submissão em C, C++, Pascal e Rust — as linguagens que não têm placeholder {memory} no run_command.

    Agora essas ganham ulimit -v, e sob o teto o malloc falha em 240 MB e o equivalente em Python levanta MemoryError. A suíte de confinamento afere isso direto, que era o último item da lista desta issue.

    A lista de linguagens é medida, não presumida: JVM, Go e V8 se recusam a subir sob ulimit -v porque ele limita espaço de endereçamento, não memória residente.

    O que continua aberto aqui

    Duas coisas que só o cgroup resolve:

    1. Go, Node, TypeScript e C# seguem sem teto. As três primeiras reservam faixa virtual grande no startup; a quarta provavelmente igual. Dá para amenizar com flags de runtime (GOMEMLIMIT, --max-old-space-size, DOTNET_GCHeapHardLimit) em vez de cgroup — é mais barato e vale considerar antes.
    2. Não existe verdict MLE. Uma falha de alocação sob ulimit -v é indistinguível de qualquer outro erro de execução: vira RE genérico. Só um OOM kill por cgroup dá o sinal necessário para dizer "estourou memória" em vez de "seu programa quebrou".

    O caminho do cgroup e a medição de por que ele não é trivial neste container continuam no comentário anterior.

  4. matbrgz commented on Sep 13, 2026

    @matbrgz
    MemberAuthor

    Pesquisei isto com fontes primárias antes de continuar, e o resultado derruba duas conclusões que eu mesmo escrevi nesta issue. Registro tudo, inclusive o que eu errei.

    O que os juízes maduros fazem

    projeto mecanismo privilégio sinal de MLE
    isolate (IOI) cgroup v2 apenas, memory.max setuid root + subtree delegado por systemd memory.events → oom_kill
    DOMjudge ≥9 cgroup v2 via libcgroup. RLIMIT_AS explicitamente RLIM_INFINITY — "Memory limits should be unlimited, since we use cgroups" root no host, sudo -n; imagem oficial roda --privileged --cgroupns=host não tem. memory-bytes é só exibição; OOM vira run-error
    CMS delega ao isolate (--cg-mem) o que o isolate exigir; compose próprio usa privileged: true SG + cg-oom-killed → EXIT_MEM_LIMIT
    Judge0 fork antigo do isolate, cgroup v1 privileged: true e boot do host com systemd.unified_cgroup_hierarchy=0 no GRUB herda do isolate v1
    DMOJ nenhum cgroup. ptrace + setrlimit(RLIMIT_AS, limit + grace) nenhum além de usuário normal lê VmHWM de /proc/<pid>/status; is_mle = max_memory > limit

    O manual do isolate é categórico sobre container:

    "Running Isolate in containers is not recommended, since container managers usually do not delegate control groups properly. [...] If you still want to use containers, you are on your own and you probably have to make them privileged."

    Os dois erros meus

    1. "Não existe verdict MLE sem cgroup." Falso. O DMOJ emite MLE sem cgroup nenhum, comparando pico de RSS com o limite. É verdade para o DOMjudge — confirmei que nem no main atual ele tem verdict de memória — mas não para juízes em geral.

    2. "ulimit -v é inutilizável para JVM, Go e V8." A observação estava certa, a conclusão era forte demais. O DMOJ roda os três em produção com RLIMIT_AS — só que com folga, e parando de usar o rlimit como fonte do veredicto. A tabela de folga deles corrobora nossa própria medição de forma independente:

    linguagem folga sobre o limite
    padrão 64 MB
    Go 768 MB
    Java 768 MB (e RLIMIT_DATA desligado)
    Node 1024 MB
    C#/Mono 256 MB
    Python 128 MB

    Nossa medição de "V8 falha silenciosamente abaixo de ~1 GB" e o address_grace = 1048576 do DMOJ para Node são o mesmo número, obtidos de forma independente.

    Duas coisas que nem o cgroup resolve

    O memory.max não garante OOM kill. Manual do isolate:

    "If it happens during memory allocation, the allocation can fail or memory can be over-committed by the kernel. If it happens when handling a page fault, the whole process is killed by the OOM killer."

    Ou seja: o caminho do malloc() devolvendo NULL continua virando RE genérico mesmo no CMS, que só checa SG + cg-oom-killed. Cgroup compra um MLE quase sempre correto, não sempre.

    O cgroup não elimina o ajuste por linguagem. O DOMjudge, que usa cgroup, ainda assim fixa -Xms/-Xmx/-Xss e MEMJVM=65536 nos wrappers de Java.

    Sobre o nosso bloqueio de delegação

    A metade do "no internal processes" tem receita documentada — está no manual do kernel:

    "the cgroup must create children and transfer all its processes to the children before enabling controllers in its cgroup.subtree_control file"

    O isolate-cg-keeper.c é a implementação de referência, em 130 linhas: cria <cg>/daemon, move o próprio pid para lá, só então escreve +memory no subtree_control do pai.

    Mas a outra metade não tem saída: o Docker monta /sys/fs/cgroup read-only a menos que o container seja privilegiado, e o moby#46763 — "Ability to mount cgroupfs as read/write with --cgroupns=private" — está aberto desde 2023. --cgroupns=private sozinho não ajuda.

    As opções reais, em ordem decrescente de privilégio:

    • --privileged --cgroupns=host — o que todos os projetos citados de fato entregam;
    • -v /sys/fs/cgroup:/sys/fs/cgroup:rw sem privileged — funciona, mas expõe a árvore de cgroup do host inteiro ao container (DoS host-wide);
    • Podman é o único runtime com resposta de primeira classe (--systemd=always monta writable; rootless delega memory pids por padrão).

    Recomendação: copiar o DMOJ, não o isolate

    Concretamente, e sem nenhuma mudança de privilégio:

    1. ulimit -v para todas as linguagens, com folga por linguagem (a tabela do DMOJ é calibração pronta e testada em produção). O rlimit vira barreira de segurança, não fonte do veredicto.
    2. Medir o pico de RSS e emitir MLE se pico > limite, independente de como o processo morreu. Isso dá verdict MLE hoje, para toda linguagem incluindo Go.

    Já verifiquei na imagem do juiz que o /usr/bin/time -f '%M' (busybox) reporta 211016 KB para uma alocação de 200 MB. O instrumento existe.

    O custo, dito sem enfeite: ulimit -v com folga é teto mole — um programa Go pode tocar limite + 768 MB de address space antes de a gente perceber. Numa máquina julgando uma submissão por vez é chateação; com 8 julgamentos concorrentes é risco real de over-commit, e aí precisa de teto de concorrência ou um -m no container como rede. E ru_maxrss é máximo entre processos, não soma — o nosso ulimit -u é o que cobre isso, exatamente como no DMOJ.

    Vou implementar isso. Esta issue continua aberta só para o teto duro por cgroup, que agora tem um preço explícito: --privileged no container do juiz, ou expor a árvore de cgroup do host. Essa continua sendo decisão de vocês, mas deixou de ser bloqueante — o MLE e o teto para Go chegam sem ela.

    Fontes

    isolate(1) · isolate cg.c · isolate-cg-keeper.c · DOMjudge runguard.cc · CMS Sandbox.py · DMOJ _cptbox.pyx · DMOJ GO.py · DMOJ NODEJS.py · kernel cgroup-v2 · moby#46763 · Judge0 compose

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions