Ce cinquième volet de notre série d'articles est consacré à des problèmes de sécurité apparaissant à cause de la nature même du fonctionnement multitâche du système d'exploitation. Les situations de concurrence (race condition) laissent plusieurs processus disposer simultanément d'une même ressource (fichier, périphérique, mémoire), alors que chacun d'eux pense en avoir l'usage exclusif. Cela conduit à l'existence de bogues intempestifs difficiles à déceler, mais également de véritables failles pouvant compromettre la sécurité globale du système.

Introduction

Le principe général des situations de concurrence est le suivant : un processus désire accéder de manière exclusive à une ressource du système. Il s'assure qu'elle ne soit déjà utilisée par un autre processus, puis se l'approprie, et l'emploie à sa guise. Le problème survient lorsqu'un autre processus profite du laps de temps s'écoulant entre la vérification et l'accès effectif pour s'attribuer la même ressource. Les conséquences peuvent être très variées. Dans certains cas classiques de la théorie des systèmes d'exploitation, on se retrouve dans des situations de blocages définitifs des deux processus. Dans les cas plus pratiques, ce comportement mène à des dysfonctionnements parfois graves de l'application, voire à de véritables failles de sécurité quand un des processus profite indûment des privilèges de l'autre.

Ce que nous avons nommé ressource précédemment se présente sous diverses formes. La plupart des problèmes de race condition régulièrement découverts et corrigés dans le noyau lui-même, se rapportent à des accès concurrentiels à des zones-mémoire. Ici nous nous intéressons plutôt aux applications système, et nous considèrons que les ressources concernées sont des noeuds du système de fichiers. Cela recouvre non seulement les fichiers usuels mais également l'accès direct aux périphériques à travers les points d'entrée spéciaux du répertoire /dev/.

Une attaque visant la sécurité du système se déroule la plupart du temps à l'encontre des applications Set-UID, puisque l'assaillant peut lancer le programme à sa guise jusqu'à parvenir à profiter des privilèges accordés au possesseur du fichier exécutable. Toutefois, contrairement aux failles que nous avons vues jusqu'à présent (débordements de buffers, chaînes de formats...), les situations de concurrence ne permettent généralement pas de faire exécuter un code personnalisé par l'application visée. Elles offrent plutôt la possibilité de profiter des ressources d'un programme parallèlement à son fonctionnement. Ce type d'attaque vise donc autant les utilitaires "normaux" (pas Set-UID), le pirate étant en embuscade, attendant qu'un autre utilisateur, de préférence root, se servent de l'application en question pour accéder à ses ressources propres. Ceci est bien sûr vrai pour l'écriture dans un fichier (par exemple, ~/.rhost dans lequel la chaîne "+ +" valide un accès direct depuis n'importe quelle machine sans mot de passe), qu'en lecture de fichier confidentiel (données commerciales sensibles, informations médicales personnelles, fichier de mots de passe, clé privée...)

Á la différence des failles étudiées dans nos précédents articles, toutes les applications sont donc concernées par ce problème de sécurité, et plus uniquement les utilitaires Set-UID et les serveurs système ou démons.

Premier exemple

Nous allons tout d'abord observer le comportement d'un programme Set-UID qui doit sauvegarder des données dans un fichier appartenant à l'utilisateur. On peut très bien imaginer par exemple le cas d'un logiciel de transport de courrier électronique à la manière de sendmail. Supposons que l'utilisateur puisse à la fois fournir le nom du fichier de sauvegarde et un message à y inscrire, ce qui est tout à fait vraisemblable dans certaines circonstances. L'application doit donc vérifier que le fichier appartienne bien à la personne ayant lancé le programme. Par sécurité, le programme vérifiera également qu'il ne s'agisse pas d'un lien symbolique qui pourrait pointer vers un fichier système. N'oublions pas que le programme étant Set-UID root, il dispose de toutes les autorisations pour modifier n'importe quel fichier de la machine. En conséquence, il comparera le propriétaire du fichier avec son propre UID réel. Nous écrivons donc quelque chose comme :

1     /* ex_01.c */
2     #include <stdio.h>
3     #include <stdlib.h>
4     #include <unistd.h>
5     #include <sys/stat.h>
6     #include <sys/types.h>
7    
8     int
9     main (int argc, char * argv [])
10    {
11        struct stat st;
12        FILE * fp;
13
14        if (argc != 3) {
15            fprintf (stderr, "usage : %s fichier message\n", argv [0]);
16            exit(EXIT_FAILURE);
17        }
18        if (stat (argv [1], & st) < 0) {
19            fprintf (stderr, "%s introuvable\n", argv [1]);
20            exit(EXIT_FAILURE);
21        }
22        if (st . st_uid != getuid ()) {
23            fprintf (stderr, "%s ne vous appartient pas !\n", argv [1]);
24            exit(EXIT_FAILURE);
25        }
26        if (! S_ISREG (st . st_mode)) {
27            fprintf (stderr, "%s n'est pas un fichier normal\n", argv[1]);
28            exit(EXIT_FAILURE);
29        }
30        
31        if ((fp = fopen (argv [1], "w")) == NULL) {
32            fprintf (stderr, "Ouverture impossible\n");
33            exit(EXIT_FAILURE);
34        }
35        fprintf (fp, "%s\n", argv [2]);
36        fclose (fp);
37        fprintf (stderr, "Écriture Ok\n");
38        exit(EXIT_SUCCESS);
39    }

Pour être franc, il serait préférable pour une application Set-UID de perdre temporairement ses privilèges, afin d'effectuer l'ouverture du fichier en employant l'UID réel de l'utilisateur l'ayant invoqué, comme nous l'avons expliqué dans notre premier article. En fait, la situation ci-dessus correspond plutôt à celle d'un programme démon, offrant des services à tous les utilisateurs. S'exécutant toujours sous l'identité root, il ferait la vérification d'appartenance avec l'UID de son interlocuteur plutôt qu'avec son propre UID réel. Nous conservons quand même ce schéma, même s'il n'est pas très réaliste, car il nous permet de bien mettre en évidence le problème en exploitant facilement la faille.

Comme nous le voyons, le programme commence par effectuer toutes les vérifications nécessaires, s'assurant que le fichier existe, qu'il appartient à l'utilisateur et qu'il s'agit bien d'un fichier normal. Ensuite il effectue l'ouverture réelle et l'écriture du message. Et c'est là que réside la faille de sécurité ! ou plutôt c'est dans le laps de temps qui s'écoule entre la lecture des attributs du fichier avec stat() et son ouverture avec fopen(). Ce délai est peut-être infime habituellement, mais il n'est pas nul, et un attaquant peut en profiter pour modifier les caractéristiques du fichier. Pour simplifier notre attaque nous allons ajouter une ligne faisant dormir le processus entre les deux opérations, afin d'avoir le temps de faire l'intervention à la main. Nous modifions donc la ligne 30 (précédemment vierge) pour insérer :

30        sleep (20);

Voyons à présent la mise en oeuvre de la faille ; tout d'abord rendons l'application Set-UID root. Profitons-en, c'est très important, pour faire une copie de secours de notre fichier de mots de passe /etc/shadow :

$ cc ex_01.c -Wall -o ex_01
$ su
Password:
# cp /etc/shadow /etc/shadow.bak
# chown root.root ex_01
# chmod +s ex_01
# exit
$ ls -l ex_01
-rwsrwsr-x 1 root  root    15454 Jan 30 14:14 ex_01
$

Tout est en place pour déclencher notre attaque. Nous sommes dans un répertoire nous appartenant. Nous avons découvert l'existence d'un utilitaire Set-UID root (ici ex_01) comportant une faille de sécurité, et nous mourrons d'envie de remplacer la ligne concernant root dans le fichier des mots de passe /etc/shadow par une ligne contenant un mot de passe vierge.

Tout d'abord nous créons un fichier fic nous appartenant :

$ rm -f fic
$ touch fic

Ensuite nous lançons notre application en arrière-plan, afin de conserver la main. Nous lui demandons d'écrire une chaîne de caractères dans ce fichier. Elle effectue ses vérifications et s'endort quelques secondes avant de passer à l'accès véritable au fichier.

$ ./ex_01 fic "root::1:99999:::::" &
[1] 4426

Le contenu de la ligne root est établi d'après la page de manuel de shadow(5), ce qui nous importe le plus est que le second champ soit vide (pas de mot de passe). Pendant que le processus dort, nous avons une vingtaine de secondes pour supprimer le fichier régulier fic et le remplacer par un lien (symbolique ou physique peu importe, les deux fonctionnent) vers le fichier /etc/shadow. Rappelons que tout utilisateur peut créer dans un répertoire lui appartenant - ou dans /tmp, comme nous le verrons plus loin - un lien vers un fichier quelconque, même s'il n'a pas le droit d'en lire le contenu. En revanche il n'est pas possible de créer une copie d'un tel fichier, car elle réclamerait une lecture complète.

$ rm -f fic
$ ln /etc/shadow ./fic

Nous demandons alors au shell de repasser le processus ex_01 à l'avant-plan avec la commande fg, et attendons qu'il se termine :

$ fg
./ex_01 fic "root::1:99999:::::"
Écriture Ok
$

Voilà ! l'opération est terminée, le fichier /etc/shadow ne contient plus qu'une seule ligne indiquant que root n'a pas de mot de passe. La preuve ?

$ su
# whoami
root
# cat /etc/shadow
root::1:99999:::::
#

N'oublions pas de terminer notre expérience en remettant en place l'ancien fichier de mots de passe :

# cp /etc/shadow.bak /etc/shadow
cp: ecraser `/etc/shadow'? o
#

Soyons plus réalistes

Nous sommes arrivés à exploiter une situation de concurrence sur un utilitaire Set-UID root. Bien entendu le programme en question mettait beaucoup de bonne volonté en attendant vingt secondes que nous ayons le temps de modifier les fichiers derrière son dos. Dans une application réelle, la situation de concurrence ne s'applique que pendant des durées très courtes. Comment en profiter ?

En général le principe repose simplement sur une attaque en force brute, en recommençant les tentatives plusieurs centaines, milliers ou dizaines de milliers de fois, grâce à des scripts automatisant la séquence. On peut améliorer les chances de "tomber" dans la faille de sécurité avec plusieurs astuces qui ont toutes pour but d'essayer d'augmenter la durée entre les deux opérations que le programme visé considère à tort comme atomiquement liées. L'idée est de ralentir le processus cible, de manière à calibrer plus facilement le retard à apporter avant de faire la modification du fichier. Différentes approches sont envisageables pour parvenir à nos fins :

La technique permettant d'exploiter véritablement une faille de sécurité reposant sur une situation de concurrence est donc dans l'ensemble assez pénible et répétitive, mais elle est réellement utilisable en pratique ! Nous allons donc essayer de trouver les palliatifs les plus efficaces.

Correction possible

Le problème exposé plus haut repose sur la possibilité de modifier les caractéristiques d'un objet entre deux opérations qui le concernent et dont l'enchaînement doit être le plus continu possible. Dans la situation précédente, la modification ne portait pas sur le fichier lui-même. D'ailleurs en tant que simple utilisateur, nous aurions été bien en peine pour modifier, voire seulement pour consulter, le fichier /etc/shadow. En réalité, la modification porte sur l'association entre le noeud existant dans l'arborescence des noms, et le fichier lui-même, en tant qu'entité physique. Il faut bien se rappeler que l'essentiel des commandes système (rm, mv, ln, etc.) agissent sur le nom du fichier et non pas sur son contenu. Même lorsqu'on demande la suppression d'un fichier (avec rm et l'appel-système unlink()), ce n'est que lorsque le dernier lien physique - la dernière référence - est effacé que son contenu est effectivement libéré.

L'erreur commise par le programme précédent est donc de considérer que l'association entre le nom du fichier et son contenu est immuable, ou du moins constant entre les opérations stat() et fopen(). Or il suffit de prendre l'exemple d'un lien physique pour voir que cette association n'a rien de permanent. Voici par exemple une petite manipulation employant ce type de lien. Nous créons, dans un répertoire nous appartenant, un nouveau lien vers un fichier système. Naturellement, le propriétaire et le mode d'accès du fichier sont conservés. L'option -f de la commande ln réclame une création "de force", même si le nom existe déjà :

$ ln -f /etc/fstab ./mon_fichier
$ ls -il /etc/fstab mon_fichier
8570 -rw-r--r--   2 root  root  716 Jan 25 19:07 /etc/fstab
8570 -rw-r--r--   2 root  root  716 Jan 25 19:07 mon_fichier
$ cat mon_fichier
/dev/hda5   /                 ext2    defaults,mand   1 1
/dev/hda6   swap              swap    defaults        0 0
/dev/fd0    /mnt/floppy       vfat    noauto,user     0 0
/dev/hdc    /mnt/cdrom        iso9660 noauto,ro,user  0 0
/dev/hda1   /mnt/dos          vfat    noauto,user     0 0
/dev/hda7   /mnt/audio        vfat    noauto,user     0 0
/dev/hda8   /home/ccb/annexe  ext2    noauto,user     0 0
none        /dev/pts          devpts  gid=5,mode=620  0 0
none        /proc             proc    defaults        0 0
$ ln -f /etc/host.conf ./mon_fichier
$ ls -il /etc/host.conf mon_fichier 
8198 -rw-r--r--   2 root  root   26 Mar 11  2000 /etc/host.conf
8198 -rw-r--r--   2 root  root   26 Mar 11  2000 mon_fichier
$ cat mon_fichier
order hosts,bind
multi on
$ 

L'option -i de /bin/ls permet d'afficher en début de ligne le numéro d'i-noeud. Nous voyons ainsi que le même nom pointe successivement sur deux i-noeuds physiques différents. Il est d'ailleurs bien évident que les deux "cat" successifs, tout en travaillant sur le même nom de fichier, avec le même propriétaire et le même mode d'accès, accèdent à deux contenus totalement différents alors qu'aucune modification n'est intervenue sur ces fichiers entre les deux opérations.

En fait, nous aimerions que les fonctions servant à vérifier, puis à accéder au fichier nous garantissent de pointer toujours vers le même contenu physique, le même i-noeud. Et c'est possible ! Le noyau effectue lui-même cette association automatiquement lorsqu'il nous fournit un descripteur de fichier. Quand nous ouvrons un fichier en lecture, l'appel-système open() nous renvoie une valeur entière, le descripteur, qu'il associe dans une table interne avec le fichier physique. Toutes les lectures que nous effectuons par la suite concerneront le contenu de ce fichier, quelles que soient les manipulations auxquelles on se livre sur le nom utilisé pour ouvrir le fichier.

Insistons sur ce point : une fois qu'un fichier est ouvert, toutes les opérations concernant le nom du fichier, y compris sa suppression, n'auront aucun effet sur le contenu du fichier. Tant qu'il reste un processus disposant d'un descripteur sur un fichier, le contenu de ce dernier ne sera pas effacé du disque, même si son nom a pu disparaître des répertoires où il résidait. Le noyau nous garantit qu'entre la fourniture d'un descripteur de fichier avec l'appel-système open() et la libération de ce descripteur avec close() ou la libération implicite à la fin du processus, l'association avec le contenu du fichier perdurera.

Mais alors, nous tenons notre solution ! Il suffit de commencer par l'ouverture du fichier, et de vérifier ensuite les autorisations, en examinant les caractéristiques du descripteur plutôt que celles du nom de fichier. Cela est possible grâce à l'appel-système fstat() qui fonctionne exactement comme stat(), mais en examinant un descripteur de fichier plutôt qu'un chemin d'accès. Pour obtenir ensuite un flux d'entrée-sortie autour du descripteur nous utiliserons la fonction fdopen() qui agit comme fopen() tout en s'appuyant sur un descripteur plutôt que sur un nom de fichier. Le programme devient donc :

1    /* ex_02.c */
2    #include <fcntl.h>
3    #include <stdio.h>
4    #include <stdlib.h>
5    #include <unistd.h>
6    #include <sys/stat.h>
7    #include <sys/types.h>
8
9     int
10    main (int argc, char * argv [])
11    {
12        struct stat st;
13        int fd;
14        FILE * fp;
15
16        if (argc != 3) {
17            fprintf (stderr, "usage : %s fichier message\n", argv [0]);
18            exit(EXIT_FAILURE);
19        }
20        if ((fd = open (argv [1], O_WRONLY, 0)) < 0) {
21            fprintf (stderr, "Impossible d'ouvrir %s\n", argv [1]);
22            exit(EXIT_FAILURE);
23        }
24        fstat (fd, & st);
25        if (st . st_uid != getuid ()) {
26            fprintf (stderr, "%s ne vous appartient pas !\n", argv [1]);
27            exit(EXIT_FAILURE);
28        }
29        if (! S_ISREG (st . st_mode)) {
30            fprintf (stderr, "%s n'est pas un fichier normal\n", argv[1]);
31            exit(EXIT_FAILURE);
32        }
33        if ((fp = fdopen (fd, "w")) == NULL) {
34            fprintf (stderr, "Ouverture impossible\n");
35            exit(EXIT_FAILURE);
36        }
37        fprintf (fp, "%s", argv [2]);
38        fclose (fp);
39        fprintf (stderr, "Écriture Ok\n");
40        exit(EXIT_SUCCESS);
41    }

Cette fois-ci, dès que nous avons franchi la ligne 20, aucune modification sur le nom du fichier (effacement, renommage, création de lien, etc.) n'aura d'effet sur le comportement de notre programme ; il s'en tiendra au contenu du fichier physique original.

Généralisation

Il est donc important, lorsqu'on manipule un fichier de s'assurer que l'association entre la représentation interne que nous en avons et son contenu réel reste constante. On utilisera donc de préférence les appels-systèmes suivants, qui manipulent le fichier physique concerné sous forme de descripteur déjà ouvert plutôt que leurs équivalents qui emploient le chemin d'accès au fichier :

Appel-système Utilisation
fchdir (int fd) Aller dans le répertoire représenté par fd.
fchmod (int fd, mode_t mode) Changer les autorisations d'accès du fichier.
fchown (int fd, uid_t uid, gid_t gif) Modifier l'appartenance du fichier.
fstat (int fd, struct stat * st) Consulter les informations stockées dans l'i-noeud du fichier physique.
ftruncate (int fd, off_t longueur) Tronquer un fichier existant.
fdopen (int fd, char * mode) Obtenir un flux d'entrée-sortie autour du descripteur déjà ouvert. Il s'agit d'une routine de la bibliothèque stdio et non pas d'un appel-système.

Il faut donc naturellement commencer par ouvrir le fichier dans le mode désiré, en invoquant open() (en prenant garde à ne pas oublier le troisième argument lorsqu'un nouveau fichier est créé). Nous reparlerons des possibilités offertes par open() un peu plus loin, en étudiant le problème des fichiers temporaires.

On ne répètera jamais assez l'importance de la vérification des codes de retour des appels-système. Signalons par exemple, bien que cela n'ait pas de rapport avec les race conditions qui nous occupent aujourd'hui, un problème survenu dans d'anciennes implémentations de /bin/login à cause d'une négligence dans l'examen du code d'erreur. Cette application offrait automatiquement un accès root si elle ne trouvait pas le fichier /etc/passwd. Ce comportement peut sembler raisonnable, pour permettre la réparation d'un système de fichiers endommagé. En revanche, le fait qu'elle ne vérifiait pas vraiment l'absence du fichier mais seulement l'impossibilité de l'ouvrir l'était déjà moins. Il suffisait en effet d'appeler /bin/login après avoir ouvert le nombre maximal de descripteurs autorisés pour un utilisateur et l'on obtenait directement un accès root... Refermons cette parenthèse en insistant sur l'importance de vérifier non seulement la réussite ou l'échec des appels-système, mais également les éventuels codes d'erreur, avant d'entreprendre une action concernant la sécurité du système.

Accès concurrents au contenu du fichier

Le fonctionnement d'un programme impliquant la sécurité du système ne devrait en principe pas reposer sur l'accès exclusif au contenu d'un fichier. Plus exactement, il est important de gérer correctement les risques d'accès concurrentiel au même fichier. Le principal danger provient d'un utilisateur qui lancerait simultanément de multiples occurences d'une application Set-UID root ou qui établirait plusieurs connexions en parallèle avec le même démon, dans l'espoir de voir une situation de concurrence s'établir, durant laquelle le contenu d'un fichier système serait modifié de manière anormale.

Pour éviter qu'un programme ne soit sensible à ce genre de situation, il faut instaurer un mécanisme d'accès exclusif aux données du fichier. Le problème est le même que celui qui se pose dans les bases de données lorsque plusieurs utilisateurs peuvent interroger ou modifier simultanément le contenu d'un fichier. Le principe du verrouillage de fichiers permet de résoudre ce problème.

Lorsqu'un processus désire écrire dans un fichier, il demande au noyau un verrouillage du fichier - ou de la portion de fichier - concerné. Tant que le processus conservera le verrou, aucun autre processus ne pourra demander de verrouillage du même fichier, ou du moins de la portion en question. De même, avant de lire le contenu d'un fichier, un processus demande un verrouillage, ce qui l'assure qu'aucune modification n'interviendra tant qu'il gardera le verrou.

En réalité, le système est encore plus fin que cela : le noyau distingue les verrous réclamés pour lire un fichier, et les verrous réclamés pour y écrire. En effet plusieurs processus peuvent très bien disposer simultanément d'un verrou en lecture, puisqu'aucun d'entre eux ne tentera de modifier le contenu du fichier. En revanche un seul processus peut disposer d'un verrou en écriture à un instant donné, et aucun autre verrou, même en lecture, ne peut être accordé simultanément.

Il existe deux types de verrouillages (incompatibles entre eux). Le premier, hérité de BSD est construit autour de l'appel-système flock(). Son premier argument est le descripteur du fichier auquel on désire accéder de manière exclusive, et le second une constante symbolique représentant l'opération désirée. Elle peut valoir LOCK_SH (verrou en lecture), LOCK_EX (en écriture), LOCK_UN (libération du verrou). L'appel-système reste bloqué tant que l'opération demandée n'est pas possible. On peut toutefois ajouter (par un OU binaire |) la constante LOCK_NB pour que l'appel échoue plutôt que de rester bloqué.

Le second type de verrouillage provient de l'univers System V, et repose sur l'appel-système fcntl() dont l'invocation est un peu compliquée. Il existe une fonction de bibliothèque nommée lockf() qui encadre l'appel-système mais elle n'offre pas toutes les possibilités de ce dernier. Le premier argument de fcntl() est le descripteur du fichier à verrouiller. Le deuxième représente l'opération désirée : F_SETLK et F_SETLKW fixent un verrou, la seconde commande restant bloquée jusqu'à ce que l'opération soit possible alors que la première revient tout de suite en cas d'échec. F_GETLK permet de consulter l'état du verrouillage d'un fichier (ce qui n'a normalement aucune utilité pour les applications courantes). Le troisième argument est un pointeur sur une variable de type struct flock qui décrit le verrouillage. Les membres importants de la structure flock sont les suivants :

Nom Type Signification
l_type int Action envisagée : F_RDLCK (verrouiller pour lecture), F_WRLCK (pour écriture) et F_UNLCK (déverrouiller).
l_whence int Origine du champ l_start (normalement SEEK_SET.
l_start off_t Emplacement du début du verrou (normalement 0).
l_len off_t Longueur du verrouillage, vaut 0 pour aller jusqu'à la fin du fichier.

Nous voyons donc que fcntl() offre la possibilité de ne bloquer que des portions limitées du fichier, mais ce n'est pas son seul avantage par rapport à flock(). Examinons un petit programme qui demande un verrouillage en écriture des fichiers dont les noms lui sont passés en argument, et attend que l'utilisateur ait pressé la touche Entrée avant de se terminer (et de libérer ainsi les verrous).

1    /* ex_03.c */
2    #include <fcntl.h>
3    #include <stdio.h>
4    #include <stdlib.h>
5    #include <sys/stat.h>
6    #include <sys/types.h>
7    #include <unistd.h>
8 
9    int
10   main (int argc, char * argv [])
11   {
12     int i;
13     int fd;
14     char buffer [2];
15     struct flock lock;
16
17     for (i = 1; i < argc; i ++) {
18       fd = open (argv [i], O_RDWR | O_CREAT, 0644);
19       if (fd < 0) {
20         fprintf (stderr, "Impossible d'ouvrir %s\n", argv [i]);
21         exit(EXIT_FAILURE);
22       }
23       lock . l_type = F_WRLCK;
24       lock . l_whence = SEEK_SET;
25       lock . l_start = 0;
26       lock . l_len = 0;
27       if (fcntl (fd, F_SETLK, & lock) < 0) {
28         fprintf (stderr, "Impossible de verrouiller %s\n", argv [i]);
29         exit(EXIT_FAILURE);
30       }
31     }
32     fprintf (stdout, "Pressez Entrée pour libérer le(s) verrou(s)\n");
33     fgets (buffer, 2, stdin);
34     exit(EXIT_SUCCESS);
35   }

Nous lançons tout d'abord ce programme sur une première console, où il reste en attente :

$ cc -Wall ex_03.c -o ex_03
$ ./ex_03 mon_fic
Pressez Entrée pour libérer le(s) verrou(s)
Pendant ce temps, sur un autre terminal...
    $ ./ex_03 mon_fic
    Impossible de verrouiller mon_fic
    $
En pressant Entrée sur la première console, nous libérons les verrous.

Avec le mécanisme de verrouillage que nous avons observé, il est possible d'empêcher les accès concurrents aux répertoires et files d'attentes en impression, à la manière du démon lpd qui place un verrouillage flock() sur un fichier /var/lock/subsys/lpd, ce qui lui permet de ne s'exécuter qu'en une seule instance. On peut aussi gérer de manière sécurisée l'accès à un fichier système important comme /etc/passwd, qui est verrouillé avec fcntl() par la bibliothèque pam lors d'une modification des données concernant un utilisateur.

Il faut reconnaître que ce système ne protège toutefois que des interférences entre les applications se comportant correctement, c'est-à-dire qui demande bien au noyau de réserver l'accès correct avant de lire et surtout d'écrire dans tout fichier système important. On parle de verrouillage coopératif, ce qui exprime bien la responsabilité de chaque application devant l'accès aux données. Malheureusement un programme mal écrit pourra parfaitement écraser le contenu d'un fichier, même si un autre processus, bien éduqué celui-là, dispose d'un verrou en écriture. En voici, un exemple. Nous écrivons quelques lettres dans un fichier, et le bloquons à l'aide du programme précédent :

$ echo "PREMIER" > mon_fic
$ ./ex_03 mon_fic
Pressez Entrée pour libérer le(s) verrou(s)
Sur une autre console, nous pouvons toutefois modifier le fichier :
    $ echo "DEUXIEME" > mon_fic
    $
De retour sur la première console nous vérifions les dégâts :
(Entrée)
$ cat mon_fic
DEUXIEME
$ 

Pour essayer de résoudre ce problème, le noyau Linux met à la disposition de l'administrateur système un mécanisme de verrouillage strict hérité de Système V. Il n'est donc utilisable qu'avec les verrous posés avec fcntl() et non ceux de type flock(). L'administrateur peut indiquer au noyau, au moyen d'une combinaison particulière des autorisations d'accès, que tous les verrous fcntl() déposés sur le fichier seront stricts. Dans ce cas, si un processus y pose un verrou en écriture, il sera impossible pour un autre processus (même s'il a l'identité root) d'y écrire. La combinaison spéciale est l'utilisation du bit Set-GID alors que le bit d'exécution est effacé pour le groupe. On obtient ceci avec la commande :

$ chmod g+s-x mon_fic
$
Cette opération n'est toutefois pas suffisante. Pour qu'un fichier devienne effectivement un lieu où les verrous coopératifs deviennent automatiquement stricts, l'attribut mandatory doit être activé sur la partition où il réside. En général il faudra donc modifier /etc/fstab pour ajouter l'option mand dans sa quatrième colonne, ou taper en ligne de commande :
# mount
/dev/hda5 on / type ext2 (rw)
[...]
# mount / -o remount,mand
# mount
/dev/hda5 on / type ext2 (rw,mand)
[...]
#
Nous pouvons vérifier qu'à présent une modification depuis une autre console est impossible :
$ ./ex_03 mon_fic
Pressez Entrée pour libérer le(s) verrou(s)
Sur un autre terminal :
    $ echo "TROISIEME" > mon_fic
    bash: mon_fic: Ressource temporairement non disponible
    $  
Et de retour sur la première console :
(Entrée)
$ cat mon_fic
DEUXIEME
$

Le fait de rendre stricts les verrouillages sur un fichier (par exemple /etc/passwd, ou /etc/shadow) est une décision qui revient à l'administrateur système et non au programmeur. Ce dernier devra simplement encadrer comme il se doit les opérations d'accès aux données ce qui l'assurera que son application, dans un environnement correctement administré, verra des données cohérentes lors d'une lecture et ne présentera pas de danger vis-à-vis des autres processus lors d'une écriture.

Fichiers temporaires

Il arrive fréquemment qu'un programme ait besoin de mémoriser temporairement des données dans un fichier externe. Le cas le plus courant est l'insertion d'un enregistrement au sein d'un fichier organisé séquentiellement, ce qui nécessite de recopier le contenu du fichier original dans un fichier temporaire, en glissant au passage les nouvelles informations. Ensuite, l'appel-système unlink() détruit le fichier original et rename() renome le fichier temporaire pour le mettre en lieu et place du précédent.

L'ouverture d'un fichier temporaire, si elle n'est pas réalisée correctement, est souvent à l'origine de situations de concurrence exploitables par un utilisateur mal intentionné. Des failles de sécurité s'appuyant sur les fichiers temporaires ont ainsi été découvertes récemment dans des applications telles qu'Apache, Linuxconf, getty_ps, wu-ftpd, rdist, gpm, inn, etc. Nous allons donc rappeler quelques principes pour éviter ces ennuis.

En général, la création de fichiers temporaires s'effectue dans le répertoire /tmp. Cela permet à l'administrateur système de savoir où le stockage de données à faible durée de vie s'effectue. On peut ainsi programmer un nettoyage périodique de ce répertoire (via l'utilitaire cron), l'utilisation d'une partition indépendante formatée à chaque démarrage du système, etc. En principe l'administrateur indique l'emplacement qu'il réserve aux fichiers temporaires dans les fichiers <paths.h> et <stdio.h>, dans la définition des constantes symboliques _PATH_TMP et P_tmpdir. En pratique, l'utilisation d'un autre répertoire par défaut que /tmp est peu envisageable car elle nécessiterait une recompilation de toutes les applications sur la machine, y compris la bibliothèque C. Signalons toutefois que le comportement des routines de la GlibC vis-à-vis du répertoire temporaire est également paramétrable grâce à la variable d'environnement TMPDIR. L'utilisateur peut donc demander que les fichiers temporaires soient stockés dans un répertoire lui appartenant plutôt que dans /tmp. Cela s'avère parfois nécessaire lorsque la partition réservée à /tmp est trop limitée pour faire fonctionner des applications gourmandes en espace de stockage temporaire.

Le répertoire système /tmp possède des vertus particulières dues à ses permissions d'accès :

$ ls -ld /tmp
drwxrwxrwt 7 root  root    31744 Feb 14 09:47 /tmp
$ 

Le Sticky-Bit représenté par la lettre t en dernière position ou le mode octal 01000, possède une signification particulière lorsqu'il s'applique à un répertoire : seuls le propriétaire du répertoire (root en l'occurrence), et le propriétaire d'un fichier s'y trouvant auront le droit de supprimer ce fichier. Le répertoire ayant en outre une autorisation d'écriture générale, chaque utilisateur peut y placer ses fichiers, en étant assuré qu'ils seront protégés - du moins jusqu'au prochain nettoyage effectué par l'administrateur.

L'utilisation du répertoire de stockage temporaire peut néanmoins poser quelques problèmes. Commençons par le cas le plus trivial, celui d'une application Set-UID root, qui dialogue avec un utilisateur. Imaginons la situation d'un programme de transport de courrier. Si ce processus reçoit un signal lui demandant de se terminer immédiatement, par exemple SIGTERM ou SIGQUIT durant une phase de shutdown du système, il peut essayer de sauvegarder rapidement le courrier déjà saisi mais pas encore envoyé. Dans des versions anciennes, une telle sauvegarde avait lieu dans /tmp/dead.letter. Il suffisait alors que l'utilisateur crée (puisqu'il a un droit d'écriture dans /tmp) un lien physique vers /etc/passwd ayant le nom dead.letter pour que l'utilitaire de courrier (s'exécutant, rappelons-le, sous l'UID effectif root) écrive dans ce fichier le contenu de la lettre à moitié saisie (qui contenait, comme par hasard, une ligne "root::1:99999:::::").

Le premier défaut dans ce comportement est la nature prévisible du nom du fichier. Il suffit d'observer une seule fois une telle application pour savoir qu'elle utilisera le nom /tmp/dead.letter. La première étape est donc d'employer un nom de fichier spécialement conçu pour l'instance du programme en cours. Pour cela, il existe plusieurs fonctions de bibliothèques capables de nous fournir un nom de fichier temporaire personnel.

Supposons que nous disposions d'une telle fonction, qui nous procure un nom unique pour créer notre fichier temporaire. Malgré tout, les logiciels libres étant disponibles en forme source, et la bibliothèque C également, le nom du fichier créé est quand même prévisible bien que cela soit très difficile. Il n'est pas impossible qu'un assaillant arrive à créer un lien symbolique avec le nom que vient de nous fournir la bibliothèque C. Notre premier réflexe est donc vouloir vérifier l'existence du fichier avant de l'ouvrir effectivement. Naïvement, on écrirait quelque chose comme :

  if ((fd = open (nom_fichier, O_RDWR)) != -1) {
    fprintf (stderr, "%s existe déjà\n", nom_fichier);
    exit(EXIT_FAILURE);
  }
  fd = open (nom_fichier, O_RDWR | O_CREAT, 0644);
  ...

Bien évidemment, nous nous trouvons ici dans un cas typique de race condition, où une faille de sécurité s'ouvre aisément sous l'action d'un utilisateur qui s'arrange pour créer le lien vers /etc/passwd entre le premier open() et le second. Il faut disposer d'un moyen d'effectuer ces deux opérations de manière atomique, sans qu'aucune manipulation ne puisse s'insérer entre elles. Cela est rendu possible grâce à une option spécifique de l'appel-système open(). Nommée O_EXCL, et utilisée nécessairement avec O_CREAT, cette option entraîne l'échec de open() si le fichier existe déjà, mais la vérification d'existence est atomiquement liée avec la création.

Par ailleurs, l'extension Gnu 'x' pour les modes d'ouverture de la fonction fopen() réclame une création exclusive du fichier, échouant s'il existe déjà :

  FILE * fp;

  if ((fp = fopen (nom_fichier, "r+x")) == NULL) {
    perror ("Impossible de créer le fichier.");
    exit (EXIT_FAILURE);
  }

Les permissions attribuées au fichier temporaires jouent également un rôle important. En effet, si vous devez y écrire des informations confidentielles et que le fichier est en mode 644 (lecture/écriture pour le propriétaire, lecture seule pour le reste du monde), ceci est un peu gênant. La fonction

       #include <sys/types.h>
       #include <sys/stat.h>

       mode_t umask(mode_t mask);
permet de fixer les permissions qui seront accordées au fichier lors de sa création. Ainsi, après un appel à umask(077);, l'ouverture d'un fichier aura lieu avec les permissions 600 (lecture/écriture pour le propriétaire, aucun droit pour les autres).

D'une manière générale, la création d'un fichier temporaire se déroule en trois étapes :

  1. création d'un nom unique (aléatoire) ;
  2. ouverture du fichier avec O_CREAT | O_EXCL, et les permissions les plus restreintes possibles ;
  3. tester le résultat obtenu lors de l'ouverture du fichier et agir en conséquence (soit recommencer, soit abandonner).

Détaillons maintenant les possibilités qui existent pour obtenir un fichier temporaire. Les fonctions

      #include <stdio.h>

      char *tmpnam(char *s);
      char *tempnam(const char *dir, const char *prefix);

retournent des pointeurs sur des noms engendrés aléatoirement.

La première fonction supporte un argument NULL, auquel cas l'adresse d'un buffer statique est retournée. Son contenu changera au prochain appel de tmpnam(NULL). Si l'argument passé est une chaîne allouée, le nom y est copié, ce qui nécessite une chaîne d'au moins L-tmpnam octets. Attention donc aux débordements de buffer ! De plus, la page man signale des problèmes lorsqu'elle est utilisée avec un paramètre NULL si _POSIX_THREADS ou _POSIX_THREAD_SAFE_FUNCTIONS sont définis.

La fonction tempnam() retourne un pointeur sur une chaîne de caractères. Le répertoire dir doit être "approprié" (la page man décrit le sens exacte de "approprié"). Cette fonction vérifie la non-existence du fichier avant d'en retourner le nom. Cependant, encore une fois, la page man déconseille son utilisation car "approprié" change de sens selon les implémentations de la fonction. Signalons tout de même que Gnome en recommande l'utilisation de la manière suivante :

  char *filename;
  int fd;

  do {
    filename = tempnam (NULL, "foo");
    fd = open (filename, O_CREAT | O_EXCL | O_TRUNC | O_RDWR, 0600);
    free (filename);
  } while (fd == -1);
La boucle utilisée ici contrebalance les risques, mais en crée d'autres. Imaginez ce qui se produira si la partition sur laquelle doit être créé le fichier temporaire est pleine, ou encore si le système à déjà ouvert le nombre maximal de fichiers disponibles simultanément...

La fonction

       #include <stdio.h>

       FILE *tmpfile (void);
crée un nom unique de fichier puis l'ouvre. Ce fichier est automatiquement détruit à sa fermeture.

Dans la GlibC-2.1.3, cette fonction utilise un mécanisme similaire à tmpnam() pour générer le nom du fichier, puis ouvre le descripteur correspondant. Le fichier est alors détruit, mais Linux ne l'effacera réellement que lorsque plus aucune ressource ne s'en servira, c'est-à-dire lorsque le descripteur de fichier sera libéré, via un appel-système close()

  FILE * fp_tmp;

  if ((fp_tmp = tmpfile()) == NULL) {
    fprintf (stderr, "Impossible de créer un fichier temporaire\n");
    exit (EXIT_FAILURE);
  }

  /* ... utilisation du fichier temporaire ... */

  fclose (fp_tmp);  /* destruction réelle par le système */

Les cas les plus simples ne demandent pas de modification du nom du fichier, ni sa transmission vers un autre processus, mais uniquement l'enregistrement et la relecture des données dans une zone temporaire. Nous n'avons donc généralement pas besoin de connaître le nom du fichier temporaire, mais simplement de pouvoir accéder à son contenu. La fonction tmpfile() répond à cette attente.

La page man ne la déconseille pas, mais le Secure-Programs-HOWTO si. D'après l'auteur, les spécifications ne fournissent aucune garantie que le fichier sera créé de manière sûre, et il n'a pu en vérifier toutes les implémentations. Cette fonction, sous cette réserve, est la plus efficace à utiliser.

Enfin, les fonctions

       #include <stdlib.h>

       char *mktemp(char *template);
       int mkstemp(char *template);
fabriquent un nom unique à partir d'un motif constitué d'une chaîne de caractères terminée par la chaîne "XXXXXX". Ces 'X' sont remplacés de manière à obtenir un nom de fichier unique.

Selon les versions, mktemp() remplace les cinq premiers 'X' par le Process ID (PID) ... ce qui rend le nom assez facilement devinable : seul le dernier 'X' est soumis à hasard. Certaines versions autorisent l'utilisation de plus de six 'X'.

mkstemp() est la fonction recommandée dans le Secure-Programs-HOWTO. Voici la méthode qu'il propose :

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>

 void failure(msg) {
  fprintf(stderr, "%s\n", msg);
  exit(1);
 }

/*
 * Crée un fichier temporaire et le renvoie.
 * Cette routine détruit le nom du fichier dans le système de
 * fichiers, afin qu'il n'apparaisse pas lors du listage du
 * répertoire.
 */
FILE *create_tempfile(char *temp_filename_pattern)
{
  int temp_fd;
  mode_t old_mode;
  FILE *temp_file;

  old_mode = umask(077);  /* Create file with restrictive permissions */
  temp_fd = mkstemp(temp_filename_pattern);
  (void) umask(old_mode);
  if (temp_fd == -1) {
    failure("Couldn't open temporary file");
  }
  if (!(temp_file = fdopen(temp_fd, "w+b"))) {
    failure("Couldn't create temporary file's file descriptor");
  }
  if (unlink(temp_filename_pattern) == -1) {
    failure("Couldn't unlink temporary file");
  }
  return temp_file;
}

Ces fonctions illustrent un problème entre l'abstraction et la portabilité. En effet, les fonctions des bibliothèques standards sont définies pour offrir des fonctionnalités (abstraction)... mais la manière de les mettre en oeuvre change selon le système (portabilité). En effet, la fonction tmpfile() ouvre un fichier temporaire de manière différente (certaines versions n'utilisent pas le O_EXCL), ou mkstemp() supporte un nombre variable de 'X' suivant les implémentations.

En conclusion

Nous avons survolé l'essentiel des problèmes de sécurité concernant les accès concurrents à une même ressource. Retenons surtout qu'il ne faut jamais considérer que deux opérations successives sont nécessairement liées à moins que cela soit garanti par le noyau. Si les situations de concurrence sur les fichiers peuvent présenter des failles de sécurité, il ne faut pas négliger pour autant celles reposant sur d'autres ressources, comme les variables communes entre différents threads, ou les segments de mémoires partagés par l'intermédiaire des mécanismes shmget(). Des mécanismes de sélection d'accès (les sémaphores par exemple) doivent être mis en place pour éviter de rencontrer des bogues difficiles à diagnostiquer.

Links


Christophe BLAESS - ccb@club-internet.fr
Christophe GRENIER - grenier@cgsecurity.org
Frédéreric RAYNAL - pappy@users.sourceforge.net