Zugriffskontrolle auf Gerätedateien

Manchmal hängt die Zuverlässigkeit eines Gerätetreibers wesentlich von der Zugriffskontrolle ab. Einerseits sollten nicht-autorisierte Benutzer auch keinen Zugriff auf das Gerät haben (das wird durch die Zugriffs-Bits des Dateisystems erzwungen), andererseits sollte aber manchmal auch nur jeweils ein autorisierter Benutzer ein Gerät öffnen können.

Das Problem ist ähnlich wie bei der Benutzung von TTYs. In diesem Fall ändert der login-Prozeß den Eigentümer der Gerätedatei, wenn sich ein Benutzer am System anmeldet, um ein Stören oder Ausspionieren des TTY-Datenflusses durch andere Benutzer zu verhindern. Aber es ist natürlich unpraktisch, jedesmal beim Öffnen eines Gerätes ein privilegiertes Programm starten zu müssen, nur um einen exklusiven Zugriff darauf zu gewährleisten.

Der Code, den wir bisher gesehen haben, implementiert keine Zugriffskontrolle über die Zugriffs-Bits des Dateisystems hinaus. Wenn der Systemaufruf open eine Anfrage an den Treiber weiterleitet, dann gelingt das auch. Wir werden jetzt einige weitere Techniken einführen, mit denen zusätzliche Überprüfungen durchgeführt werden können.

Jedes Gerät in diesem Abschnitt verhält sich wie das normale scull-Gerät (implementiert also einen persistenten Speicherbereich); sie unterscheiden sich von scull nur in der Zugriffskontrolle, die in den Methoden open und close implementiert wird.

Nur einmal zu öffnende Geräte

Der radikalste Ansatz bei der Zugriffskontrolle besteht darin, ein Gerät nur von jeweils einem Prozeß öffnen zu lassen. Wir mögen diese Technik nicht, weil sie den Einfallsreichtum des Benutzers bremst. Ein Benutzer kann durchaus mehrere Prozesse auf ein und demselben Gerät ausführen wollen, so könnte einer der Prozesse Status-Informationen lesen, während ein anderer gerade Daten schreibt. In manchen Fällen können Benutzer viel erreichen, indem sie ein paar einfache Programme durch ein Shell-Skript laufen lassen, solange sie gleichzeitig auf das Gerät zugreifen dürfen. Das Implementieren des einmaligen Öffnens ist also mit anderen Worten das Einführen einer Policy, die Ihren Benutzern im Weg sein könnte.

Es hat zwar unangenehme Eigenschaften, wenn man nur einem Prozeß erlaubt, ein Gerät zu öffnen, aber das ist auch die einfachste Zugriffskontrolle, die man für einen Gerätetreiber implementieren kann. Deswegen zeigen wir dieses Verfahren hier. Der Quellcode stammt aus einem Gerät namens scullsingle.

Der open-Aufruf verweigert den Zugriff anhand eines globalen Integer-Schalters:


 

int scull_s_open(struct inode *inode, struct file *filp)
{
  Scull_Dev *dev = &scull_s_device; /* Geraeteinformation */
  int num = NUM(inode->i_rdev);

  if (!filp->private_data && num > 0)
    return -ENODEV; /* kein devfs: nur ein Geraet erlauben */
  spin_lock(&scull_s_lock);
  if (scull_s_count) {
    spin_unlock(&scull_s_lock);
    return -EBUSY; /* bereits geoeffnet */
  }
  scull_s_count++;
  spin_unlock(&scull_s_lock);
    /* alles weitere ist aus dem normalen scull-Geraet kopiert */

  if ( (filp->f_flags & O_ACCMODE) == O_WRONLY)
    scull_trim(dev);
  if (!filp->private_data)
    filp->private_data = dev;
  MOD_INC_USE_COUNT;
  return 0;     /* Erfolg */
}

Der close-Systemaufruf markiert das Gerät entsprechend als nicht mehr belegt.


 
int scull_s_release (struct inode *inode, struct file *filp)
{
    scull_s_count--; /* Geraet freigeben */
    MOD_DEC_USE_COUNT;
    return 0;
}

Der beste Platz für das Flag scull_s_count (mit dem zugehörigen Spinlock scull_s_lock, dessen Aufgabe im nächsten Unterabschnitt erläutert wird) ist in der Gerätestruktur (hier Scull_dev), weil er konzeptionell zum Gerät gehört. Der scullsingle-Treiber verwendet aber doch freistehende Variablen für das Flag und das Lock, um die gleiche Gerätestruktur und die gleichen Methoden wie das einfache scull-Gerät verwenden zu können und um die Code-Duplikation zu minimieren.

Ein weiterer Abstecher zu den Race Conditions

Denken Sie noch einmal über die gerade gezeigte Abfrage der Variable scull_s_count ab. Hier erfolgen zwei separate Aktionen: Zunächst wird der Wert der Variablen abgefragt und das Öffnen verweigert, wenn dieser Wert nicht 0 ist; danach wird die Variable inkrementiert, um das Gerät als belegt zu markieren. Auf einem Einzel-Prozessor-System sind diese Tests sicher, weil kein anderer Prozeß zwischen die beiden Aktionen kommen kann.

Sobald Sie aber in die SMP-Welt kommen, tritt ein Problem auf. Wenn zwei Prozesse auf zwei Prozessoren versuchen, das Gerät gleichzeitig zu öffnen, kann es passieren, daß beide den Wert der Variable scull_s_count abfragen, bevor einer der beiden sie modifiziert. In diesem Szenario wird im günstigsten Fall die Einmal-Öffnen-Semantik des Geräts nicht eingehalten. Im schlimmsten Fall kann ein unerwarteter nebenläufiger Zugriff Datenstrukturen zerstören und Systemabstürze verursachen.

Wir haben hier also mit anderen Worten eine weitere Race Condition. Auch diese könnte sehr ähnlich wie die in Kapitel 3 beseitigt werden. Die Race Conditions dort wurden durch den Zugriff auf eine Status-Variable in einer potentiell gemeinsam genutzten Datenstruktur verursacht und mit Semaphoren beseitigt. Im allgemeinen können Semaphore aber teuer sein, weil sie den aufrufenden Prozeß schlafen legen können. Sie sind eine schwergewichtige Lösung für das Problem, einfach nur die schnelle Abfrage einer Status-Variable zu schützen.

Statt dessen verwendet scullsingle einen anderen Sperrmechanismus namens Spinlock. Spinlocks legen nie einen Prozeß schlafen, wenn eine Sperre nicht verfügbar ist. Statt dessen versuchen sie immer und immer wieder aufs neue, an der Sperre vorbeizukommen, bis sie einmal frei wird und dann dem entsprechenden Prozeß gehört. Spinlocks haben damit einen sehr geringen Sperr-Overhead, aber auch das Potential, einen Prozessor sehr lange rotieren zu lassen, wenn jemand auf der Sperre hocken bleibt. Ein weiterer Vorteil von Spinlocks gegenüber Semaphoren besteht darin, daß ihre Implementation leer ist, wenn der Code für ein Einzelprozessor-System kompiliert wird (wo solche SMP-spezifischen Race Conditions nicht auftreten können). Semaphore sind eine allgemeinere Ressource, die auch auf Einzelprozessor-Systemen sinnvoll ist, und werden daher auf solchen Systemen nicht wegoptimiert.

Spinlocks können der ideale Mechanismus für kleine kritische Abschnitte sein. Prozesse sollten Spinlocks so kurz wie möglich halten und dürfen nie schlafen gehen, während sie so eine Sperre halten. Daher ist der Haupt-scull-Treiber, der Daten mit dem User-Space austauscht und deswegen schlafen gehen kann, nicht für eine Spinlock-Lösung geeignet. Aber Spinlocks funktionieren sehr schön in der Zugriffskontrolle auf scull_s_single (selbst wenn auch sie nicht die optimale Lösung sind, wie wir in Kapitel 9 sehen werden).

Spinlocks werden mit dem Typ spinlock_t deklariert, der in <linux/spinlock.h> definiert ist. Vor ihrer Verwendung müssen sie initialisiert werden:


 spin_lock_init(spinlock_t *lock);

Ein Prozeß, der in einen kritischen Abschnitt eintritt, holt sich die Sperre mit spin_lock:


 spin_lock(spinlock_t *lock);

Die Sperre wird am Ende mit spin_unlock wieder freigegeben:


 spin_unlock(spinlock_t *lock);

Spinlocks können komplizierter als hier beschrieben sein; darauf gehen wir noch in Kapitel 9 ein. Aber der hier gezeigte einfache Fall reicht uns im Moment, und wann immer es in scull um Zugriffskontrolle geht, werden wir einfache Spinlocks wie hier gezeigt einsetzen.

Aufmerksame Leser haben vielleicht bemerkt, daß scull_s_open die scull_s_lock-Sperre vor dem Inkrementieren des Flags scull_s_count erwirbt, während scull_s_close keine solche Vorsichtsmaßnahme enthält. Dieser Code ist sicher, weil kein anderer Code den Wert von scull_s_count verändern wird, wenn dieser kleiner als 0 ist, so daß es hier nicht zu einem Konflikt kommen kann.

Den Zugriff auf jeweils einen Benutzer gleichzeitig beschränken

Als nächsten Schritt nach einer systemweiten Sperre können wir einen Benutzer das Gerät in mehreren Prozessen öffnen lassen, aber nur einen einzigen Benutzer auf einmal. Diese Lösung kann man leicht testen, weil der Benutzer von mehreren Prozessen gleichzeitig lesen und schreiben kann, aber davon ausgegangen werden kann, daß der Benutzer selbst einen Teil der Verantwortung für die Erhaltung der Datenintegrität während der gleichzeitigen Zugriffe trägt. Dies geschieht durch zusätzliche Überprüfungen in der Methode open; solche Überprüfungen geschehen nach der normalen Überprüfung der Zugriffsrechte und können den Zugriff nur restriktiver machen, als in den Benutzer- und Gruppen-Zugriffsrechten angegeben ist. Dies ist die gleiche Zugangs-Policy wie bei TTYs, bedarf aber keines externen privilegierten Programms.

Solche Zugriffs-Policies sind etwas schwieriger zu implementieren als das nur einmalige Öffnen. In diesem Fall benötigen wir zwei Daten: einen Zähler, der registriert wie oft das Gerät geöffnet wurde, und die Benutzer-ID des “Eigentümers” des Gerätes. Der beste Platz für solche Elemente ist wieder einmal die Gerätestruktur, trotzdem werden in den Beispielen aus den gleichen Gründen, die schon bei scullsingle angegeben wurden, globale Variablen verwendet. Das neue Gerät heißt sculluid.

Der Systemaufruf open gewährt beim ersten Mal den Zugriff, merkt sich aber den Eigentümer des Geräts. Damit kann ein Benutzer ein Gerät mehrfach öffnen, womit dann auch kooperierende Prozesse gleichzeitig auf dem Gerät arbeiten dürfen. Gleichzeitig kann aber solange kein anderer Benutzer das Gerät öffnen, was eine Beeinflussung von außen verhindert. Weil diese Version der Funktion fast identisch mit der letzten ist, zeigen wir hier nur den relevanten Teil:


 

 spin_lock(&scull_u_lock);
 if (scull_u_count &&
   (scull_u_owner != current->uid) && /* Benutzer darf */
   (scull_u_owner != current->euid) && /* wer su benutzt, darf */
         !capable(CAP_DAC_OVERRIDE)) { /* root darf immer */
     spin_unlock(&scull_u_lock);
     return -EBUSY;  /* -EPERM würde den Benutzer verwirren */
 }

 if (scull_u_count == 0)
   scull_u_owner = current->uid; /* das gehoert uns */

 scull_u_count++;
 spin_unlock(&scull_u_lock);

Wir haben uns entschieden, -EBUSY anstelle von -EPERM zurückzugeben. Zwar überprüft der Code die Zugriffsrechte, aber so wird ein Benutzer eher in die richtige Richtung geleitet. Die normale Reaktion auf “Permission denied” ist normalerweise ein Überprüfen des Modus und der Eigentümerschaft der /dev-Datei, während “Device Busy” eher nahelegt, daß der Benutzer schauen sollte, welcher andere Prozeß das Gerät belegt.

Dieser Code überprüft auch, ob der Prozeß, der versucht, das Gerät zu öffnen, die Fähigkeit hat, die Dateizugriffsrechte zu überschreiben. Wenn das der Fall ist, ist das Öffnen selbst dann erlaubt, wenn der öffnende Prozeß nicht der Besitzer des Geräts ist. Die Capability CAP_DAC_OVERRIDE kommt uns da gut zupasse.

Der Code für close ist hier nicht aufgeführt, weil er lediglich den Verwendungszähler herunterzählt.

Blockieren im open-Aufruf als Alternative zu EBUSY

Meistens ist es das Beste, einen Fehler zu melden, wenn das Gerät gerade nicht zur Verfügung steht, aber es gibt auch Situationen, in denen man lieber auf das Gerät warten würde.

Ein Beispiel: Ein Datenkommunikationskanal wird sowohl dazu verwendet, um Berichte auf regelmäßiger Basis (mit crontab) zu senden, als auch, um gelegentlich Benutzeranfragen zu übertragen. In diesem Fall ist es sehr viel besser, wenn der regelmäßige Bericht geringfügig verspätet ist, anstatt ganz auszufallen, wenn der Kanal gerade belegt ist.

Hier haben wir eine der Entscheidungen, die ein Programmierer treffen muß, wenn er einen Gerätetreiber schreibt. Die richtige Antwort hängt vom zu lösenden Problem ab.

Wie Sie sich sicher schon gedacht haben, besteht die Alternative zu EBUSY darin, ein blockierendes open zu implementieren.

Das scullwuid-Gerät ist eine Version von sculluid, die auf das Gerät wartet, anstelle -EBUSY zurückzugeben. Es unterscheidet sich von sculluid nur im folgenden Teil von open:


 
 spin_lock(&scull_w_lock);
 while (scull_w_count &&
  (scull_w_owner != current->uid) && /* Benutzer darf */
  (scull_w_owner != current->euid) && /* wer su ausführt, darf */
  !capable(CAP_DAC_OVERRIDE)) {
   spin_unlock(&scull_w_lock);
   if (filp->f_flags & O_NONBLOCK) return -EAGAIN;
   interruptible_sleep_on(&scull_w_wait);
   if (signal_pending(current)) /* ein Signal ist eingetroffen */
    return -ERESTARTSYS; /* die FS-Schicht soll sich damit beschaeftigen */
   /* ansonsten in die Schleife eintreten */
   spin_lock(&scull_w_lock);
 }
 if (scull_w_count == 0)
   scull_w_owner = current->uid; /* das gehoert uns */
 scull_w_count++;
 spin_unlock(&scull_w_lock);






Diese Implementation basiert wieder einmal auf einer Warteschlange. Warteschlangen wurden geschaffen, um eine Liste von Prozessen zu verwalten, die schlafend auf ein Ereignis warten, und sind deswegen hier perfekt geeignet.

Die release-Methode ist dann dafür zuständig, schlafende Prozesse aufzuwecken:


 
int scull_w_release (struct inode *inode, struct file *filp)
{
    scull_w_count--;
    if (scull_w_count == 0)
        wake_up_interruptible(&scull_w_wait); /* andere UIDs aufwecken */
    MOD_DEC_USE_COUNT;
    return 0;
}

Bei interaktiver Benutzung ist eine Implementation mit blockierendem open ziemlich unangenehm, weil der Benutzer andauernd raten muß, was gerade nicht funktioniert. Interaktive Benutzer verwenden normalerweise vorkompilierte Befehle wie cp und tar und können daher nicht einfach O_NONBLOCK zum open-Aufruf hinzufügen. Jemand, der ein Backup auf das Bandlaufwerk im nächsten Raum macht, hätte lieber eine “device or resource busy”-Meldung, anstatt raten zu müssen, warum tar beim Durchsuchen der Festplatte heute so ruhig ist.

Diese Art von Problem (verschiedene inkompatible Policies für ein und dasselbe Gerät) läßt sich am besten lösen, indem man für jede Zugriffs-Policy einen eigenen Geräteknoten erzeugt. Ein Beispiel für diese Praktik finden Sie im Linux-Treiber für Bandlaufwerke, der mehrere Gerätedateien für ein und dasselbe Gerät enthält. Verschiedene Gerätedateien lassen das Laufwerk beispielsweise mit oder ohne Komprimierung aufzeichnen oder das Band nach dem Schließen des Geräts automatisch zurückspulen.

Kopieren des Gerätes beim Öffnen

Das Erzeugen verschiedener privater Kopien eines Gerätes für jeden Prozeß, der das Gerät öffnen will, ist eine weitere Technik, Zugriffskontrollen zu implementieren.

Natürlich ist das nur möglich, wenn das Gerät an keine Hardware gebunden ist. scull ist ein Beispiel für ein “Software”-Gerät. Die Interna von /dev/tty verwenden eine ähnliche Technik, um ihrem Prozeß eine andere “Sicht” auf das zu geben, was der /dev-Eintrag repräsentiert. Wenn ein Software-Treiber Kopien eines Gerätes erzeugt, nennen wir diese virtuelle Geräte — analog zu den “virtuellen Konsolen”, die auf einem einzigen physikalischen Terminal existieren.

Eine solche Zugriffskontrolle wird zwar nur selten benötigt, aber die Implementation ist insofern interessant, als sie zeigt, wie einfach Kernel-Code die Perspektive von Anwendungen auf die umgebende Welt (also den Computer) verändern kann. Das Thema ist eigentlich ziemlich esoterisch; wenn es Sie also nicht interessiert, dann können Sie gleich zum nächsten Abschnitt vorgehen.

Der Geräteknoten /dev/scullpriv implementiert im scull-Paket virtuelle Geräte. Die Implementation von scullpriv verwendet die Minor-Nummer der kontrollierenden TTYs des Prozesses als Schlüssel für den Zugriff auf das virtuelle Gerät. Natürlich können Sie die Quellen so anpassen, daß ein beliebiger Integer-Wert für den Schlüssel verwendet wird; jede Wahl führt zu einer anderen Policy. Wenn Sie beispielsweise uid verwenden, dann wird für jeden Benutzer ein anderes virtuelles Gerät erzeugt; benutzen Sie pid, dann gibt es ein neues Gerät für jeden Prozeß, der darauf zugreifen will.

Wir haben hier das kontrollierende Terminal gewählt, weil es damit einfach ist, das Gerät mittels I/O-Umleitung zu testen: Das Gerät wird von allen Befehlen auf dem gleichen virtuellen Terminal gemeinsam genutzt, ist aber von den Befehlen, die auf einem anderen Terminal laufen, getrennt.

Die Methode open sieht etwa folgendermaßen aus. Sie muß das richtige virtuelle Gerät finden und gegebenenfalls ein neues erzeugen. Der letzte Teil der Funktion ist hier nicht wiedergegeben, weil er einfach nur aus scull kopiert wurde und wir diesen Code schon kennen.


 

/* Die Clone-spezifische Datenstruktur enthaelt ein Schluesselfeld key */
struct scull_listitem {
  Scull_Dev device;
  int key;
  struct scull_listitem *next;

};

/* Die Liste der Geraete sowie eine Sperre zum Schutz derselben */
struct scull_listitem *scull_c_head;
spinlock_t scull_c_lock;

/* Nach einem Geraet suchen oder eines erzeugen, wenn keines gefunden wird */
static Scull_Dev *scull_c_lookfor_device(int key)
{
  struct scull_listitem *lptr, *prev = NULL;

  for (lptr = scull_c_head; lptr && (lptr->key != key); lptr = lptr->next)
    prev=lptr;
  if (lptr) return &(lptr->device);

  /* nicht gefunden */
  lptr = kmalloc(sizeof(struct scull_listitem), GFP_ATOMIC);
  if (!lptr) return NULL;

  /* das Geraet initialisieren */
  memset(lptr, 0, sizeof(struct scull_listitem));
  lptr->key = key;
  scull_trim(&(lptr->device)); /* initialisieren */
  sema_init(&(lptr->device.sem), 1);

  /* in die Liste stellen */
  if (prev) prev->next = lptr;
  else    scull_c_head = lptr;

  return &(lptr->device);
}

int scull_c_open(struct inode *inode, struct file *filp)
{
  Scull_Dev *dev;
  int key, num = NUM(inode->i_rdev);

  if (!filp->private_data && num > 0)
    return -ENODEV; /* kein devfs: nur ein Geraet erlauben */

  if (!current->tty) {
    PDEBUG("Process \"%s\" has no ctl tty\n",current->comm);
    return -EINVAL;
  }
  key = MINOR(current->tty->device);

  /* nach einem scullc-Geraet in der Liste suchen */
  spin_lock(&scull_c_lock);
  dev = scull_c_lookfor_device(key);
  spin_unlock(&scull_c_lock);

  if (!dev) return -ENOMEM;

  /* alles andere wird aus dem einfachen scull-Geraet kopiert */

Die release-Methode macht nichts Besonderes. Sie könnte das Gerät beim letzten Schließen freigeben, aber wir haben uns entschlossen, keinen Zähler zu verwalten, der protokolliert, wie oft das Gerät geöffnet wurde, um das Testen des Treibers zu erleichtern. Wenn das Gerät beim letzten Schließen freigegeben werden würde, könnten Sie nicht dieselben Daten, die Sie gerade hineingeschrieben haben, wieder lesen, es sei denn, mindestens ein Hintergrundprozeß hält das Gerät offen. Der Beispiel-Treiber geht hier den einfacheren Weg und behält die Daten einfach, so daß Sie diese beim nächsten open auf jeden Fall vorfinden werden. Die Geräte werden beim Aufruf von scull_cleanup freigegeben.

Hier sehen Sie die Implementation von release in scullpriv, die auch diese Besprechung der Geräte-Methoden abschließt:


 
void scull_c_release (struct inode *inode, struct file *filp)
{
    /*
     * Nichts zu tun, weil das Geraet persistent ist.
     * Ein "echtes" geklontes Geraet sollte beim letzten Schliessen
     * freigegeben werden.
     */
    MOD_DEC_USE_COUNT;
    return 0;
}