Un peu sur les délocalisations dans le noyau Linux

Nous allons résoudre un problème simple - sélectionner un bloc de mémoire dans l'espace du noyau Linux, y mettre du code binaire et l'exécuter. Pour ce faire, nous écrivons un module du noyau, nous y définissons la fonction foo, qui jouera le rôle du code binaire dont nous avons besoin, puis, en utilisant la fonction module_alloc, sélectionnez le bloc de mémoire, copiez-lui toute cette fonction via memcpy et lui donnez le contrôle.

Voici à quoi ça ressemble:

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;
}

La fonction exe_init est appelée lorsque le module est chargé. Nous regardons le résultat du travail dans le journal du noyau:

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

Tout fonctionne correctement. Et maintenant, nous ajoutons la fonction printk à foo pour afficher l'argument:

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

et vider 25 octets du contenu de la fonction new_foo () avant de lui passer le contrôle:

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

le vidage est défini comme

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");
}

Nous chargeons le module et obtenons un plantage avec le message suivant dans le journal:

[ 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

D'une manière ou d'une autre, nous nous sommes retrouvés dans la fonction irq_create_direct_mapping, bien que nous ayons dû appeler printk. Voyons ce qui s'est passé.

Tout d'abord, jetez un œil à la liste démontée de la fonction foo. Obtenez-le avec la commande 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 

La fonction foo est située au début de la section de texte. À l'offset 0xC, l'opcode de la commande d'appel proche e8 est situé - près, car il est exécuté dans le segment de code actuel, la valeur du sélecteur ne change pas. Les 4 octets suivants sont le décalage par rapport à la valeur dans le registre RIP vers lequel le contrôle sera transféré, c'est-à-dire RIP = RIP + offset, selon la documentation d'Intel (Intel 64 et IA-32 Architectures Software Developer's Manual, Instruction Set Reference AZ):
Un décalage relatif (rel16 ou rel32) est généralement spécifié sous forme d'étiquette dans le code d'assemblage. Mais au niveau du code machine, il est codé en tant que valeur immédiate signée, 16 ou 32 bits.
Cette valeur est ajoutée à la valeur du registre EIP (RIP). En mode 64 bits, le décalage relatif est toujours une valeur immédiate de 32 bits qui est étendue à 64 bits avant d'être ajoutée à la valeur du registre RIP pour le calcul cible.

Nous connaissons l'adresse de la fonction foo, c'est 0xffffffffc0000000, donc dans RIP = 0xffffffffc0000000 + 0xc + 0x5 = 0xffffffffc00000011 (0xc est l'offset à l'instruction e8, 1 octet de l'instruction et 4 octets de l'offset). Nous connaissons le décalage, car fonctions corporelles sous-évaluées. Calculons où l'appel à nous envoyer vers la fonction foo enverra:

0xffffffffc00000011 + 0xffffffffc10b3de8 = 0xffffffff810b3df9

Voici l'adresse de la fonction printk:
# cat /proc/kallsyms | grep ffffffff810b3df9  
ffffffff810b3df9 T printk

Et maintenant, il en va de même pour new_foo, dont l'adresse est 0xffffffffc0007000

0xffffffffc0007011 + 0xffffffffc10b3de8 = 0xffffffff810badf9

Il n'y a pas une telle adresse dans les kallsymes, mais il y a 0xffffffff810badf9 - 0x79 = 0xffffffff810bad80
# cat /proc/kallsyms | grep ffffffff810bad80
ffffffff810bad80 T irq_create_direct_mapping

C'est la fonction même sur laquelle le crash s'est produit.

Pour éviter un plantage, recalculez simplement l'offset en connaissant l'adresse de la fonction 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;

Après cette correction, il n'y aura pas de plantage, la fonction new_foo s'exécutera avec succès et renverra le contrôle.

Le problème est résolu. Il ne reste plus qu'à comprendre pourquoi dans le désassembleur listant le décalage après l'opcode e8 est zéro, mais il n'y a pas de fonction dans le vidage. Pour ce faire, considérez ce que sont les délocalisations et comment le noyau fonctionne avec elles. Mais d'abord, un peu sur le format ELF.

ELF signifie Executable and Linkable Format - le format des fichiers exécutables et composables. Un fichier ELF est une collection de sections. La section stocke un ensemble d'objets nécessaires pour que l'éditeur de liens forme une image exécutable - instructions, données, tables de symboles, enregistrements de relocalisations, etc. Chaque section est décrite par un en-tête. Tous les en-têtes sont collectés dans un tableau d'en-têtes et sont essentiellement un tableau où chaque élément a un index. L'en-tête de section contient un décalage par rapport au début de la section et d'autres informations de surcharge, telles que des liens vers d'autres sections en spécifiant un index dans la table d'en-tête.

Lors de la construction de notre cas de test, le compilateur ne connaît pas l'adresse de la fonction printk, il remplit donc l'emplacement d'appel avec une valeur nulle et, à l'aide d'un enregistrement de relocalisation, indique au noyau que cette position doit être remplie avec une valeur valide. Un enregistrement de relocalisation contient un décalage par rapport à la position où vous souhaitez apporter des modifications (position de relocalisation), le type de relocalisation et l'index du symbole dans la table des symboles, dont l'adresse doit être remplacée à l'offset spécifié. À quoi sert la réinstallation? Réfléchissez ci-dessous. L'en-tête de la section des enregistrements de relocalisation fait référence par des index aux en-têtes de la section avec une table de caractères et de sections, par rapport au début desquels un décalage par rapport à la position de la relocalisation est spécifié.

Vous pouvez consulter le contenu des enregistrements de relocalisation à l'aide de l'utilitaire objdump avec le commutateur -r.
De la liste démontée, nous savons qu'à l'offset 0xD, il est nécessaire d'écrire l'adresse de la fonction printk, nous recherchons donc la sortie objdump avec la position suivante:

000000000000000d R_X86_64_PC32     printk-0x0000000000000004

Ainsi, nous avons l'enregistrement de relocalisation nécessaire indiquant la position à l'offset 0xD et le nom du symbole dont l'adresse doit être écrite à cette position.

Valeur (-4). qui est ajoutée à l'adresse de la fonction printk est appelée addendum, et elle est prise en compte lors du calcul du résultat final de la relocalisation.

Regardez maintenant le symbole printk:

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

Il y a un symbole, il n'est pas défini à l'intérieur du module (non défini), nous allons donc le rechercher dans le noyau.

Il sera plus instructif d'examiner les enregistrements de relocalisation et les symboles sous forme binaire. Cela peut être fait à l'aide de Wireshark, il peut analyser le format ELF. Voici notre entrée de relocalisation (copier-coller de writeshark, LSB à gauche):

  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  ------+

Comparez cette entrée avec la définition de la structure correspondante de <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;

Ici, nous avons 8 octets de décalage 0x00000000d, 4 octets de type 0x00000002, 4 octets d'index dans la table de caractères 0x00000022 (ou 34 en décimal) et 8 octets d'addendum -4.

Et voici l'entrée de la table des symboles au numéro 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

et structure connexe

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;

Les 4 premiers octets 0x00000101 sont l'index dans le tableau des chaînes .strtab du nom de ce caractère, c'est-à-dire printk. Le champ st_info définit le type de symbole, il peut s'agir d'une fonction, d'un objet de données, etc., voir la spécification ELF pour plus de détails. Nous allons sauter le champ st_other, maintenant cela ne nous intéresse plus, et regarder les trois derniers champs st_shndx, st_value et st_size. st_shndx - l'index d'en-tête de la section dans laquelle le caractère est défini. Nous voyons ici une valeur nulle, car le symbole n'est pas défini à l'intérieur du module, il n'est pas dans les sections disponibles.
Par conséquent, sa valeur st_value et sa taille st_size sont également nulles. Ces champs seront remplis par le noyau lors du chargement du module.

À titre de comparaison, regardez le symbole foo, qui est clairement présent:

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

Le symbole définit une fonction qui se trouve dans la section .text à l'adresse par rapport au début de la section 0x00000000, c'est-à-dire au tout début de la section, comme nous l'avons vu dans la liste démontée, la taille de la fonction est de 22 octets.

Objdump nous montrera les mêmes informations à ce sujet:

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

Lorsque le noyau charge le module, il trouve tous les caractères non définis et remplit les champs st_value et st_size avec des valeurs valides. Cela se fait dans la fonction simplify_symbols, fichier 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)
{
...

Dans les paramètres de la fonction, la structure load_info du formulaire suivant est passée

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;
};

Les champs suivants nous intéressent:
- hdr - en-tête de fichier ELF
- sechdrs - pointeur vers la table des en-têtes de section
- strtab - table des noms de symboles - un ensemble de chaînes séparées par des zéros
- index.sym - index de l'en-tête de section contenant la table des symboles

Tout d'abord, la fonction aura accès à la section avec la table des symboles. La table des symboles est un tableau d'éléments de type Elf64_Sym:

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

Ensuite, dans la boucle, nous parcourons tous les caractères du tableau, déterminant pour chacun son nom:
for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) {
	const char *name = info->strtab + sym[i].st_name;

Le champ st_shndx contient l'index d'en-tête de la section dans laquelle ce caractère est défini. S'il y a une valeur nulle (notre cas), alors ce symbole n'est pas à l'intérieur du module, vous devez le chercher dans le noyau:

	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;
	}

Vient ensuite la file d'attente de relocalisation dans la fonction 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++) {
	.....

Dans la boucle, nous recherchons des sections de relocalisation et traitons les enregistrements de chaque section trouvée dans la fonction apply_relocate_add:

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

Un pointeur vers une table d'en-tête de section, un pointeur vers une table de noms de symboles, un index d'en-tête de section avec une table de symboles et un index d'en-tête de section de relocalisation sont passés à apply_relocate_add:

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

Nous abordons d'abord la section des délocalisations:

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

Ensuite, en boucle, nous trions un tableau de ses entrées:

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

Nous trouvons la section pour la réinstallation et la position en elle, c'est-à-dire où nous devons apporter des modifications. Le champ sh_info de l'en-tête de section de relocalisation est l'index de l'en-tête de section pour la relocalisation, le champ r_offset de l'enregistrement de relocalisation est le décalage par rapport à la position à l'intérieur de la section pour la relocalisation:

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

L'adresse du personnage à remplacer dans cette position, compte tenu de l'addendum. Le champ r_info de l'entrée de relocalisation contient l'index de ce symbole dans la table des symboles:

	/* 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;

Le type de délocalisation détermine le résultat final des calculs, dans notre exemple c'est 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;
	.....

Maintenant, nous pouvons calculer nous-mêmes la valeur finale, sachant que sym-> st_value est l'adresse de la fonction printk 0xffffffff810b3df9, r_addend est (-4), le décalage par rapport à la position de relocalisation est 0xd à partir du début de la section de texte du module, ou à partir du début de la fonction foo, c'est-à-dire sera ffffffffc000000d. Remplacez toutes ces valeurs et obtenez:

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

Regardons le vidage de la fonction foo, que nous avons obtenu au tout début:

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

Au décalage 0xD, la valeur 0xc10b3de8 est trouvée, qui est identique à celle que nous avons calculée.

C'est ainsi que le noyau traite les délocalisations et obtient le décalage nécessaire pour la commande close call.

Lors de la préparation de l'article, la version 5.4.27 du noyau Linux a été utilisée.

All Articles