Ein bisschen über Umzüge im Linux-Kernel

Wir werden ein einfaches Problem lösen - wählen Sie einen Speicherblock im Bereich des Linux-Kernels aus, fügen Sie Binärcode ein und führen Sie ihn aus. Dazu schreiben wir ein Kernelmodul, definieren darin die Funktion foo, die die Rolle des benötigten Binärcodes spielt, wählen dann mit der Funktion module_alloc den Speicherblock aus, kopieren diese gesamte Funktion über memcpy und geben ihr die Kontrolle.

So sieht es aus:

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

Die Funktion exe_init wird beim Laden des Moduls aufgerufen. Wir betrachten das Ergebnis der Arbeit im Kernel-Protokoll:

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

Alles funktioniert richtig. Und jetzt fügen wir die Funktion printk zu foo hinzu, um das Argument anzuzeigen:

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

und sichern Sie 25 Bytes des Inhalts der Funktion new_foo (), bevor Sie die Kontrolle an sie übergeben:

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

dump ist definiert als

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

Wir laden das Modul und erhalten einen Absturz mit der folgenden Meldung im Protokoll:

[ 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

Irgendwie sind wir in der Funktion irq_create_direct_mapping gelandet, obwohl wir printk aufrufen mussten. Lassen Sie uns herausfinden, was passiert ist.

Schauen Sie sich zunächst die zerlegte Liste der foo-Funktion an. Holen Sie es sich mit dem Befehl 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 

Die Funktion foo befindet sich am Anfang des Textabschnitts. Bei Offset 0xC befindet sich der Opcode des Near-Call-Befehls e8 - Near ändert sich nicht, da er im aktuellen Codesegment ausgeführt wird. Die nächsten 4 Bytes sind der Versatz relativ zu dem Wert im RIP-Register, an den die Steuerung übertragen wird, d. H. RIP = RIP + Offset gemäß Intel-Dokumentation (Entwicklerhandbuch für Intel 64- und IA-32-Architekturen-Software, Befehlssatzreferenz AZ):
Ein relativer Versatz (rel16 oder rel32) wird im Assemblycode im Allgemeinen als Beschriftung angegeben. Auf der Ebene des Maschinencodes wird es jedoch als vorzeichenbehafteter 16- oder 32-Bit-Sofortwert codiert.
Dieser Wert wird zum Wert im EIP-Register (RIP) addiert. Im 64-Bit-Modus ist der relative Offset immer ein 32-Bit-Sofortwert, dessen Vorzeichen auf 64 Bit erweitert wird, bevor er dem Wert im RIP-Register für die Zielberechnung hinzugefügt wird.

Wir kennen die Adresse der Funktion foo, sie ist 0xffffffffc0000000, also in RIP = 0xffffffffc0000000 + 0xc + 0x5 = 0xffffffffc00000011 (0xc ist der Offset zum Befehl e8, 1 Byte des Befehls und 4 Bytes des Offsets). Wir kennen den Offset, weil gedumpte Körperfunktionen. Berechnen wir, wohin der Anruf, der uns an die Funktion foo senden soll, gesendet wird:

0xffffffffc00000011 + 0xffffffffc10b3de8 = 0xffffffff810b3df9

Dies ist die Adresse der printk-Funktion:
# cat /proc/kallsyms | grep ffffffff810b3df9  
ffffffff810b3df9 T printk

Und jetzt gilt das Gleiche für new_foo, dessen Adresse 0xffffffffc0007000 lautet

0xffffffffc0007011 + 0xffffffffc10b3de8 = 0xffffffff810badf9

In kallsyms gibt es keine solche Adresse, aber 0xffffffff810badf9 - 0x79 = 0xffffffff810bad80
# cat /proc/kallsyms | grep ffffffff810bad80
ffffffff810bad80 T irq_create_direct_mapping

Dies ist genau die Funktion, bei der der Absturz passiert ist.

Um einen Absturz zu verhindern, berechnen Sie einfach den Offset neu und kennen die Adresse der Funktion 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;

Nach dieser Korrektur gibt es keinen Absturz, die Funktion new_foo wird erfolgreich ausgeführt und gibt die Steuerung zurück.

Das Problem ist behoben. Es bleibt nur zu verstehen, warum in der Disassembler-Liste der Offset nach dem e8-Opcode Null ist, aber es gibt keine Funktion im Dump. Überlegen Sie sich dazu, was Verschiebungen sind und wie der Kernel mit ihnen arbeitet. Aber zuerst ein wenig zum ELF-Format.

ELF steht für Executable and Linkable Format - das Format für ausführbare und zusammensetzbare Dateien. Eine ELF-Datei ist eine Sammlung von Abschnitten. Der Abschnitt speichert eine Reihe von Objekten, die der Linker benötigt, um ein ausführbares Bild zu erstellen - Anweisungen, Daten, Symboltabellen, Aufzeichnungen von Verschiebungen usw. Jeder Abschnitt wird durch eine Überschrift beschrieben. Alle Header werden in einer Tabelle mit Headern gesammelt und sind im Wesentlichen ein Array, in dem jedes Element einen Index hat. Der Abschnittskopf enthält einen Versatz zum Anfang des Abschnitts und andere Overhead-Informationen, z. B. Links zu anderen Abschnitten, indem ein Index in der Kopfzeilentabelle angegeben wird.

Beim Zusammenstellen unseres Testfalls kennt der Compiler die Adresse der printk-Funktion nicht, füllt daher den Aufrufort mit einem Nullwert und teilt dem Kernel mithilfe eines Verschiebungsdatensatzes mit, dass diese Position mit einem gültigen Wert gefüllt werden muss. Ein Umzugsdatensatz enthält einen Versatz zu der Position, an der Sie Änderungen vornehmen möchten (Umzugsposition), die Art des Umzugs und den Index des Symbols in der Symboltabelle, dessen Adresse durch den angegebenen Versatz ersetzt werden muss. Wofür ist die Art des Umzugs? Wir betrachten unten. Die Überschrift des Abschnitts der Umzugsdatensätze verweist durch Indizes auf die Überschriften des Abschnitts mit einer Tabelle von Zeichen und Abschnitten, zu deren Beginn ein Versatz zur Position des Umzugs angegeben ist.

Sie können den Inhalt von Verschiebungsdatensätzen mit dem Dienstprogramm objdump mit der Option -r anzeigen.
Aus der zerlegten Auflistung wissen wir, dass es bei Offset 0xD notwendig ist, die Adresse der printk-Funktion zu schreiben, daher suchen wir nach einer objdump-Ausgabe mit der folgenden Position:

000000000000000d R_X86_64_PC32     printk-0x0000000000000004

Wir haben also den erforderlichen Umzugsdatensatz, der die Position am Versatz 0xD angibt, und den Namen des Symbols, dessen Adresse an diese Position geschrieben werden soll.

Wert (-4). Die zur Adresse der printk-Funktion hinzugefügte Funktion wird als Nachtrag bezeichnet und bei der Berechnung des Endergebnisses der Verlagerung berücksichtigt.

Schauen Sie sich nun das printk-Symbol an:

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

Es gibt ein Symbol, es ist innerhalb des Moduls undefiniert (undefiniert), daher werden wir es im Kernel suchen.

Es wird informativer sein, die Aufzeichnungen über Umzüge und Symbole in binärer Form zu betrachten. Dies kann mit Wireshark erfolgen und das ELF-Format analysieren. Hier ist unser Umzugseintrag (Kopieren Einfügen von Writeshark, LSB links):

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

Vergleichen Sie diesen Eintrag mit der Definition der entsprechenden Struktur aus <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;

Hier haben wir 8 Bytes Offset 0x00000000d, 4 Bytes Typ 0x00000002, 4 Bytes Index in der Zeichentabelle 0x00000022 (oder 34 in Dezimalzahl) und 8 Bytes Nachtrag -4.

Und hier ist der Eintrag aus der Symboltabelle unter Nummer 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

und verwandte Struktur

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;

Die ersten 4 Bytes 0x00000101 sind der Index in der Tabelle der Zeichenfolgen .strtab zum Namen dieses Zeichens, d. H. printk. Das Feld st_info definiert den Symboltyp. Es kann sich um eine Funktion, ein Datenobjekt usw. handeln. Weitere Informationen finden Sie in der ELF-Spezifikation. Wir werden das Feld st_other überspringen, jetzt ist es für uns nicht mehr von Interesse, und uns die letzten drei Felder st_shndx, st_value und st_size ansehen. st_shndx - der Header-Index des Abschnitts, in dem das Zeichen definiert ist. Wir sehen hier einen Nullwert, weil Das Symbol ist im Modul nicht definiert und befindet sich nicht in den verfügbaren Abschnitten.
Dementsprechend sind auch sein st_value-Wert und seine st_size-Größe Null. Diese Felder werden beim Laden des Moduls vom Kernel ausgefüllt.

Schauen Sie sich zum Vergleich das Symbol foo an, das deutlich vorhanden ist:

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

Das Symbol definiert eine Funktion, die sich im Textabschnitt an der Adresse relativ zum Anfang des Abschnitts 0x00000000 befindet, d. H. Ganz am Anfang des Abschnitts, wie wir in der zerlegten Liste gesehen haben, beträgt die Funktionsgröße 22 Bytes.

Objdump zeigt uns die gleichen Informationen dazu:

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

Wenn der Kernel das Modul lädt, findet er alle undefinierten Zeichen und füllt die Felder st_value und st_size mit gültigen Werten. Dies erfolgt in der Funktion simplify_symbols, Datei 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)
{
...

In den Parametern der Funktion wird die load_info-Struktur des folgenden Formulars übergeben

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

Die folgenden Felder sind für uns von Interesse:
- hdr - ELF-Dateikopf
- sechdrs - Zeiger auf die Abschnittskopfzeilentabelle
- strtab - Symbolnamentabelle - eine Reihe von Zeichenfolgen, die durch Nullen getrennt sind
- index.sym - Index des Abschnittskopfes, der die Symboltabelle enthält

Zunächst erhält die Funktion Zugriff zu dem Abschnitt mit der Symboltabelle. Die Symboltabelle ist ein Array von Elementen vom Typ Elf64_Sym:

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

Als nächstes gehen wir in der Schleife alle Zeichen in der Tabelle durch und bestimmen für jedes den Namen:
for (i = 1; i < symsec->sh_size / sizeof(Elf_Sym); i++) {
	const char *name = info->strtab + sym[i].st_name;

Das Feld st_shndx enthält den Header-Index des Abschnitts, in dem dieses Zeichen definiert ist. Wenn es einen Nullwert gibt (unser Fall), befindet sich dieses Symbol nicht im Modul. Sie müssen es im Kernel suchen:

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

Dann kommt die Umzugswarteschlange in der Funktion 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++) {
	.....

In der Schleife suchen wir nach Verschiebungsabschnitten und verarbeiten die Datensätze jedes Abschnitts, die in der Funktion apply_relocate_add gefunden wurden:

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

Ein Zeiger auf eine Abschnittsüberschriftentabelle, ein Zeiger auf eine Symbolnamentabelle, ein Abschnittsüberschriftenindex mit einer Symboltabelle und ein Verschiebungsabschnittsüberschriftenindex werden an apply_relocate_add übergeben:

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

Zuerst befassen wir uns mit dem Abschnitt Umzüge:

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

Dann iterieren Sie in einer Schleife über das Array seiner Einträge:

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

Wir finden den Abschnitt für den Umzug und die Position darin, d.h. wo wir Änderungen vornehmen müssen. Das Feld sh_info des Verschiebungsabschnittskopfs ist der Index des Abschnittskopfs für den Umzug, das Feld r_offset des Umzugsdatensatzes ist der Versatz zur Position innerhalb des Abschnitts für den Umzug:

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

Die Adresse des Zeichens, das an dieser Stelle unter Berücksichtigung des Nachtrags ersetzt werden soll. Das Feld r_info des Umzugseintrags enthält den Index dieses Symbols in der Symboltabelle:

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

Die Art der Verlagerung bestimmt das Endergebnis der Berechnungen. In unserem Beispiel ist dies 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;
	.....

Jetzt können wir den endgültigen Wert selbst berechnen, wobei wir wissen, dass sym-> st_value die Adresse der printk-Funktion 0xffffffff810b3df9 ist, r_addend (-4) ist, der Versatz zur Position der Verschiebung 0xd vom Anfang des Modultextabschnitts oder vom Beginn der foo-Funktion ist, d. H. wird ffffffffc000000d sein. Ersetzen Sie alle diese Werte und erhalten Sie:

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

Schauen wir uns den Dump der foo-Funktion an, den wir ganz am Anfang haben:

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

Bei Offset 0xD wird der Wert 0xc10b3de8 gefunden, der mit dem von uns berechneten identisch ist.

Auf diese Weise verarbeitet der Kernel Verschiebungen und erhält den erforderlichen Offset für den Befehl zum Schließen des Aufrufs.

Bei der Vorbereitung des Artikels wurde der Linux-Kernel Version 5.4.27 verwendet.

All Articles