Pesquisa de site

Como Tracee resolve a falta de informações sobre BTF


Ao rastrear processos usando a tecnologia Linux eBPF (filtro de pacotes de Berkeley), o Tracee pode correlacionar informações coletadas e identificar padrões de comportamento maliciosos.

Tracee é um projeto da Aqua Security para rastrear processos em tempo de execução. Ao rastrear processos usando a tecnologia Linux eBPF (filtro de pacotes de Berkeley), o Tracee pode correlacionar informações coletadas e identificar padrões de comportamento maliciosos.

eBPF

BPF é um sistema para auxiliar na análise de tráfego de rede. O sistema eBPF posterior estende o BPF clássico para melhorar a programabilidade do kernel Linux em diferentes áreas, como filtragem de rede, conexão de funções e assim por diante. Graças à sua máquina virtual baseada em registros, embutida no kernel, o eBPF pode executar programas escritos em uma linguagem C restrita sem a necessidade de recompilar o kernel ou carregar um módulo. Através do eBPF, você pode executar seu programa no contexto do kernel e conectar vários eventos no caminho do kernel. Para fazer isso, o eBPF precisa ter profundo conhecimento sobre as estruturas de dados que o kernel está utilizando.

eBPF CO-RE

O eBPF faz interface com o kernel Linux ABI (interface binária de aplicativo). O acesso às estruturas do kernel da VM eBPF depende da versão específica do kernel Linux.

eBPF CO-RE (compilar uma vez, executar em qualquer lugar) é a capacidade de escrever um programa eBPF que irá compilar com sucesso, passar na verificação do kernel e funcionar corretamente em diferentes versões do kernel sem a necessidade de recompilá-lo para cada kernel específico.

Ingredientes

CO-RE precisa de um sinergismo preciso destes componentes:

  • Informações BTF (formato de tipo BPF): permite a captura de informações cruciais sobre os tipos e códigos do kernel e do programa BPF, habilitando todas as outras partes do quebra-cabeça BPF CO-RE.
     
  • Compilador (Clang): registra informações de realocação. Por exemplo, se você fosse acessar o campo task_struct->pid, o Clang registraria que era exatamente um campo chamado pid do tipo pid_t residindo em uma estrutura task_struct. Esse sistema garante que, mesmo que um kernel de destino tenha um layout task_struct no qual o campo pid seja movido para um deslocamento diferente dentro de uma estrutura task_struct, você ainda poderá encontrá-lo apenas pelo nome e tipo de informação.
     
  • Carregador BPF (libbpf): Une BTFs do kernel e programas BPF para ajustar o código BPF compilado a kernels específicos em hosts de destino.

Então, como esses ingredientes se misturam para obter uma receita de sucesso?

Desenvolvimento/construção

Para tornar o código portátil, os seguintes truques entram em ação:

  • Auxiliares/macros CO-RE
  • Mapas definidos pelo BTF
  • #include "vmlinux.h" (o arquivo de cabeçalho contendo todos os tipos de kernel)

Correr

O kernel deve ser construído com a opção CONFIG_DEBUG_INFO_BTF=y para fornecer a interface /sys/kernel/btf/vmlinux que expõe os tipos de kernel formatados em BTF. Isso permite que o libbpf resolva e combine todos os tipos e campos e atualize os deslocamentos necessários e outros dados relocáveis para garantir que o programa eBPF esteja funcionando corretamente para o kernel específico no host de destino.

O problema

O problema surge quando um programa eBPF é escrito para ser portátil, mas o kernel alvo não expõe a interface /sys/kernel/btf/vmlinux. Para obter mais informações, consulte esta lista de distribuições que suportam BTF.

Para carregar e executar um objeto eBPF em diferentes kernels, o carregador libbpf usa as informações do BTF para calcular as realocações de deslocamento de campo. Sem a interface BTF, o carregador não possui as informações necessárias para ajustar os tipos previamente registrados que o programa tenta acessar após processar o objeto para o kernel em execução.

É possível evitar esse problema?

Casos de uso

Este artigo explora o Tracee, um projeto de código aberto da Aqua Security, que oferece uma possível solução.

Tracee oferece diferentes modos de execução para se adaptar às condições ambientais. Suporta dois modos de integração eBPF:

  • CO-RE: um modo portátil, que funciona perfeitamente em todos os ambientes suportados
  • Não CO-RE: Um modo específico do kernel, exigindo que o objeto eBPF seja construído para o host de destino

Ambos são implementados no código eBPF C (pkg/ebpf/c/tracee.bpf.c), onde ocorre a diretiva condicional de pré-processamento. Isso permite compilar CO-RE o binário eBPF, passando o argumento -DCORE em tempo de construção com Clang (dê uma olhada no alvo bpf-core Make).

Neste artigo, vamos cobrir um caso de modo portátil quando o binário eBPF é construído CO-RE, mas o kernel alvo não foi construído com a opção CONFIG_DEBUG_INFO_BTF=y.

Para entender melhor esse cenário, é útil entender o que é possível quando o kernel não expõe tipos formatados em BTF no sysfs.

Sem suporte BTF

Se você deseja executar o Tracee em um host sem suporte BTF, há duas opções:

  1. Construa e instale o objeto eBPF para seu kernel. Isso depende do Clang e da disponibilidade de um pacote kernel-headers específico da versão do kernel.
     
  2. Faça download dos arquivos BTF do BTFHUB para a versão do seu kernel e forneça-os ao carregador do tracee-ebpf por meio da variável de ambiente TRACEE_BTF_FILE.

A primeira opção não é uma solução CO-RE. Ele compila o binário eBPF, incluindo uma longa lista de cabeçalhos de kernel. Isso significa que você precisa de pacotes de desenvolvimento de kernel instalados no sistema de destino. Além disso, esta solução precisa do Clang instalado na máquina de destino. O compilador Clang pode consumir muitos recursos, portanto, compilar o código eBPF pode usar uma quantidade significativa de recursos, afetando potencialmente uma carga de trabalho de produção cuidadosamente equilibrada. Dito isto, é uma boa prática evitar a presença de um compilador no seu ambiente de produção. Isso pode levar os invasores a criar com sucesso uma exploração e a executar uma escalada de privilégios.

A segunda opção é uma solução CO-RE. O problema aqui é que você precisa fornecer os arquivos BTF em seu sistema para fazer o Tracee funcionar. O arquivo inteiro tem quase 1,3 GB. É claro que você pode fornecer o arquivo BTF correto para a versão do seu kernel, mas isso pode ser difícil ao lidar com diferentes versões do kernel.

No final, essas soluções possíveis também podem introduzir problemas, e é aí que Tracee faz sua mágica.

Uma solução portátil

Com um procedimento de construção não trivial, o projeto Tracee compila um binário para ser CO-RE mesmo que o ambiente de destino não forneça informações de BTF. Isso é possível com o pacote embed Go que fornece, em tempo de execução, acesso aos arquivos incorporados no programa. Durante a construção, o pipeline de integração contínua (CI) baixa, extrai, minimiza e incorpora arquivos BTF junto com o objeto eBPF dentro do binário resultante tracee-ebpf.

Tracee pode extrair o arquivo BTF correto e fornecê-lo ao libbpf, que por sua vez carrega o programa eBPF para ser executado em diferentes kernels. Mas como o Tracee pode incorporar todos esses arquivos BTF baixados do BTFHub sem pesar muito no final?

Ele usa um recurso recentemente introduzido no bpftool pela equipe Kinvolk chamado BTFGen, disponível usando o subcomando bpftool gen min_core_btf. Dado um programa eBPF, o BTFGen gera arquivos BTF reduzidos, coletando apenas o que o código eBPF necessita para sua execução. Essa redução permite que o Tracee incorpore todos esses arquivos que agora são mais leves (apenas alguns kilobytes) e suportam kernels que não têm a interface /sys/kernel/btf/vmlinux exposta.

Construção de rastreamento

Aqui está o fluxo de execução da construção do Tracee:

(Alessio Greggi e Massimiliano Giovagnoli, CC BY-SA 4.0)

Primeiro, você deve construir o binário tracee-ebpf, o programa Go que carrega o objeto eBPF. O Makefile fornece o comando make bpf-core para construir o objeto tracee.bpf.core.o com registros BTF.

Então STATIC=1 BTFHUB=1 faça todas compilações tracee-ebpf, que tem btfhub direcionado como uma dependência. Este último alvo executa o script 3rdparty/btfhub.sh, que é responsável por baixar os repositórios do BTFHub:

    btfhub
    btfhub-archive

Depois de baixado e colocado no diretório 3rdparty, o procedimento executa o script baixado 3rdparty/btfhub/tools/btfgen.sh. Este script gera arquivos BTF reduzidos, adaptados para o binário eBPF tracee.bpf.core.o.

O script coleta arquivos *.tar.xz de 3rdparty/btfhub-archive/ para descompactá-los e finalmente processá-los com bpftool, usando o seguinte comando:

for file in $(find ./archive/${dir} -name *.tar.xz); do
    dir=$(dirname $file)
    base=$(basename $file)
    extracted=$(tar xvfJ $dir/$base)
    bpftool gen min_core_btf ${extracted} dist/btfhub/${extracted} tracee.bpf.core.o
done

Este código foi simplificado para facilitar a compreensão do cenário.

Agora você tem todos os ingredientes disponíveis para a receita:

  • tracee.bpf.core.o objeto eBPF
  • Arquivos reduzidos BTF (para todas as versões do kernel)
  • tracee-ebpf Acesse o código-fonte

Neste ponto, go build é invocado para fazer seu trabalho. Dentro do arquivo embedded-ebpf.go, você pode encontrar o seguinte código:

//go:embed "dist/tracee.bpf.core.o"
//go:embed "dist/btfhub/*"

Aqui, o compilador Go é instruído a incorporar o objeto eBPF CO-RE com todos os arquivos reduzidos em BTF dentro de si. Depois de compilados, esses arquivos estarão disponíveis usando o sistema de arquivos embed.FS. Para se ter uma ideia da situação atual, você pode imaginar o binário com um sistema de arquivos estruturado assim:

dist
├── btfhub
│   ├── 4.19.0-17-amd64.btf
│   ├── 4.19.0-17-cloud-amd64.btf
│   ├── 4.19.0-17-rt-amd64.btf
│   ├── 4.19.0-18-amd64.btf
│   ├── 4.19.0-18-cloud-amd64.btf
│   ├── 4.19.0-18-rt-amd64.btf
│   ├── 4.19.0-20-amd64.btf
│   ├── 4.19.0-20-cloud-amd64.btf
│   ├── 4.19.0-20-rt-amd64.btf
│   └── ...
└── tracee.bpf.core.o

O binário Go está pronto. Agora é experimentar!

Rastreamento de corrida

Aqui está o fluxo de execução da execução do Tracee:

(Alessio Greggi e Massimiliano Giovagnoli, CC BY-SA 4.0)

Como ilustra o fluxograma, uma das primeiras fases da execução do tracee-ebpf é descobrir o ambiente onde ele está sendo executado. A primeira condição é uma abstração do arquivo cmd/tracee-ebpf/initialize/bpfobject.go, especificamente onde a função BpfObject() ocorre. O programa realiza algumas verificações para entender o ambiente e tomar decisões com base nele:

  1. Arquivo BPF fornecido e BTF (vmlinux ou env) existe: sempre carregue BPF como CO-RE
  2. Arquivo BPF fornecido mas não existe nenhum BTF: é um BPF não CO-RE
  3. Nenhum arquivo BPF fornecido e BTF (vmlinux ou env) existe: carregue o BPF incorporado como CO-RE
  4. Nenhum arquivo BPF fornecido e nenhum BTF disponível: verifique os arquivos BTF incorporados
  5. Nenhum arquivo BPF fornecido e nenhum BTF disponível e nenhum BTF incorporado: não CO-RE BPF

Aqui está o extrato do código:

func BpfObject(config *tracee.Config, kConfig *helpers.KernelConfig, OSInfo *helpers.OSInfo) error {
	...
	bpfFilePath, err := checkEnvPath("TRACEE_BPF_FILE")
	...
	btfFilePath, err := checkEnvPath("TRACEE_BTF_FILE")
	...
	// Decision ordering:
	// (1) BPF file given & BTF (vmlinux or env) exists: always load BPF as CO-RE
        ...
	// (2) BPF file given & if no BTF exists: it is a non CO-RE BPF
        ...
	// (3) no BPF file given & BTF (vmlinux or env) exists: load embedded BPF as CO-RE
        ...
	// (4) no BPF file given & no BTF available: check embedded BTF files
	unpackBTFFile = filepath.Join(traceeInstallPath, "/tracee.btf")
	err = unpackBTFHub(unpackBTFFile, OSInfo)
	
	if err == nil {
		if debug {
			fmt.Printf("BTF: using BTF file from embedded btfhub: %v\n", unpackBTFFile)
		}
		config.BTFObjPath = unpackBTFFile
		bpfFilePath = "embedded-core"
		bpfBytes, err = unpackCOREBinary()
		if err != nil {
			return fmt.Errorf("could not unpack embedded CO-RE eBPF object: %v", err)
		}
	
		goto out
	}
	// (5) no BPF file given & no BTF available & no embedded BTF: non CO-RE BPF
	...
out:
	config.KernelConfig = kConfig
	config.BPFObjPath = bpfFilePath
	config.BPFObjBytes = bpfBytes
	
	return nil
}

Esta análise concentra-se no quarto caso, quando o programa eBPF e os arquivos BTF não são fornecidos ao tracee-ebpf. Nesse ponto, tracee-ebpf tenta carregar o programa eBPF extraindo todos os arquivos necessários de seu sistema de arquivos incorporado. tracee-ebpf é capaz de fornecer os arquivos necessários para execução, mesmo em um ambiente hostil. É uma espécie de modo de alta resiliência usado quando nenhuma das condições foi satisfeita.

Como você pode ver, BpfObject() chama essas funções na quarta ramificação case:

    unpackBTFHub()
    unpackCOREBinary()

Eles extraem respectivamente:

  • O arquivo BTF para o kernel subjacente
  • O binário BPF CO-RE

Descompacte o BTFHub

Agora dê uma olhada começando em unpackBTFHub():

func unpackBTFHub(outFilePath string, OSInfo *helpers.OSInfo) error {
	var btfFilePath string

	osId := OSInfo.GetOSReleaseFieldValue(helpers.OS_ID)
	versionId := strings.Replace(OSInfo.GetOSReleaseFieldValue(helpers.OS_VERSION_ID), "\"", "", -1)
	kernelRelease := OSInfo.GetOSReleaseFieldValue(helpers.OS_KERNEL_RELEASE)
	arch := OSInfo.GetOSReleaseFieldValue(helpers.OS_ARCH)

	if err := os.MkdirAll(filepath.Dir(outFilePath), 0755); err != nil {
		return fmt.Errorf("could not create temp dir: %s", err.Error())
	}

	btfFilePath = fmt.Sprintf("dist/btfhub/%s/%s/%s/%s.btf", osId, versionId, arch, kernelRelease)
	btfFile, err := embed.BPFBundleInjected.Open(btfFilePath)
	if err != nil {
		return fmt.Errorf("error opening embedded btfhub file: %s", err.Error())
	}
	defer btfFile.Close()

	outFile, err := os.Create(outFilePath)
	if err != nil {
		return fmt.Errorf("could not create btf file: %s", err.Error())
	}
	defer outFile.Close()

	if _, err := io.Copy(outFile, btfFile); err != nil {
		return fmt.Errorf("error copying embedded btfhub file: %s", err.Error())

	}

	return nil
}

A função possui uma primeira fase onde coleta informações sobre o kernel em execução (osId, versionId, kernelRelease, etc). Em seguida, ele cria o diretório que hospedará o arquivo BTF (/tmp/tracee por padrão). Ele recupera o arquivo BTF correto do sistema de arquivos embed:

btfFile, err := embed.BPFBundleInjected.Open(btfFilePath)

Finalmente, ele cria e preenche o arquivo.

Descompacte o binário CORE

A função unpackCOREBinary() faz algo semelhante:

func unpackCOREBinary() ([]byte, error) {
	b, err := embed.BPFBundleInjected.ReadFile("dist/tracee.bpf.core.o")
	if err != nil {
		return nil, err
	}

	if debug.Enabled() {
		fmt.Println("unpacked CO:RE bpf object file into memory")
	}

	return b, nil
}

Assim que a função principal BpfObject()retornar, tracee-ebpf estará pronto para carregar o binário eBPF através de libbpfgo. Isso é feito na função initBPF(), dentro de pkg/ebpf/tracee.go. Aqui está a configuração da execução do programa:

func (t *Tracee) initBPF() error {
        ...
        newModuleArgs := bpf.NewModuleArgs{
		KConfigFilePath: t.config.KernelConfig.GetKernelConfigFilePath(),
		BTFObjPath:      t.config.BTFObjPath,
		BPFObjBuff:      t.config.BPFObjBytes,
		BPFObjName:      t.config.BPFObjPath,
	}

	// Open the eBPF object file (create a new module)

	t.bpfModule, err = bpf.NewModuleFromBufferArgs(newModuleArgs)
	if err != nil {
		return err
	}
        ...
}

Neste trecho de código, inicializamos os argumentos eBPF preenchendo a estrutura libbfgo NewModuleArgs{}. Através do seu argumento BTFObjPath, podemos instruir a libbpf a utilizar o arquivo BTF, previamente extraído pela função BpfObject().

Neste ponto, tracee-ebpf está pronto para ser executado corretamente!

(Alessio Greggi e Massimiliano Giovagnoli, CC BY-SA 4.0)

Inicialização do módulo eBPF

A seguir, durante a execução da função Tracee.Init(), os argumentos configurados serão utilizados para abrir o arquivo objeto eBPF:

Tracee.bpfModule = libbpfgo.NewModuleFromBufferArgs(newModuleArgs)

Inicialize as sondas:

t.probes, err = probes.Init(t.bpfModule, netEnabled)

Carregue o objeto eBPF no kernel:

err = t.bpfModule.BPFLoadObject()

Preencha mapas eBPF com dados iniciais:

err = t.populateBPFMaps()

E finalmente, anexe programas eBPF às sondagens de eventos selecionados:

err = t.attachProbes()

Conclusão

Assim como o eBPF simplificou a forma de programar o kernel, o CO-RE está enfrentando outra barreira. Mas aproveitar esses recursos tem alguns requisitos. Felizmente, com o Tracee, a equipe Aqua Security encontrou uma maneira de aproveitar as vantagens da portabilidade caso esses requisitos não possam ser atendidos.

Ao mesmo tempo, temos certeza de que este é apenas o começo de um subsistema em constante evolução que encontrará suporte cada vez maior, mesmo em diferentes sistemas operacionais.

Artigos relacionados: