Hogar Mapa Indice Busqueda Noticias Arca Enlaces Sobre LF
[Top bar]
[Bottom bar]
Este documento está disponible en los siguientes idiomas: English  Castellano  Deutsch  Francais  Nederlands  Portugues  Russian  Turkce  

convert to palmConvert to GutenPalm
or to PalmDoc

[image of the authors]
por Frédéric Raynal, Christophe Blaess, Christophe Grenier

Sobre el autor:

Christophe Blaess es un ingeniero aeronáutico independiente. Es un fanático de Linux y gran parte de su trabajo lo realiza en este sistema operativo. Coordina la traducción de las páginas del manual del Proyecto de Documentación de Linux.

Christophe Grenier es un estudiante de quinto año del ESIEA donde trabaja como administrador de sistemas. Es un apasionado de la seguridad informática

Frédéric Raynal ha usado Linux durante muchos años ya que no contamina, no usa hormonas, MSG o harinas animales...sólo requiere de un poco de sudor y astucia.


Contenidos:

Evitando agujeros de seguridad al desarrollar una aplicación - Parte 5: condiciones de carrera

[article illustration]

Resumen:

Este es el quinto artículo de nuestra serie dedicada a los problemas de seguridad vinculados con la multitarea. Una condición de carrera ó acceso concurrente ocurre cuando diferentes procesos usan un mismo recurso (archivo, dispositivo, memoria) al mismo tiempo y cada uno "piensa" que tiene acceso exclusivo. Esto conduce a dificultar la detección de fallos y también a crear agujeros de seguridad que pueden comprometer la seguridad global de nuestro sistema.



 

Introducción

El principio general que define una condición de carrera es la siguiente. Un proceso quiere acceder a un recurso del sistema en forma exclusiva, primero verifica que el recurso no esta siendo usado y a continuación lo usa a su antojo. El problema surge cuando otro proceso aprovecha el lapso de tiempo comprendido entre la verificación y el acceso efectivo para atribuirse el mismo recurso. Las consecuencias pueden ser diversas. El clásico ejemplo en la teoría de los sistemas operativos es el abrazo mortal (deadlock) entre ambos procesos. En la mayoría de los casos prácticos esto ocasiona a menudo el mal funcionamiento de una aplicación o incluso da lugar a agujeros de seguridad cuando un proceso injustamente se beneficia de los privilegios que tiene otro.

Lo que hemos previamente denominado recurso puede presentarse bajo distintas formas. La mayoría de las condiciones de carrera que han sido descubiertas y corregidas en el propio kernel fueron debido a accesos concurrentes a áreas de memoria. Nosotros únicamente, nos centraremos en las aplicaciones del sistema y supondremos que los recursos involucrados son nodos del sistema de archivos. Esto incluye no sólo a los archivos comunes sino también a los accesos directos a los dispositivos a través de los puntos de entradas especiales del directorio /dev/.

La mayoría de las veces, un ataque que tiende a comprometer la seguridad de un sistema se realiza contra aplicaciones Set-UID pues de esta manera el atacante puede beneficiarse de los privilegios del propietario de un archivo ejecutable. Sin embargo, a diferencia de los agujeros de seguridad que se han discutido previamente (desbordamiento de búfer, formateo de cadenas...), las condiciones de carrera no permiten la ejecución de código "personalizado" y sólo se benefician de los recursos de un programa mientras está ejecutándose. Este tipo de ataque está dirigido también a utilidades normales (no sólo a las del tipo Set-UID). El cracker tiende una emboscada en espera de un usuario, preferentemente root, para que ejecute la aplicación afectada y de esta manera poder acceder a sus recursos. Esto también es válido para escribir un archivo (es decir, ~/.rhost en donde la cadena "+ +" proporciona un acceso directo desde cualquier máquina sin contraseña) o para leer un archivo confidencial (datos comerciales reservados, información médica personal, archivo de contraseñas, clave privada...)

A diferencia de los agujeros de seguridad discutidos en nuestros artículos previos este problema afecta a todas las aplicaciones y no únicamente a las utilidades Set-UID, servidores de sistemas o demonios.

 

Primer ejemplo

Analicemos el comportamiento de un programa Set-UID que necesita guardar datos en un archivo perteneciente a un usuario. Podemos considerar el caso, por ejemplo, de un cliente de correo como sendmail. Supongamos que el usuario puede proporcionar un nombre al archivo y un mensaje para escribir en él lo cual es posible en determinadas circunstancias. En este caso, la aplicación debe verificar que el archivo pertenece a la persona que inició el programa y que no se trata de un enlace simbólico a un archivo del sistema. No olvidemos que, al ser un programa Set-UID root, puede modificar cualquier archivo del sistema. En consecuencia, comparará el propietario del archivo con su UID real. Escribamos algo así:

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, "Uso : %s mensaje a archivo\n", argv [0]);
16            exit(EXIT_FAILURE);
17        }
18        if (stat (argv [1], & st) < 0) {
19            fprintf (stderr, "No se puede ubicar %s\n", argv [1]);
20            exit(EXIT_FAILURE);
21        }
22        if (st . st_uid != getuid ()) {
23            fprintf (stderr, "No es el propietario de %s \n", argv [1]);
24            exit(EXIT_FAILURE);
25        }
26        if (! S_ISREG (st . st_mode)) {
27            fprintf (stderr, "%s no es un archivo normal\n", argv[1]);
28            exit(EXIT_FAILURE);
29        }
30
31        if ((fp = fopen (argv [1], "w")) == NULL) {
32            fprintf (stderr, "No se puede abrir\n");
33            exit(EXIT_FAILURE);
34        }
35        fprintf (fp, "%s\n", argv [2]);
36        fclose (fp);
37        fprintf (stderr, "Escritura exitosa\n");
38        exit(EXIT_SUCCESS);
39    }

Como explicamos en nuestro primer artículo, sería conveniente para una aplicación Set-UID abandonar momentáneamente sus privilegios y abrir el archivo usando el UID real del usuario que lo llamó. De hecho, la situación descripta corresponde más bien a un demonio que proporciona servicios a todos los usuarios. Al correr siempre con el ID root, hará la verificación de pertenencia con el UID de su interlocutor más bien que con su propio UID real. Sin embargo, seguiremos por el momento con esta suposición a pesar de no ser realista pues nos permitirá comprender fácilmente cómo explotar el agujero de seguridad.

Como podemos ver, el programa empieza a efectuar todas las verificaciones pertinentes. Es decir: que el archivo existe, que pertenece al usuario y que se trata de un archivo normal. A continuación, abre el archivo y escribe el mensaje. ¡Es aquí donde radica el agujero de seguridad!. O, para ser más precisos, entre el lapso de tiempo comprendido entre la lectura de los atributos del archivo con stat() y su apertura con fopen(). Si bien este intervalo de tiempo es extremadamente breve un atacante puede beneficiarse con él cambiando las características del archivo. Para que nuestro ataque sea aún más sencillo, agreguemos una línea que ponga a dormir el proceso entre las dos operaciones para contar con el tiempo suficiente para poder realizar la tarea manualmente. Cambiemos la línea 30 (previamente vacía) por la siguiente:

30        sleep (20);

Manos a la obra. Primero hagamos a la aplicación Set-UID root. Es muy importante previamente realizar una copia de seguridad de nuestro archivo de contraseñas ocultas /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
$

Todo está listo para el ataque. Estamos en un directorio que es nuestro. Hemos descubierto una utilidad Set-UID root (en este caso ex_01) que contiene un agujero de seguridad y queremos reemplazar la línea del archivo de contraseñas /etc/shadow que contiene la palabra root por una línea con el campo de contraseña vacío.

Primero, creamos un archivo fic:

$ rm -f fic
$ touch fic

A continuación, ejecutamos nuestra aplicación en segundo plano a fin de conservar la principal y le pedimos que escriba una cadena en el archivo. Primero, el programa hace las verificaciones pertinentes para posteriormente dormir momentáneamente antes de acceder al archivo.

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

El contenido de la línea referente al root se detalla en la página del manual shadow(5). Lo más importante es que el segundo campo se encuentra vacío (sin contraseña). Mientras el proceso duerme, contamos con alrededor de 20 segundos para eliminar el archivo fic y reemplazarlo por un enlace (simbólico o físico, cualquiera de los dos funciona correctamente) al archivo /etc/shadow. Recordemos que todo usuario puede crear un enlace a un archivo situado en un directorio de su pertenencia (o como veremos más tarde en /tmp)aún cuando no sea capaz de leer su contenido . Sin embargo, no es posible crear una copia de dicho archivo pues requeriría permiso de lectura

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

A continuación mediante el comando fg del shell traemos el proceso ex_01 al primer plano y esperamos a que finalize:

$ fg
./ex_01 fic "root::1:99999:::::"
Escritura exitosa
$

¡Voilà! Operación terminada. El archivo /etc/shadow contiene una única línea indicando que el root no tiene contraseña. ¿No lo creen?

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

Terminemos con nuestro experimento recuperando nuestro archivo de contraseñas original:

# cp /etc/shadow.bak /etc/shadow
cp: replace `/etc/shadow¿ y
#
 

Seamos más realistas

Hemos explotado con éxito una condición de carrera en una utilidad Set-UID root. Por supuesto, este programa fue demasiado "generoso" al darnos 20 segundos para modificar los archivos a sus espaldas. En una aplicación real la condición de carrera sólo se aplica a un intervalo muy breve de tiempo. ¿Cómo podemos aprovecharnos de esta situación entonces?

Generalmente, un cracker recurre a un ataque de fuerza bruta renovando los intentos cientos, miles o millones de veces mediante scripts que automatizan la tarea. Es posible aumentar las posibilidades de "caer" dentro del agujero de seguridad con diversas artimañas con el propósito de incrementar el intervalo de tiempo entre las dos operaciones que el programa incorrectamente considera íntimamente enlazadas. La idea consiste en frenar el proceso objetivo para aprovechar más fácilmente la demora precedente a la modificación del archivo. Distintos enfoques pueden ayudarnos a alcanzar nuestra meta:

El método que permite beneficiarnos de un agujero de seguridad basado en una condición de carrera es aburrido y repetitivo pero realmente se puede usar. Intentemos hallar otras soluciones más efectivas.

 

Posible mejoras

El problema discutido anteriormente está relacionado con la capacidad de cambiar las características de un objeto durante el intervalo de tiempo entre dos operaciones prácticamente simultáneas. En la situación descripta, el cambio no estaba relacionado con propio archivo. Dicho sea de paso, como usuario normal sería bastante difícil modificar o incluso leer el archivo /etc/shadow. De hecho, los cambios estan relacionados con el enlace entre el nodo del archivo existente en el árbol de nombres y el propio archivo considerado como entidad física. Recordemos que la mayoría de los comandos del sistema (rm, mv, ln, etc.) actúan sobre el nombre del archivo y no sobre el contenido del mismo. Incluso cuando se borra un archivo (usando rm y la llamada del sistema unlink()), realmente se borra el contenido cuando se elimina el úlimo enlace físico, la última referencia.

El error cometido por el programa es haber considerado la asociación entre el nombre del archivo y su contenido como intercambiables, o al menos constantes, durante el intervalo de tiempo entre las operaciones stat() y fopen(). Bastará con recurrir a un enlace físico para comprobar que esta asociación no es permanente en absoluto. Consideremos un ejemplo usando este tipo de enlace. En un directorio nuestro creamos un nuevo enlace a un archivo del sistema. Obviamente, conservamos el propietario del archivo y el modo de acceso. La opción -f del comando ln fuerza su creación incluso si el nombre ya existe: :

$ ln -f /etc/fstab ./mi_archivo
$ ls -il /etc/fstab mi_archivo
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 mi_archivo
$ cat mi_archivo
/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 ./mi_archivo
$ ls -il /etc/host.conf mi_archivo 
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 mi_archivo
$ cat mi_archivo
order hosts,bind
multi on
$

La opción -i de /bin/ls muestra el número de ínodo al comienzo de la línea. Podemos ver que el mismo nombre apunta a dos ínodos físicos diferentes. Es evidente que los dos comandos"cat" actuando sobre el mismo nombre de archivo muestran dos contenidos totalmente diferentes a pesar de que no ha ocurrido ningún cambio en estos archivos entre las dos operaciones.

En verdad, nos gustaría que las funciones que verifican y acceden al archivo siempre apunten al mismo contenido y al mismo ínodo. ¡Y es posible! El propio kernel efectúa esta asociación de manera automática cuando nos proporciona un descriptor de archivo. Cuando abrimos un archivo para lectura, la llamada al sistema open() devuelve un valor entero -el descriptor- que lo asocia mediante una tabla interna con un archivo físico. Todas las lecturas que hagamos posteriormente estarán relacionadas con el contenido de este archivo independientemente de lo que ocurra con el nombre usado durante la operación de apertura del mismo.

Hagamos hincapié en lo siguiente: una vez que se ha abierto un archivo, cada operación relacionada con el nombre del mismo, incluyendo su eliminación, no tendrá ningún efecto sobre su contenido. Mientras exista un proceso que contenga el descriptor de un archivo, el contenido del mismo no se eliminará del disco incluso si su nombre desaparece del directorio donde fue almacenado. El kernel mantiene la asociación entre un descriptor y el contenido de un archivo durante el tiempo comprendido entre la llamada al sistema open() que proporciona el descriptor y la liberación del mismo mediante close() o hasta que ocurra la finalización del proceso.

¡Aquí tenemos nuestra solución! Podemos abrir el archivo y verificar a continuación sus permisos examinando las características de su descriptor en vez de su nombre. Esto se puede realizar usando la llamada al sistema fstat() que funciona como stat() pero verifica un descriptor de archivo en vez de una ruta. Para acceder al contenido de un archivo usando su descriptor emplearemos la función fdopen() que funciona como fopen() pero haciendo uso del descriptor en vez del nombre del archivo. Por lo tanto, el programa quedará:

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, "Uso : %s mensaje a archivo\n", argv [0]);
18            exit(EXIT_FAILURE);
19        }
20        if ((fd = open (argv [1], O_WRONLY, 0)) < 0) {
21            fprintf (stderr, "No es posible abrir %s\n", argv [1]);
22            exit(EXIT_FAILURE);
23        }
24        fstat (fd, & st);
25        if (st . st_uid != getuid ()) {
26            fprintf (stderr, "¡ %s no le pertenece !\n", argv [1]);
27            exit(EXIT_FAILURE);
28        }
29        if (! S_ISREG (st . st_mode)) {
30            fprintf (stderr, "%s no es un archivo normal\n", argv[1]);
31            exit(EXIT_FAILURE);
32        }
33        if ((fp = fdopen (fd, "w")) == NULL) {
34            fprintf (stderr, "No es posible abrirlo\n");
35            exit(EXIT_FAILURE);
36        }
37        fprintf (fp, "%s", argv [2]);
38        fclose (fp);
39        fprintf (stderr, "Escritura exitosa\n");
40        exit(EXIT_SUCCESS);
41    }

Como se puede ver, a partir de la línea 20 ningún cambio del nombre del archivo (eliminación, cambio de nombre, enlace) afectará el comportamiento de nuestro programa. Es decir, el contenido del archivo físico original se conservará.

 

Generalización

Al manipular un archivo es importante asegurarse que la asociación entre su representación interna y su contenido real permanezca constante. Preferentemente, usaremos las siguientes llamadas al sistema para manipular al archivo físico:

Llamada al sistema Uso
fchdir (int fd) Va al directorio representado por fd.
fchmod (int fd, mode_t mode) Modifica los permisos de acceso a un archivo.
fchown (int fd, uid_t uid, gid_t gif) Cambia el propietario de un archivo.
fstat (int fd, struct stat * st) Consulta la infomación almacenada en el ínodo de un archivo físico.
ftruncate (int fd, off_t length) Trunca un archivo existente.
fdopen (int fd, char * mode) Inicializa IO desde un descriptor ya abierto. Es una rutina de la biblioteca stdio y no una llamada del sistema.

Obviamente, debemos al principio abrir el archivo en el modo elegido invocando a open() (no olvidarse del tercer argumento al crear el nuevo archivo). Continuaremos hablando sobre open() más tarde cuando discutamos el problema de los archivos temporales.

Debemos insistir en la importancia de verificar los códigos de retorno de las llamadas al sistema. A pesar de no tener nada que ver con las condiciones de carrera mencionemos a modo de ejemplo un error encontrado en las primeras implementaciones de /bin/login debido a que no tenía en cuenta una verificación de un código de error. Esta aplicación, proporcionaba automáticamente acceso de root cuando no encontraba el archivo /etc/passwd. Este comportamiento puede resultar razonable en lo que respecta a la reparación de un sistema de archivos dañado. En el otro extremo, el verificar que era imposible abrir el archivo en vez de comprobar su existencia, es menos aceptable. En efecto, bastaba con llamar a /bin/login después de abrir el número máximo de descriptores permitido para un usuario para obtener directamente el acceso root ... Finalizemos esta disgresión insistiendo en la importancia de comprobar, antes de tomar cualquiern acción sobre la seguridad de un sistema, no sólo si la llamada al sistema tuvo o no éxito sino también los códigos de error

 

Accesos concurrentes al contenido de un archivo

Un programa vinculado con la seguridad de un sistema no debe depender del acceso exclusivo al contenido de un archivo. Más precisamente es importante evaluar los riesgos que implican los accesos concurrentes a un mismo archivo. El mayor peligro proviene de un usuario ejecutando simultáneamente múltiples instancias de una aplicación Set-UID root o estableciendo múltiples conexiones a la vez con el mismo demonio con la esperanza de crear una condición de carrera para modificar de una manera inusual el contenido de un archivo del sistema.

Para evitar que un programa sea permeable a este tipo de situación, es necesario implementar un mecanismo de acceso exclusivo a los datos del archivo. Este es el mismo problema que tienen las bases de datos en donde a varios usuarios se les permite consultar o cambiar el contenido de un archivo. El principio de bloqueo de un archivo resuelve este problema.

Cuando un proceso quiere escribir en un archivo, le pide al kernel que bloquee al archivo o parte de él. Mientras el proceso conserve el bloqueo ningún otro proceso puede pedir el bloqueo del mismo archivo o parte de él. De la misma manera, un proceso solicita un bloqueo antes de la lectura del contenido de un archivo para asegurarse que no habrán cambios mientras dure el bloqueo.

De hecho, el sistema es más listo que esto: el kernel distingue entre los bloqueos solicitados para la lectura de un archivo de aquellos reclamados para la escritura del mismo. Diversos procesos pueden retener un bloqueo de lectura en forma simultánea ya que nadie intentará modificar el contenido del archivo. No obstante, solo un proceso puede conservar un bloqueo para escritura en un determinado instante de tiempo y ningún otro puede hacerlo simultáneamente incluso para lectura.

Existen dos tipos de bloqueos (en gral. incompatibles entre sí). El primero heredado del BSD se basa en la llamada al sistema flock(). Su primer argumento es el descriptor del archivo al que se desea acceder de manera exclusiva y el segundo es una constante simbólica que representa la operación a realizar. Puede tener diferentes valores: LOCK_SH (bloqueo de lectura), LOCK_EX (bloqueo de escritura), LOCK_UN (para destrabar el bloqueo). La llamada al sistema mantendrá el bloqueo mientras la operación solicitada no resulte posible. No obstante, a veces es posible agregar (mediante un OR | binario) la constante LOCK_NB para que la llamada dé error en vez de permanecer bloqueada.

El segundo tipo de bloqueo proviene del Sistema V y se dundamenta en la llamada al sistema fcntl() cuya invocación es un tanto complicada. Existe una función de biblioteca llamada lockf() similar a la llamada al sistema pero que no ofrece todas las posibilidades de esta última. El primer argumento de fcntl()es el descriptor del archivo a bloquear. El segundo representa la operación a realizar: F_SETLK y F_SETLKW gestionan el bloqueo, la segunda permanece bloqueada hasta que la operación resulte posible mientras que la primera retorna imediatamente en caso de error. F_GETLK consulta el estado de bloqueo de un archivo (lo cual normalmente carece de utilidad para las aplicaciones actuales). El tercer argumento es un puntero a una variable de tipo struct flock que describe el bloqueo. Los miembros más importante de la estructura flock son las siguientes:

Nombre Tipo Significado
l_type int Acción esperada : F_RDLCK (bloqueo de lectura), F_WRLCK (bloqueo de escritura) y F_UNLCK (desbloqueo).
l_whence int Origen del campo l_start (generalmente SEEK_SET).
l_start off_t Posición al comienzo del bloqueo (generalmente 0).
l_len off_t Duración del bloqueo, 0 para alcanzar el final del arhivo.

Podemos ver que fcntl() puede bloquear porciones limitadas del archivo pero no es la única ventaja en relación a flock(). Analicemos con detalle un pequeño programa que nos pide hacer un bloqueo de lectura a una serie de archivos dados como argumento y que espera que el usuario presione la tecla Enter antes de finalizar y de esta manera destrabar el bloqueo.

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, "No es posible abrir %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, "No es posible destrabar %s\n", argv [i]);
29         exit(EXIT_FAILURE);
30       }
31     }
32     fprintf (stdout, "Presione Enter para destrabar el/los bloqueo(s)\n");
33     fgets (buffer, 2, stdin);
34     exit(EXIT_SUCCESS);
35   }

Primero ejecutamos el programa desde una primer consola donde quedará a la espera:

$ cc -Wall ex_03.c -o ex_03
$ ./ex_03 mi_archivo
Presione Enter para destrabar el/los bloqueo(s)
>Desde otra terminal...
    $ ./ex_03 mi_archivo
    No es posible desbloquear mi_archivo     $
Al presionar Enter en la primer consola, destrabamos el bloqueo.

Con este mecanismo es posible impedir accesos concurrentes a directorios y colas de impresión como lo hace el demonio lpd que usa un bloqueo flock() sobre el archivo /var/lock/subsys/lpd de manera de permitir una única instancia. Es posible asimismo administrar en forma segura el acceso a un archivo importante del sistema como ocurre con /etc/passwd que se bloquea mediante la función fcntl() de la biblioteca pam cuando se modifican los datos del usuario.

No obstante, hay que reconocer que esto sólo protege de interferencias con aplicaciones que tienen un comportamiento correcto, es decir, que piden al kernel reservar el acceso adecuado antes de leer o escribir un archivo del sistema importante. En este caso, se habla de bloqueo cooperativo lo que expresa la responsabilidad de cada aplicación sobre los accesos a los datos. Desafortunadamente un programa mal escrito es capaz de reemplazar el contenido de un archivo incluso si otro proceso con buen comportamiento tiene un bloqueo para escritura. Aquí tenemos un ejemplo. Escribimos unas pocas palabras en un archivo y lo bloqueamos usando el programa anterior:

$ echo "PRIMERO" > mi_archivo
$ ./ex_03 mi_archivo
Presione Enter para destrabar el/los bloqueo(s)
>Desde otra consola, podemos modificar al archivo:
    $ echo "SEGUNDO" > mi_archivo
    $
Volviendo a la primer consola, verificamos los "daños":
(Enter)
$ cat mi_archivo
SEGUNDO
$

Para solucionar este problema, el kernel de Linux brinda al administrador del sistema un mecanismo de bloqueo estricto heredado del System V. Por lo tanto, únicamente se puede usar con los bloqueos de fcntl() y no con los de flock(). El administrador puede indicar al kernel que todos los bloqueos de fcntl() sean estrictos usando una combinación determinada de permisos de acceso. De este modo, si un proceso bloquea un archivo para escritura otro proceso no podrá escribir en él incluso siendo superusuario La combinación particular consiste en usar el bit Set-GID mientras se quita al grupo el bit de ejecución. Esto se logra con el comando:

$ chmod g+s-x mi_archivo
$
Sin embargo, esto no es suficiente. Para que un archivo automáticamente se beneficie con bloqueos cooperativos estrictos se debe activar el atributo mandatory en la partición donde se encuentra. Generalmente, hay que modificar el archivo /etc/fstab agregando la opción mand en la cuarta columna o escribiendo en la línea de comandos:
# mount
/dev/hda5 on / type ext2 (rw)
[...]
# mount / -o remount,mand
# mount
/dev/hda5 on / type ext2 (rw,mand)
[...]
#
Ahora, podemos comprobar que es imposible realizar algún cambio desde otra consola:
$ ./ex_03 mi_archivo
Presionar Enter para destrabar el/los bloqueo(s)
>Desde otra terminal:
    $ echo "TERCERO" > mi_archivo
    bash: mi_archivo: Recurso momentáneamente no disponible
    $
Y volviendo a la primer consola:
(Enter)
$ cat mi_archivo
SEGUNDO
$

Es el administrador y no el programador quien debe decidir si hace o no un bloqueo estricto a un archivo (por ejemplo, /etc/passwd o /etc/shadow). El programador tiene que controlar la manera en que se acceden los datos lo que asegurará que su aplicación administre los mismos en forma coherente al leer y que no resulte peligroso para otros procesos al escribir mientras se administre el entorno adecuadamente.

 

Archivos temporales

A menudo un programa necesita almacenar datos en forma transitoria en un archivo. El caso más común ocurre cuando se desea insertar un registro en la mitad de un archivo ordenado en forma secuencial lo que implica hacer una copia del archivo original en un archivo temporal mientras se agrega el nuevo dato. A continuación la llamada al sistema unlink() elimina el archivo original y rename() renombra al archivo temporal para reemplazarlo por el original.

Si no se hace manera adecuada, la apertura de un archivo temporal es a menudo el origen de situaciones de concurrencia explotables por usuarios malintencionados. Recientemente se han descubierto agujeros de seguridad basados en archivos temporales en aplicaciones tales como Apache, Linuxconf, getty_ps, wu-ftpd, rdist, gpm, inn, etc. Recordemos unos pocos principios para evitar este tipo de inconvenientes.

En general, la creación de un archivo temporal se realiza en el directorio /tmp. Esto permite saber al administrador del sistema dónde se almacenan los datos de corta duración. Asimismo, también es posible programar una limpieza periódica (usando cron), usar una partición independiente formateada en tiempo de arranque, etc. En general, el administrador elige el lugar reservado para los archivos temporales en los archivos <paths.h> y <stdio.h> mediante la definición de las constantes simbólicas _PATH_TMP y P_tmpdir. De hecho, el usar otro directorio diferente al predeterminado /tmp no es una buena idea pues implicaría recompilar cada una de las aplicaciones incluyendo las bibliotecas de C. No obstante, mencionemos que el comportamiento de la rutina GlibC se puede definir mediante la variable de entorno TMPDIR. De esta forma, el usuario puede pedir que los archivos temporales se almacenen en un directorio propio en vez de hacerlo en el directorio predeterminado /tmp. Esto resulta a veces necesario cuando la partición donde se encuentra /tmp es demasiado pequeña como para ejecutar aplicaciones que requieran de un almacenamiento temporal muy grande.

El directorio /tmp del sistema es algo especial debido a sus permisos de acceso:

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

El Sticky-Bit representado por la letra t al final o o por el valor 01000 en modo octal tiene un significado determinado cuando se aplica a un directorio: sólo el propietario del directorio (el superusuario) y el propietario de un archivo que se encuentre en este directorio pueden eliminar al archivo. Puesto que el directorio tiene un acceso completo para escritura, cada usuario puede colocar sus archivos en él con la seguridad que se encontrarán protegidos al menos hasta que el administrador del sistema proceda a la próxima limpieza del sistema.

Sin embargo, usar el directorio de almacenamiento temporal puede ocasionar algunos problemas. Comencemos con el caso más sencillo, el de una aplicación Set-UID root que se comunica con un usuario. Imaginemos un cliente de correo. Si este proceso recibe una señal que le pide finalizar inmediatamente (SIGTERM o SIGQUIT durante el apagado del sistema, por ejemplo) puede intentar guardar al vuelo el correo ya escrito pero que aún no ha sido enviado. En las primeras versiones, se creaba el archivo /tmp/dead.letter. Bastaba entonces con que el usuario creara (puesto que puede escribir en el directorio /tmp) un enlace físico al directorio /etc/passwd con el nombre dead.letter para que el cliente de correo (ejecutándose con UID efectivo root) escribiera en este archivo el contenido del mensaje a medio terminar (que contenía, por casualidad, la línea "root::1:99999:::::").

El primer problema con este comportamiento es la naturaleza previsible del nombre del archivo. Basta con observar una única vez la aplicación para deducir que usará el nombre de archivo /tmp/dead.letter. Por lo tanto, el primer paso consiste en emplear un nombre de archivo especialmente concebido para la instancia del programa actual. Existen diversas funciones de biblioteca capaces de proporcionarnos un nombre de archivo temporal personal

Supongamos que tenemos una función de este tipo que nos proporcione un único nombre para nuestro archivo temporal. Hay software libre disponible con su código fuente (con su correspondiente biblioteca C). No obstante, el nombre del archivo resultante es previsible aunque bastante difícil de adivinar. Un atacante podría crear un enlace simbólico al nombre proporcionado por la biblioteca C. Nuestra primer reacción es, por lo tanto, verificar que el archivo existe antes de abrirlo. Ingenuamente podríamos escribir algo como :

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

Obviamente, este es un típico caso de condición de carrera donde un usuario se las arregla para crear un enlace al /etc/passwd entre el primer open() y el segundo creando de esta manera un agujero de seguridad. Es necesario contar con un medio para efectuar estas dos operaciones prácticamente en forma simultánea de modo que no pueda ocurrir ninguna manipulación entre ellas. Existe una opción específica de la llamada al sistema open() denominada O_EXCL y que se debe usar conjuntamente con O_CREAT. Esta opción hace que open() dé error si el archivo ya existe pero pero la verificación de existencia está íntimamente ligada a la creación.

A propósito, la extensión Gnu 'x' para los modos de apertura de la función fopen() exige una creación exclusiva del archivo y falla si el archivo ya existe:

  FILE * fp;

  if ((fp = fopen (nombre_archivo, "r+x")) == NULL) {
    perror ("No es posible crear el archivo.");
    exit (EXIT_FAILURE);
  }

Los permisos asociados a los archivos temporales juegan igualmente un rol importante. En efecto, si se debe escribir información confidencial y el archivo está en modo 644 (lectura/escritura para el propietario, sólo lectura para el resto de los usuarios) puede resultar un tanto molesto. La función

	#include <sys/types.h>
	#include <sys/stat.h>
        mode_t umask(mode_t mask);
nos permite fijar los permisos que serán otorgados a un archivo durante su creación. De esta manera, luego de la llamada umask(077) el archivo se abrirá en modo 600 (lectura/escritura para el propietario, sin derechos para el resto de los usuarios).

Generalmente, la creación de archivos temporales se efectúa en tres etapas:

  1. se crea un nombre único (al azar) ;
  2. se abre el archivo usando O_CREAT | O_EXCL con una política de permisos lo más restrictiva posible;
  3. se verifica el resultado al abrir el archivo y se actúa en consecuencia (ya sea reintentar o abandonar).

Detallemos ahora las posibilidades que existen para obtener un archivo temporal. Las funciones

      #include <stdio.h>

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

devuelven punteros a nombres creados al azar.

La primera función admite un argumento NULL en cuyo caso devuelve la dirección de un búfer estático. Su contenido cambiará en la siguiente llamada de tmpnam(NULL). Si el argumento es una cadena asignada, el nombre se copia aquí lo que requiere de una cadena de por lo menos L-tmpnam bytes. ¡Tengan cuidado con los desbordamientos de búfer! La página del manual informa acerca de problemas cuando se usa esta función con el parámetro NULL si se definen _POSIX_THREADS o _POSIX_THREAD_SAFE_FUNCTIONS.

La función tempnam() devuelve un puntero a una cadena. El directorio dir debe ser "apropiado" (la página man describe el significado exacto de la palabra "apropiado"). Esta función verifica que el archivo no exista antes de devolver su nombre. Sin embargo, una vez más la página del manual (man) no recomienda su uso pues el término "apropiado" puede tener diferentes significados según las implementaciones de la función. Mencionemos que Gnome recomienda su uso de la siguiente manera :

  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);
El uso del bucle reduce algunos riesgos pero crea otros. Imaginen lo que sucedería si la partición donde se desea crear el archivo temporal estuviese llena o si el sistema ya hubiera abierto el número máximo de archivos disponible a la vez...

La función

       #include <stdio.h>

       FILE *tmpfile (void);
crea un único nombre de archivo y lo abre. Este archivo se borra automáticamente al cerrarlo.

En GlibC-2.1.3, esta función usa un mecanismo similar a tmpnam() para generar el nombre del archivo y abrir el correspondiente descriptor. El archivo luego es eliminado, pero Linux realmente no lo borrará sino hasta que ningún recurso lo utilice, es decir, cuando el descriptor del archivo se libere a través de la llamada al sistema close().

  FILE * fp_tmp;

  if ((fp_tmp = tmpfile()) == NULL) {
    fprintf (stderr, "No es posible crear un archivo temporal\n");
    exit (EXIT_FAILURE);
  }

  /* ... uso del archivo temporal ... */

  fclose (fp_tmp);  /* verdadera eliminación del archivo por el sistema */

Los casos más sencillos no requieren del cambio del nombre del archivo ni la transmición a otro proceso, sino únicamente del almacenamiento y de la relectura de datos en un área temporal. Por lo tanto, generalmente no se necesita conocer el nombre del archivo temporal sino sólo acceder a su contenido. La función tmpfile() hace precisamente esto.

La página del manual no desaconseja su uso pero sí lo hace el Secure-Programs-HOWTO. Según el autor, las especificaciones no garantizan la creación del archivo y no ha podido verificar cada implementación. A pesar de esta reserva, esta función es la más eficiente.

Por último, las funciones

       #include <stdlib.h>

       char *mktemp(char *template);
       int mkstemp(char *template);
crean un único nombre desde una plantilla que consta de una cadena que termina con la cadena "XXXXXX". Estas 'Xs' se reemplazan para obtener un nombre de archivo único.

Segun las distintas versiones, mktemp() reemplaza las primeras cinco 'X' con el ID del proceso (PID) ...lo que hace fácil suponer el nombre ya que únicamente la última 'X' es aleatoria. Algunas versiones permiten más de seis 'X'.

El Secure-Programs-HOWTO recomienda el uso de la función

mkstemp() Aquí está el método propuesto:

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

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

/*
 * Crea un archivo temporal y lo devuelve
 * Esta rutina elimina el nombre del archivo del sistema de archivos
 * con lo cual no volverá a aparecer al listar el contenido del directorio.
 */
FILE *create_tempfile(char *temp_filename_pattern)
{
  int temp_fd;
  mode_t old_mode;
  FILE *temp_file;

  /* Crea un archivo con permisos restrictivos */
  old_mode = umask(077);
  temp_fd = mkstemp(temp_filename_pattern);
  (void) umask(old_mode);
  if (temp_fd == -1) {
    failure("No se pudo abrir el archivo temporal");
  }
  if (!(temp_file = fdopen(temp_fd, "w+b"))) {
    failure("No se pudo crear el descriptor del archivo temporal");
  }
  if (unlink(temp_filename_pattern) == -1) {
    failure("No se pudo eliminar el enlace al archivo temporal");
  }
  return temp_file;
}

Estas funciones muestran los problemas relacionados con la abstracción y portabilidad. Es decir, se espera que las funciones de la biblioteca estándar proporcionen características (abstracción)...pero la forma de implementarlas varía según el sistema empleado (portabilidad). Por ejemplo, la función tmpfile() abre un archivo temporal de distintas maneras (algunas versiones no usan O_EXCL) o mkstemp() maneja un número variable de 'X' de acuerdo a determinadas implementaciones.  

Conclusión

Hemos analizado la mayoría de los problemas de seguridad relacionados con los accesos concurrentes a un mismo recurso. Tengamos presente que nunca se debe suponer que dos operaciones consecutivas siempre se procesan en forma secuencial en la CPU a menos que el kernel lo considere así. Si bien las condiciones de carrera generan agujeros de seguridad no se deben despreciar los que se basan en otros recursos como las variables comunes entre diferentes hebras o los segmentos de memoria compartidos por intermedio de los mecanismos shmget(). Se deben implementar mecanismos de selección de accesos (mediante semáforos, por ejemplo) para evitar fallas difíciles de diagnosticar.

 

Enlaces


 

Formulario de "talkback" para este artículo

Cada artículo tiene su propia página de "talkback". A través de esa página puedes enviar un comentario o consultar los comentarios de otros lectores
 Ir a la página de "talkback" 

Contactar con el equipo de LinuFocus
© Frédéric Raynal, Christophe Blaess, Christophe Grenier, FDL
LinuxFocus.org

Pinchar aquí para informar de algún problema o enviar comentarios a LinuxFocus
Información sobre la traducción:
fr -> -- Frédéric Raynal, Christophe Blaess, Christophe Grenier
fr -> en Georges Tarbouriech
en -> en Lorne Bailey
en -> es Walter Echarri

2001-09-24, generated by lfparser version 2.9