Truques ELF em Go


Nesta nota, aprenderemos como obter o código de máquina de uma função Go diretamente em tempo de execução, imprimi-lo usando um desmontador e, ao longo do caminho, descobriremos vários truques como obter o endereço de uma função sem chamá-lo.


Aviso : este mini-artigo não ensinará nada de útil.


Valor da função em Go


Primeiro, vamos determinar o que é uma função Go e por que precisamos do conceito de valor da função .


Isso é melhor explicado pelo documento Chamadas de Função Go 1.1 . O documento não é novo, mas a maioria das informações ainda é relevante.


No nível mais baixo, é sempre um ponteiro para código executável, mas quando usamos funções / fechamentos anônimos ou passamos uma função como interface{} , esse ponteiro fica oculto dentro de alguma estrutura.


O nome da função em si não é uma expressão; portanto, esse código não funciona:


 // https://play.golang.org/p/wXeVLU7nLPs package main func add1(x int) int { return 1 } func main() { addr := &add1 println(addr) } 

compile: cannot take the address of add1

Mas, ao mesmo tempo, podemos obter o function value com o mesmo nome de função:


 // https://play.golang.org/p/oWqv_FQq4hy package main func add1(x int) int { return 1 } func main() { f := add1 // <-------- addr := &f println(addr) } 

Este código é iniciado, mas imprimirá o endereço de uma variável local na pilha, que não é exatamente o que queríamos. Mas, como mencionado acima, o endereço da função ainda está lá, você só precisa saber como acessá-la.


O pacote reflect depende dos detalhes desta implementação para executar com sucesso reflect.Value.Call() . Lá (reflect / makefunc.go), você pode espionar o próximo passo para obter o endereço da função:


 dummy := makeFuncStub code := **(**uintptr)(unsafe.Pointer(&dummy)) 

O código acima demonstra uma ideia básica que você pode refinar para uma função:


 // funcAddr returns function value fn executable code address. func funcAddr(fn interface{}) uintptr { // emptyInterface is the header for an interface{} value. type emptyInterface struct { typ uintptr value *uintptr } e := (*emptyInterface)(unsafe.Pointer(&fn)) return *e.value } 

O add1 função add1 pode ser add1 chamando funcAddr(add1) .


Obtendo um bloco de código de função da máquina


Agora que temos o endereço do início do código da função da máquina, gostaríamos de obter todo o código da função da máquina. Aqui você precisa determinar onde o código da função atual termina.


Se a arquitetura x86 tivesse instruções de comprimento fixo, não seria tão difícil e várias heurísticas poderiam nos ajudar, entre as quais:


  • Como regra, no final do código de função, há uma INT3 instruções INT3 . Este é um bom marcador para o final do código da função, mas pode estar ausente.
  • As funções com um quadro diferente de zero para a pilha têm um prólogo que verifica se essa pilha precisa ser expandida. Se sim, um salto para o código é realizado imediatamente após o código da função e, em seguida, um salto para o início da função. O código em que estamos interessados ​​estará no meio.

Mas você precisará decodificar honestamente as instruções, porque um byte-a-passo pode encontrar o byte INT3 em outra instrução. Calcular o comprimento de uma instrução para pular também não é tão fácil, porque é x86, querida .


O endereço de uma função no contexto do pacote de runtime de runtime às vezes é chamado de PC , para enfatizar a capacidade de usar o endereço em algum lugar dentro da função, e não apenas no ponto de entrada da função. O resultado de funcAddr pode ser usado como argumento para a função runtime.FuncForPC() para obter runtime.Func sem chamar a própria função. Através de transformações inseguras de Ano Novo, podemos acessar runtime._func , que é informativo, mas não muito útil: não há informações sobre o tamanho do bloco de código de função.


Parece que sem a ajuda dos ELFs não podemos lidar.


Para plataformas em que os arquivos executáveis ​​têm um formato diferente, a maior parte do artigo permanecerá relevante, mas você precisará usar não debug/elf , mas outro pacote de debug .

O ELF que está escondido no seu programa


As informações que precisamos já estão contidas nos metadados do arquivo ELF .


Através do os.Args[0] podemos acessar o próprio arquivo executável e já obter a tabela de símbolos dele.


 func readELF() (*elf.File, error) { f, err := os.Open(os.Args[0]) if err != nil { return nil, fmt.Errorf("open argv[0]: %w", err) } return elf.NewFile(f) } 

Procure um personagem dentro de elf.File


Todos os caracteres podem ser File.Symbols() usando o método File.Symbols() . Este método retorna []elf.Symbol , que contém o campo Symbol.Size - este é o "tamanho da função" que estamos Symbol.Size . O campo Symbol.Value deve corresponder ao valor retornado por funcAddr .


Você pode procurar o símbolo desejado pelo endereço ( Symbol.Value ) ou pelo nome ( Symbol.Name ). Se os caracteres foram classificados por nome, seria possível usar sort.Search() , mas não é assim:


Os símbolos serão listados na ordem em que aparecem no arquivo.

Se você frequentemente precisar encontrar caracteres na tabela, crie um índice adicional, por exemplo, através de map[string]*elf.Symbol ou map[uintptr]*elf.Symbol .


Como já sabemos como obter o endereço de uma função por seu valor, procuraremos por ela:


 func elfLookup(f *elf.File, value uint64) *elf.Symbol { symbols, err := f.Symbols() if err != nil { return nil } for _, sym := range symbols { if sym.Value == value { return &sym } } return nil } 

Nota : para que essa abordagem funcione, precisamos de uma tabela de caracteres. Se o binário for construído com ` -ldflags "-s" ', elfLookup() sempre retornará nil . Se você executar o programa em go run poderá encontrar o mesmo problema. Para exemplos do artigo, é recomendável executar ' go build ' ou ' go install ' para obter arquivos executáveis.

Obtendo o código de função da máquina


Conhecendo o intervalo de endereços em que o código executável está localizado, resta apenas retirá-lo na forma de []byte para processamento conveniente.


 func funcCode(addr uintptr) ([]byte, error) { elffile, err := readELF() if err != nil { return nil, fmt.Errorf("read elf: %w", err) } sym := elfLookup(elffile, uint64(addr)) if sym == nil { return nil, fmt.Errorf("can't lookup symbol for %x", addr) } code := *(*[]byte)(unsafe.Pointer(&reflect.SliceHeader{ Data: addr, Len: int(sym.Size), Cap: int(sym.Size), })) return code, nil } 

Este código é intencionalmente simplificado para demonstração. Você não deve ler ELF todas as vezes e fazer uma pesquisa linear em sua tabela.


O resultado da função funcCode() é uma fatia com bytes do código de função da máquina. Ela deve funcAddr() resultado da chamada de funcAddr() .


 code, err := funcCode(funcAddr(add1)) if err != nil { log.Panicf("can't get function code: %v", err) } fmt.Printf("% x\n", code) // => 48 8b 44 24 08 48 ff c0 48 89 44 24 10 c3 

Código da máquina de desmontagem


Para facilitar a leitura do código da máquina, usaremos um desmontador.


Estou mais familiarizado com os projetos zydis e Intel XED , portanto, antes de tudo, minha escolha é deles.


Para o Go, você pode usar a associação go-zydis , que é boa o suficiente e fácil de instalar para a nossa tarefa.


Vamos descrever a abstração de "ignorar as instruções da máquina", com a ajuda da qual é possível implementar outras operações:


 func walkDisasm(code []byte, visit func(*zydis.DecodedInstruction) error) error { dec := zydis.NewDecoder(zydis.MachineMode64, zydis.AddressWidth64) buf := code for len(buf) > 0 { instr, err := dec.Decode(buf) if err != nil { return err } if err := visit(instr); err != nil { return err } buf = buf[int(instr.Length):] } return nil } 

Essa função usa uma fatia do código da máquina como entrada e chama uma função de retorno de chamada para cada instrução decodificada.


Com base nisso, podemos escrever o printDisasm que printDisasm :


 func printDisasm(code []byte) error { const ZYDIS_RUNTIME_ADDRESS_NONE = math.MaxUint64 formatter, err := zydis.NewFormatter(zydis.FormatterStyleIntel) if err != nil { return err } return walkDisasm(code, func(instr *zydis.DecodedInstruction) error { s, err := formatter.FormatInstruction(instr, ZYDIS_RUNTIME_ADDRESS_NONE) if err != nil { return err } fmt.Println(s) return nil }) } 

Se executarmos printDisasm no add1 função add1 , obteremos o resultado esperado:


 mov rax, [rsp+0x08] inc rax mov [rsp+0x10], rax ret 

Validação de resultado


Agora tentaremos garantir que o código do assembler obtido na seção anterior esteja correto.


Como já temos um binário compilado, você pode usar o objdump fornecido com o Go:


 $ go tool objdump -s 'add1' exe TEXT main.add1(SB) example.go example.go:15 0x4bb760 488b442408 MOVQ 0x8(SP), AX example.go:15 0x4bb765 48ffc0 INCQ AX example.go:15 0x4bb768 4889442410 MOVQ AX, 0x10(SP) example.go:15 0x4bb76d c3 RET 

Tudo converge, apenas a sintaxe é um pouco diferente, o que é esperado.


Expressões de método


Se precisarmos fazer o mesmo com os métodos, em vez do nome da função, usaremos a expressão method .


Digamos que nosso add1 não seja realmente uma função, mas um método do tipo adder :


 type adder struct{} func (adder) add1(x int) int { return x + 2 } 

Em seguida, a chamada para obter o endereço da função se parecerá com funcAddr(adder.add1) .


Conclusão


Cheguei a essas coisas não por acaso e, talvez, em um dos seguintes artigos, eu lhe direi como foi planejado usar todos esses mecanismos. Enquanto isso, proponho tratar este artigo como uma descrição superficial de como o runtime de runtime e o reflect olham para nossas funções Go através do valor da função.


Lista de recursos usados:


Source: https://habr.com/ru/post/pt482392/


All Articles