- system(): Esta es la llamada que cualquier programa setuidado
debe evitar a toda costa. Si aparece en un código destinado a ejecutarse con
privilegios, significa casi con toda certeza un grave problema de seguridad; en
algunas ocasiones su peligrosidad es obvia (por ejemplo si leemos datos
tecleados por el usuario y a continuación hacemos un system() de esos
datos, ese usuario no tendría más que teclear /bin/bash para
conseguir los privilegios del propietario del programa), pero en otras no lo es
tanto: imaginemos un código que invoque a system() de una forma similar
a la siguiente:
#include <stdio.h>
#include <stdlib.h>
main(){
system("/bin/ls");
}
El programa anterior se limitaría a realizar un listado del directorio
desde el que lo ejecutemos. Al menos en teoría, ya que podemos comprobar
que no es difícil `engañar' a system(): no tenemos más que
modificar la variable de entorno $IFS (Internal Field Separator)
del shell desde el que ejecutemos el programa para conseguir que este
código ejecute realmente lo que nosotros le indiquemos. Esta variable delimita
las palabras (o símbolos) en una línea de órdenes, y por defecto
suele estar inicializada a Espacio, Tabulador, y Nueva
Línea (los separadores habituales de palabras); pero, >qué sucede si le
indicamos al shell que el nuevo carácter separador va a ser la barra,
`/'?. Muy sencillo: ejecutar `/bin/ls' será equivalente a
ejecutar `bin ls', es decir, una posible orden denominada `bin' que
recibe como parámetro `ls'. Por ejemplo, bajo SunOS - bajo la
mayoría de Unices -, y utilizando sh (no bash) podemos hacer
que `bin' sea un programa de nuestra elección, como `id':
$ cp /bin/id bin
$ ejemplo
bin ejemplo.c ejemplo
$ IFS=/
$ export IFS
$ ejemplo
uid=672(toni) gid=10(staff)
$
Como podemos ver, acabamos de ejecutar un programa arbitrario; si en lugar de
`id' hubiéramos escogido un intérprete de órdenes, como `bash'
o `sh', habríamos ejecutado ese shell. Y si el programa
anterior estuviera setudiado, ese shell se habría ejecutado
con los privilegios del propietario del archivo (si imaginamos que fuera root, podemos hacernos una idea de las implicaciones de seguridad que esto
representa).
- exec(), popen(): Similares a la anterior; es preferible
utilizar execv() o execl(), pero si han de recibir parámetros del
usuario sigue siendo necesaria una estricta comprobación de los mismos.
- setuid(), setgid()...: Los programas de usuario no
deberían utilizar estas llamadas, ya que no han de tener privilegios
innecesarios.
- strcpy(), strcat(), sprintf(), vsprintf()...:
Estas funciones no comprueban la longitud de las cadenas con las que trabajan,
por lo que son una gran fuente de buffer overflows. Se han de sustituir
por llamadas equivalentes que sí realicen comprobación de límites
(strncpy(), strncat()...) y, si no es posible, realizar dichas
comprobaciones manualmente.
- getenv(): Otra excelente fuente de desbordamientos de buffer;
además, el uso que hagamos de la información leída puede ser peligroso,
ya que recordemos que es el usuario el que generalmente puede modificar el valor
de las variables de entorno. Por ejemplo, >qué sucedería si ejecutamos
desde un programa una orden como `cd $HOME', y resulta que esta variable
de entorno no corresponde a un nombre de directorio sino que es de la forma
`/;rm -rf /'? Si algo parecido se hace desde un programa que se ejecute
con privilegios en el sistema, podemos imaginarnos las consecuencias...
- gets(), scanf(), fscanf(), getpass(), realpath(), getopt()...: Estas funciones no realizan las
comprobaciones adecuadas de los datos introducidos, por lo que pueden desbordar
en algunos casos el buffer destino o un buffer estático interno
al sistema. Es preferible el uso de read() o fgets() siempre que sea
posible (incluso para leer una contraseña, haciendo por supuesto que no se
escriba en pantalla), y si no lo es al menos realizar manualmente comprobaciones
de longitud de los datos leídos.
- gethostbyname(), gethostbyaddr(): Seguramente ver las amenazas
que provienen del uso de estas llamadas no es tan inmediato como ver las del
resto; generalmente hablamos de desbordamiento de buffers, de
comprobaciones de límites de datos introducidos por el usuario...pero
no nos paramos a pensar en datos que un atacante no introduce directamente
desde teclado o desde un archivo, pero cuyo valor puede forzar incluso desde
sistemas que ni siquiera son el nuestro. Por ejemplo, todos tendemos a asumir
como ciertas las informaciones que un servidor DNS - más o menos fiables, por
ejemplo alguno de nuestra propia organización - nos brinda. Imaginemos un
programa como
el siguiente (se han omitido las comprobaciones de errores habituales por
cuestiones de claridad):
#include <stdio.h>
#include <stdlib.h>
#include <netdb.h>
#include <unistd.h>
#include <arpa/inet.h>
int main(int argc, char **argv){
struct in_addr *dir=(struct in_addr *)malloc(sizeof(struct in_addr));
struct hostent *maquina=(struct hostent *)malloc(sizeof(struct \
hostent));
char *orden=(char *)malloc(30);
dir->s_addr=inet_addr(*++argv);
maquina=gethostbyaddr((char *)dir,sizeof(struct in_addr),AF_INET);
sprintf(orden,"finger @%s\n",maquina->h_name);
system(orden);
return(0);
}
Este código recibe como argumento una dirección IP, obtiene su nombre
vía /etc/hosts o DNS,y ejecuta un finger sobre dicho
nombre; aparte de otros posibles problemas de seguridad (por ejemplo,
>seríamos capaces de procesar cualquier información que devuelva
el finger?, >qué sucede con la llamada a system()?), nada
extraño ha de suceder si el nombre de máquina devuelto al programa es
`normal':
luisa:~/tmp$ ./ejemplo 192.168.0.1
[rosita]
No one logged on.
luisa:~/tmp$
Pero, >qué pasaría si en lugar de devolver un nombre `normal' (como `rosita') se devuelve un nombre algo más elaborado, como `rosita;ls'?
Podemos verlo:
luisa:~/tmp$ ./ejemplo 192.168.0.1
[rosita;ls]
No one logged on.
ejemplo ejemplo.c
luisa:~/tmp$
Exactamente: se ha ejecutado la orden `finger @rosita;ls' (esto es, un
`finger' a la máquina seguido de un `ls'). Podemos imaginar los
efectos que tendría el uso de este programa si sustituimos el inocente `ls' por un `rm -rf $HOME'. Un atacante que consiga controlar un
servidor DNS (algo no muy complicado) podría inyectarnos datos
maliciosos en nuestra máquina sin ningún problema. Para evitar esta
situación debemos hacer una doble búsqueda inversa y además no hacer
ninguna suposición sobre la corrección o el formato de los datos recibidos;
en nuestro código debemos insertar las comprobaciones necesarias para
asegurarnos de que la información que recibimos no nos va a causar problemas.
- syslog(): Hemos de tener la precaución de utilizar una versión
de esta función de librería que compruebe la longitud de
sus argumentos; si no lo hacemos y esa longitud sobrepasa un cierto
límite (generalmente, 1024 bytes) podemos causar un desbordamiento en
los buffers de nuestro sistema de log, dejándolo inutilizable.
- realloc(): Ningún programa - privilegiado o no - que maneje
datos sensibles (por ejemplo, contraseñas, correo electrónico...y
especialmente aplicaciones criptográficas) debe utilizar esta llamada; realloc() se suele utilizar para aumentar dinámicamente la cantidad de
memoria reservada para un puntero. Lo habitual es que la nueva zona de memoria
sea contigua a la que ya estaba reservada, pero si esto no es posible realloc() copia la zona antigua a una nueva ubicación donde pueda añadirle
el espacio especificado. >Cuál es el problema? La zona de memoria antigua se
libera (perdemos el puntero a ella) pero no se pone a cero, con lo que sus
contenidos permanecen inalterados hasta que un nuevo proceso reserva esa zona;
accediendo a bajo nivel a la memoria (por ejemplo, leyendo /proc/kcore o
/dev/kmem) sería posible para un atacante tener acceso a esa
información.
Realmente, malloc() tampoco pone a cero la memoria reservada, por lo que a
primera vista puede parecer que cualquier proceso de usuario (no un acceso a
bajo nivel, sino un simple malloc() en un programa) podría permitir
la lectura del antiguo contenido de la zona de memoria reservada. Esto es falso
si se trata de nueva memoria que el núcleo reserva para el proceso invocador:
en ese caso, la memoria es limpiada por el propio kernel del operativo,
que invoca a kmalloc() (en el caso de Linux, en otros Unices el nombre
puede variar aunque la idea sea la misma) para hacer la reserva. Lo que sí
es posible es que si
liberamos una zona de memoria (por ejemplo con free()) y a continuación
la volvemos a reservar, en el mismo proceso, podamos acceder a su contenido: esa
zona no es `nueva' (es decir, el núcleo no la ha reservado de nuevo), sino
que ya pertenecía al proceso. De cualquier forma, si vamos a liberar una
zona en la que está almacenada información sensible, lo mejor en cualquier
caso es ponerla a cero manualmente, por ejemplo mediante bzero() o memset().
- open(): El sistema de ficheros puede modificarse durante la
ejecución de un programa de formas que en ocasiones ni siquiera imaginamos;
por ejemplo, en Unix se ha de evitar escribir siguiendo enlaces de archivos
inesperados (un archivo que cambia entre una llamada a lstat() para comprobar si existe y una llamada a open() para abrirlo en
caso positivo, como hemos visto antes). No obstante, no hay ninguna forma de
realizar esta operación atómicamente sin llegar a mecanismos de
entrada/salida de muy bajo nivel; Peter Gutmann propone el siguiente código
para asegurarnos de que estamos realizando un open() sobre el archivo que
realmente queremos abrir, y no sobre otro que un atacante nos ha puesto en su
lugar:
struct stat lstatInfo;
char *mode="rb+";
int fd;
if(lstat(fileName,&lstatInfo)==-1)
{
if(errno!=ENOENT) return( -1 );
if((fd=open(fileName,O_CREAT|O_EXCL|O_RDWR,0600))==-1) return(-1);
mode="wb";
}
else
{
struct stat fstatInfo;
if((fd=open(fileName,O_RDWR))==-1) return(-1);
if(fstat(fd,&fstatInfo)==-1 || \
lstatInfo.st_mode!=fstatInfo.st_mode || \
lstatInfo.st_ino!=fstatInfo.st_ino || \
lstatInfo.st_dev!=fstatInfo.st_dev)
{
close(fd);
return(-1);
}
if(fstatInfo.st_nlink>1||!S_ISREG(lstatInfo.st_mode))
{
close(fd);
return(-1);
}
#ifdef NO_FTRUNCATE
close(fd);
if((fd=open(fileName,O_CREAT|O_TRUNC|O_RDWR))==-1) return( -1 );
mode="wb";
#else
ftruncate(fd,0);
#endif /* NO_FTRUNCATE */
}
stream->filePtr=fdopen(fd,mode);
if(stream->filePtr==NULL)
{
close(fd);
unlink(fileName);
return(-1); /* Internal error, should never happen */
}
}
Como podemos ver, algo tan elemental como una llamada a open() se ha
convertido en todo el código anterior si queremos garantizar unas mínimas
medidas de seguridad; esto nos puede dar una idea de hasta que punto la
programación `segura' puede complicarse. No obstante, en muchas ocasiones es
preferible toda la complicación y parafernalia anteriores para realizar un
simple open() a que esa llamada se convierta en un fallo de seguridad en
nuestro sistema. No hay ningún programa que se pueda considerar perfecto o
libre de errores (como se cita en el capítulo 23 de [GS96], una
rutina de una librería puede tener un fallo...o un rayo gamma puede
alterar un bit de memoria para hacer que nuestro programa se comporte de
forma inesperada), pero cualquier medida que nos ayude a minimizar las
posibilidades de problemas es siempre positiva.