Este artículo está disponible en los siguientes idiomas: English Castellano Deutsch Francais Nederlands Portugues Russian Turkce |
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: |
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.
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.
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 #
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:
nice -n 20
;
while (1);
);
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.
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á.
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
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.
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:
O_CREAT | O_EXCL
con una política
de permisos lo más restrictiva posible;
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'.
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.
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.
|
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:
|
2001-09-24, generated by lfparser version 2.9