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.
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.
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 #
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 :
nice -n 20
;
while (1);
) ;
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.
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.
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.
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.
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 :
O_CREAT | O_EXCL
, et les
permissions les plus restreintes possibles ;
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.
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.
Last modified: Fri Mar 9 11:44:28 CET 2001