Um pouco sobre realocações no kernel do Linux

Vamos resolver um problema simples - selecione um bloco de memória no espaço do kernel do Linux, coloque algum código binário nele e execute-o. Para fazer isso, escrevemos um módulo do kernel, nele definimos a função foo, que desempenhará o papel do código binário de que precisamos, usando a função module_alloc, selecione o bloco de memória, copie toda a função para ele através do memcpy e dê o controle.

Aqui está o que parece:

static noinline int foo(int ret)
{
	return (ret + 2);
}

static int exe_init(void)
{
	int ret = 0;
	int (*new_foo)(int);

	ret = foo(0);
	printk(KERN_INFO "ret=%d\n", ret);

	new_foo = module_alloc(PAGE_SIZE);
	set_memory_x((unsigned long)new_foo, 1);

	printk(KERN_INFO "foo=%lx new_foo=%lx\n",
		(unsigned long)foo, (unsigned long)new_foo);

	memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE);

	ret = new_foo(1);
	printk(KERN_INFO "ret=%d\n", ret);

	vfree(new_foo);
	return 0;
}

A função exe_init é chamada quando o módulo é carregado. Examinamos o resultado do trabalho no log do kernel:

[ 6972.522422] ret=2
[ 6972.522443] foo=ffffffffc0000000 new_foo=ffffffffc0007000
[ 6972.522457] ret=3

Tudo está funcionando corretamente. E agora adicionamos a função printk a foo para exibir o argumento:

static noinline int foo(int ret)
{
	printk(KERN_INFO "ret=%d\n", ret);
	return (ret + 2);
}

e despejar 25 bytes do conteúdo da função new_foo () antes de passar o controle para ela:

	memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE);
	dump((unsigned long)new_foo);

despejo é definido como

static inline void dump(unsigned long x)
{
	int i;
	for (i = 0; i < 25; i++) \
		pr_cont("%.2x ", *((unsigned char *)(x) + i) & 0xFF); \
	pr_cont("\n");
}

Carregamos o módulo e obtemos uma falha com a seguinte mensagem no log:

[ 8482.806092] ret=0
[ 8482.806092] ret=2
[ 8482.806111] foo=ffffffffc0000000 new_foo=ffffffffc0007000
[ 8482.806113] 53 89 fe 89 fb 48 c7 c7 24 10 00 c0 e8 e8 3d 0b c1 8d 43 02 5b c3 66 2e 0f 
[ 8482.806135] invalid opcode: 0000 [#1] SMP NOPTI
[ 8482.806639] CPU: 0 PID: 5081 Comm: insmod Tainted: G           O      5.4.27 #12
[ 8482.807669] Hardware name: innotek GmbH VirtualBox/VirtualBox, BIOS VirtualBox 12/01/2006
[ 8482.808560] RIP: 0010:irq_create_direct_mapping+0x79/0x90

De alguma forma, acabamos na função irq_create_direct_mapping, embora precisássemos chamar printk. Vamos descobrir o que aconteceu.

Primeiro, dê uma olhada na lista desmontada da função foo. Obtenha-o com o comando objdump -d:

Disassembly of section .text:

0000000000000000 <foo>:
   0:	53                   	push   %rbx
   1:	89 fe                	mov    %edi,%esi
   3:	89 fb                	mov    %edi,%ebx
   5:	48 c7 c7 00 00 00 00 	mov    $0x0,%rdi
   c:	e8 00 00 00 00       	callq  11 <foo+0x11>
  11:	8d 43 02             	lea    0x2(%rbx),%eax
  14:	5b                   	pop    %rbx
  15:	c3                   	retq   
  16:	66 2e 0f 1f 84 00 00 	nopw   %cs:0x0(%rax,%rax,1)
  1d:	00 00 00 

A função foo está localizada no início da seção de texto. No deslocamento 0xC, o código de operação do comando de chamada local e8 está localizado - próximo, porque é executado no segmento de código atual, o valor do seletor não muda. Os próximos 4 bytes são o deslocamento relativo ao valor no registro RIP para o qual o controle será transferido, ou seja, RIP = deslocamento RIP +, de acordo com a documentação da Intel (Manual do desenvolvedor de software das arquiteturas Intel 64 e IA-32, Referência do conjunto de instruções AZ):
Um deslocamento relativo (rel16 ou rel32) geralmente é especificado como uma etiqueta no código de montagem. Porém, no nível do código da máquina, ele é codificado como um valor imediato assinado de 16 ou 32 bits.
Este valor é adicionado ao valor no registro EIP (RIP). No modo de 64 bits, o deslocamento relativo é sempre um valor imediato de 32 bits, que é estendido para 64 bits antes de ser adicionado ao valor no registro RIP para o cálculo de destino.

Sabemos o endereço da função foo, que é 0xffffffffc0000000, portanto, em RIP = 0xffffffffc0000000 + 0xc + 0x5 = 0xffffffffc00000011 (0xc é o deslocamento da instrução e8, 1 byte da instrução e 4 bytes do deslocamento). Conhecemos o deslocamento, porque funções corporais despejadas. Vamos calcular para onde a chamada para nos enviar para a função foo enviará:

0xffffffffc00000011 + 0xffffffffc10b3de8 = 0xffffffff810b3df9

Este é o endereço da função printk:
# cat /proc/kallsyms | grep ffffffff810b3df9  
ffffffff810b3df9 T printk

E agora o mesmo vale para new_foo, cujo endereço é 0xffffffffc0007000

0xffffffffc0007011 + 0xffffffffc10b3de8 = 0xffffffff810badf9

Não existe esse endereço nas academias, mas há 0xffffffff88badbad9 - 0x79 = 0xffffffff810bad80
# cat /proc/kallsyms | grep ffffffff810bad80
ffffffff810bad80 T irq_create_direct_mapping

Esta é a própria função na qual o acidente ocorreu.

Para evitar uma falha, basta recalcular o deslocamento, sabendo o endereço da função new_foo:

memcpy((void *)new_foo, (const void *)foo, PAGE_SIZE);
unsigned int delta = (unsigned long)printk - (unsigned long)new_foo - 0x11;
*(unsigned int *)((void *)new_foo + 0xD) = delta;

Após essa correção, não haverá falha, a função new_foo executará e retornará o controle com êxito.

O problema está resolvido. Resta apenas entender por que no desmontador que lista o deslocamento após o código de operação e8 ser zero, mas não há função no despejo. Para fazer isso, considere o que são as realocações e como o kernel trabalha com elas. Mas primeiro, um pouco sobre o formato ELF.

ELF significa Executable and Linkable Format - o formato de arquivos executáveis ​​e composíveis. Um arquivo ELF é uma coleção de seções. A seção armazena um conjunto de objetos necessários para o vinculador formar uma imagem executável - instruções, dados, tabelas de símbolos, registros de realocações etc. Cada seção é descrita por um cabeçalho. Todos os cabeçalhos são coletados em uma tabela de cabeçalhos e são essencialmente uma matriz em que cada elemento tem um índice. O cabeçalho da seção contém um deslocamento para o início da seção e outras informações gerais, como links para outras seções, especificando um índice na tabela de cabeçalho.

Ao montar nosso caso de teste, o compilador não sabe o endereço da função printk, portanto, preenche o local da chamada com um valor zero e, usando um registro de realocação, informa ao kernel que esta posição deve ser preenchida com um valor válido. Um registro de realocação contém um deslocamento para a posição em que você deseja fazer alterações (posição de realocação), o tipo de realocação e o índice do símbolo na tabela de símbolos, cujo endereço deve ser substituído no deslocamento especificado. Qual é o tipo de realocação? Considere abaixo. O cabeçalho da seção dos registros de realocação refere-se, por meio de índices, aos cabeçalhos da seção com uma tabela de caracteres e seções, relativos ao início dos quais é especificado um deslocamento para a posição da realocação.

Você pode examinar o conteúdo dos registros de realocação usando o utilitário objdump com a opção -r.
A partir da lista desmontada, sabemos que no deslocamento 0xD é necessário escrever o endereço da função printk, portanto, procuramos a saída objdump com a seguinte posição:

000000000000000d R_X86_64_PC32     printk-0x0000000000000004

Portanto, temos o registro de realocação necessário indicando a posição no deslocamento 0xD e o nome do símbolo cujo endereço deve ser gravado nessa posição.

Valor (-4). que é adicionado ao endereço da função printk é chamado de adendo e é levado em consideração no cálculo do resultado final da realocação.

Agora observe o símbolo printk:

$ objdump -t exe.ko | grep printk
0000000000000000         *UND*	0000000000000000 printk

Há um símbolo, ele é indefinido dentro do módulo (indefinido), então vamos procurá-lo no kernel.

Será mais informativo examinar os registros de realocação e símbolos em formato binário. Isso pode ser feito usando o wireshark, ele pode analisar o formato ELF. Aqui está a nossa entrada de realocação (copie e cole do writehark, LSB à esquerda):

  0d 00 00 00 00 00 00 00  02 00 00 00 22 00 00 00  fc ff ff ff ff ff ff ff
  |                     |  |          ||         |  |                     |
  +----  -------+  +--  ---++---+  +---- addendum  ------+

Compare esta entrada com a definição da estrutura correspondente em <linux / elf.h>:

typedef struct elf64_rela {
  Elf64_Addr r_offset;	/* Location at which to apply the action */
  Elf64_Xword r_info;	/* index and type of relocation */
  Elf64_Sxword r_addend;	/* Constant addend used to compute value */
} Elf64_Rela;

Aqui temos deslocamento de 8 bytes 0x00000000d, tipo de 4 bytes 0x00000002, índice de 4 bytes na tabela de caracteres 0x00000022 (ou 34 em decimal) e adendo de 8 bytes -4.

E aqui está a entrada da tabela de símbolos no número 34:

  01 01 00 00 10 00 00 00  00 00 00 00 00 00 00 00  00 00 00 00 00 00 00 00

e estrutura relacionada

typedef struct elf64_sym {
  Elf64_Word st_name;		/* Symbol name, index in string tbl */
  unsigned char	st_info;	/* Type and binding attributes */
  unsigned char	st_other;	/* No defined meaning, 0 */
  Elf64_Half st_shndx;		/* Associated section index */
  Elf64_Addr st_value;		/* Value of the symbol */
  Elf64_Xword st_size;		/* Associated symbol size */
} Elf64_Sym;

Os primeiros 4 bytes 0x00000101 são o índice na tabela de cadeias .strtab para o nome desse caractere, ou seja, printk. O campo st_info define o tipo de símbolo, pode ser uma função, objeto de dados etc., consulte a especificação ELF para obter mais detalhes. Iremos pular o campo st_other, agora não nos interessa, e examinar os três últimos campos st_shndx, st_value e st_size. st_shndx - o índice do cabeçalho da seção na qual o caractere está definido. Vemos aqui um valor zero, porque o símbolo não está definido dentro do módulo, não está nas seções disponíveis.
Assim, seu valor st_value e tamanho st_size também são zero. Esses campos serão preenchidos pelo kernel ao carregar o módulo.

Para comparação, observe o símbolo foo, que está claramente presente:

  08 00 00 00 02 00 02 00  00 00 00 00 00 00 00 00  16 00 00 00 00 00 00 00

O símbolo define uma função que está localizada na seção .text no endereço relativo ao início da seção 0x00000000, ou seja, no início da seção, como vimos na lista desmontada, o tamanho da função é 22 bytes.

O Objdump nos mostrará as mesmas informações sobre isso:

$ objdump -t exe.ko | grep foo
0000000000000000 l     F .text	0000000000000016 foo

Quando o kernel carrega o módulo, ele encontra todos os caracteres indefinidos e preenche os campos st_value e st_size com valores válidos. Isso é feito na função simplify_symbols, arquivo kernel / module.c:

/* Change all symbols so that st_value encodes the pointer directly. */
static int simplify_symbols(struct module *mod, const struct load_info *info)
{
...

Nos parâmetros da função, a estrutura load_info do seguinte formulário é passada

struct load_info {
	const char *name;
	/* pointer to module in temporary copy, freed at end of load_module() */
	struct module *mod;
	Elf_Ehdr *hdr;
	unsigned long len;
	Elf_Shdr *sechdrs;
	char *secstrings, *strtab;
	unsigned long symoffs, stroffs, init_typeoffs, core_typeoffs;
	struct _ddebug *debug;
	unsigned int num_debug;
	bool sig_ok;
#ifdef CONFIG_KALLSYMS
	unsigned long mod_kallsyms_init_off;
#endif
	struct {
		unsigned int sym, str, mod, vers, info, pcpu;
	} index;
};

Os seguintes campos são interessantes para nós:
- hdr - cabeçalho do arquivo ELF
- sechdrs - ponteiro para a tabela de cabeçalho da seção
- strtab - tabela de nome do símbolo - um conjunto de cadeias separadas por zeros
- index.sym - índice do cabeçalho da seção que contém a tabela de símbolos

Antes de tudo, a função terá acesso para a seção com a tabela de símbolos. A tabela de símbolos é uma matriz de elementos do tipo Elf64_Sym:

Elf64_Shdr *symsec = &info->sechdrs[info->index.sym];
Elf64_Sym *sym = (void *)symsec->sh_addr;

Em seguida, no loop, examinamos todos os caracteres da tabela, determinando para cada um seu nome:
for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) {
	const char *name = info->strtab + sym[i].st_name;

O campo st_shndx contém o índice do cabeçalho da seção na qual esse caractere está definido. Se houver um valor zero (nosso caso), esse símbolo não está dentro do módulo, você deve procurá-lo no kernel:

	switch (sym[i].st_shndx) {
	.....
	 case SHN_UNDEF: //  0
	ksym = resolve_symbol_wait(mod, info, name);
 	/* Ok if resolved.  */
	if (ksym && !IS_ERR(ksym)) {
		sym[i].st_value = kernel_symbol_value(ksym);
		break;
	}

Em seguida, vem a fila de realocação na função apply_relocations:

static int apply_relocations(struct module *mod, const struct load_info *info)
{
	unsigned int i;
	int err = 0;

	/* Now do relocations. */
	for (i = 1; i < info->hdr->e_shnum; i++) {
	.....

No loop, procuramos seções de realocação e processamos os registros de cada seção encontrada na função apply_relocate_add:

if (info->sechdrs[i].sh_type == SHT_RELA) //   
	err = apply_relocate_add(info->sechdrs, info->strtab,
				info->index.sym, i, mod);

Um ponteiro para uma tabela de cabeçalho de seção, um ponteiro para uma tabela de nome de símbolo, um índice de cabeçalho de seção com uma tabela de símbolos e um índice de cabeçalho de seção de realocação são passados ​​para apply_relocate_add:

int apply_relocate_add(Elf64_Shdr *sechdrs,
	   const char *strtab,
	   unsigned int symindex,
	   unsigned int relsec,
	   struct module *me)
{

Primeiro, abordamos a seção de realocações:

Elf64_Rela *rel = (void *)sechdrs[relsec].sh_addr;

Então, em um loop, classificamos uma matriz de suas entradas:

for (i = 0; i < sechdrs[relsec].sh_size / sizeof(*rel); i++) {

Encontramos a seção para realocação e a posição nela, ou seja, onde precisamos fazer alterações. O campo sh_info do cabeçalho da seção de realocação é o índice do cabeçalho da seção para realocação, o campo r_offset do registro de realocação é o deslocamento para a posição dentro da seção para realocação:

/* This is where to make the change */
loc = (void *)sechdrs[sechdrs[relsec].sh_info].sh_addr + rel[i].r_offset;

O endereço do caractere a ser substituído nesta posição, levando em consideração o adendo. O campo r_info da entrada de realocação contém o índice desse símbolo na tabela de símbolos:

	/* This is the symbol it is referring to.  Note that all
	   undefined symbols have been resolved.  */
	sym = (Elf64_Sym *)sechdrs[symindex].sh_addr
		+ ELF64_R_SYM(rel[i].r_info);

	val = sym->st_value + rel[i].r_addend;

O tipo de realocação determina o resultado final dos cálculos, em nosso exemplo é R_X86_64_PLT32:

	switch (ELF64_R_TYPE(rel[i].r_info)) {
	......
	case R_X86_64_PLT32:	
		if (*(u32 *)loc != 0)
			goto invalid_relocation;
		val -= (u64)loc;	//   
		*(u32 *)loc = val;  //    
		break;
	.....

Agora podemos calcular o valor final, sabendo que sym-> st_value é o endereço da função printk 0xffffffff810b3df9, r_addend é (-4), o deslocamento para a posição de realocação é 0xd no início da seção de texto do módulo ou no início da função foo, ou seja, será ffffffffc000000d. Substitua todos esses valores e obtenha:

val = (u32)(0xffffffff810b3df9 - 0x4 - 0xffffffffc000000d) = 0xc10b3de8

Vejamos o despejo da função foo, que recebemos no início:

53 89 fe 89 fb 48 c7 c7 24 10 00 c0 e8 e8 3d 0b c1 8d 43 02 5b c3 66 2e 0f

No deslocamento 0xD, o valor 0xc10b3de8 é encontrado, idêntico ao calculado.

É assim que o kernel processa as realocações e obtém o deslocamento necessário para o comando fechar chamada.

Na preparação do artigo, foi utilizado o kernel Linux versão 5.4.27.

All Articles