Reduzir a superfície sem perder a operação
Num projeto fictício de modernização de uma plataforma de fundos, a pool de nodes contém ferramentas instaladas durante incidentes antigos. Algumas não têm responsável e um exportador legado continua à escuta. Antes de remover componentes, identifica finalidade, dependências, processo ativo, ativação futura e via de recuperação. A ausência de utilização visível durante uma hora não prova que um componente é dispensável num fecho mensal. Transforma a decisão numa mudança com responsável, janela, teste de negócio e critério de retorno. O objetivo é reduzir exposição conhecida mantendo as funções aprovadas. Distingue instalação, ativação e execução. systemctl disable não termina por si só um serviço ativo. A paragem também não demonstra que desapareceu uma origem de ativação, como uma unidade socket ainda necessária à análise. Revê as unidades e os endpoints efetivos. Numa pool gerida por imagem, compara a baseline com o estado real dos nodes e fecha as exceções de diagnóstico. A documentação de image mode da Red Hat ilustra este problema de deriva; não significa que todos os hosts do projeto usem esse produto. A investigação deve terminar com um inventário atualizado e uma forma de suporte que não dependa de alterações esquecidas.
Ler a identidade efetiva do processo
Uma configuração de hardening tem de corresponder ao processo que executa a aplicação. Regista UID, GID e capabilities efetivas, permitidas e bounding, em vez de concluir que tudo está limitado porque o utilizador tem um nome diferente de root. Um UID não zero não é prova universal de ausência de capabilities. No exercício desta aula, o processo tem UID e GID 1000 e os três conjuntos observados estão vazios. A tentativa de mudar para UID zero é recusada. Esse resultado pertence a esta combinação de identidade e controlos; não deve ser atribuído isoladamente a um único flag. no_new_privs trata um problema específico: impedir determinados ganhos de privilégio na execução de um novo programa. Não é uma operação que apaga todas as capabilities existentes. Uma revisão que exige reduzir privilégios deve identificar quais já estão presentes e quais podem ser adquiridos por percursos permitidos. O laboratório consulta o flag e depois verifica a sua herança num novo processo. Liga este teste a uma necessidade de trabalho: um agente de reconciliação que só lê ficheiros aprovados não deve receber direitos administrativos para resolver um erro de permissões sem primeiro verificar o acesso realmente necessário.
Escolher controlos para o recurso certo
Seccomp filtra chamadas ao kernel; não substitui todos os mecanismos de controlo de acesso. Um filtro clássico observa números de chamadas e argumentos, mas não dereferencia um ponteiro para comparar o texto arbitrário de um caminho. Se a necessidade é limitar escrita em caminhos concretos, analisa também permissões, montagens e políticas adequadas de acesso a recursos. AppArmor e SELinux têm modelos próprios que exigem distribuição e validação. Não apresentes uma regra seccomp que permite openat como garantia de que só um ficheiro aprovado pode ser aberto. Uma raiz de leitura também tem um âmbito concreto. Um volume separado pode continuar gravável se a montagem e as permissões o permitirem. Para um job que produz relatórios, define onde escreve dados temporários, onde publica resultados e que partes da imagem devem permanecer imutáveis. No exercício, uma tentativa de escrita em /var/tmp encontra EROFS, enquanto /tmp é uma montagem temporária gravável. Esta comparação ajuda a diagnosticar falhas sem desativar a proteção de toda a raiz. O plano de aceitação deve testar a função necessária e uma operação que o controlo pretende recusar, conservando o caminho e a identidade usados em cada observação.
Distribuir perfis e limitar o plano de gestão
Um perfil Localhost referenciado num Pod precisa de estar disponível no node onde o container arranca. Se a pool A tem o perfil AppArmor close-report e a pool B não, um teste bem-sucedido em A não cobre B. Define a distribuição dos perfis e os critérios de colocação, e verifica o perfil efetivamente aplicado. A documentação Kubernetes 1.35 descreve que o scheduler não descobre automaticamente os perfis AppArmor carregados. Uma mudança na pool ou na imagem do node deve voltar a testar esta dependência. O laboratório local desta aula não executa AppArmor nem substitui esse teste de cluster. Revê exceções que podem anular outros campos. Um container privileged não conserva necessariamente o confinamento seccomp que o manifesto parece pedir. O sucesso funcional nesse modo não prova que o perfil anterior estava errado nem que a configuração ficou segura. Em paralelo, separa a exposição da aplicação da do plano de gestão. Uma aplicação pública não precisa de tornar público o kubelet. Define origens autorizadas, percursos administrativos e evidência de recusa a partir de uma origem não autorizada. TLS, autenticação e controlo de rede são camadas distintas; o resultado de uma não demonstra automaticamente a aplicação das restantes.
Preparar o exercício e prever os resultados
Guarda o código abaixo como run.py num diretório próprio e escolhe um caminho novo para o relatório. O exercício requer Python no host, Docker com runtime Linux aarch64 e a imagem indicada pelo digest no código, previamente disponível. O script usa --pull=never e recusa sobrescrever um relatório. A execução registada utilizou Docker 29.1.3, kernel 6.12.54-linuxkit e Python 3.13.16 dentro do container. Estes valores descrevem o ensaio observado; não são uma afirmação de que reproduzem o ambiente oficial CKS, cuja página indica Kubernetes 1.35. Executa python3 run.py --output evidence.json. O contentor tem um nome aleatório, não tem rede, usa UID/GID 1000, capabilities removidas, raiz de leitura e uma montagem temporária limitada. Conserva o seccomp incorporado no runtime. Só o ficheiro do exercício é montado a partir do host, em leitura. CPU, memória e processos têm limites. Antes de executar, prevê o resultado da escrita na raiz, da escrita em /tmp, da consulta de no_new_privs e da tentativa de mudar UID. Escreve também o que cada resultado consegue demonstrar. Um controlo de rede declarado nos argumentos não equivale a uma campanha de testes de firewall, que este exercício não realiza.
Acompanhar uma restrição adicional
O programa começa por observar o estado do próprio processo e uma consulta que funciona. Depois instala um filtro BPF original que recusa apenas prctl com a operação PR_GET_NO_NEW_PRIVS, mantendo os filtros do runtime. O filtro verifica primeiro a arquitetura de auditoria aarch64, depois o número da chamada e o argumento da operação. Os offsets e constantes foram confrontados com os headers UAPI Linux 6.12. A implementação é limitada à arquitetura declarada; os mesmos números não são uma interface universal entre ABIs. Um erro de ABI deve interromper a experiência, em vez de produzir uma conclusão sobre outra chamada. O objetivo é observar uma diferença controlada. A consulta devolve 1 antes do filtro e passa a devolver -1 com EPERM depois. Entretanto, /proc/self/status continua a indicar NoNewPrivs: 1. O flag não foi desligado: a operação de consulta foi recusada. O número de filtros passa de um para dois na execução registada. Uma leitura sem relação com a chamada bloqueada continua a funcionar. Explica estas quatro observações em conjunto. Instalar um filtro adicional que permite outras chamadas não retira as recusas de maior precedência já impostas pelo runtime.
Herança, evidência e limpeza
O exercício inicia um novo processo Python antes e depois de acrescentar o filtro. Na segunda execução filha, a consulta continua recusada e o flag permanece definido. Esta observação cobre o percurso de criação e execução usado pelo script. Não demonstra que uma aplicação com várias threads recebeu uma atualização sincronizada dos filtros, nem valida todas as chamadas possíveis. A instalação via seccomp sem o flag TSYNC atua sobre a thread que a faz; a aceitação de outro modelo de concorrência precisa de testes próprios. Esta distinção evita transportar uma conclusão local para um agente de produção com arquitetura diferente. O relatório guarda 14 observações, versões e o hash do ficheiro executado. O script remove o contentor que criou e confirma a sua ausência. A montagem temporária desaparece com esse contentor; não são alterados perfis do host. Foram feitas duas execuções finais com o mesmo código, ambas concluídas. O teste não executa Kubernetes, AppArmor, SELinux, systemd ou regras de firewall do host. Também não demonstra isolamento completo, resistência a exploração ou desempenho sob carga. Ao entregar a evidência, associa cada afirmação ao resultado que a suporta e deixa explícitas as áreas que ainda precisam de laboratório próprio.
Decidir a recuperação e preparar RUN
Considera um fecho batch que começou a falhar após uma release alterar UID, capabilities e seccomp. Restam 25 minutos para decidir a contingência e existe uma versão anterior aprovada. Recolhe a operação recusada, a identidade efetiva e as diferenças de configuração. Separa a recuperação do negócio da investigação que exige mais tempo, usando os critérios de mudança acordados. Executar com privileged pode fazer o job terminar e, ao mesmo tempo, anular o controlo que se pretendia introduzir. Esse resultado não identifica sozinho a causa nem substitui aceitação de segurança. A entrega para RUN deve conter o comportamento esperado, os erros que justificam diagnóstico e o ponto de escalamento. Para cada mecanismo, define um teste funcional e uma recusa esperada: gravação no volume aprovado e recusa na raiz; chamada necessária e chamada restringida; acesso de gestão autorizado e origem recusada. Regista quem mantém perfis, imagens e regras, e em que mudanças é necessário repetir a validação. O resumo útil apresenta decisões verificáveis, com âmbito e responsáveis. Os casos bancários desta aula são originais e fictícios; não descrevem procedimentos internos da BNP Paribas.
"""Original DR Linux aarch64 hardening lab. Run on host with --output; container uses --inside."""
import sys
def probe():
"""Original Linux aarch64 experiment: add a restrictive filter to the current thread."""
import ctypes,errno,json,os,platform,subprocess,sys
from pathlib import Path
assert platform.system()=='Linux' and platform.machine()=='aarch64'
libc=ctypes.CDLL(None,use_errno=True);libc.prctl.restype=ctypes.c_int
PR_GET_NO_NEW_PRIVS=39;SECCOMP_SET_MODE_FILTER=1;SYS_SECCOMP=277;libc.syscall.restype=ctypes.c_long
def prctl(option,arg=0):
ctypes.set_errno(0);result=libc.prctl(ctypes.c_int(option),ctypes.c_ulong(arg),ctypes.c_ulong(0),ctypes.c_ulong(0),ctypes.c_ulong(0));return dict(result=result,errno=ctypes.get_errno())
def status():
raw=dict(line.split(':',1)for line in Path('/proc/self/status').read_text().splitlines()if ':'in line)
return {k:raw[k].strip()for k in ['Uid','Gid','CapEff','CapPrm','CapBnd','NoNewPrivs','Seccomp','Seccomp_filters']}
if '--child'in sys.argv:
print(json.dumps(dict(query=prctl(PR_GET_NO_NEW_PRIVS),status=status())));sys.exit(0)
records=[]
def check(name,condition,observed):
assert condition,(name,observed);records.append(dict(name=name,passed=True,observed=observed))
initial=status();query=prctl(PR_GET_NO_NEW_PRIVS)
check('non-root-identity',os.getuid()==1000 and os.getgid()==1000,dict(uid=os.getuid(),gid=os.getgid()))
check('capability-sets-empty',all(int(initial[k],16)==0 for k in ['CapEff','CapPrm','CapBnd']),{k:initial[k]for k in ['CapEff','CapPrm','CapBnd']})
check('no-new-privileges-set',initial['NoNewPrivs']=='1'and query==dict(result=1,errno=0),query)
check('runtime-filter-present',initial['Seccomp']=='2'and int(initial['Seccomp_filters'])>=1,initial)
# /var/tmp has writable DAC permissions in this image; EROFS identifies the mount constraint.
try:Path('/var/tmp/dr-root-write').write_text('synthetic');root_errno=0
except OSError as err:root_errno=err.errno
check('root-filesystem-read-only',root_errno==errno.EROFS,dict(errno=root_errno))
path=Path('/tmp/dr-writable');path.write_text('synthetic');check('temporary-volume-writable',path.read_text()=='synthetic',True);path.unlink()
try:os.setuid(0);uid_errno=0
except OSError as err:uid_errno=err.errno
check('uid-change-rejected',uid_errno==errno.EPERM and os.getuid()==1000,dict(errno=uid_errno,uid=os.getuid()))
child=json.loads(subprocess.check_output([sys.executable,'-B',__file__,'--inside','--child'],text=True,timeout=10));check('baseline-child-inherits-nnp',child['query']==dict(result=1,errno=0)and child['status']['NoNewPrivs']=='1',child)
class Filter(ctypes.Structure):_fields_=[('code',ctypes.c_ushort),('jt',ctypes.c_ubyte),('jf',ctypes.c_ubyte),('k',ctypes.c_uint)]
class Program(ctypes.Structure):_fields_=[('len',ctypes.c_ushort),('filter',ctypes.POINTER(Filter))]
# Original BPF: reject unexpected ABI, then deny only prctl(PR_GET_NO_NEW_PRIVS).
# Linux6.12 aarch64: audit arch0xc00000b7, prctl syscall167; nr@0,arch@4,args[0]@16.
rows=[(0x20,0,0,4),(0x15,1,0,0xc00000b7),(0x06,0,0,0x80000000),
(0x20,0,0,0),(0x15,0,3,167),(0x20,0,0,16),(0x15,0,1,39),
(0x06,0,0,0x00050000|errno.EPERM),(0x06,0,0,0x7fff0000)]
filters=(Filter*len(rows))(*(Filter(*row)for row in rows));program=Program(len(rows),filters)
ctypes.set_errno(0);result=libc.syscall(ctypes.c_long(SYS_SECCOMP),ctypes.c_uint(SECCOMP_SET_MODE_FILTER),ctypes.c_uint(0),ctypes.byref(program))
check('additional-filter-installed',result==0,dict(result=result,errno=ctypes.get_errno()))
after=status();blocked=prctl(PR_GET_NO_NEW_PRIVS)
check('query-now-denied',blocked==dict(result=-1,errno=errno.EPERM),blocked)
check('flag-remains-set',after['NoNewPrivs']=='1',after['NoNewPrivs'])
check('filter-count-increased',int(after['Seccomp_filters'])==int(initial['Seccomp_filters'])+1,dict(before=initial['Seccomp_filters'],after=after['Seccomp_filters']))
child=json.loads(subprocess.check_output([sys.executable,'-B',__file__,'--inside','--child'],text=True,timeout=10));check('exec-child-inherits-filter',child['query']==blocked and child['status']['NoNewPrivs']=='1',child)
check('unrelated-read-still-works',Path('/etc/os-release').read_text().startswith('NAME='),True)
assert len(records)==14
print(json.dumps(dict(python=platform.python_version(),kernel=platform.release(),architecture=platform.machine(),observations=records)))
def run():
"""Run only a newly named, resource-limited Docker container; keep runtime seccomp."""
import argparse,datetime,hashlib,json,subprocess,uuid
from pathlib import Path
p=argparse.ArgumentParser();p.add_argument('--output',required=True);a=p.parse_args();out=Path(a.output);assert not out.exists();here=Path(__file__).resolve().parent
image='python@sha256:2d9aefe2fef018a7eb2c13064c89c71929800fd2e5dccdbf52ea5da5bb8d929a';name='dr-cks-system-'+uuid.uuid4().hex[:12]
def docker(*args,ok=True,timeout=30):
r=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=timeout)
if ok and r.returncode:raise RuntimeError(r.stderr[:600])
return r
info=json.loads(docker('info','--format','{{json .}}').stdout);assert info['Architecture']=='aarch64';assert any('seccomp'in x for x in info['SecurityOptions'])
try:
args=['run','--rm','--pull=never','--name',name,'--label','dr.lesson=cks-system','--network=none','--read-only','--user','1000:1000','--cap-drop=ALL','--security-opt=no-new-privileges=true','--security-opt=seccomp=builtin','--pids-limit=32','--memory=64m','--cpus=0.5','--tmpfs','/tmp:rw,noexec,nosuid,nodev,size=1m,mode=1777','--mount','type=bind,src='+str(Path(__file__).resolve())+',dst=/lesson/run.py,readonly','--entrypoint','python',image,'-B','/lesson/run.py','--inside']
result=docker(*args,timeout=45);evidence=json.loads(result.stdout);assert len(evidence['observations'])==14 and all(x['passed']for x in evidence['observations'])
finally:
remaining=docker('ps','-aq','--filter','name=^/'+name+'$').stdout.strip()
if remaining:docker('rm','-f',name)
assert not docker('ps','-aq','--filter','name=^/'+name+'$').stdout.strip()
report=dict(executedAt=datetime.datetime.now(datetime.timezone.utc).isoformat(),files={n:hashlib.sha256((here/n).read_bytes()).hexdigest()for n in ['run.py']},dockerVersion=info['ServerVersion'],imageReference=image,container=name,execution=evidence,cleanup=dict(ownedContainerRemoved=True,hostSecurityChanged=False),scope='Actual Linux aarch64 process,filesystem,credential and additional seccomp-filter observations. Built-in Docker seccomp retained. No Kubernetes,AppArmor,SELinux,host firewall,full isolation audit,performance benchmark or official practical exam execution.')
out.write_text(json.dumps(report,indent=2)+'\n');print('PASS14',evidence['python'],evidence['kernel'])
if __name__=='__main__':
if '--inside' in sys.argv:probe()
else:run()
Uma consulta passa de sucesso para EPERM após um filtro adicional, mas o flag observado em /proc continua definido.
Armadilhas comuns
Atribuir EPERM a uma única causa; confundir disable com stop; usar privileged como prova de correção; inferir isolamento completo de um teste local.
Tópicos relacionados: Identidades e delegação · Perfis e admissão de workloads · Investigação de incidentes
Cada controlo tem um âmbito. A aceitação liga a configuração ao processo real e distingue função recuperada de restrição comprovada.
Referência: CKS certification and domains · Kubernetes v1.35; current six-domain CKS outline