
Autor do artigo https://github.com/Nalen98
Boa tarde
O tópico da minha pesquisa como parte do estágio de verão do Summer of Hack 2019 na Digital Security foi "Descompilar o eBPF em Ghidra". Foi necessário desenvolver na linguagem Sleigh um sistema de tradução de bytecode do eBPF no PCode Ghidra para poder desmontar e descompilar os programas do eBPF. O resultado do estudo é uma extensão desenvolvida para Ghidra que adiciona suporte ao processador eBPF. O estudo, como o de outros estagiários, pode ser considerado "pioneiro", pois anteriormente não era possível descompilar o eBPF em outras ferramentas de engenharia reversa.
Antecedentes
Esse tópico foi para mim em uma grande ironia do destino, porque eu não conhecia o eBPF antes e Ghidr não o havia usado antes, porque havia algum dogma de que "o IDA Pro é melhor". Como se viu, isso não é inteiramente verdade.
O conhecimento de Ghidra acabou sendo muito rápido, pois seus desenvolvedores elaboraram documentação muito competente e acessível. Além disso, eu tive que dominar a linguagem de especificação do processador Sleigh, na qual o desenvolvimento foi realizado. Os desenvolvedores fizeram o melhor possível e criaram uma documentação muito detalhada para a ferramenta em si e para o Sleigh , pela qual agradecemos muito.
Do outro lado da barricada, havia um filtro de pacotes Berkeley estendido. O eBPF é uma máquina virtual no kernel do Linux que permite carregar código de usuário arbitrário que pode ser usado para rastrear processos e filtrar pacotes no espaço do kernel. A arquitetura é uma máquina de registro RISC com 11 registros de 64 bits, um contador de software e uma pilha de 512 bytes. Existem várias limitações para o eBPF:
- ciclos são proibidos;
- o acesso à memória é possível apenas através da pilha (haverá uma história separada sobre ela);
- As funções do kernel estão disponíveis apenas através das funções especiais do wrapper (eBPF-helpers).

A estrutura da tecnologia eBPF. Fonte da imagem: http://www.brendangregg.com/ebpf.html .
Basicamente, essa tecnologia é usada para tarefas de rede - depuração, filtragem de pacotes e assim por diante no nível do kernel. O suporte ao EBPF foi adicionado desde a versão 3.15 do kernel; vários relatórios foram dedicados a essa tecnologia na conferência Linux Plumbers 2019. Mas no eBPF, ao contrário de Ghidra, a documentação está incompleta e não contém muito. Portanto, esclarecimentos e informações ausentes precisavam ser pesquisadas na Internet. Demorou um pouco para encontrar as respostas, e tudo o que resta é esperar que a tecnologia seja finalizada e a documentação normal seja criada.
Documentação incorreta
Para desenvolver uma especificação para o Sleigh, primeiro você precisa entender como a arquitetura do processador de destino funciona. E aqui nos voltamos para a documentação oficial.
Ele contém várias falhas:
A estrutura das instruções do eBPF não está totalmente descrita.
A maioria das especificações, como Intel x86, geralmente indica a que cada bit de instrução se destina, a qual bloco pertence. Infelizmente, na especificação do eBPF, esses detalhes estão espalhados por todo o documento ou completamente ausentes, como resultado, temos que extrair os grãos ausentes dos detalhes da implementação no kernel do Linux.
Por exemplo, na estrutura de instruções op:8, dst_reg:4, src_reg:4, off:16, imm:32
nenhuma palavra é dita que offset (off) e imediato (imm) são signed
, e isso é extremamente importante, pois afeta para trabalhar de instruções aritméticas a saltos. O código fonte do kernel do Linux ajudou.
Não existe uma imagem completa de todas as mnemônicas possíveis da arquitetura.
Em alguma documentação, não apenas todas as instruções, seus operandos são indicados, mas também sua semântica em C, casos de aplicativos, recursos de operandos e assim por diante. A documentação do eBPF contém classes de instruções, mas isso não é suficiente para o desenvolvedor. Vamos considerá-los com mais detalhes.
Todas as instruções do eBPF são de 64 bits, exceto LDDW
(Load double word), tem um tamanho de 128 bits, concatena dois imm com 32 bits cada. As instruções do eBPF têm a seguinte estrutura.

Codificação de instruções eBPF
A estrutura do campo OPAQUE
depende da classe de instruções (ALU / JMP, Carga / Armazenamento).
Por exemplo, a classe de instrução ALU
:

Codificação de instruções da ALU
e a classe JMP
tem sua própria estrutura de campo:

Codificação de instruções de ramificação
Para instruções Load / Store, a estrutura é diferente:

Carregar / armazenar instruções de codificação
A documentação não oficial do eBPF ajudou a resolver isso .
Não há informações sobre os auxiliares de chamada, nos quais a maior parte da lógica dos programas eBPF para o kernel Linux é construída.
E isso é extremamente estranho, já que os auxiliares são a coisa mais importante nos programas eBPF, eles apenas executam as tarefas nas quais a tecnologia está focada.

Interoperabilidade EBPF com funções nucleares
O programa extrai essas funções do kernel e elas apenas trabalham com processos, manipulam pacotes de rede, trabalham com mapas eBPF, acessam sockets, interagem com o espaço do usuário. Apesar do fato de as funções ainda serem nucleares, na documentação oficial valeria a pena escrever com mais detalhes sobre elas. Detalhes completos são encontrados na fonte Linux.
- Nem uma palavra sobre chamadas de cauda.

Chamadas finais de EBPF. Fonte da imagem: https://cilium.readthedocs.io/en/latest/bpf/#tail-calls .
As chamadas de cauda são um mecanismo que permite que um programa eBPF chame outro sem retornar ao anterior, ou seja, saltando entre diferentes programas eBPF. Eles não são implementados na extensão desenvolvida; informações detalhadas podem ser encontradas na documentação do Cilium .
A documentação deficiente e vários recursos arquitetônicos do eBPF foram os principais "fragmentos" em desenvolvimento, pois criaram outros problemas. Felizmente, a maioria deles foi resolvida com sucesso.
Sobre o ambiente de desenvolvimento

Nem todos os desenvolvedores sabem que, para criar e editar o código Sleigh e, geralmente, todos os arquivos de extensão / plug-in para o Ghidra, existe uma ferramenta bastante conveniente - o Eclipse IDE com suporte para os plugins GhidraDev e GhidraSleighEditor . Ao criar a extensão, ela será imediatamente enquadrada na forma de um rascunho de trabalho; há um destaque bastante conveniente para o código Sleigh, além de um verificador dos principais erros na sintaxe do idioma.
No Eclipse, você pode executar o Ghidra (já com a extensão ativada), debug, o que é extremamente conveniente. Mas talvez a melhor oportunidade seja oferecer suporte ao modo "Ghidra Headless", não é necessário reiniciar o Ghidr a partir da GUI 100500 vezes para encontrar um erro no código, todos os processos são executados em segundo plano.
O bloco de notas pode ser fechado! E você pode baixar o Eclipse no site oficial . Para instalar o plug-in, no Ecplise, selecione Ajuda → Instalar Novo Software ... , clique em Incluir e selecione o arquivo zip do plug-in.
Desenvolvimento de extensão
Para a extensão, foram desenvolvidos arquivos de especificação do processador, um carregador que herda do carregador ELF principal e expande seus recursos em termos de reconhecimento de programas eBPF, um processador de realocação para implementar Mapas eBPF no desmontador e descompilador Ghidra , além de um analisador para determinar assinaturas auxiliares do eBPF.


Arquivos de extensão como um projeto no Eclipse IDE
Agora sobre os arquivos principais:
.cspec
- indica quais tipos de dados são usados, quanta memória é alocada a eles no eBPF, o tamanho da pilha é definido, o rótulo “stackpointer” é definido para registrar R10
e o contrato de chamada é assinado. O contrato (como o restante) foi implementado de acordo com a documentação:
Portanto, a convenção de chamada eBPF é definida como:
- R0 - valor de retorno da função no kernel e valor de saída para o programa eBPF
- R1 - R5 - argumentos do programa eBPF para a função no kernel
- R6 - R9 - callee salvo registra que a função no kernel preservará
- R10 - ponteiro de quadro somente leitura para acessar a pilha
eBPF.cspec <?xml version="1.0" encoding="UTF-8"?> <compiler_spec> <data_organization> <absolute_max_alignment value="0" /> <machine_alignment value="2" /> <default_alignment value="1" /> <default_pointer_alignment value="4" /> <pointer_size value="4" /> <wchar_size value="4" /> <short_size value="2" /> <integer_size value="4" /> <long_size value="4" /> <long_long_size value="8" /> <float_size value="4" /> <double_size value="8" /> <long_double_size value="8" /> <size_alignment_map> <entry size="1" alignment="1" /> <entry size="2" alignment="2" /> <entry size="4" alignment="4" /> <entry size="8" alignment="8" /> </size_alignment_map> </data_organization> <global> <range space="ram"/> <range space="syscall"/> </global> <stackpointer register="R10" space="ram"/> <default_proto> <prototype name="__fastcall" extrapop="0" stackshift="0"> <input> <pentry minsize="1" maxsize="8"> <register name="R1"/> </pentry> <pentry minsize="1" maxsize="8"> <register name="R2"/> </pentry> <pentry minsize="1" maxsize="8"> <register name="R3"/> </pentry> <pentry minsize="1" maxsize="8"> <register name="R4"/> </pentry> <pentry minsize="1" maxsize="8"> <register name="R5"/> </pentry> </input> <output killedbycall="true"> <pentry minsize="1" maxsize="8"> <register name="R0"/> </pentry> </output> <unaffected> <varnode space="ram" offset="8" size="8"/> <register name="R6"/> <register name="R7"/> <register name="R8"/> <register name="R9"/> <register name="R10"/> </unaffected> </prototype> </default_proto> </compiler_spec>
Antes de continuar a descrever os arquivos de desenvolvimento, vou .cspec
sobre uma pequena linha do arquivo .cspec
.
<stackpointer register="R10" space="ram"/>
É a principal fonte do mal ao descompilar o eBPF em Ghidra, e iniciou uma emocionante jornada para a pilha do eBPF, que possui vários momentos desagradáveis e que trouxe mais sofrimento ao desenvolvimento.
Tudo o que precisamos é ... Stack
Vamos dar uma olhada na documentação oficial do kernel:
P: Os programas BPF podem acessar o ponteiro das instruções ou o endereço de retorno?
A: NÃO.
P: Os programas BPF podem acessar o ponteiro da pilha?
A: NÃO. Somente o ponteiro de quadro (registro R10) está acessível. Do ponto de vista do compilador, é necessário ter um ponteiro de pilha. Por exemplo, o LLVM define o registro R11 como ponteiro de pilha em seu back-end BPF, mas garante que o código gerado nunca o use.
O processador não possui um ponteiro de instrução (IP) nem um ponteiro de pilha (SP), e o último é extremamente importante para o Ghidra, e a qualidade da descompilação depende disso. No arquivo cspec
, você precisa especificar qual registro é o stackpointer (como demonstrado acima). R10
é o único registro eBPF que permite acessar a pilha de programas, é o ponteiro de quadros, é estático e sempre zero. Pendurar o rótulo “stackpointer” no R10
no arquivo cspec
é fundamentalmente errado, mas não há outras opções, porque o Ghidra não funcionará com a pilha do programa. Portanto, o SP original está ausente e nada o substitui na arquitetura do eBPF.
Vários problemas surgem disso:
O campo "Stack Depth" em Ghidra será garantido como zero, pois simplesmente temos que designar o R10
empilhador nessas condições arquitetônicas e, em essência, é sempre zero, o que foi discutido anteriormente. "Stack Depth" refletirá o registro com o rótulo "stackpointer".
E você tem que aturar isso, esses são os recursos da arquitetura.
As instruções que operam no R10
(ou seja, aquelas que manipulam a pilha) geralmente não são descompiladas. Ghidra geralmente não descompila o que considera código morto (ou seja, trechos que nunca são executados). E como o R10
imutável, muitas instruções de armazenamento / carregamento são reconhecidas pelo Ghidr como código morto e desaparecem do descompilador.
Felizmente, esse problema foi resolvido escrevendo um analisador personalizado e declarando um espaço de endereço adicional com os auxiliares do eBPF em um arquivo pspec
, solicitado por um dos desenvolvedores do Ghidra no projeto Issue .
Desenvolvimento de extensão (continuação)
.ldefs
descreve os recursos do processador, define arquivos de especificação.
eBPF.ldefs <?xml version="1.0" encoding="UTF-8"?> <language_definitions> <language processor="eBPF" endian="little" size="64" variant="default" version="1.0" slafile="eBPF.sla" processorspec="eBPF.pspec" id="eBPF:LE:64:default"> <description>eBPF processor 64-bit little-endian</description> <compiler name="default" spec="eBPF.cspec" id="default"/> <external_name tool="DWARF.register.mapping.file" name="eBPF.dwarf"/> </language> </language_definitions>
O arquivo .opinion
carregador para o processador.
eBPF.opinion <opinions> <constraint loader="Executable and Linking Format (ELF)" compilerSpecID="default"> <constraint primary="247" processor="eBPF" endian="little" size="64" /> </constraint> </opinions>
Um contador de programa é declarado em .pspec, mas com o eBPF, ele está implícito e não é usado na especificação de nenhuma maneira, portanto, é apenas para fins pró-forma. A propósito, o PC
do eBPF é aritmético, não de endereço (indica a instrução, não o byte específico do programa), lembre-se disso ao pular.
O arquivo também contém um espaço de endereço adicional para os auxiliares do eBPF, aqui eles são declarados como caracteres.
eBPF.pspec <?xml version="1.0" encoding="UTF-8"?> <processor_spec> <programcounter register="PC"/> <default_symbols> <symbol name="bpf_unspec" address="syscall:0x0"/> <symbol name="bpf_map_lookup_elem" address="syscall:0x1"/> <symbol name="bpf_map_update_elem" address="syscall:0x2"/> <symbol name="bpf_map_delete_elem" address="syscall:0x3"/> <symbol name="bpf_probe_read" address="syscall:0x4"/> <symbol name="bpf_ktime_get_ns" address="syscall:0x5"/> <symbol name="bpf_trace_printk" address="syscall:0x6"/> <symbol name="bpf_get_prandom_u32" address="syscall:0x7"/> <symbol name="bpf_get_smp_processor_id" address="syscall:0x8"/> <symbol name="bpf_skb_store_bytes" address="syscall:0x9"/> <symbol name="bpf_l3_csum_replace" address="syscall:0xa"/> <symbol name="bpf_l4_csum_replace" address="syscall:0xb"/> <symbol name="bpf_tail_call" address="syscall:0xc"/> <symbol name="bpf_clone_redirect" address="syscall:0xd"/> <symbol name="bpf_get_current_pid_tgid" address="syscall:0xe"/> <symbol name="bpf_get_current_uid_gid" address="syscall:0xf"/> <symbol name="bpf_get_current_comm" address="syscall:0x10"/> <symbol name="bpf_get_cgroup_classid" address="syscall:0x11"/> <symbol name="bpf_skb_vlan_push" address="syscall:0x12"/> <symbol name="bpf_skb_vlan_pop" address="syscall:0x13"/> <symbol name="bpf_skb_get_tunnel_key" address="syscall:0x14"/> <symbol name="bpf_skb_set_tunnel_key" address="syscall:0x15"/> <symbol name="bpf_perf_event_read" address="syscall:0x16"/> <symbol name="bpf_redirect" address="syscall:0x17"/> <symbol name="bpf_get_route_realm" address="syscall:0x18"/> <symbol name="bpf_perf_event_output" address="syscall:0x19"/> <symbol name="bpf_skb_load_bytes" address="syscall:0x1a"/> <symbol name="bpf_get_stackid" address="syscall:0x1b"/> <symbol name="bpf_csum_diff" address="syscall:0x1c"/> <symbol name="bpf_skb_get_tunnel_opt" address="syscall:0x1d"/> <symbol name="bpf_skb_set_tunnel_opt" address="syscall:0x1e"/> <symbol name="bpf_skb_change_proto" address="syscall:0x1f"/> <symbol name="bpf_skb_change_type" address="syscall:0x20"/> <symbol name="bpf_skb_under_cgroup" address="syscall:0x21"/> <symbol name="bpf_get_hash_recalc" address="syscall:0x22"/> <symbol name="bpf_get_current_task" address="syscall:0x23"/> <symbol name="bpf_probe_write_user" address="syscall:0x24"/> </default_symbols> <default_memory_blocks> <memory_block name="eBPFHelper_functions" start_address="syscall:0" length="0x200" initialized="true"/> </default_memory_blocks> </processor_spec>
.sinc
arquivo .sinc
é o arquivo de extensão mais volumoso; todos os registros, a estrutura da instrução eBPF, tokens, mnemônicos e semântica de instruções no Sleigh são definidos aqui.
Pequeno fragmento EBPF.sinc define space ram type=ram_space size=8 default; define space register type=register_space size=4; define space syscall type=ram_space size=2; define register offset=0 size=8 [ R0 R1 R2 R3 R4 R5 R6 R7 R8 R9 R10 PC ]; define token instr(64) imm=(32, 63) signed off=(16, 31) signed src=(12, 15) dst=(8, 11) op_alu_jmp_opcode=(4, 7) op_alu_jmp_source=(3, 3) op_ld_st_mode=(5, 7) op_ld_st_size=(3, 4) op_insn_class=(0, 2) ; #We'll need this token to operate with LDDW instruction, which has 64 bit imm value define token immtoken(64) imm2=(32, 63) ; #To operate with registers attach variables [ src dst ] [ R0 R1 R2 R3 R4 R5 R6 R7 R8 R9 R10 _ _ _ _ _ ]; … :ADD dst, src is src & dst & op_alu_jmp_opcode=0x0 & op_alu_jmp_source=1 & op_insn_class=0x7 { dst=dst + src; } :ADD dst, imm is imm & dst & op_alu_jmp_opcode=0x0 & op_alu_jmp_source=0 & op_insn_class=0x7 { dst=dst + imm; } …
O carregador eBPF estende os recursos básicos do carregador ELF para que ele possa reconhecer que o programa que você baixou para o Ghidra possui um processador eBPF. Para ele, uma constante BPF é alocada no ElfConstants
Ghidra, e o carregador determina o processador eBPF a partir dele.
eBPF_ElfExtension.java package ghidra.app.util.bin.format.elf.extend; import ghidra.app.util.bin.format.elf.*; import ghidra.program.model.lang.*; import ghidra.util.exception.*; import ghidra.util.task.TaskMonitor; public class eBPF_ElfExtension extends ElfExtension { @Override public boolean canHandle(ElfHeader elf) { return elf.e_machine() == ElfConstants.EM_BPF && elf.is64Bit(); } @Override public boolean canHandle(ElfLoadHelper elfLoadHelper) { Language language = elfLoadHelper.getProgram().getLanguage(); return canHandle(elfLoadHelper.getElfHeader()) && "eBPF".equals(language.getProcessor().toString()) && language.getLanguageDescription().getSize() == 64; } @Override public String getDataTypeSuffix() { return "eBPF"; } @Override public void processGotPlt(ElfLoadHelper elfLoadHelper, TaskMonitor monitor) throws CancelledException { if (!canHandle(elfLoadHelper)) { return; } super.processGotPlt(elfLoadHelper, monitor); } }
O manipulador de realocação é necessário para implementar mapas eBPF no desmontador e descompilador. A interação com eles é realizada através de vários auxiliares; as funções usam um descritor de arquivo para indicar mapas. Com base na tabela de realocação, pode-se ver que o carregador corrige a instrução LDDW, que gera Rn
para esses auxiliares (por exemplo, bpf_map_lookup_elem(…)
).
Portanto, o manipulador analisa a tabela de realocação do programa, localiza os endereços de realocação (instruções) e também coleta informações de seqüência de caracteres sobre o nome do mapa. Além disso, consultando a tabela de símbolos, calcula os endereços reais desses mapas e corrige as instruções.
eBPF_ElfRelocationHandler.java public class eBPF_ElfRelocationHandler extends ElfRelocationHandler { @Override public boolean canRelocate(ElfHeader elf) { return elf.e_machine() == ElfConstants.EM_BPF; } @Override public void relocate(ElfRelocationContext elfRelocationContext, ElfRelocation relocation, Address relocationAddress) throws MemoryAccessException, NotFoundException { ElfHeader elf = elfRelocationContext.getElfHeader(); if (elf.e_machine() != ElfConstants.EM_BPF) { return; } Program program = elfRelocationContext.getProgram(); Memory memory = program.getMemory(); int type = relocation.getType(); int symbolIndex = relocation.getSymbolIndex(); long value; boolean appliedSymbol = true;

O resultado da desmontagem e descompilação do eBPF
E no final, temos o desmontador e descompilador eBPF! Use para a saúde!
Extensão no GitHub: eBPF para Ghidra .
Lançamentos aqui: aqui .
PS
Muito obrigado à Digital Security por um estágio interessante, especialmente aos mentores do departamento de pesquisa (Alexander e Nikolai). Eu me curvo para você!