Hardening de Serviços systemd no Linux: systemd-analyze, SystemCallFilter e Sandboxing em 2026

Hardening completo de serviços systemd com systemd-analyze security, sandboxing seccomp, cgroups v2, DynamicUser e drop-ins não-invasivos, com exemplos para RHEL 10, Ubuntu 24.04 e Debian 13.

Hardening systemd no Linux: Guia 2026

Atualizado: 12 de julho de 2026

Hardening de serviços systemd no Linux é o processo de restringir cada unit (.service) usando as diretivas de sandbox nativas do systemd (ProtectSystem, SystemCallFilter, CapabilityBoundingSet, DynamicUser e cgroups v2) para reduzir a superfície de ataque de um serviço comprometido. Na prática, isso significa transformar um daemon rodando como root com acesso total ao filesystem em um processo confinado, sem capabilities, com syscalls filtradas por seccomp e diretórios de estado efêmeros. A ferramenta systemd-analyze security quantifica o resultado numa escala de 0 (perfeito) a 10 (exposto), permitindo iterar sobre a unit até chegar em UNSAFEOKSAFE.

  • O comando systemd-analyze security <unit> (disponível desde systemd 240 e melhorado em 256/257) retorna uma pontuação de exposição de 0 a 10 e lista cada diretiva de hardening ausente.
  • ProtectSystem=strict, ProtectHome=yes e PrivateTmp=yes montam /usr, /boot, /etc e /home como read-only ou vazios com custo próximo de zero para a maioria dos daemons.
  • SystemCallFilter=@system-service combinado com SystemCallErrorNumber=EPERM aplica seccomp-bpf sem exigir uma allow-list customizada por serviço.
  • CapabilityBoundingSet= vazio remove todas as 40+ capabilities do kernel; adicione somente as necessárias (ex.: CAP_NET_BIND_SERVICE para bindar portas <1024).
  • DynamicUser=yes aloca um UID/GID efêmero por execução e integra StateDirectory=, CacheDirectory= e LogsDirectory= com montagens namespace privadas.
  • cgroups v2 (MemoryMax=, CPUQuota=, TasksMax=) é o mecanismo canônico em RHEL 10, Ubuntu 24.04 e Debian 13 para prevenir DoS local por consumo de recursos.

O que é systemd-analyze security e como interpretá-lo

O systemd-analyze security é a ferramenta de referência para medir o quão exposto um serviço systemd está. Ela varre todas as diretivas conhecidas de hardening da unit, atribui um peso a cada uma, e produz uma pontuação de exposição entre 0.0 (blindado) e 10.0 (irrestrito). Em minha experiência auditando estados regulados, é o primeiro comando que executo depois de instalar qualquer pacote de terceiros, antes mesmo de olhar o binário. Honestamente, um daemon vindo do fornecedor com pontuação 9.6 (típico de instaladores de agentes de EDR) já me diz que preciso escrever um drop-in antes de habilitá-lo em produção.

A saída lista cada diretiva, seu estado atual e o impacto no score. As faixas oficiais são: 0.0–0.9 SAFE, 1.0–2.9 OK, 3.0–4.9 MEDIUM, 5.0–7.9 EXPOSED, 8.0–10.0 UNSAFE. Para uma leitura completa das diretivas suportadas, consulte a página de manual systemd.exec(5), que documenta cada opção de sandbox exec.

# Instalar (já presente em RHEL 10 / Ubuntu 24.04 / Debian 13)
# Pontuar uma unit específica
systemd-analyze security nginx.service

# Auditar TODOS os serviços habilitados e ordenar por exposição
systemd-analyze security --no-pager | sort -k2 -rn | head -20

# Ver por que uma diretiva contribui para o score
systemd-analyze security --json=pretty sshd.service | jq '.[] | select(.exposure > 0)'

A saída padrão é uma tabela colorida com colunas NAME, DESCRIPTION, EXPOSURE e PREDICATE. As linhas em vermelho (0.5 ou mais) são as mudanças de maior impacto. Priorize corrigi-las nesta ordem: PrivateNetwork, SystemCallFilter, CapabilityBoundingSet, ProtectSystem. Cada uma dessas quatro derruba o score em aproximadamente 1.0–1.8 pontos e cobre a maior parte dos vetores de escape.

Sandbox básico: ProtectSystem, ProtectHome, PrivateTmp

Três diretivas resolvem cerca de 40% dos problemas de exposição sem exigir conhecimento sobre o serviço. Elas isolam o daemon do filesystem host usando namespaces mount, e devem estar presentes em toda unit customizada.

[Service]
# Monta /usr, /boot, /efi como read-only. 'strict' também protege /etc, /opt, /srv.
ProtectSystem=strict

# Torna /home, /root, /run/user inacessíveis (tmpfs vazio)
ProtectHome=yes

# Cria /tmp e /var/tmp privados em tmpfs, destruídos ao parar o serviço
PrivateTmp=yes

# Whitelist explícita de diretórios graváveis (obrigatório com ProtectSystem=strict)
ReadWritePaths=/var/lib/meu-servico /var/log/meu-servico

# Bloqueia escrita em /proc/sys, /sys, cgroups
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes

ProtectSystem=strict é mais agressivo que ProtectSystem=full: torna praticamente todo o filesystem read-only, exigindo que você declare cada caminho gravável em ReadWritePaths=. Isso força uma revisão explícita de quais arquivos o serviço realmente precisa modificar. Na prática, é o que separa uma unit "razoável" de uma "auditável". Em ambientes com políticas SELinux customizadas para RHEL 10, essas montagens namespace complementam os labels MCS/MLS; o namespace impede o acesso mesmo se a política SELinux for permissiva.

Para daemons que precisam gerenciar módulos de kernel ou tocar sysctls (raro, mas existe: firewalld, chronyd), mantenha ProtectKernelModules=yes mesmo assim e adicione CAP_SYS_MODULE ao CapabilityBoundingSet. A diretiva de namespace e a capability agem em camadas diferentes.

SystemCallFilter e filtragem seccomp por grupos

SystemCallFilter= instala um filtro seccomp-bpf no processo, bloqueando syscalls que não pertencem a uma allow-list. Escrever essa lista à mão é tedioso, já que o Linux tem mais de 400 syscalls e uma allow-list mal escrita causa SIGSYS em runtime. O systemd resolve isso com grupos pré-definidos prefixados com @.

Os grupos mais úteis para serviços comuns:

GrupoDescriçãoUso típico
@system-serviceSuperset seguro para daemons genéricosPonto de partida para 90% dos serviços
@network-iosocket, connect, sendmsg, recvmsgServidores HTTP, banco de dados
@file-systemopen, read, write, stat, unlinkPraticamente todos os serviços
@privilegedmount, reboot, setuid (geralmente NEGAR)Bloquear em quase todos os casos
@raw-ioioperm, iopl (acesso direto a hardware)NEGAR sempre exceto para X11/wayland
@obsoletesyscalls legadas (stime, ustat)NEGAR, vetor comum de exploits
@debugptrace, process_vm_readvNEGAR em produção

A receita canônica para um serviço de rede sem privilégios especiais:

[Service]
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete @debug @mount @raw-io @reboot @swap @cpu-emulation
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native

# Bloqueia execve() após o start (útil para daemons que não fazem fork+exec)
LockPersonality=yes
MemoryDenyWriteExecute=yes
RestrictRealtime=yes
RestrictSUIDSGID=yes

O prefixo ~ nega o grupo, mesmo que @system-service incluísse alguns membros. SystemCallErrorNumber=EPERM faz syscalls bloqueadas retornarem -EPERM em vez de matar o processo. Isso deixa bibliotecas mal-comportadas (Python, alguns bindings Go) reagirem graciosamente em vez de crashar. Consulte a página oficial de práticas de segurança do projeto systemd para a lista completa de grupos e sua evolução por versão.

MemoryDenyWriteExecute=yes bloqueia mprotect(PROT_EXEC) em páginas graváveis, um requisito de FIPS-mode que ganhei o hábito de aplicar em toda unit. Ele quebra alguns runtimes JIT (V8, LuaJIT, PyPy), então teste antes de habilitar em serviços Node.js ou Java com JIT ativo.

Restringir capabilities do kernel com CapabilityBoundingSet

Linux capabilities dividem o poder do UID 0 em ~40 privilégios granulares. Um daemon rodando como root com CAP_SYS_ADMIN pode montar filesystems, carregar módulos e ler qualquer arquivo, enquanto um processo com apenas CAP_NET_BIND_SERVICE pode fazer bind() em portas privilegiadas e nada mais. O CapabilityBoundingSet= define o teto: qualquer capability fora desse set é irrecuperavelmente descartada.

# Zero capabilities (obrigatório para serviços não-root)
CapabilityBoundingSet=
AmbientCapabilities=

# Ou: manter apenas CAP_NET_BIND_SERVICE (bindar 80/443 sem root)
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes

NoNewPrivileges=yes é o irmão essencial: impede que o processo ganhe capabilities via execve() de binários com setuid ou setcap. Sem essa diretiva, um serviço com boundingset restrito ainda pode disparar um su ou sudo e escapar. Combine sempre.

Para serviços que fazem bind() em portas <1024 mas rodam como usuário não-privilegiado, a receita é:

[Service]
User=nginx
Group=nginx
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
NoNewPrivileges=yes
# nginx pode agora bindar 80/443 sem UID 0

Uma alternativa moderna é usar setcap 'cap_net_bind_service=+ep' /usr/sbin/nginx no binário e omitir AmbientCapabilities=. Prefiro a versão via unit: ela sobrevive a atualizações de pacote sem exigir hooks pós-instalação.

DynamicUser, StateDirectory e diretórios efêmeros

DynamicUser=yes é uma das features mais subestimadas do systemd. Ele aloca dinamicamente um UID/GID no range 61184–65519, cria o usuário via NSS para a duração da execução, e o remove ao parar. Sem entrada em /etc/passwd, sem lixo em /etc/group, sem risco de reutilização acidental.

[Service]
DynamicUser=yes

# Cria /var/lib/meu-app com dono no UID dinâmico, persiste entre reinícios
StateDirectory=meu-app

# /var/cache/meu-app (limpável a qualquer momento)
CacheDirectory=meu-app

# /var/log/meu-app (journald ainda captura stdout/stderr)
LogsDirectory=meu-app

# /run/meu-app (apagado ao parar)
RuntimeDirectory=meu-app
RuntimeDirectoryMode=0700

O truque é que o systemd sabe que o UID muda entre execuções e mantém o dono dos StateDirectory=/LogsDirectory= sincronizado. Não há necessidade de chown em unit files. Este é o mecanismo que uso em roles Ansible para bastions com OpenSSH 10 e MFA onde queremos um agente auxiliar (telemetria, keepalive) rodando sem usuário estático provisionado.

DynamicUser= implica automaticamente PrivateTmp=yes, ProtectSystem=strict, ProtectHome=read-only, RemoveIPC=yes. Você ganha meia unit hardened de graça. A restrição principal: o serviço não pode manter arquivos com dono fixo fora dos diretórios listados. Se seu daemon grava em /opt/vendor/data, você precisa migrar para /var/lib/ ou aceitar UID estático.

Limites de recursos com cgroups v2

Desde 2023, todas as distros mainstream (RHEL 9+, Ubuntu 22.04+, Debian 12+) usam cgroups v2 unified hierarchy por padrão. O systemd é o único cgroup manager suportado, e as diretivas ficaram muito mais limpas em relação a v1.

[Service]
# Memória máxima (RSS + swap + tmpfs), OOM-kill acima disso
MemoryMax=512M
MemoryHigh=384M                # throttle antes do kill

# CPU em percentual (100% = 1 core inteiro)
CPUQuota=50%
CPUWeight=100                  # 100 = padrão; 200 = 2x prioridade

# Máximo de processos (PIDs)
TasksMax=128

# I/O (só funciona com BFQ scheduler em kernel 6.x)
IOWeight=100
IOReadBandwidthMax=/var/lib/meu-app 50M
IOWriteBandwidthMax=/var/lib/meu-app 20M

# Bloquear OOM killer para não matar o processo principal
OOMPolicy=stop                 # Alternativas: continue, kill
OOMScoreAdjust=500

Verifique os limites em runtime com systemd-cgls e systemd-cgtop. Para debugging de estouros de memória, journalctl -u <unit> --grep='memory' combinado com systemctl status mostra se o OOM killer disparou. Em minha experiência com deployments regulados, MemoryMax= é a diretiva mais eficaz contra DoS acidental: um worker Python com memory leak que consumiria 8 GB antes trava em 512 MB e reinicia sozinho.

Namespaces: PrivateNetwork, PrivateDevices, RestrictAddressFamilies

Namespaces Linux isolam recursos entre processos: PID, network, mount, UTS, IPC, cgroup, user, time. O systemd expõe cada um via diretivas dedicadas. Para daemons que não precisam de rede (jobs de backup, geradores de relatório, hooks), PrivateNetwork=yes é o kill-switch definitivo:

[Service]
# Sem interfaces exceto loopback isolado
PrivateNetwork=yes

# Sem acesso a /dev (apenas /dev/null, zero, random, tty, ptmx, urandom)
PrivateDevices=yes

# Sem visibilidade de outros processos (não roda ps)
ProtectProc=invisible
ProcSubset=pid

# Restringe famílias de socket disponíveis
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# Ou, para daemons sem rede: RestrictAddressFamilies=AF_UNIX

# Bloqueia mudanças de hostname e domainname
ProtectHostname=yes

# Filtra namespaces que o processo pode criar (bloqueia unshare)
RestrictNamespaces=yes

RestrictAddressFamilies= merece atenção especial. A maioria dos daemons só precisa de AF_INET, AF_INET6 e AF_UNIX. Bloquear AF_NETLINK, AF_PACKET, AF_RAW elimina uma classe inteira de exploits que dependem de sockets raw, um vetor de CVEs recentes em containers.

ProtectProc=invisible combinado com ProcSubset=pid torna /proc quase vazio: o processo vê apenas seus próprios PIDs e não pode enumerar outros. Isso complementa perfis AppArmor no Ubuntu 24.04 e Debian 12: o AppArmor confina o que o processo pode fazer, e o namespace decide o que ele pode ver.

Portable Services e drop-ins para hardening não-invasivo

Nem sempre você controla a unit original. Pacotes de terceiros (agentes de monitoramento, drivers de vendor) trazem seus próprios .service. Modificar diretamente esses arquivos é perdido na próxima atualização. A solução são drop-ins: arquivos em /etc/systemd/system/<unit>.d/*.conf que sobrescrevem seções específicas.

# /etc/systemd/system/vendor-agent.service.d/10-hardening.conf
[Service]
# Aplica sandbox sem tocar na unit do fornecedor
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
NoNewPrivileges=yes
CapabilityBoundingSet=
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
SystemCallErrorNumber=EPERM
MemoryMax=256M
CPUQuota=25%

# ReadWritePaths= é aditivo em drop-ins
ReadWritePaths=/var/lib/vendor-agent /var/log/vendor

Após criar o drop-in, execute systemctl daemon-reload, reinicie a unit e valide com systemd-analyze security vendor-agent.service. Se o score não baixou como esperado, use systemctl cat vendor-agent.service para ver a versão final "mergeada" da unit: drop-ins e a unit base compostas em uma única visão.

Portable Services (portablectl) vão um passo além: empacotam a unit e seu rootfs numa imagem raw ou dir, com todas as diretivas de hardening já aplicadas, e você faz attach na máquina. Para agentes de vendor problemáticos, é frequentemente mais limpo do que patchear drop-ins.

Exemplo completo: hardening de um serviço Node.js

Suponha um serviço Node.js Express rodando em /opt/api, ouvindo em 8080. A unit original é permissiva; queremos chegar em OK (score < 3.0). Este é o padrão que aplico em roles Ansible para credit unions europeias:

# /etc/systemd/system/api.service
[Unit]
Description=Internal API (Node.js)
After=network-online.target
Wants=network-online.target

[Service]
Type=exec
ExecStart=/usr/bin/node /opt/api/server.js
Environment=NODE_ENV=production PORT=8080
WorkingDirectory=/opt/api

# ----- Identidade -----
DynamicUser=yes
StateDirectory=api
LogsDirectory=api
RuntimeDirectory=api
RuntimeDirectoryMode=0700

# ----- Filesystem -----
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
ReadOnlyPaths=/opt/api
ReadWritePaths=/var/lib/api

# ----- Kernel & procfs -----
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectHostname=yes
ProtectProc=invisible
ProcSubset=pid

# ----- Capabilities -----
CapabilityBoundingSet=
AmbientCapabilities=
NoNewPrivileges=yes

# ----- Namespaces -----
PrivateDevices=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
LockPersonality=yes

# ----- Seccomp -----
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources @obsolete @debug @mount @raw-io @reboot @swap @cpu-emulation
SystemCallErrorNumber=EPERM
SystemCallArchitectures=native

# ----- Anti-exploitation -----
RestrictRealtime=yes
RestrictSUIDSGID=yes
RemoveIPC=yes
# ATENÇÃO: MemoryDenyWriteExecute=yes quebra V8 JIT, deixamos DESABILITADO
# MemoryDenyWriteExecute=yes

# ----- Recursos -----
MemoryMax=512M
MemoryHigh=384M
CPUQuota=100%
TasksMax=64
OOMPolicy=stop

# ----- Restart -----
Restart=on-failure
RestartSec=5s

[Install]
WantedBy=multi-user.target

Aplicando e medindo:

sudo systemctl daemon-reload
sudo systemctl restart api.service
sudo systemd-analyze security api.service

# Exemplo de saída resumida:
# → Overall exposure level for api.service: 1.6 OK

O score cai de aproximadamente 9.6 UNSAFE (unit original sem hardening) para 1.6 OK. Combine com uma auditoria periódica com Lynis e CIS Benchmarks para garantir que as unidades permanecem hardened após atualizações. Para o resto do estado (hosts, containers, rede), o mesmo princípio se aplica: quantifique com uma ferramenta objetiva, itere, reveja no próximo release.

Perguntas frequentes

Como fazer hardening de um serviço systemd sem quebrar o daemon?

Comece com um drop-in em /etc/systemd/system/<unit>.d/10-hardening.conf aplicando ProtectSystem=strict, ProtectHome=yes, PrivateTmp=yes, NoNewPrivileges=yes. Reinicie, monitore journalctl -u <unit> -p err -f por 24h, e só então adicione SystemCallFilter= e CapabilityBoundingSet=. Diretivas seccomp são as mais propensas a quebrar bibliotecas C exóticas.

O que significa a pontuação do systemd-analyze security?

É a soma ponderada das diretivas de hardening ausentes, numa escala de 0.0 a 10.0. As faixas são: SAFE (< 1.0), OK (1.0–2.9), MEDIUM (3.0–4.9), EXPOSED (5.0–7.9), UNSAFE (8.0+). Para serviços de produção, o alvo é OK ou melhor. Serviços root sem hardening tipicamente pontuam entre 9.0 e 9.8.

DynamicUser=yes é seguro em produção?

Sim, é uma feature madura desde systemd 232 (2016) e amplamente usada em RHEL, Ubuntu e Debian. O UID muda a cada execução mas o systemd mantém o dono dos StateDirectory=/LogsDirectory= sincronizado. A única restrição: o serviço não pode depender de arquivos com dono fixo fora dos diretórios gerenciados pelo systemd.

Qual a diferença entre ProtectSystem=full e ProtectSystem=strict?

full torna /usr e /boot read-only mas deixa /etc gravável. strict torna praticamente todo o filesystem read-only, incluindo /etc, /opt, /srv, e exige que você declare cada caminho gravável em ReadWritePaths=. Sempre prefira strict: a lista explícita de caminhos graváveis é um artefato de auditoria valioso.

SystemCallFilter quebra containers ou runtimes JIT?

Runtimes JIT (V8, LuaJIT, PyPy) são compatíveis com SystemCallFilter=@system-service, mas quebram com MemoryDenyWriteExecute=yes porque precisam de páginas W^X. Containers OCI (runc, crun) exigem @mount, @cpu-emulation e capabilities como CAP_SYS_ADMIN, ou seja, hardening agressivo geralmente incompatível. Para hosts container, aplique hardening no containerd/podman e não nos runtimes de containers individuais.

Sobre o Autor Mateusz Wojciechowski

Mateusz spent eight years on the Red Hat consulting bench before going independent in 2024, embedded with banks and telcos rolling out RHEL 8 and 9 across regulated estates. Most of that work was SELinux policy debugging, FIPS-mode enablement, and cleaning up the kind of sudoers files that grow organically over a decade. He holds OSCP and RHCE, and maintains a small set of Ansible roles for STIG-hardened RHEL builds that a few European credit unions now run in production. Before Red Hat he was a junior sysadmin at Allegro in Poznan, mostly babysitting Postfix and learning why you don't run updatedb on an NFS root. Mateusz writes about the boring half of Linux security: package signing, audit daemon tuning, and the unglamorous work of actually reading journalctl output before paging anyone.