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.