9.2. Realisierung

9.2.1. Sicherheitsgerichtetes Programmieren

Linux wird zunehmend in sicherheitskritischen Bereichen eingesetzt. In diesen Einsatzfeldern ist ein Fehlverhalten des Systems mit hohen Kosten verbunden. Jeder Treiber kann in besonderer Weise den Betriebssystemkern kompromittieren. Daher ist es wichtig, auf ein sicherheitsgerichtetes Programmieren zu achten. Es ist auf der einen Seite für Fehlerfreiheit bezüglich der funktionalen Anforderungen zu sorgen, auf der anderen Seite müssen Fehler vermieden werden, die die Sicherheit des Systems gefährden.

Zur Gruppe der funktionalen Fehlermöglichkeiten gehört typischerweise der fehlende Schutz eines kritischen Abschnittes. Kritische Abschnitte gibt es im Wesentlichen dann, wenn sich zwei oder mehr Rechenprozesse Variablen teilen oder wenn sich zwei oder mehr Programmeinheiten synchronisieren müssen.

Sobald gemeinsame Daten zwischen Rechenprozessen vorliegen, muss eine Überprüfung auf einen zu schützenden kritischen Abschnitt stattfinden! Im Kernel 2.6 dürfte das in den meisten Fällen gegeben sein, da man hier grundsätzlich vom Vorhandensein einer Mehrprozessormaschine ausgeht. In diesem Fall sollte zunächst untersucht werden, ob beispielsweise über eine andere Datenstruktur der kritische Abschnitt generell vermieden werden kann. Ist dies nicht der Fall, müssen die Arten der beteiligten Codestücke identifiziert werden. Aufgrund der Art wiederum kann das passende Schutzelement ausgewählt werden (siehe Der Schutz kritischer Abschnitte aufgrund der beteiligten Instanzen).

Auch bezüglich der Synchronisation muss zunächst das Problem als solches analysiert werden. Geht es darum sicherzustellen, dass Code abgearbeitet ist, bevor ein Modul wieder entladen wird, muss das Completion Object eingesetzt werden. Geht es dagegen darum, eine Treiberinstanz auf ein Ereignis warten zu lassen, ist die Funktion wait_for_completion einzusetzen.

Zu den Fehlern, die die Sicherheit des Systems gefährden, zählt man Code, der zwar die geforderte Funktion erfüllt (unter diesem Aspekt also fehlerfrei ist), der aber durch einen böswilligen Angreifer ausgenutzt werden kann. Typisches Beispiel für derartigen Code ist die Verwendung der Funktion strcpy. Bei dieser Variante werden Daten in einen Buffer geschrieben, ohne dass darauf geachtet wird, ob nicht mehr Daten geschrieben werden, als im Buffer Platz finden. Die Funktion strcpy sollte in keinem Treiber vorkommen, sondern durch die funktional äquivalente Funktion strlcpy ersetzt werden. Das Gleiche gilt für die Funktion sprintf. Hier ist die Funktion snprintf vorzuziehen.

9.2.2. Mit Stil programmieren

Im Kernel wird leicht anders programmiert als sonst üblich. Hier wird streng auf effizienten und schnellen Code geachtet. Dabei darf durchaus auch schon mal goto verwendet werden. Rekursiv darf allerdings niemals programmiert werden. Der Code ist so ausgelegt, dass kurze Latenzzeiten zu erwarten sind: Die Hardware-ISRs sind sehr kurz, wichtige Aufgaben der ISR werden in ein Tasklet verlagert, Hintergrundjobs werden in Kernel-Threads bzw. in Workqueues und periodische Jobs werden in Timer-Softirqs bearbeitet.

Kodierung. Sie sollten eine Reihe von Kodierungsaspekten beachten, um einen guten Treiber zu erhalten:

Wirkliche Konstanten sollten – wie unter C üblich – durch Defines repräsentiert werden. Wenn es sich um Konfigurationsparameter handelt, sollten die Konstanten möglicherweise in Variablen überführt werden, die beim Laden des Treibers – oder durch das Sys-Filesystem auch zu einem späteren Zeitpunkt – mit Alternativwerten besetzt werden können.

Makros, die in Conditional-Konstrukten verwendet werden könnten, müssen in einer leeren dowhile-Schleife eingepackt werden (siehe [kernelnewbies]). Wird beispielsweise das Makro

#define makro( a,b ) { printk("a=%d ", a); printk("b=%d\n", b); }
in der folgenden Bedingung verwendet:
if( eine_bedingung )
    makro( 2,3 );
else
    printk("Bedingung ist nicht erfüllt\n");
wird das zu einem nicht kompilierbaren Code übersetzt:
if( eine_bedingung ) {
    printk("a=%d ", a);
    printk("a=%d\n", b );
}; else
    printk("Bedingung nicht erfüllt\n");

Das Semikolon vor dem else lässt sich hier vom Compiler nicht übersetzen. Wird jedoch das Makro in die do-while-Schleife eingepackt, ist für den Compiler alles in Ordnung:

#define makro( a,b ) do { printk("a=%d ", a);printk("b=%d\n", b);}while(0)
ergibt
if( eine_bedingung )
    do {
        printk("a=%d ", a);
        printk("b=%d\n", b);
    } while(0);
else
    printk("Bedingung nicht erfüllt\n");

Kodierungen, insbesondere der Minor-Nummern, sollten nicht als Aufzählung, sondern Bit-kodiert erfolgen. Durch diese Art der Kodierung ist es nicht nur von Seiten des Anwenders einfacher, auf eine spezifische Minornumber zu schließen – auch das Programmieren wird durch Verwendung einer Bitmaske einfacher. Im folgenden ein triviales Beispiel, wie vier logische Geräte bitweise kodiert werden. Die Geräte stellen verschiedene Zugriffsweisen auf einen Timerwert dar. Der Timerwert kann sowohl als ASCII-String, als auch als Binärwert ausgelesen werden. Dazu kann als Zeitreferenz der Wert der Variablen jiffies oder ein absoluter Zeitwert gewählt werden. Das Codefragment in Beispiel Kodierung von Minor-Nummern zeigt die Implementierung.

Beispiel 9-1. Kodierung von Minor-Nummern

...
/*******************************/
/* Coding of the minor numbers */
/* Bit 0: 0=ASCII-Output       */
/*        1=binary             */
/* Bit 4: 0=absolute time      */
/*        1=jiffies            */
/*******************************/
#define KINDOFOUTPUTMASK 0x01
#define KINDOFTIMEMASK   0x10

/* Minornumbers */
#define ABSOLUTETIME     0x00
#define OUTPUTASCII      0x00

...

static ssize_t minor_read( struct file *instanz, char *user, size_t count,
    loff_t *offs )
{
    int minor = MINOR(File->f_dentry->d_inode->i_rdev);
    struct timeval tv;
    ...

	if( (minor&KINDOFTIMEMASK)==ABSOLUTETIME ) {
        do_gettimeofday( &tv );
        ...
        if( (minor&KINDOFOUTPUTMASK)==OUTPUTASCII ) {
            ...
        }
	} else { // Jiffies
        if( (minor&KINDOFOUTPUTMASK)==OUTPUTASCII ) {
            ...
        } else {
            ...
        }
    }
    ...
}
...

Bei der Kodierung ist weiterhin darauf zu achten, dass allozierte Speicherbereiche hinterher auch wieder freigegeben werden.

Sollen in einem Treiber doch IO-Controls verwendet werden, so müssen diese vollständig definiert werden. Es müssen die Kommandos, deren Parameter, das Verhalten (z.B. im Fehlerfall) und die Rückgabewerte (z.B. auch Fehlercodes) festgelegt werden. Für die Kodierung der IO-Control-Kommandos haben die Entwickler einen Standard festgelegt und Makros definiert (siehe Header-Datei <asm/ioctl.h>). Dieser Standard soll innerhalb des Kernels eindeutige Kommandos und ein vereinfachtes Debugging ermöglichen.

Zur Kodierung wird der 32-Bit-Wert eines IO-Controls in vier Felder aufgeteilt:

  1. Nummer

  2. Typ

  3. Größe (der zu transferierenden Daten)

  4. Transferrichtung

Die Nummer kennzeichnet das eigentliche Kommando. Die 8 Bit breite Nummer ermöglicht 256 unterschiedliche Kommandos.

Der Typ sollte für jeden Treiber eindeutig sein. Für den Typ stehen ebenfalls 8 Bit zur Verfügung. Welche Kodierungen bereits vergeben sind, ist in der (veralteten) Datei Documentation/ioctl-numbers.txt (im Quellcodeverzeichnis des Kernels) abgelegt.

Für die Kodierung der zu transferierenden Daten stehen auf einer x86-Architektur weitere 14 Bits zur Verfügung. Sollen mehr Daten zwischen Applikation und Kernel ausgetauscht werden, wird dieses Feld mit »Null« besetzt.

Als Letztes wird noch die Transferrichtung (Lesen oder Schreiben) des IO-Control-Kommandos definiert. Insgesamt stehen vier Auswahlpunkte zur Verfügung: Kommando ohne Daten (_IOC_NONE), Lesen (_IOC_READ), Schreiben (_IOC_WRITE), Lesen und Schreiben (_IOC_READ | _IOC_WRITE).

Das IO-Control-Kommando wird beispielsweise mit Hilfe des Makros _IOC erstellt.

Skalierbarkeit und Portierbarkeit. Ein guter Treiber ist skalierbar. Wird ein Treiber in einer fixen Konfiguration eingesetzt, wie dies beispielsweise bei einem eingebetteten System der Fall ist, wird weniger Funktionalität benötigt als in einer sich ständig wechselnden Umgebung. So wird im eingebetteten System das Proc-Device möglicherweise nicht benötigt. Um Ressourcen (Speicherplatz und Laufzeit) zu sparen, sollte der Treiber skalierbar, das heißt mit über Compile-Switches einstellbarer Funktionalität aufgebaut werden.

Wie in Abbildung Einsparung von Code durch Verwendung von »#define« gezeigt wurde, sind diese Compile-Switches zwar nicht in jedem Fall notwendig, ihre Verwendung sorgt aber für kompakten Code.

Des Weiteren ist darauf zu achten, im Treiber möglichst keine Abhängigkeiten zu wählbaren Kernelkomponenten zuzulassen. Ein Treiber sollte sich auch dann einsetzen lassen, wenn beispielsweise kein ACPI in der Kernelkonfiguration ausgewählt wurde.

Ein Aspekt der Skalierbarkeit ist auch der Ressourcenverbrauch innerhalb des Treibers. Dieser sollte so gering wie irgend möglich gehalten werden. Das bedeutet, dass innerhalb des Kernels keine großen Speicherbereiche alloziert werden.

Ressourcen sollten erst dann wirklich alloziert werden, wenn selbige auch benötigt werden. Sobald sie nicht mehr benötigt werden, sind sie wieder freizugeben.

Nun zur Portierbarkeit: Verwenden Sie beim Hardwarezugriff grundsätzlich die vorgestellten Makros (z.B. readb)! Darüber hinaus müssen Sie beim Hardwarezugriff auf die Standard-Defines für prozessorunabhängige Datentypen zurückgreifen.

Aus Effizienzgründen schließlich sollte zu jeder Wartebedingung im Treiber genau eine Wait-Queue gehören. Wenn eine Wait-Queue nicht für unterschiedliche Wartebedingungen verwendet wird, vermeidet man das unnötige Aufwecken einer Task.

Sollten Sie für den Einsatz in eingebetteten Systemen einen besonders schlanken 2.6er-Kernel benötigen, ist möglicherweise der Tiny-Linux-Patch [Mackall2003] für Sie interessant.

Datenhaltung. Die im Treiber (in Variablen bzw. Datenstrukturen) abzuspeichernden Daten lassen sich in vier Kategorien einteilen:

  1. treiberspezifische Daten (z.B. der Treibername),

  2. Daten, die das bzw. die physikalischen Geräte betreffen (z.B. Interrupt, IO-Bereich),

  3. Daten, die ein logisches Gerät betreffen (z.B. Anzahl der aktuell auf das Gerät zugreifenden Instanzen), und schließlich

  4. Daten, die zu jeder Instanz des Treibers gehören. Eine Instanz des Treibers wird geschaffen, wenn eine Applikation ein open ausführt, und die Instanz wird wieder gelöscht, wenn die Applikation das zugehörige close ausführt.

Die Datenhaltung im Treiber sollte dieser Einteilung folgen. Für jedes physikalische Gerät sollte eine Datenstruktur vorgesehen werden, die die Geräteparameter beinhaltet. Im Treiber wird für jedes physikalische Gerät eine solche Datenstruktur angelegt (Array).

Gleiches gilt für logische Geräte. Auch für diese wird jeweils eine eigene Datenstruktur erstellt bzw. angelegt.

Die Zuordnung von Daten zu einer Instanz im Treiber wird durch das System unterstützt. Das Betriebssystem legt für jede Treiberinstanz eine Struktur vom Typ struct file an, die Parameter wie Prozess-ID, Owner des Prozesses und Ähnliches enthält. Das letzte Element dieser Struktur (void *private_data) dient dem Treiber zum Abspeichern der instanzen- und treiberspezifischen Parameter.

    int driver_open( struct inode *geraetedatei, struct file *instanz )
    {
        struct instance *instancedata;

        instancedata = kmalloc( sizeof(struct instance) );
        // initialize the structure
        ...
        file->private_data = (void *)instancedata;
        ...


Lizenz